feat(dkg): schema 22 -- a ritual row belongs to a profile, not to a device

`DkgSession.id` is minted by its coordinator and carried on every message, so it
is one string for every participant -- and two profiles on one device are two
participants. Keyed by that id alone there was one row for both of them:
`acceptProposal` asked "do I know this ceremony?", found the row the first
profile wrote, and returned it. The second then ran the ritual out of the first's
row: the first's host key, the first's randomness, and in the end the first's
share, while the protocol had them as two members with two of each.

`679d5249` refuses the second profile's *Welcome*, so this is unreachable for a
Marmot group. A NIP-17 room has no Welcome and no equivalent check, and a group's
own key ceremony is held in exactly such a room, so it was reachable where it
matters most -- at the founding.

**The key is the rule.** `(id, userPublicKey)` is "one row per participant per
ceremony", and `DkgParticipantMessage` gains the receiving profile for two
reasons: a message is an input to *that* profile's recomputation, alongside its
own randomness, and a foreign key needs a unique parent to point at -- a child
keyed by the ceremony alone could not reference the new parent at all, and
deleting a room would leave its rituals' messages behind.

Reads and key are one change, not two. Every query in `DkgSessionDao` now names a
profile, because with this key a ceremony id or a room identifies no row -- it
would return whichever the database ordered first, and hand back that profile's
host key, randomness and share. `completedKey` and `canSign` take the asking
profile and scope all three of their answers by it; `DatabaseDkgRepository` joins
the chat and signing repositories in taking `activeIdentity` at construction, so
a screen asking about "the ceremony" means its own and no call site can forget.

One read names no profile and says so: `getCeremonyThresholdPublicKey` projects a
single public column, the same value on every participant's row, for the two
flows that want the room a ceremony's key derives. Scoping it would make a screen
resolve nothing while no profile is open, for a fact that does not depend on one.

The migration copies both tables into backups holding no foreign key before
dropping anything, which is what makes it safe rather than the drop order:
`DROP TABLE` fires cascades, and whether Room left them enabled is not a thing to
depend on. `DkgSessionPerProfileMigrationJvmTest` runs with foreign keys **on**
and passes with the two drops in either order, which is the evidence.

Schema 22 rather than 21: the subgroups-multiple-parents line allocated 21 while
this was being written, and the rule in docs/curated-to-mantra-profiles.md is that
a version belongs to whichever tree migrates first. 21 leaves both ritual tables
untouched, so the rebuild's DDL is unchanged apart from the versions it runs
between.

`TwoProfilesInOneCeremonyJvmTest` drives both profiles through one ceremony in one
database; all five of its tests fail against the old lookup.
`ProfileScopedSigningKeyJvmTest` covers `completedKey`'s three routes and the
repository seam the UI reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 19:52:26 +02:00
parent a14ea3d9d3
commit 3c8489ad66
26 changed files with 7508 additions and 153 deletions

View File

@@ -736,7 +736,8 @@ the reason is the interesting part: P1 signed before P2 was a possibility. See
## Found while reading
Six things, none caused by this, all worth knowing.
Seven things, none caused by this, all worth knowing. Two have since been fixed,
and say so.
**A built, unreachable path would put any group-signed event on public relays.**
`BroadcastGroupSignedEventViewModel`, not restricted by kind, reachable by wiring in
@@ -745,17 +746,33 @@ one button. Argued above as the
listed again here because it is a fact about the tree rather than about this design,
and the next person to wire a broadcast button will not be reading this note.
**`ChatRoom` is keyed by the group id alone, not by (group, profile).** So two
profiles on one device cannot both hold one group — the second one's Welcome is
refused by `679d5249` — while rows belonging to different profiles sit in one database
with nothing scoping a read by id across them.
**`ChatRoom` is keyed by the group id alone, not by (group, profile).** Rows belonging
to different profiles sit in one database with nothing scoping a read by id across
them. `679d5249` refuses the second profile's *Welcome*, so two of them cannot both
hold one **Marmot** group — but a NIP-17 room has no Welcome and no equivalent check,
and a group's own key ceremony is held in exactly such a room. So two profiles both
taking part in one ceremony was reachable, and the room is still keyed by the group id.
**`FrostSigningManager.canSign` is device-wide, not profile-wide.**
`getKeyHoldingSessions()` carries no identity predicate although `DkgSession` has the
column, so the active profile can resolve a key whose share another profile
generated. Device-local and pre-existing, and a profile-isolation gap rather than a
cross-user one, but it is load-bearing for every "can this act" check in the app and
not only for the ones here.
**`FrostSigningManager.canSign` was device-wide, not profile-wide — fixed.**
`getKeyHoldingSessions()` carried no identity predicate although `DkgSession` had the
column, so the active profile could resolve a key whose share another profile
generated. Device-local and a profile-isolation gap rather than a cross-user one, but
load-bearing for every "can this act" check in the app and not only for the ones here.
`completedKey` and `canSign` now take the asking profile and scope all three of their
answers by it; `ProfileScopedSigningKeyJvmTest` covers the three routes and the
repository seam the UI reads.
**`DkgSession` was keyed by the ceremony alone — fixed, schema 22.** The id is minted
by the coordinator and travels on the wire, so it is one string for every participant,
and two profiles are two participants: `acceptProposal` returned the row the first of
them wrote and the second ran the ritual out of it, behind the first's host key, its
randomness and in the end its share. The key is `(id, userPublicKey)` now, and
`DkgParticipantMessage` carries the receiving profile for the same reason — a message
is an input to *that* profile's recomputation, and the cascade needs a unique parent to
point at. `TwoProfilesInOneCeremonyJvmTest` runs both profiles through one ceremony in
one database; `DkgSessionPerProfileMigrationJvmTest` covers the rebuild with foreign
keys on, which is the setting under which a mis-ordered drop would take the messages
with it.
**`ChatRoom.parentChatRoomId` is write-only in production.**
`MarmotGroupCreation` sets it; its only reader is