docs: reconcile the adoption note with several profiles and a broadcast button
The seventy-one commits left the subgroup code byte-identical, so the first pass over
this note changed only a schema number. That was too quick a reading. None of what
arrived is *about* subgroups and four pieces of it bear on this design anyway.
**A built, unreachable path would put a birth certificate on public relays.**
`BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member
picks, and its own comment names the property that makes it dangerous here: the
signature is the group's, so any member holding the event can send it and no share of
the key is needed. It is not restricted by kind -- "the seed is the same for every
event of every kind" -- and nothing opens it today only because the rows that carried
the button went with the curated lists. The route is still registered and
`BroadcastButton` still built.
A certificate is the worst event in the app to send: the parent-child lineage, the
child's threshold key, and the founding admin roster in its `p` tags, which is a
membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with
everything that never leaves an encryption, and
`SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable
semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on
the child's room id, so relays would start treating certificates as replacements of
one another. So the note now carries a negative rule: the subgroups list and the parent
cards must never grow a broadcast button. It is also why relays are not the answer to
the overlap requirement, which they otherwise look like -- the price is publishing the
lineage and the roster to anyone, to save one person being in two groups.
**"One person in both groups" has a second reading now, and the mechanism wants
care.** The same human can be a P2 admin under one profile and hold C's share under
another, which satisfies both steps with no single key in both groups. `ChatRoom` is
keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and
the shares all hang off the room -- so the two rooms are two rows with different
holders in one database, and `getById` on the certificate is not scoped by identity.
It works by reading across a boundary the app has already had to defend once: that
commit exists because the Welcome branch could not tell a redelivered Welcome from one
for a room another profile holds, and told nobody.
**And the check this design leans on does not draw that boundary either.**
`DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity
predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and
`parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every
sentence in this note saying "this device holds no share" means the profile in the
chair, and both guard tables now say so. Device-local and pre-existing -- both
profiles' secrets are on one disk, owned by one person, so nothing crosses a user
boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own
look rather than fixed here, since it is load-bearing for every "can this act" check
in the app and not only for these.
**The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md`
now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on
anything reached by `navigate(...)`, because the desktop target has neither a system
back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the
twenty-third.
Checked and needing nothing: the subgroups section and the parent card are still inline
on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open
their own pushed screens, and it is worth knowing that pattern exists, but it took the
group's profile, relays, posts, schemas and entries -- it left the members and the
subgroups where they were, which is where this note puts its two rungs.
Docs only -- no code changed, so there are no test counts to report.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -297,9 +297,46 @@ admin rights in P2, step 2 needs a share of C's key. But **adoption requires at
|
||||
least one person in both groups**, and there is no way around it: the certificate
|
||||
has to cross a boundary that nothing in the app crosses.
|
||||
|
||||
That is still much weaker than founding-time multi-parent, where one coordinator
|
||||
had to administer both parents *and* run a ceremony. And it is a realistic
|
||||
requirement — a group being adopted by another group almost certainly has someone
|
||||
### One human, two profiles, and an unscoped read
|
||||
|
||||
Since several profiles landed, "one person in both groups" has a second reading, and
|
||||
it is the one that will happen first: the same human as a P2 admin under one profile
|
||||
and a holder of C's share under another.
|
||||
|
||||
That satisfies both steps and needs no single key in both groups, which is a real
|
||||
loosening. The mechanism is the part to be careful about. `ChatRoom` is keyed by **the
|
||||
group id alone** — `679d5249`'s message says so, and the MLS state, the DKG sessions
|
||||
and the FROST shares all hang off the room — so the two rooms are two rows with
|
||||
different ids and different `userPublicKey` holders in **one** database. P2's
|
||||
certificate is a `GroupSignedEvent` hanging off P2's room, and
|
||||
`groupSignedEventDao().getById(id)` is not scoped by identity, so the other profile's
|
||||
code can read it.
|
||||
|
||||
So it works, and it works by reading across a boundary the app has already had to
|
||||
defend once: `679d5249` exists because the Welcome branch could not tell a redelivered
|
||||
Welcome from one for a room another profile holds, and told nobody.
|
||||
|
||||
**And the key check this design leans on does not draw that boundary either.**
|
||||
`DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` is
|
||||
`WHERE thresholdPublicKey IS NOT NULL AND secretShare IS NOT NULL` with no identity in
|
||||
it, and `getLatestOwnSessionForChatRoom` is scoped only by room and
|
||||
`parentChatRoomId IS NULL`. So `FrostSigningManager.canSign` answers a **device-wide**
|
||||
question — this device holds a share — where every sentence in this note that says
|
||||
"this device holds no share of P2's key" means the profile in the chair.
|
||||
|
||||
That is pre-existing and device-local: both profiles' shares are already on one disk,
|
||||
owned by one person, so nothing crosses a user boundary. What it costs is profile
|
||||
isolation, which is the point of having profiles at all — and it is why **a step-2
|
||||
proposer must take the certificate from a source scoped to the profile that is
|
||||
acting**, rather than from whatever the database happens to hold. Otherwise adoption
|
||||
is a feature that silently works better when you own both sides, which is the shape of
|
||||
a leak rather than of a convenience. Worth its own look, not this note's to fix.
|
||||
|
||||
### Still weaker than founding-time multi-parent
|
||||
|
||||
Where one coordinator had to administer both parents *and* run a ceremony. And it is a
|
||||
realistic requirement — a group being adopted by another group almost certainly has
|
||||
someone
|
||||
in both.
|
||||
|
||||
What would remove it is a cross-group request: a way for C to publish "we would
|
||||
@@ -320,6 +357,36 @@ the certificate travels inside it — verified standalone by `stateFrom` on ever
|
||||
child device, exactly as at founding. The proposer needs the event; nobody else
|
||||
does.
|
||||
|
||||
### The one channel that must not be used, and it is already built
|
||||
|
||||
There is now a path that would carry a certificate out of the encryption, and it is
|
||||
dormant rather than absent. `BroadcastGroupSignedEventViewModel` sends one
|
||||
group-signed event to relays a member picks, and its own comment states the property
|
||||
that makes it dangerous here: *"the signature is the group's, so any member holding
|
||||
the event can send it and no share of the key is needed."* It is **not** restricted by
|
||||
kind — "the seed is the same for every event of every kind" — and nothing opens it
|
||||
today only because the rows that carried the button went with the curated lists.
|
||||
`BroadcastGroupSignedEventRoute` is still registered and `BroadcastButton` still
|
||||
built, so, in that file's words, wiring a button in is all it takes.
|
||||
|
||||
A certificate is the worst event in the app to send. It carries the parent-child
|
||||
lineage, the child's threshold key, and the founding admin roster in its `p` tags —
|
||||
a membership list — and `subgroups.md` places kind 30329 "in the private 303xx range
|
||||
with everything else that never leaves an encryption".
|
||||
`SubgroupBirthCertificateEvent`'s own comment leans on that: because it only ever
|
||||
exists inside an encryption a relay cannot open, "the addressable semantics of the
|
||||
3xxxx range never fire". Published, they fire — it is `d`-tagged on the child's room
|
||||
id, so relays would treat certificates as replacements of one another, which is a
|
||||
thing this design settles locally and on purpose.
|
||||
|
||||
**So this design carries a negative rule: the subgroups list and the parent cards must
|
||||
never grow a broadcast button**, and a certificate must not be reachable from whatever
|
||||
screen a future "broadcast this" sweep walks. It is also why relays are not the answer
|
||||
to the overlap requirement above, tempting as it looks: a P2 member could publish the
|
||||
certificate and a C member could fetch and verify it — `certifies` needs no lookup —
|
||||
but the price is publishing the lineage and the roster to anyone, to save one person
|
||||
being in two groups.
|
||||
|
||||
## Choosing the parent
|
||||
|
||||
Worth its own section, because "pick a group" is not what the picker can offer and
|
||||
@@ -329,8 +396,10 @@ designing the screen around the wrong list would be the easy mistake.
|
||||
|
||||
A certificate is proposed **inside** the adopting group, by
|
||||
`FrostSigningManager.proposeSigning` in that room. So the candidate list is not "the
|
||||
groups that exist" or "the groups C's admins can name" — it is the rooms *this
|
||||
device* can already act in:
|
||||
groups that exist" or "the groups C's admins can name" — it is the rooms **the active
|
||||
profile** can act in. Not the device's: `getChatRoomListByUserPublicKey` is keyed by
|
||||
identity, and since several profiles landed a device can hold rooms it must not offer
|
||||
here, because the key that would have to sign is not the one in the chair.
|
||||
|
||||
```
|
||||
getChatRoomListByUserPublicKey(user)
|
||||
@@ -342,8 +411,8 @@ getChatRoomListByUserPublicKey(user)
|
||||
filter { it.id is not a descendant of C }
|
||||
```
|
||||
|
||||
The first three are `SubgroupManager.refuseSubgroup`'s existing checks with the
|
||||
target moved from "the parent of a new subgroup" to "the adopting group", and the
|
||||
The first three filters are `SubgroupManager.refuseSubgroup`'s existing checks with
|
||||
the target moved from "the parent of a new subgroup" to "the adopting group", and the
|
||||
admin one reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for
|
||||
the reason that function already gives: the flag is written only by
|
||||
`processGroupMembershipChanges` on an arriving commit, so it is null for everybody on
|
||||
@@ -483,6 +552,11 @@ retargeted from "the parent of a new subgroup" to "the adopting group":
|
||||
| the set has a duplicate parent | check (5), at the door rather than only on read |
|
||||
| the set is unchanged from the current row | nothing to agree |
|
||||
|
||||
**"This device" in both tables means the profile in the chair**, and `canSign` does
|
||||
not currently tell the two apart — see
|
||||
[one human, two profiles](#one-human-two-profiles-and-an-unscoped-read). Until it
|
||||
does, these guards are honest about capability and silent about persona.
|
||||
|
||||
## New failure modes
|
||||
|
||||
**A lineage is a DAG now, so a walk can loop.** With one parent it is a tree and
|
||||
@@ -512,7 +586,10 @@ already show and approve.
|
||||
|
||||
**"Add a parent group"** opens the picker described under [choosing the
|
||||
parent](#choosing-the-parent) and proposes the certificate in whichever group is
|
||||
chosen. Offered only where step 1's guards pass, hidden rather than disabled, in the
|
||||
chosen. The picker is a pushed screen, so it takes an `onNavigateBack` and puts
|
||||
`NavigateBackButton` in its app bar's leading slot — `CLAUDE.md` gained that rule
|
||||
while this note sat unbuilt, and the desktop target has neither a system back nor an
|
||||
edge swipe to fall back on. Offered only where step 1's guards pass, hidden rather than disabled, in the
|
||||
same way the shared-key entry is hidden on an MLS room — and empty for a member who
|
||||
administers none of C's candidate parents, which is a fact about them rather than a
|
||||
failure.
|
||||
@@ -631,7 +708,26 @@ the reason is the interesting part: P1 signed before P2 was a possibility. See
|
||||
|
||||
## Found while reading
|
||||
|
||||
Four things, none caused by this, all worth knowing.
|
||||
Six things, none caused by this, all worth knowing.
|
||||
|
||||
**A built, unreachable path would put any group-signed event on public relays.**
|
||||
`BroadcastGroupSignedEventViewModel`, not restricted by kind, reachable by wiring in
|
||||
one button. Argued above as the
|
||||
[channel that must not be used](#the-one-channel-that-must-not-be-used-and-it-is-already-built);
|
||||
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.
|
||||
|
||||
**`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.
|
||||
|
||||
**`ChatRoom.parentChatRoomId` is write-only in production.**
|
||||
`MarmotGroupCreation` sets it; its only reader is
|
||||
|
||||
Reference in New Issue
Block a user