fix(privacy): what decides a row's key is bound into its seal (ADR 0019)

The final reading of 22 Oct found that an item learned its first key from
any sealed row pointed at it: the instance pointed a revision of private B
at a card of private A, the card's next edit was sealed under B's key, and
making B readable opened it. Every sealed payload and every signed readable
one is now bound to the row it belongs to where that decides its key (a
revision's card, note or step, an answer's step, a path's or a PDF's
studyset; sealed format v2, the old dropped as nothing was released), so a
re-pointed row doesn't open and reads as locked, and an item learns its key
only from its own rows, before any write decides on one. A studyset's
parentId isn't bound (a move and an edit on two devices merge column by
column); instead each studyset on the way up keeps its own key before any
above it counts.

Also from the reading:
- whether the account has keys fails closed: any sign counts (keys open,
  the register's yes, key rows, records, sealed tags and settings), the
  register's no is never kept and it is asked again;
- a pending rewrite keeps whether it may take in unsigned rows, a sweep's
  never does, and a sweep never takes over the person's own rewrite;
- settings carry one version vector per person, rows folded away here are
  never read again, a row never seen is read only if it brings news, and
  rows without a vector (HLKS) are dropped;
- a tree stays partly readable while rows a sweep can't move remain, which
  are looked at again once more keys open.

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 11:27:45 +02:00
commit 037abb0cc6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 759 additions and 125 deletions

View file

@ -64,6 +64,7 @@ class SyncAssetGateway(
payload,
keyId: keyId,
table: 'asset',
bound: '',
id: id,
item: 'asset:$id',
);
@ -111,6 +112,7 @@ class SyncAssetGateway(
final opened = _privacy.openItem(
(keyId: asset.keyId, nonce: asset.nonce, payload: asset.payload),
table: 'asset',
bound: '',
id: asset.id!.uuid,
item: 'asset:${asset.id!.uuid}',
treeKey: null,

View file

@ -56,29 +56,44 @@ class ContentKeys(
log('Opening data keys failed', name: 'Privacy', error: error),
);
/// How many keys were opened here so far: rows that didn't open may with
/// more.
int opened = 0;
/// Whether this device holds the account's keys, so new content can be
/// made private; known once [refresh] ran.
bool get canEncrypt => _unlocked;
/// Whether the person has keys at all: some are kept, or this device saw
/// some. A device without the account's keys on such an account writes
/// nothing that would be private (the reading of 22 Oct).
/// Whether the person has keys at all. A device without the account's
/// keys on such an account writes nothing that would be private (the
/// reading of 22 Oct).
///
/// Decided by the register where it said (the short reading of 22 Oct):
/// keys rows the instance withholds or plants change nothing then.
/// Any sign is enough, and none is taken back (the final reading of 22
/// Oct): the keys open here, the register saying so (once it said yes),
/// key rows kept, records or sealed tags and settings this device saw. The
/// register's answer comes through the instance unsigned, so its "no"
/// never outweighs the rest, and it is asked again until it says yes: a
/// password set on another device later counts here too. Rows the instance
/// plants on an account without keys only make this device refuse to
/// write, never write readable.
bool get accountHasKeys =>
_unlocked ||
(ledger.registerHasKeys ??
(_rows.isNotEmpty || ledger.studysetsWithFacts.isNotEmpty));
ledger.registerHasKeys == true ||
_rows.isNotEmpty ||
ledger.studysetsWithFacts.isNotEmpty ||
ledger.personalSealed;
/// Opens the keys kept that aren't open yet: as the person's data opens,
/// and once a sync may have brought more.
Future<void> refresh() => _refreshing.run();
Future<void> _refresh() async {
if (ledger.registerHasKeys == null && registerHasKeys != null) {
if (ledger.registerHasKeys != true && registerHasKeys != null) {
try {
ledger.registerHasKeys = await registerHasKeys!();
// Kept only once it says yes; a no is asked again next time.
if (await registerHasKeys!() == true) {
ledger.registerHasKeys = true;
}
} on Object catch (error) {
log(
'Asking whether the account has keys failed',
@ -108,6 +123,7 @@ class ContentKeys(
_refusedRows.add(row.id);
continue;
}
opened++;
}
if (id != null) {
_learn(id);
@ -192,6 +208,7 @@ class ContentKeys(
isUtc: true,
);
final sealedKey = cipher.newKey(id.value);
opened++;
final kind = switch (id.scope) {
ContentKeyScope.studyset => ContentKeyKind.studyset,
ContentKeyScope.personal => ContentKeyKind.personal,

View file

@ -34,11 +34,14 @@ final class const Locked() extends Opened;
/// Seals and opens every synced payload (ADR 0019), the one place the
/// gateways go through: a payload is sealed for its table, row and column,
/// so the instance can't move it to another row unnoticed.
/// and for the row it belongs to where that decides its key (`bound`: a
/// revision's card, note or step, an answer's step, a path's or PDF's
/// studyset), so the instance can neither move it to another row nor point
/// it at another item unnoticed (the final reading of 22 Oct).
///
/// A readable payload a device holding the account's keys writes carries the
/// account's signature (64 bytes) in place of a nonce, for its table, row
/// and column: a device can tell it from one the instance made up, and
/// account's signature (64 bytes) in place of a nonce, over the same: a
/// device can tell it from one the instance made up or re-pointed, and
/// trusts the latter nowhere privacy holds (the reading of 22 Oct).
class PayloadEnvelope(
final ContentCipher _cipher, {
@ -47,14 +50,17 @@ class PayloadEnvelope(
final bool Function()? canSign,
}) {
/// [plain] as the row keeps it: sealed with the key [keyId], readable
/// without one, and then signed where this device can. A blank payload
/// stays blank. Throws a [StateError] for a key not open on this device.
/// without one, and then signed where this device can; for its [table],
/// row [id], [column] and the row it is [bound] to (empty for none). A
/// blank payload stays blank. Throws a [StateError] for a key not open on
/// this device.
Envelope seal(
ByteData plain, {
required String? keyId,
required String table,
required String id,
String column = 'payload',
String bound = '',
}) {
if (plain.lengthInBytes == 0) {
return (keyId: keyId, nonce: null, payload: plain);
@ -65,7 +71,9 @@ class PayloadEnvelope(
keyId: null,
nonce: signs
? ByteData.sublistView(
_cipher.sign(_readableMessage(table, id, column, plain)),
_cipher.sign(
_readableMessage(table, id, column, bound, plain),
),
)
: null,
payload: plain,
@ -74,7 +82,7 @@ class PayloadEnvelope(
final sealed = _cipher.seal(
Uint8List.sublistView(plain),
keyId: keyId,
context: _context(table, id, column),
context: _context(table, id, column, bound),
);
return (
keyId: keyId,
@ -84,12 +92,14 @@ class PayloadEnvelope(
}
/// [envelope]'s payload opened: as it is while readable or blank, opened
/// with its key, or [Locked].
/// with its key, or [Locked]; one sealed or signed for another row, or
/// [bound] to another, doesn't open or isn't trusted.
Opened open(
Envelope envelope, {
required String table,
required String id,
String column = 'payload',
String bound = '',
}) {
final (:keyId, :nonce, :payload) = envelope;
if (payload.lengthInBytes == 0) {
@ -101,7 +111,7 @@ class PayloadEnvelope(
trusted:
nonce != null &&
_cipher.verify(
_readableMessage(table, id, column, payload),
_readableMessage(table, id, column, bound, payload),
Uint8List.sublistView(nonce),
),
);
@ -115,26 +125,33 @@ class PayloadEnvelope(
nonce: Uint8List.sublistView(nonce),
payload: Uint8List.sublistView(payload),
),
context: _context(table, id, column),
context: _context(table, id, column, bound),
);
return plain == null
? const Locked()
: Readable(ByteData.sublistView(plain));
}
/// What binds a payload to where it is stored.
static List<int> _context(String table, String id, String column) =>
utf8.encode('$table|$id|$column');
/// What binds a payload to where it is stored and what it belongs to; its
/// format's version first (2 since the final reading of 22 Oct, which
/// added `bound`; nothing sealed before was released).
static List<int> _context(
String table,
String id,
String column,
String bound,
) => utf8.encode('v2|$table|$id|$column|$bound');
/// What a readable payload's signature is over: that it is readable, where
/// it is stored, and what it says.
/// it is stored, what it belongs to, and what it says.
static List<int> _readableMessage(
String table,
String id,
String column,
String bound,
ByteData payload,
) => [
...utf8.encode('readable|$table|$id|$column|'),
...utf8.encode('readable|v2|$table|$id|$column|$bound|'),
...Uint8List.sublistView(payload),
];
}

View file

@ -39,6 +39,7 @@ final class const PrivacyRefused(final PrivacyRefusal reason)
typedef _Row = ({
String table,
String id,
String bound,
String item,
Envelope current,
Opened opened,
@ -98,9 +99,12 @@ class Privacy(
Future<void>? _rewriting;
/// Rows a sweep found it can't move (locked, or readable without a
/// signature): not looked at again until they change.
/// signature): not looked at again until they change, or more keys open.
final _unmovable = <String>{};
/// How many keys were open at the last upkeep.
int? _openedBefore;
/// How many rows of each private tree the last sweep left unsealed (rows
/// that don't open here, or readable ones nobody signed): its marker says
/// "not fully private" while any are.
@ -151,7 +155,7 @@ class Privacy(
/// [keyOfStudyset] on [parents] already read, for reading many rows.
String? treeKeyIn(Map<String, String?> parents, String studysetId) =>
_nearestPrivate(parents, studysetId) ?? _itemKey('studyset:$studysetId');
_nearestPrivate(parents, studysetId);
/// Every studyset's tree key, for reading many rows at once.
Future<String? Function(String studysetId)> treeKeys() async {
@ -179,20 +183,30 @@ class Privacy(
}
}
/// The key of the nearest studyset to [studysetId], itself or above, that
/// a signed record says is private; null where none is. In a cycle the
/// studysets below it come first, then its members by id, so every member
/// finds the same.
/// The key of the nearest studyset to [studysetId], itself or above,
/// that has one: a signed record saying it is private, or the key its own
/// row was last seen sealed with; null where none has.
///
/// Each studyset on the way keeps its own key before any above it counts
/// (the final reading of 22 Oct): `parentId` is a column the instance can
/// change, so a topic it moves under another private studyset goes on
/// with the key it had, and making that studyset readable never opens what
/// is written in the topic. `parentId` isn't bound into a studyset's seal:
/// a move on one device and an edit on another merge column by column, so
/// the row would lock for no fault; the keys kept here guard it instead.
/// In a cycle the studysets below it come first, then its members by id,
/// so every member finds the same.
String? _nearestPrivate(Map<String, String?> parents, String studysetId) {
String? keyOf(String id) => _privateKeyOf(id) ?? _itemKey('studyset:$id');
final path = <String>[];
final seen = <String, int>{};
String? current = studysetId;
while (current != null && parents.containsKey(current)) {
while (current != null) {
if (seen.containsKey(current)) {
final below = path.sublist(0, seen[current]);
final cycle = path.sublist(seen[current]!)..sort();
for (final id in [...below, ...cycle]) {
if (_privateKeyOf(id) case final keyId?) {
if (keyOf(id) case final keyId?) {
return keyId;
}
}
@ -200,9 +214,12 @@ class Privacy(
}
seen[current] = path.length;
path.add(current);
if (_privateKeyOf(current) case final keyId?) {
if (keyOf(current) case final keyId?) {
return keyId;
}
if (!parents.containsKey(current)) {
return null;
}
current = parents[current];
}
return null;
@ -271,6 +288,19 @@ class Privacy(
final tree = card == null
? null
: await keyOfStudyset(card.studysetId.uuid);
if (ledger.keyOfItem('card:$cardId') == null) {
_learnItem('card:$cardId', 'card_revision', 'card:$cardId', [
for (final r in await protocol.CardRevision.db.find(
_db,
where: (t) =>
t.cardId.equals(_uuid(cardId)) & t.keyId.notEquals(null),
))
(
id: r.id!.uuid,
e: (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
),
]);
}
return _writeKey('card:$cardId', tree);
}
@ -280,6 +310,19 @@ class Privacy(
final tree = note == null
? null
: await keyOfStudyset(note.studysetId.uuid);
if (ledger.keyOfItem('note:$noteId') == null) {
_learnItem('note:$noteId', 'note_revision', 'note:$noteId', [
for (final r in await protocol.NoteRevision.db.find(
_db,
where: (t) =>
t.noteId.equals(_uuid(noteId)) & t.keyId.notEquals(null),
))
(
id: r.id!.uuid,
e: (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
),
]);
}
return _writeKey('note:$noteId', tree);
}
@ -289,6 +332,19 @@ class Privacy(
final tree = path == null
? null
: await keyOfStudyset(path.studysetId.uuid);
if (path != null && ledger.keyOfItem('path:$pathId') == null) {
_learnItem(
'path:$pathId',
'learning_path',
'studyset:${path.studysetId.uuid}',
[
(
id: pathId,
e: (keyId: path.keyId, nonce: path.nonce, payload: path.payload),
),
],
);
}
return _writeKey('path:$pathId', tree);
}
@ -299,6 +355,24 @@ class Privacy(
_uuid(stepId),
);
final tree = step == null ? null : await _treeKeyOfPath(step.pathId.uuid);
if (ledger.keyOfItem('step:$stepId') == null) {
_learnItem(
'step:$stepId',
'learning_path_step_revision',
'step:$stepId',
[
for (final r in await protocol.LearningPathStepRevision.db.find(
_db,
where: (t) =>
t.stepId.equals(_uuid(stepId)) & t.keyId.notEquals(null),
))
(
id: r.id!.uuid,
e: (keyId: r.keyId, nonce: r.nonce, payload: r.payload),
),
],
);
}
return _writeKey('step:$stepId', tree);
}
@ -307,12 +381,65 @@ class Privacy(
_writeKey('answers:$stepId', await keyOfStep(stepId));
/// The key the PDF [pdfId] is written with, in [studysetId]'s tree.
Future<String?> keyOfPdf(String pdfId, String studysetId) async =>
_writeKey('pdf:$pdfId', await keyOfStudyset(studysetId));
Future<String?> keyOfPdf(String pdfId, String studysetId) async {
final tree = await keyOfStudyset(studysetId);
if (ledger.keyOfItem('pdf:$pdfId') == null) {
if (await protocol.PdfSource.db.findById(_db, _uuid(pdfId))
case final pdf?) {
_learnItem(
'pdf:$pdfId',
'pdf_source',
'studyset:${pdf.studysetId.uuid}',
[
(
id: pdfId,
e: (keyId: pdf.keyId, nonce: pdf.nonce, payload: pdf.payload),
),
],
);
}
}
return _writeKey('pdf:$pdfId', tree);
}
/// The key a new or changed studyset's own row is written with.
Future<String?> keyOfStudysetRow(String studysetId) async =>
_writeKey('studyset:$studysetId', await keyOfStudyset(studysetId));
Future<String?> keyOfStudysetRow(String studysetId) async {
final tree = await keyOfStudyset(studysetId);
if (ledger.keyOfItem('studyset:$studysetId') == null) {
if (await protocol.Studyset.db.findById(_db, _uuid(studysetId))
case final row?) {
_learnItem('studyset:$studysetId', 'studyset', '', [
(
id: studysetId,
e: (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
),
]);
}
}
return _writeKey('studyset:$studysetId', tree);
}
/// Learns [item]'s key from its own sealed [rows] of [table], [bound] as
/// they are stored, before a write decides on a key (the final reading of
/// 22 Oct): a row that doesn't open for [item] (pointed at it by the
/// instance) teaches nothing.
void _learnItem(
String item,
String table,
String bound,
List<({String id, Envelope e})> rows,
) {
for (final row in rows) {
final keyId = row.e.keyId;
if (keyId == null || !keys.canOpen(keyId)) {
continue;
}
if (open(row.e, table: table, id: row.id, bound: bound)
case Readable(:final payload) when payload.lengthInBytes > 0) {
_sawSealed(item, keyId);
}
}
}
Future<String?> _treeKeyOfPath(String pathId) async {
final path = await protocol.LearningPath.db.findById(_db, _uuid(pathId));
@ -354,20 +481,31 @@ class Privacy(
static final _versionMark = utf8.encode('HLKV');
/// The sealed settings row [rowId]'s [payload] (opened) without its
/// version vector; null for a rollback: one whose every count is at most
/// what this device has seen of the row, and one lower, or one without a
/// vector once one was seen. A row with news from any device is taken,
/// so two devices' changes at once both count. A blank one is as it is.
/// version vector; null for one that isn't read (the final reading of 22
/// Oct):
/// - a row this device folded away, whatever is handed back;
/// - a rollback of a row seen before: every count at most what was seen
/// of it, and one lower;
/// - a row never seen here that brings no news: every count at most what
/// was seen of the person's settings as a whole, so a row folded away on
/// another device can't come back;
/// - one without a vector (sealed before settings had them, or under the
/// short reading's counter, `HLKS`): dropped, as nothing sealed so was
/// released.
///
/// A row with news from any device is taken, so two devices' changes at
/// once both count. A blank one is as it is.
ByteData? settingsWithoutCounter(String rowId, ByteData payload) {
if (payload.lengthInBytes == 0) {
return payload;
}
if (ledger.foldedSettings(rowId)) {
return null;
}
final bytes = Uint8List.sublistView(payload);
final seen = ledger.settingsVersionOf(rowId);
final mark = _versionMark.length;
if (bytes.length < mark + 2 || !_startsWith(bytes, _versionMark)) {
// Sealed before settings had versions: only until one was seen.
return seen.isEmpty ? payload : null;
return null;
}
final length = bytes[mark] << 8 | bytes[mark + 1];
if (bytes.length < mark + 2 + length) {
@ -386,25 +524,35 @@ class Privacy(
} on FormatException {
return null;
}
final devices = {...seen.keys, ...vector.keys};
final older =
devices.every((d) => (vector[d] ?? 0) <= (seen[d] ?? 0)) &&
devices.any((d) => (vector[d] ?? 0) < (seen[d] ?? 0));
if (older) {
return null;
if (ledger.sawSettingsRow(rowId)) {
final seen = ledger.settingsVersionOf(rowId);
final devices = {...seen.keys, ...vector.keys};
final older =
devices.every((d) => (vector[d] ?? 0) <= (seen[d] ?? 0)) &&
devices.any((d) => (vector[d] ?? 0) < (seen[d] ?? 0));
if (older) {
return null;
}
} else {
final person = ledger.settingsPersonVersion;
final news = vector.entries.any((e) => e.value > (person[e.key] ?? 0));
if (!news) {
return null;
}
}
ledger.sawSettingsVersion(rowId, vector);
return ByteData.sublistView(bytes, mark + 2 + length);
}
/// The settings row [rowId]'s [payload] with this device's next version in
/// front, to be sealed.
/// front, to be sealed: everything seen of the person's settings, and one
/// more write of this device's, so it dominates what it replaces.
ByteData settingsWithCounter(String rowId, ByteData payload) {
if (payload.lengthInBytes == 0) {
return payload;
}
final device = ledger.deviceId;
final vector = {...ledger.settingsVersionOf(rowId)};
final vector = {...ledger.settingsPersonVersion};
vector[device] = (vector[device] ?? 0) + 1;
ledger.sawSettingsVersion(rowId, vector);
final json = utf8.encode(jsonEncode(vector));
@ -419,32 +567,56 @@ class Privacy(
);
}
/// Keeps that the settings row [rowId] was folded away here: never read
/// again.
void foldedSettings(String rowId) => ledger.foldSettings(rowId);
/// Whether a readable tag or settings row is no longer trusted: this
/// device sealed the person's once their personal key existed.
bool get personalProtected => ledger.personalSealed;
/// [envelope]'s payload opened for its [table], row [id] and [column].
/// [envelope]'s payload opened for its [table], row [id], [column] and
/// the row it is [bound] to: a revision's `card:`, `note:` or `step:`, an
/// answer's `step:`, a path's or a PDF's `studyset:`, empty for rows whose
/// key no other row decides (the final reading of 22 Oct). Required, so no
/// row is opened without what it belongs to.
Opened open(
Envelope envelope, {
required String table,
required String id,
required String bound,
String column = 'payload',
}) => this.envelope.open(envelope, table: table, id: id, column: column);
}) => this.envelope.open(
envelope,
table: table,
id: id,
column: column,
bound: bound,
);
/// The row [id] of [table] of [item], in a tree with [treeKey] (for tags
/// and settings, [protected] once readable ones aren't trusted), opened:
/// sealed and opened, its key is kept as the item's; readable without
/// the account's signature where privacy holds, [Locked].
/// The row [id] of [table] of [item], [bound] as [open] says, in a tree
/// with [treeKey] (for tags and settings, [protected] once readable ones
/// aren't trusted), opened: sealed and opened, its key is kept as the
/// item's, which only a row bound to it can be (the final reading of 22
/// Oct); readable without the account's signature where privacy holds,
/// [Locked].
Opened openItem(
Envelope envelope, {
required String table,
required String id,
required String bound,
required String item,
required String? treeKey,
bool protected = false,
String column = 'payload',
}) {
final opened = open(envelope, table: table, id: id, column: column);
final opened = open(
envelope,
table: table,
id: id,
column: column,
bound: bound,
);
if (opened case Readable(:final payload) when payload.lengthInBytes > 0) {
if (envelope.keyId case final keyId?) {
_sawSealed(item, keyId);
@ -458,14 +630,15 @@ class Privacy(
return opened;
}
/// [plain] sealed with [keyId] for its [table], row [id] and [column];
/// readable without, and signed where this device can. A sealed row's key
/// is kept as [item]'s.
/// [plain] sealed with [keyId] for its [table], row [id], [column] and
/// the row it is [bound] to ([open]); readable without, and signed where
/// this device can. A sealed row's key is kept as [item]'s.
Envelope seal(
ByteData plain, {
required String? keyId,
required String table,
required String id,
required String bound,
String column = 'payload',
String? item,
}) {
@ -475,6 +648,7 @@ class Privacy(
table: table,
id: id,
column: column,
bound: bound,
);
if (item != null && keyId != null) {
_sawSealed(item, keyId);
@ -515,11 +689,19 @@ class Privacy(
Future<Map<String, int>> unsignedIn(String studysetId) async {
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
final counts = <String, int>{};
void count(String kind, String table, String id, Envelope e) {
void count(
String kind,
String table,
String id,
String bound,
Envelope e,
) {
if (e.keyId != null || e.payload.lengthInBytes == 0) {
return;
}
if (open(e, table: table, id: id) case Readable(trusted: false)) {
if (open(e, table: table, id: id, bound: bound) case Readable(
trusted: false,
)) {
counts[kind] = (counts[kind] ?? 0) + 1;
}
}
@ -528,7 +710,7 @@ class Privacy(
_db,
where: (t) => t.id.inSet(ids) & t.keyId.equals(null),
)) {
count('studysets', 'studyset', r.id!.uuid, (
count('studysets', 'studyset', r.id!.uuid, '', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
@ -545,7 +727,7 @@ class Privacy(
_db,
where: (t) => t.cardId.inSet(cards) & t.keyId.equals(null),
)) {
count('cards', 'card_revision', r.id!.uuid, (
count('cards', 'card_revision', r.id!.uuid, 'card:${r.cardId.uuid}', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
@ -562,7 +744,7 @@ class Privacy(
_db,
where: (t) => t.noteId.inSet(notes) & t.keyId.equals(null),
)) {
count('notes', 'note_revision', r.id!.uuid, (
count('notes', 'note_revision', r.id!.uuid, 'note:${r.noteId.uuid}', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
@ -573,11 +755,17 @@ class Privacy(
where: (t) => t.studysetId.inSet(ids),
);
for (final r in paths) {
count('paths', 'learning_path', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
count(
'paths',
'learning_path',
r.id!.uuid,
'studyset:${r.studysetId.uuid}',
(
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
),
);
}
final steps = {
for (final step in await protocol.LearningPathStep.db.find(
@ -590,17 +778,23 @@ class Privacy(
_db,
where: (t) => t.stepId.inSet(steps) & t.keyId.equals(null),
)) {
count('paths', 'learning_path_step_revision', r.id!.uuid, (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
));
count(
'paths',
'learning_path_step_revision',
r.id!.uuid,
'step:${r.stepId.uuid}',
(
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
),
);
}
for (final r in await protocol.PdfSource.db.find(
_db,
where: (t) => t.studysetId.inSet(ids) & t.keyId.equals(null),
)) {
count('pdfs', 'pdf_source', r.id!.uuid, (
count('pdfs', 'pdf_source', r.id!.uuid, 'studyset:${r.studysetId.uuid}', (
keyId: r.keyId,
nonce: r.nonce,
payload: r.payload,
@ -791,7 +985,7 @@ class Privacy(
final done = Completer<void>();
_rewriting = done.future;
forgetTrees();
final rewrite = (studysetId: studysetId, keyId: keyId);
final rewrite = (studysetId: studysetId, keyId: keyId, adopt: adopt);
ledger.begin(rewrite);
try {
final scope = await scopeOf(studysetId);
@ -979,6 +1173,7 @@ class Privacy(
_Row row(
String table,
String id,
String bound,
String item,
String? rowKey,
ByteData? nonce,
@ -988,9 +1183,10 @@ class Privacy(
return (
table: table,
id: id,
bound: bound,
item: item,
current: current,
opened: open(current, table: table, id: id),
opened: open(current, table: table, id: id, bound: bound),
);
}
@ -1001,6 +1197,7 @@ class Privacy(
'card_revision',
r.id!.uuid,
'card:${r.cardId.uuid}',
'card:${r.cardId.uuid}',
r.keyId,
r.nonce,
r.payload,
@ -1010,6 +1207,7 @@ class Privacy(
'note_revision',
r.id!.uuid,
'note:${r.noteId.uuid}',
'note:${r.noteId.uuid}',
r.keyId,
r.nonce,
r.payload,
@ -1018,6 +1216,7 @@ class Privacy(
row(
'learning_path',
r.id!.uuid,
'studyset:${r.studysetId.uuid}',
'path:${r.id!.uuid}',
r.keyId,
r.nonce,
@ -1028,6 +1227,7 @@ class Privacy(
'learning_path_step_revision',
r.id!.uuid,
'step:${r.stepId.uuid}',
'step:${r.stepId.uuid}',
r.keyId,
r.nonce,
r.payload,
@ -1036,6 +1236,7 @@ class Privacy(
row(
'pdf_source',
r.id!.uuid,
'studyset:${r.studysetId.uuid}',
'pdf:${r.id!.uuid}',
r.keyId,
r.nonce,
@ -1046,6 +1247,7 @@ class Privacy(
row(
'studyset',
r.id!.uuid,
'',
'studyset:${r.id!.uuid}',
r.keyId,
r.nonce,
@ -1056,6 +1258,7 @@ class Privacy(
row(
'studyset',
r.id!.uuid,
'',
'studyset:${r.id!.uuid}',
r.keyId,
r.nonce,
@ -1228,7 +1431,7 @@ class Privacy(
}
await write(
r,
seal(swapped, keyId: keyId, table: r.table, id: r.id),
seal(swapped, keyId: keyId, table: r.table, id: r.id, bound: r.bound),
);
_keepItem(r.item, keyId);
}
@ -1247,11 +1450,13 @@ class Privacy(
continue;
}
final item = 'answers:${answer.stepId.uuid}';
final bound = 'step:${answer.stepId.uuid}';
final opened = open(
current,
table: 'learning_path_answer',
id: answer.id!.uuid,
column: 'answer',
bound: bound,
);
switch (opened) {
case Readable(:final payload, :final trusted)
@ -1267,6 +1472,7 @@ class Privacy(
table: 'learning_path_answer',
id: answer.id!.uuid,
column: 'answer',
bound: bound,
);
written.first = true;
await protocol.LearningPathAnswer.db.updateRow(
@ -1340,8 +1546,13 @@ class Privacy(
final movedCards = {for (final id in cards) _uuid(id)};
final named = <String>{};
var unknown = false;
void add(String table, protocol.UuidValue? id, Envelope envelope) {
switch (open(envelope, table: table, id: id!.uuid)) {
void add(
String table,
protocol.UuidValue? id,
String bound,
Envelope envelope,
) {
switch (open(envelope, table: table, id: id!.uuid, bound: bound)) {
case Readable(:final payload):
named.addAll(assetsIn(payload));
case Locked():
@ -1353,7 +1564,7 @@ class Privacy(
_db,
where: (t) => t.id.notInSet(insideIds),
)) {
add('studyset', row.id, (
add('studyset', row.id, '', (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
@ -1370,7 +1581,7 @@ class Privacy(
_db,
where: (t) => t.cardId.inSet(outsideCards),
)) {
add('card_revision', row.id, (
add('card_revision', row.id, 'card:${row.cardId.uuid}', (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
@ -1387,7 +1598,7 @@ class Privacy(
_db,
where: (t) => t.noteId.inSet(notes),
)) {
add('note_revision', row.id, (
add('note_revision', row.id, 'note:${row.noteId.uuid}', (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
@ -1398,7 +1609,7 @@ class Privacy(
where: (t) => t.studysetId.notInSet(insideIds),
);
for (final row in paths) {
add('learning_path', row.id, (
add('learning_path', row.id, 'studyset:${row.studysetId.uuid}', (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
@ -1415,7 +1626,7 @@ class Privacy(
_db,
where: (t) => t.stepId.inSet(steps),
)) {
add('learning_path_step_revision', row.id, (
add('learning_path_step_revision', row.id, 'step:${row.stepId.uuid}', (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
@ -1425,7 +1636,7 @@ class Privacy(
_db,
where: (t) => t.studysetId.notInSet(insideIds),
)) {
add('pdf_source', row.id, (
add('pdf_source', row.id, 'studyset:${row.studysetId.uuid}', (
keyId: row.keyId,
nonce: row.nonce,
payload: row.payload,
@ -1475,6 +1686,12 @@ class Privacy(
if (!keys.canEncrypt || ledger.damaged) {
return;
}
// Rows that didn't open may open with keys that came since (the final
// reading of 22 Oct).
if (keys.opened != _openedBefore) {
_openedBefore = keys.opened;
_unmovable.clear();
}
for (final rewrite in ledger.pending) {
// One the latest record no longer asks for (another device made the
// studyset private, or readable, since) is dropped, never run over it
@ -1483,25 +1700,38 @@ class Privacy(
ledger.finish(rewrite);
continue;
}
// As it was begun: a sweep's never takes in what nobody signed (the
// final reading of 22 Oct).
await _guarded(
() => rekeyTree(rewrite.studysetId, rewrite.keyId, adopt: true),
() => rekeyTree(
rewrite.studysetId,
rewrite.keyId,
adopt: rewrite.adopt,
),
);
}
await _guarded(_sealPersonal);
forgetTrees();
final parents = await _structure();
final owed = {for (final p in ledger.pending) p.studysetId};
for (final studyset in [...ledger.studysetsWithFacts]) {
final keyId = _privateKeyOf(studyset);
if (keyId == null ||
!parents.containsKey(studyset) ||
!keys.canOpen(keyId)) {
!keys.canOpen(keyId) ||
// A rewrite still owed is finished as it was begun, never taken
// over by a sweep.
owed.contains(studyset)) {
continue;
}
await _guarded(() async {
if (await _needsSweep(studyset, keyId)) {
final need = await _needsSweep(studyset, keyId);
if (need.moves) {
await rekeyTree(studyset, keyId);
} else {
unsealed[studyset] = 0;
// What a sweep can't move still counts (the final reading of 22
// Oct): the tree stays partly readable while any is there.
unsealed[studyset] = need.stuck;
}
});
}
@ -1523,14 +1753,30 @@ class Privacy(
}
/// Whether a row in [studysetId]'s scope isn't sealed with [keyId] and a
/// sweep could move it: readable, or under one of the studyset's own
/// older keys, and not known to be unmovable. Rows are looked up by their
/// key, so a merge costs little (the short reading of 22 Oct).
Future<bool> _needsSweep(String studysetId, String keyId) async {
/// sweep could move it (`moves`): readable, or under one of the
/// studyset's own older keys, and not known to be unmovable; and how many
/// such rows are known to be unmovable (`stuck`), which keep the tree
/// partly readable. Rows are looked up by their key, so a merge costs
/// little (the short reading of 22 Oct).
Future<({bool moves, int stuck})> _needsSweep(
String studysetId,
String keyId,
) async {
final ids = {for (final id in await scopeOf(studysetId)) _uuid(id)};
final older = 's1.$studysetId.%';
bool moves(String table, protocol.UuidValue? id, ByteData payload) =>
payload.lengthInBytes > 0 && !_unmovable.contains('$table|${id!.uuid}');
var stuck = 0;
// Whether the row moves; a stuck one is counted.
bool moves(String table, protocol.UuidValue? id, ByteData payload) {
if (payload.lengthInBytes == 0) {
return false;
}
if (_unmovable.contains('$table|${id!.uuid}')) {
stuck++;
return false;
}
return true;
}
for (final row in await protocol.Studyset.db.find(
_db,
where: (t) =>
@ -1539,7 +1785,7 @@ class Privacy(
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
)) {
if (moves('studyset', row.id, row.payload)) {
return true;
return (moves: true, stuck: stuck);
}
}
final cards = {
@ -1557,7 +1803,7 @@ class Privacy(
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
)) {
if (moves('card_revision', row.id, row.payload)) {
return true;
return (moves: true, stuck: stuck);
}
}
final notes = {
@ -1575,7 +1821,7 @@ class Privacy(
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
)) {
if (moves('note_revision', row.id, row.payload)) {
return true;
return (moves: true, stuck: stuck);
}
}
final paths = await protocol.LearningPath.db.find(
@ -1588,7 +1834,7 @@ class Privacy(
(rowKey != keyId &&
ContentKeyId.parse(rowKey)?.studysetId == studysetId)) &&
moves('learning_path', row.id, row.payload)) {
return true;
return (moves: true, stuck: stuck);
}
}
final steps = {
@ -1606,7 +1852,7 @@ class Privacy(
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
)) {
if (moves('learning_path_step_revision', row.id, row.payload)) {
return true;
return (moves: true, stuck: stuck);
}
}
for (final row in await protocol.PdfSource.db.find(
@ -1617,10 +1863,10 @@ class Privacy(
(t.keyId.like(older) & t.keyId.notEquals(keyId))),
)) {
if (moves('pdf_source', row.id, row.payload)) {
return true;
return (moves: true, stuck: stuck);
}
}
return false;
return (moves: false, stuck: stuck);
}
/// Seals the person's tags and settings with their personal key: once,
@ -1641,10 +1887,17 @@ class Privacy(
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
table: 'tag',
id: tag.id!.uuid,
bound: '',
);
if (opened case Readable(:final payload, :final trusted)
when first || trusted) {
final e = seal(payload, keyId: keyId, table: 'tag', id: tag.id!.uuid);
final e = seal(
payload,
keyId: keyId,
table: 'tag',
id: tag.id!.uuid,
bound: '',
);
wrote = true;
await protocol.Tag.db.updateRow(
_db,
@ -1665,6 +1918,7 @@ class Privacy(
),
table: 'person_settings',
id: settings.id!.uuid,
bound: '',
);
if (opened case Readable(:final payload, :final trusted)
when first || trusted) {
@ -1673,6 +1927,7 @@ class Privacy(
keyId: keyId,
table: 'person_settings',
id: settings.id!.uuid,
bound: '',
);
wrote = true;
await protocol.PersonSettings.db.updateRow(

View file

@ -12,8 +12,11 @@ import 'package:halkyon_core/halkyon_core.dart' show SerialQueue;
typedef TreeFact = ({int at, String? keyId});
/// A rewrite of a tree to another key that hasn't finished on this device:
/// the studyset at its top and the key it goes to (null: readable).
typedef PendingRewrite = ({String studysetId, String? keyId});
/// the studyset at its top, the key it goes to (null: readable), and whether
/// it takes in readable rows nobody signed (only where the person made a
/// readable tree private; never a sweep's, the final reading of 22 Oct), as
/// it was begun, so a resumed one does no more than it was asked.
typedef PendingRewrite = ({String studysetId, String? keyId, bool adopt});
/// Where the device keeps what it has seen of the person's privacy: in their
/// folder, never synced, so the instance can't take it back by withholding
@ -91,6 +94,8 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
final _pending = <PendingRewrite>[];
var _personalSealed = false;
final _settingsVersions = <String, Map<String, int>>{};
final _settingsPerson = <String, int>{};
final _foldedSettings = <String>{};
String? _deviceId;
bool? _registerHasKeys;
@ -131,23 +136,35 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
if (kept['pending'] case final List<Object?> pending) {
for (final item in pending) {
if (item case {'studyset': final String id, 'key': final String? key}) {
_pending.add((studysetId: id, keyId: key));
_pending.add((
studysetId: id,
keyId: key,
adopt: item['adopt'] == true,
));
}
}
}
_personalSealed = kept['personalSealed'] == true;
Map<String, int> vectorOf(Map<String, Object?> vector) => {
for (final MapEntry(:key, :value) in vector.entries)
if (value case final int counter) key: counter,
};
if (kept['settingsVersions'] case final Map<String, Object?> rows) {
for (final MapEntry(key: row, :value) in rows.entries) {
if (value case final Map<String, Object?> vector) {
_settingsVersions[row] = {
for (final MapEntry(:key, :value) in vector.entries)
if (value case final int counter) key: counter,
};
_settingsVersions[row] = vectorOf(vector);
}
}
}
if (kept['settingsPerson'] case final Map<String, Object?> vector) {
_settingsPerson.addAll(vectorOf(vector));
}
if (kept['foldedSettings'] case final List<Object?> folded) {
_foldedSettings.addAll(folded.whereType<String>());
}
_deviceId = kept['device'] as String?;
_registerHasKeys = kept['registerHasKeys'] as bool?;
// A no kept by an earlier version is asked again.
_registerHasKeys = kept['registerHasKeys'] == true ? true : null;
}
/// The latest signed fact seen about [studysetId]'s privacy.
@ -225,28 +242,51 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
}
/// The version vector seen of the settings row [rowId] (each device's
/// count of writes), empty for one never seen.
/// count of writes to the person's settings), empty for one never seen.
Map<String, int> settingsVersionOf(String rowId) =>
Map.unmodifiable(_settingsVersions[rowId] ?? const {});
/// Keeps what [vector] adds to what was seen of the settings row [rowId]:
/// each device's highest count.
/// Whether the settings row [rowId] was ever seen here.
bool sawSettingsRow(String rowId) => _settingsVersions.containsKey(rowId);
/// The highest count of each device seen across all the person's settings
/// rows (the final reading of 22 Oct): one row's news is everyone's.
Map<String, int> get settingsPersonVersion =>
Map.unmodifiable(_settingsPerson);
/// Keeps what [vector] adds to what was seen of the settings row [rowId],
/// and of the person's settings as a whole: each device's highest count.
void sawSettingsVersion(String rowId, Map<String, int> vector) {
var changed = !_settingsVersions.containsKey(rowId);
final seen = _settingsVersions[rowId] ??= {};
var changed = false;
for (final MapEntry(key: device, value: count) in vector.entries) {
if (count > (seen[device] ?? 0)) {
seen[device] = count;
changed = true;
}
if (count > (_settingsPerson[device] ?? 0)) {
_settingsPerson[device] = count;
changed = true;
}
}
if (changed) {
_changed();
}
}
/// Whether the register said the account has keys, as last asked; null
/// before it was.
/// Whether the settings row [rowId] was folded away here, so never read
/// again, whatever the instance hands back (the final reading of 22 Oct).
bool foldedSettings(String rowId) => _foldedSettings.contains(rowId);
/// Keeps that the settings row [rowId] was folded away here.
void foldSettings(String rowId) {
if (_foldedSettings.add(rowId)) {
_changed();
}
}
/// True once the register said the account has keys; null until then (a
/// no is never kept, the final reading of 22 Oct).
bool? get registerHasKeys => _registerHasKeys;
set registerHasKeys(bool? has) {
@ -303,10 +343,12 @@ class PrivacyLedger(final PrivacyLedgerStore _store) {
'items': _items,
'pending': [
for (final p in _pending)
{'studyset': p.studysetId, 'key': p.keyId},
{'studyset': p.studysetId, 'key': p.keyId, 'adopt': p.adopt},
],
'personalSealed': _personalSealed,
'settingsVersions': _settingsVersions,
'settingsPerson': _settingsPerson,
'foldedSettings': [..._foldedSettings],
'device': ?_deviceId,
'registerHasKeys': ?_registerHasKeys,
});

View file

@ -100,6 +100,7 @@ class SyncCardGateway(
(keyId: revision.keyId, nonce: revision.nonce, payload: revision.payload),
table: 'card_revision',
id: revision.id!.uuid,
bound: 'card:${revision.cardId.uuid}',
item: 'card:${revision.cardId.uuid}',
treeKey: treeKey,
)) {
@ -121,6 +122,7 @@ class SyncCardGateway(
keyId: keyId,
table: 'card_revision',
id: id.uuid,
bound: 'card:${cardId.uuid}',
item: 'card:${cardId.uuid}',
);
return protocol.CardRevision(

View file

@ -38,6 +38,7 @@ class SyncLearningPathGateway(
'learning_path',
path.id!.uuid,
(keyId: path.keyId, nonce: path.nonce, payload: path.payload),
bound: 'studyset:${path.studysetId.uuid}',
item: 'path:${path.id!.uuid}',
treeKey: treeKey(path.studysetId.uuid),
),
@ -45,18 +46,21 @@ class SyncLearningPathGateway(
];
}
/// [envelope]'s payload opened as [item]'s in a tree with [treeKey],
/// blank where it doesn't open here, or isn't trusted there.
/// [envelope]'s payload opened as [item]'s, [bound] to the row it
/// belongs to, in a tree with [treeKey]; blank where it doesn't open here,
/// or isn't trusted there.
ByteData _opened(
String table,
String id,
Envelope envelope, {
required String bound,
required String item,
required String? treeKey,
}) => switch (_privacy.openItem(
envelope,
table: table,
id: id,
bound: bound,
item: item,
treeKey: treeKey,
)) {
@ -76,6 +80,7 @@ class SyncLearningPathGateway(
keyId: await _privacy.keyOfStudyset(studysetId),
table: 'learning_path',
id: id.uuid,
bound: 'studyset:$studysetId',
item: 'path:${id.uuid}',
);
final path = await protocol.LearningPath.db.insertRow(
@ -102,6 +107,7 @@ class SyncLearningPathGateway(
keyId: await _privacy.keyOfPath(id),
table: 'learning_path',
id: id,
bound: 'studyset:${path.studysetId.uuid}',
item: 'path:$id',
);
// Only the named columns change (ADR 0001, rule 1).
@ -209,6 +215,7 @@ class SyncLearningPathGateway(
nonce: revision.nonce,
payload: revision.payload,
),
bound: 'step:${revision.stepId.uuid}',
item: 'step:${revision.stepId.uuid}',
treeKey: treeKeyOf(revision.stepId.uuid),
),
@ -299,6 +306,7 @@ class SyncLearningPathGateway(
keyId: keyId,
table: 'learning_path_step_revision',
id: id.uuid,
bound: 'step:${stepId.uuid}',
item: 'step:${stepId.uuid}',
);
return protocol.LearningPathStepRevision(

View file

@ -58,6 +58,7 @@ class SyncPathAnswerGateway(
table: 'learning_path_answer',
id: row.id!.uuid,
column: 'answer',
bound: 'step:${row.stepId.uuid}',
item: 'answers:${row.stepId.uuid}',
treeKey: treeKey,
)) {
@ -96,6 +97,7 @@ class SyncPathAnswerGateway(
table: 'learning_path_answer',
id: id.uuid,
column: 'answer',
bound: 'step:${answer.stepId}',
item: 'answers:${answer.stepId}',
);
return protocol.LearningPathAnswer(

View file

@ -69,6 +69,7 @@ class SyncNoteGateway(
),
table: 'note_revision',
id: revision.id!.uuid,
bound: 'note:${revision.noteId.uuid}',
item: 'note:${revision.noteId.uuid}',
treeKey: switch (studysetOf[revision.noteId.uuid]) {
final studyset? => treeKey(studyset),
@ -116,6 +117,7 @@ class SyncNoteGateway(
keyId: await _privacy.keyOfNote(noteId),
table: 'note_revision',
id: id.uuid,
bound: 'note:$noteId',
item: 'note:$noteId',
);
final revision = await protocol.NoteRevision.db.insertRow(

View file

@ -62,6 +62,7 @@ class SyncPdfSourceGateway(
keyId: await _privacy.keyOfStudyset(studysetId),
table: 'pdf_source',
id: id.uuid,
bound: 'studyset:$studysetId',
item: 'pdf:${id.uuid}',
);
final source = await protocol.PdfSource.db.insertRow(
@ -88,6 +89,7 @@ class SyncPdfSourceGateway(
keyId: await _privacy.keyOfPdf(id, source.studysetId.uuid),
table: 'pdf_source',
id: id,
bound: 'studyset:${source.studysetId.uuid}',
item: 'pdf:$id',
);
// Only the named columns change (ADR 0001, rule 1).
@ -203,6 +205,7 @@ class SyncPdfSourceGateway(
(keyId: source.keyId, nonce: source.nonce, payload: source.payload),
table: 'pdf_source',
id: source.id!.uuid,
bound: 'studyset:${source.studysetId.uuid}',
item: 'pdf:${source.id!.uuid}',
treeKey: treeKey,
)) {

View file

@ -24,6 +24,7 @@ class SyncSettingsGateway(
if (_privacy.openItem(
(keyId: row.keyId, nonce: row.nonce, payload: row.payload),
table: 'person_settings',
bound: '',
id: row.id!.uuid,
item: 'settings:${row.id!.uuid}',
treeKey: null,
@ -50,6 +51,7 @@ class SyncSettingsGateway(
_withCounter(id.uuid, payload, keyId),
keyId: keyId,
table: 'person_settings',
bound: '',
id: id.uuid,
);
final row = await protocol.PersonSettings.db.insertRow(
@ -76,6 +78,7 @@ class SyncSettingsGateway(
_withCounter(id, payload, keyId),
keyId: keyId,
table: 'person_settings',
bound: '',
id: id,
);
// Only the named columns change (ADR 0001, rule 1).
@ -93,6 +96,9 @@ class SyncSettingsGateway(
@override
Future<void> delete(String id) async {
// Folded away here: never read again, whatever the instance hands back
// (the final reading of 22 Oct).
_privacy.foldedSettings(id);
await protocol.PersonSettings.db.deleteWhere(
_db,
where: (t) => t.id.equals(_uuid(id)),

View file

@ -39,6 +39,7 @@ class SyncStudysetGateway(
payload: studyset.payload,
),
table: 'studyset',
bound: '',
id: id,
item: 'studyset:$id',
treeKey: treeKey,
@ -83,6 +84,7 @@ class SyncStudysetGateway(
payload,
keyId: keyId,
table: 'studyset',
bound: '',
id: id.uuid,
item: 'studyset:${id.uuid}',
);
@ -114,6 +116,7 @@ class SyncStudysetGateway(
payload,
keyId: await _privacy.keyOfStudysetRow(id),
table: 'studyset',
bound: '',
id: id,
item: 'studyset:$id',
);

View file

@ -27,6 +27,7 @@ class SyncTagGateway(
if (_privacy.openItem(
(keyId: tag.keyId, nonce: tag.nonce, payload: tag.payload),
table: 'tag',
bound: '',
id: tag.id!.uuid,
item: 'tag:${tag.id!.uuid}',
treeKey: null,
@ -74,6 +75,7 @@ class SyncTagGateway(
payload,
keyId: await _privacy.personalKey(),
table: 'tag',
bound: '',
id: id.uuid,
);
final tag = await protocol.Tag.db.insertRow(
@ -99,6 +101,7 @@ class SyncTagGateway(
payload,
keyId: await _privacy.personalKey(),
table: 'tag',
bound: '',
id: id,
);
// Only the named columns change (ADR 0001, rule 1).