feat(base,app,learn): a shared space's key rolls once a member goes

What is written in a shared space after a member went takes a key they
never get (Halkyon's ADR 0044, D22). The instance marks the space as one
of its members goes, removed, leaving or deleted (`keyRollDue` on
`shared_space`, set in `removeMember`, through which each of them
passes); an invitation taken back never had a key and marks nothing, nor
does a new role. The Owner's or an Editor's device that next keeps its
keys up after a sync is told so (`sharedSpaceKeys.rollDue`, never true
for a Viewer, who seals no key) and makes a new key, sealed to those who
stay (D33). The first seal bringing a key ID the space never had clears
the mark in the same transaction (D34); members the rolling device
missed get it later as newcomers do. Earlier rows keep the key they had,
which whoever went had read already.

The migration, the same in the module and in each server including it
(Learn, Meditate, Todo, Train):

  ALTER TABLE "shared_space"
    ADD COLUMN "keyRollDue" boolean NOT NULL DEFAULT false;

It adds the column with false for every space there is, so no space is
due a roll from it; PostgreSQL adds a column with a constant default
without rewriting the table. The server before the switch reads and
writes the table naming its own columns, so it keeps working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 16:15:33 +01:00
commit 7f1f07883a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 100883 additions and 6 deletions

View file

@ -46,5 +46,9 @@ class ServerpodSharedKeysGateway(final protocol.Caller _base)
),
];
@override
Future<bool> rollDue(String space) =>
_base.sharedSpaceKeys.rollDue(_id(space));
protocol.UuidValue _id(String id) => protocol.UuidValue.withValidation(id);
}

View file

@ -88,6 +88,11 @@ class SharedKeys(
);
}
/// Whether [space] is due a new key, as a member went since its latest
/// (ADR 0044, D22, D33): a writer's device then makes one and [share]s
/// it. Never for a Viewer.
Future<bool> rollDue(String space) => _gateway.rollDue(space);
/// Seals the keys of [space] that this device holds to the members who
/// lack them: newcomers, and those who have keys only now. Nothing for a
/// Viewer, whom the instance asks for none.

View file

@ -27,4 +27,8 @@ abstract interface class SharedKeysGateway() {
/// The keys of [space] sealed to the person, each naming its sealer.
Future<List<SealedKey>> mine(String space);
/// Whether [space] is due a new key, a member having gone since its
/// latest (ADR 0044, D22); never for a Viewer.
Future<bool> rollDue(String space);
}

View file

@ -36,10 +36,15 @@ class FakeSharedKeysServer() {
handle: handle,
);
/// Ends [person]'s membership of [space], with the keys sealed to them.
/// The spaces due a new key, a member having gone (ADR 0044, D22).
final _rollDue = <String>{};
/// Ends [person]'s membership of [space], with the keys sealed to them;
/// the space is due a new key.
void removeMember(String space, String person) {
_members[space]?.remove(person);
_sealed[space]?.removeWhere((key) => key.to == person);
_rollDue.add(space);
}
/// The keys handed out for [person] from now on, in every space.
@ -110,6 +115,11 @@ class _View(final FakeSharedKeysServer _server, final String _person)
throw StateError('Not allowed');
}
final sealed = _server._sealed[space] ??= [];
// A key the space never had is the one a roll made (D34).
final known = {for (final key in sealed) key.keyId};
if (keys.any((key) => !known.contains(key.keyId))) {
_server._rollDue.remove(space);
}
for (final key in keys) {
if (!members.containsKey(key.person)) {
throw StateError('No such member');
@ -125,6 +135,12 @@ class _View(final FakeSharedKeysServer _server, final String _person)
}
}
@override
Future<bool> rollDue(String space) async {
final members = _server._membersAs(space, _person);
return members[_person]!.writes && _server._rollDue.contains(space);
}
@override
Future<List<SealedKey>> mine(String space) async {
_server._membersAs(space, _person);

View file

@ -193,6 +193,32 @@ void main() {
expect((await alice.sealForNewcomers(space)).sealedTo, isEmpty);
});
test('a member going makes the space due a new key, for writers alone, '
'which the first key it never had ends (D22, D33, D34)', () async {
aliceCipher.newKey('k1');
await alice.share(space, 'k1');
server.addMember(space, 'carol', writes: false);
expect(await alice.rollDue(space), isFalse);
server.removeMember(space, 'bob');
expect(await alice.rollDue(space), isTrue);
expect(
await SharedKeys(
server.as('carol'),
KeyPins(MemoryKeyPinStore()),
FakeContentCipher(),
me: 'carol',
).rollDue(space),
isFalse,
);
await alice.share(space, 'k1');
expect(await alice.rollDue(space), isTrue, reason: 'not a new key');
aliceCipher.newKey('k2');
await alice.share(space, 'k2');
expect(await alice.rollDue(space), isFalse);
});
test("a key of a member's who left opens with the keys pinned for "
'them', () async {
aliceCipher.newKey('k1');