fix(privacy): privacy is a signed fact the instance can't take back (ADR 0019)

The reading of 22 Oct, against an instance that alters data. A data key's
ID now says what it is for (a studyset's key naming it, the person's key,
or a record that a studyset was made readable), and the ID is sealed with
the key and signed by the account, so the instance can't relabel a key or
make one up; the row's own columns are no longer trusted. A studyset is
private while the latest signed record about it says so, and the device
keeps the latest it saw in a ledger in the person's folder (privacy.json),
so a record withheld or a row replayed changes nothing; a tree's key is
the nearest studyset's with such a record, itself or above. An item keeps
the key it was last seen sealed with unless this device moves it or a
signed record makes its tree readable.

A readable row a device holding the keys writes carries the account's
signature in place of a nonce; one nobody signed is locked wherever
privacy holds (in private trees, for items once sealed, and for tags and
settings once sealed), so the instance can't plant content, confirm a
guessed tag name by folding, or switch Count me on. Signed readable rows
from a device that hadn't heard are shown and sealed anew.

Rewrites are kept as under way until done, write the top studyset last,
hold writes into their tree meanwhile, and are finished as the data opens
and after each merge; a sweep seals what a stale device wrote. Cards moved
into a private tree are sealed with their files first; cards of a private
tree are never moved into a readable one. A private topic moved out of
its tree keeps its privacy with a key of its own. Settings carry a counter
inside their sealed payload, and a lower one than seen is refused. The
person's readable tags and settings are sealed once. A device without the
keys of an account that has them writes nothing that would be private and
reports nothing for the learner count; an anonymous person's keys kept but
unreadable are never replaced. Publishing sends only the names of the
tags the release carries. Key refusals are remembered per row.

Tests on the real gateways over a local database: a replayed readable top
row, a withheld key record, a moved parent, a planted revision, a signed
stale revision, card and topic moves, an interrupted rewrite, a settings
rollback, a device without keys, planted tags.

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 09:12:35 +02:00
commit 88e79155bc
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -55,6 +55,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: isPrivate,
locked: locked,
fullyPrivate: true,
keysHere: true,
);
_remoteChanges.add(null);
}
@ -97,6 +99,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: row.updatedAt,
isPrivate: private,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
privacyChanges.add((id, private));
@ -136,6 +140,8 @@ class FakeStudysetGateway() implements StudysetGateway {
null => privateByDefault,
},
locked: false,
fullyPrivate: true,
keysHere: true,
);
writes.add((id, payload));
return id;
@ -159,6 +165,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
writes.add((id, payload));
}
@ -182,6 +190,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
}
@ -205,6 +215,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
moves.add((id, parentId, position));
}
@ -228,6 +240,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
moves.add((id, row.parentId, position));
}
@ -251,6 +265,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
}
@ -273,6 +289,8 @@ class FakeStudysetGateway() implements StudysetGateway {
updatedAt: updatedAt,
isPrivate: row.isPrivate,
locked: row.locked,
fullyPrivate: true,
keysHere: true,
);
}
}