fix(privacy): a newest settings row taken away leaves out what it outweighed (ADR 0019)

Found by the hostile-instance test (seed 186): the instance withheld the
laptop's personal key from the phone, so the phone wrote Count me off in a
row of its own beside the laptop's on; then it deleted the phone's row and
handed the key back, and the phone read the laptop's on alone, neither
row's version newer than the other's. A row a device saw as newest that is
gone, without this device folding it and without a row covering it (as
another device's fold always leaves), now leaves out every row it doesn't
cover: Count me falls back to off rather than to what the missing row
outweighed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-22 13:17:29 +02:00
commit 988aa42617
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 70 additions and 0 deletions

View file

@ -638,6 +638,21 @@ class Privacy(
))
rowId: row,
};
// A row seen as newest that is gone, without this device folding it
// and without a row covering it (as another device's fold leaves), was
// taken away: the rows it doesn't cover are left out too, as what it
// said might outweigh them (the final reading of 22 Oct; another
// device's older Count me on is never read alone).
for (final MapEntry(key: rowId, value: seen)
in ledger.settingsFrontier.entries) {
if (read.containsKey(rowId) || ledger.foldedSettings(rowId)) {
continue;
}
if (kept.values.any((row) => _covers(row.vector, seen))) {
continue;
}
kept.removeWhere((_, row) => !_covers(row.vector, seen));
}
for (final MapEntry(key: rowId, value: row) in kept.entries) {
ledger.sawSettingsVersion(rowId, row.vector);
}
@ -648,6 +663,10 @@ class Privacy(
};
}
/// Whether [vector] has every count of [seen] at least.
static bool _covers(Map<String, int> vector, Map<String, int> seen) =>
seen.entries.every((e) => (vector[e.key] ?? 0) >= e.value);
/// [payload]'s version vector and the payload after it; null without one.
({Map<String, int> vector, ByteData payload})? _versioned(
ByteData payload,

View file

@ -1105,6 +1105,57 @@ void main() {
expect([for (final r in await laptop.settings.rows()) r.id], [newer]);
});
test("a device's own settings row deleted by the instance leaves out the "
"rows it doesn't cover, so another device's older on isn't read "
'alone', () async {
final phone = await device(account: alice);
final mine = await phone.settings.insert(json({'countLearning': false}));
// The laptop, which never heard of it, turns Count me on in a row of
// its own.
final laptop = await device(account: alice);
await protocol.PersonSettings.db.deleteWhere(
db,
where: (t) => t.id.equals(protocol.UuidValue.withValidation(mine)),
);
final theirs = await laptop.settings.insert(
json({'countLearning': true}),
);
final row = (await protocol.PersonSettings.db.findById(
db,
protocol.UuidValue.withValidation(theirs),
))!;
// The instance hands the phone the laptop's row, withholding the
// phone's own.
expect(await protocol.PersonSettings.db.findById(db, row.id!), isNotNull);
expect([for (final r in await phone.settings.rows()) r.id], isEmpty);
});
test('a settings row folded away on another device, its keeper covering '
'it, is no gap', () async {
final phone = await device(account: alice);
final mine = await phone.settings.insert(json({'countLearning': false}));
final laptop = await device(account: alice);
final theirs = await laptop.settings.insert(
json({'countLearning': true}),
);
// The laptop folds the phone's into its own, as the repository does.
final rows = [...await laptop.settings.rows()]
..sort((a, b) => a.id.compareTo(b.id));
expect(rows, hasLength(2));
final keeper = rows.first.id;
final other = rows.last.id;
await laptop.settings.updatePayload(other, redactedPayload);
await laptop.settings.delete(other);
await laptop.settings.updatePayload(
keeper,
json({'countLearning': false}),
);
expect([for (final r in await phone.settings.rows()) r.id], [keeper]);
expect({mine, theirs}, contains(keeper));
});
test('a settings row this device never saw is read only if it brings '
'news', () async {
final laptop = await device(account: alice);