Merge branch 'mantra' into claude/project-feature-plan-b58ab2
Eighteen commits of subgroup adoption and key-ceremony work, and one collision
that was the point of writing Phase 0 down.
**Schema.** `mantra` took 21 for `GroupKeyStateParent` -- a room's parentage as a
row, so it can have two -- as a manual `MIGRATION_20_21` that copies before it
drops. Projects had taken 21 on this branch. Neither had shipped, so projects
renumbered to **22**, an `AutoMigration(from = 21, to = 22)` on top of theirs;
`schemas/21.json` is now theirs and `22.json` is the two together, 58 tables.
docs/project-feature.md records it, including that Phase 0 told us to check for
exactly this and checked the sibling fork rather than the base branch.
Three conflicts. `MantraDatabase.kt` wanted both entity sets and both
migrations, and their v21 contributes only a comment there since the entry is
manual. `ProposedEventTest.kt` and the schema file were additive either way.
Nine files auto-merged, and `ChatMessage.kt` was the one to watch: they reshaped
the `SubgroupBirthCertificateEvent` arm while this branch added three project
arms to the same `when`. Git saw no conflict and the compiler agrees.
**One test fails, and it arrives with the merge rather than from it.**
`NavigationRoutingTest` reports "uncaught exceptions before the test started",
which is leakage, not its own failure: `CeremonyQueueRoutingJvmTest` (new in
b6c3e3dd) builds a `ChatMessageListViewModel` over
`NO_OP_NOSTR_REPOSITORY`, whose `queueNegentropySynchronizeRequest` is a
`TODO()`, and it throws on a background dispatcher. Whichever `runTest` starts
next is blamed, so it is order-dependent -- the same test passes alone and in
smaller selections. This branch touches neither `scheduleSynchronization` nor
`NostrRepository`; its only edit to that view model is the `summarize` call.
1051 jvmTest with that one failure, 482 testDebugUnitTest clean, m3Audit's
budgets all met.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
3cd9001c27
57 files changed
+12661
-1055
No files matched your search
+6
-1
@@ -9,6 +9,7 @@ silent, or a decision that looked arbitrary and was not.
|
||||
| [shared-key-ceremony.md](./shared-key-ceremony.md) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
|
||||
| [shared-key-derivation.md](./shared-key-derivation.md) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
|
||||
| [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived |
|
||||
| [subgroups-multiple-parents.md](./subgroups-multiple-parents.md) | adding a parent to a group that already exists — two signings and no ceremony, why the child's consent is the whole gate, and the certificate a group can truthfully sign about itself |
|
||||
| [frost-batch-signing.md](./frost-batch-signing.md) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
|
||||
| [marmot-membership.md](./marmot-membership.md) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
|
||||
| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
|
||||
@@ -39,7 +40,11 @@ and the one thing they cannot. The
|
||||
jvm-target note is unrelated to all of them: it is a build and packaging story.
|
||||
The subgroups note is a phased plan that has been built; it assumes both
|
||||
shared-key notes and reads as the ceremony's second half — what a group does
|
||||
once it has a key, and what it can say about a group that does not yet.
|
||||
once it has a key, and what it can say about a group that does not yet. The
|
||||
multiple-parents note is a phased plan that has been built, and reads only after
|
||||
it: it takes the one assumption that note never had to state — that a lineage is
|
||||
settled at founding — and finds that dropping it costs less than founding a
|
||||
subgroup does, because no ceremony is involved.
|
||||
The Material Design note is a phased plan that has not been built, and is the
|
||||
only one about what the app looks like rather than what it does; read the
|
||||
jvm-target note first if you want to know why its adaptive-layout phase exists.
|
||||
|
||||
+10
-2
@@ -14,6 +14,7 @@ it belongs to:
|
||||
| the `summarize` resolver is Phase 4 | the seam is Phase 4, the resolution is Phase 7c | two of its four call sites are composables and cannot await a database, so the names are pre-resolved in `FrostSigningViewModel` |
|
||||
| three chronicle tests to add | three to add and four to fix | the existing suite compares a seeded room against `CHRONICLABLE_KINDS` itself, and one test asserts every chroniclable kind can also be *rebuilt* -- which projects deliberately cannot |
|
||||
| around twenty new strings | eighteen, three of them already there | `projects`, `no_projects_exists_in_this_group` and `create_project` came with the stub |
|
||||
| schema 21 | schema 22 | `mantra` took 21 for the subgroup parentage table while this branch was open; Phase 0 said to check for exactly this and checked the sibling fork rather than the base |
|
||||
| the apply arms catch the foreign key violation | they let it out | `ChronicleManager.sweep` retries a page only on a *counted* failure, so catching it loses a membership that crosses a page boundary — and `applySignedEvent`, the reason for catching, already has its own try/catch |
|
||||
|
||||
The one thing found while building that the plan did not anticipate: a project
|
||||
@@ -388,6 +389,13 @@ No code. Settle, in this document:
|
||||
takes 21; check the Curare clone before claiming it, because two trees that
|
||||
both call their next migration 21 is a merge nobody can fix afterwards.
|
||||
|
||||
**This is the one decision the build got wrong, and it checked the wrong
|
||||
tree.** Projects took 21 on their own branch while `mantra` took 21 for the
|
||||
subgroup parentage table (`eba97333`), which is a manual `MIGRATION_20_21`
|
||||
rather than an auto-migration. The collision cost nothing because neither had
|
||||
shipped — projects renumbered to **22** on the way in — but the lesson is that
|
||||
the sibling fork was not the only tree to check. Check the base branch.
|
||||
|
||||
## Phase 1 — three events and their tags
|
||||
|
||||
New, under `press.mantra.compose.nostr.nip30303`:
|
||||
@@ -559,7 +567,7 @@ rather than four events that share a session.
|
||||
regression test against anyone folding them back together;
|
||||
- a `ProjectEvent` case asserting `build` rejects an empty owner set.
|
||||
|
||||
## Phase 2 — three rows, and schema 20 → 21
|
||||
## Phase 2 — three rows, and schema 21 → 22
|
||||
|
||||
New, under `press.mantra.compose.database.model`:
|
||||
|
||||
@@ -651,7 +659,7 @@ and `signedEventsOf` finds it. Adding three more rebuilds would be adding to a
|
||||
mechanism being retired, to serve rows that cannot exist.
|
||||
|
||||
**Schema.** Three tables, nothing existing changes shape, so this is an
|
||||
`AutoMigration(from = 20, to = 21)` entry with a comment in the style of the other
|
||||
`AutoMigration(from = 21, to = 22)` entry with a comment in the style of the other
|
||||
nineteen, plus three entities in `@Database`, three `abstract fun`s on
|
||||
`MantraDatabase`, and three DAOs.
|
||||
|
||||
|
||||
@@ -0,0 +1,803 @@
|
||||
# Adding a parent to a group that already exists
|
||||
|
||||
How a group gains a second parent after it was founded.
|
||||
|
||||
**Built**, five phases, one commit each. The phases below are kept as written
|
||||
because they are the reasoning, and the code reads better against the argument it
|
||||
came from than against a summary of itself. Where the implementation chose
|
||||
differently the section says so, and it did so eight times worth reading:
|
||||
|
||||
| what the note said | what it turned out to be |
|
||||
|---|---|
|
||||
| the 30329 arm "gains a second branch" | a pure `SubgroupManager.classify` in front of it. The arm cannot tell its two callers apart and the decision wanted testing without a database, which `certifies` makes possible -- it checks a signature against a room id and needs no lookup |
|
||||
| nothing about a group certifying itself | **a real gap, found by a test.** A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent. The self-parent guard began inside the offer branch, where it never ran: the child branch catches such a certificate first and reads it as an ordinary child, which would list a group as its own subgroup. It predates this work -- `subgroupsOf` would have shown it |
|
||||
| drop `ChatRoom.parentChatRoomId` | and the `parentChatRoomId` parameter threaded through four files to reach the write that was its only writer. The note counted the column and missed the thread |
|
||||
| `stateFrom` returns a state and a lineage | `LocalGroupKeyState`, a Room read model that is also a plain data class -- so the pure verifier builds one with no database near it, and `@Relation` serves the DAO |
|
||||
| the certificate's roster is the founding roster, frozen | true, and silent about adoption, where there is no founding. The adopting group's admins are asked about a group that *exists*, so the roster and name are its **current** ones -- still frozen the moment they are signed, still never read as live |
|
||||
| the summary says which parents are being dropped | not done, and it cannot be: a summary is pure and a disavowal is a difference against the row this device holds. It belongs to the screen, which has the room, and there is a comment where somebody will look |
|
||||
| `EmptyState` takes a supporting line | it does not. The reason folded into the message, which is the whole content of that absence anyway |
|
||||
| `inComparableGroups` moved to the key extensions | in the fork's tree, not this one, where it is still private to `ChatRoomDetailScreen`. A second copy to add three spaces to an id is not worth having |
|
||||
|
||||
Two things the note got right that are worth keeping if any of this is rewritten.
|
||||
Emitting the parentage tags **pairwise** rather than as two runs, which is the one
|
||||
decision here made entirely for builds that do not exist yet -- and which has a
|
||||
test asserting the tag order, not just the round trip. And `replace` rewriting a
|
||||
lineage rather than merging it, which is what makes a disavowal expressible at all
|
||||
and is the reason that method is a transaction.
|
||||
|
||||
**Not reachable yet:** `proposeDisavowal` is built and tested and has no button.
|
||||
Dropping a parent is a destructive-looking act that wants a confirmation step, and
|
||||
it belongs in its own change.
|
||||
|
||||
Written against the code as it stands at `e15d31b8`.
|
||||
|
||||
Read [subgroups.md](./subgroups.md) first. This extends it and contradicts it in
|
||||
one place — the assumption, never stated because it never had to be, that a
|
||||
lineage is a single link settled at founding.
|
||||
|
||||
Written against the code as it stands at `0be31803` — that is, **after**
|
||||
`subgroups.md`'s Phase 10 moved the child's ceremony into the parent's Marmot room.
|
||||
That move removed two of the costs an earlier draft of this note claimed adoption
|
||||
would avoid and reshaped a third, so the case is made here on what is left. It also
|
||||
makes founding with two parents *harder* — see the
|
||||
[rejected alternatives](#considered-and-rejected).
|
||||
|
||||
## The short answer
|
||||
|
||||
**Adoption is two FROST sessions and no ceremony.**
|
||||
|
||||
```
|
||||
1. birth certificate, in the new parent -> P2's quorum signs C
|
||||
2. key state, in C itself -> C's quorum re-announces, carrying
|
||||
every certificate it accepts
|
||||
```
|
||||
|
||||
That is the whole flow. Compare it with founding, which is four steps:
|
||||
|
||||
| founding a subgroup | adopting an existing group |
|
||||
|---|---|
|
||||
| 1. ChillDKG among the child's admins | — the key already exists |
|
||||
| 2. birth certificate, in the parent | **the same step, unchanged** |
|
||||
| 3. key state, in the parent's room where the ceremony ran | **the same event, in the child's own Marmot room** |
|
||||
| 4. create the Marmot room | — the room already exists |
|
||||
|
||||
Losing step 1 is the point. `subgroups.md` names it as the single biggest
|
||||
practical cost of a subgroup — *"a ChillDKG cannot finish without all `n`… a
|
||||
member who never approves stalls the subgroup at step 1 indefinitely"* — and it
|
||||
is also the last thing standing between a lineage and being an ordinary edit.
|
||||
|
||||
Phase 10 has already paid down what it used to drag in: the NIP-17 sibling room and
|
||||
the member-derived id are gone, and the collision guard is now
|
||||
`DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather
|
||||
than an address that could only be occupied once. So this note claims none of those as
|
||||
savings — only the ceremony and the room creation, which is enough.
|
||||
|
||||
**The whole of `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`,
|
||||
`SelectSubgroupAdminsScreen` and the ritual screen's rung is untouched by this
|
||||
design** — and Phase 10 makes that a stronger statement than it was, because those
|
||||
files have just absorbed a large change that adoption does not have to follow.
|
||||
|
||||
Three things in the existing code make step 2 work as it stands:
|
||||
|
||||
- `GroupKeyStateManager.propose` already permits a room to propose its own key
|
||||
state: `check(chatRoomId == signingRoomId || key.chatRoomId == signingRoomId)`,
|
||||
and for an existing child the first branch holds. Nothing forbids
|
||||
re-announcement today; it is simply never done.
|
||||
- `FrostSigningManager.signingPath` resolves correctly for it. Its first candidate
|
||||
is `GroupKeyStateManager.keyStateFor(chatRoomId)?.pathIndices()`, which for the
|
||||
child is `m/9420/0/0`, and `marmotGroupId(K, path) == chatRoomId` self-checks.
|
||||
The one un-self-checkable case is **not** reached. Phase 10 widened it from
|
||||
`mlsGroupState == null` to `isCeremonyRoom && (mlsGroupState == null ||
|
||||
isSubgroupCeremony)`, so it now admits the parent's room as well as a NIP-17 one —
|
||||
and adoption enters neither arm, because the child's own room is neither the
|
||||
ceremony's room nor its claimed parent.
|
||||
- `GroupKeyStateDao.replace` keeps the newest state per room by `announcedAt`, and
|
||||
`announcedAt` comes off the signed event. So a re-announcement replaces the old
|
||||
lineage cleanly, and an out-of-order arrival is dropped rather than applied.
|
||||
|
||||
## A parent link is two signatures, one from each side
|
||||
|
||||
Worth stating plainly, because the founding flow bundles them and so hides it.
|
||||
|
||||
The certificate is the **parent's** consent: P's quorum saying "C is our child".
|
||||
The parentage tags on the key state are the **child's** consent: C's quorum saying
|
||||
"we accept P". Neither alone is a link. A parent cannot adopt a group that has not
|
||||
agreed, and a group cannot claim a parent that has not signed — which is exactly
|
||||
why `GroupKeyStateManager.parentageOf` drops a state whose certificate does not
|
||||
check out, and why `SubgroupManager.proposeBirthCertificate` runs in the parent's
|
||||
room rather than the child's.
|
||||
|
||||
At founding the two are bundled because the key state has to be signed anyway: it
|
||||
is the founding fact, and the parentage rides along. For an existing group there
|
||||
is no founding fact left to settle, so **step 2 is a key state signed purely as a
|
||||
carrier for the child's consent.** That is the one slightly odd thing about this
|
||||
design and it is worth doing anyway — see [why not a separate adoption
|
||||
kind](#considered-and-rejected).
|
||||
|
||||
Once that is seen, the whole thing collapses to one sentence:
|
||||
|
||||
> A group's lineage is a set of (parent, certificate) pairs carried on its key
|
||||
> state. Adding a parent is: that parent certifies, then the child re-announces
|
||||
> its key state with the pair added. Founding a subgroup is the case where the
|
||||
> set has one member and the key state was being signed anyway.
|
||||
|
||||
## The invariant, and how it differs from founding-time multi-parent
|
||||
|
||||
If two parents certified a child *at founding*, both certificates would be signed
|
||||
at the same moment and could name each other — so a reader could demand that every
|
||||
certificate agree on the whole parent set, and a coordinator could not staple two
|
||||
unrelated certificates into a joint child.
|
||||
|
||||
**That check is not available here, and cannot be made available.** P1 certified C
|
||||
before P2 existed as a possibility. An earlier signature cannot name a later
|
||||
co-parent, and asking P1 to re-certify every time a sibling appears makes every
|
||||
adoption cost a quorum in every existing parent.
|
||||
|
||||
So the invariant has to be weaker, and the honest version is:
|
||||
|
||||
> **The child's key state is the sole authority on the lineage set. Each
|
||||
> certificate is checked individually — this parent signed for this child — and
|
||||
> the set is whatever the child's quorum most recently declared.**
|
||||
|
||||
Which means a parent gets a veto on being *added* and no veto on *who else is in
|
||||
the set*. That is not a compromise; it is the correct answer:
|
||||
|
||||
- Nothing P1 signed becomes false. P1 said "C is our child". C being P2's child
|
||||
as well does not contradict it.
|
||||
- Exclusivity was never expressible and could not be enforced anyway. P2 can
|
||||
certify C whether P1 likes it or not — a certificate is a unilateral statement
|
||||
about a public room id. What P2 cannot do is make C *accept* it.
|
||||
- **The child's consent is the whole gate**, and it should be, because the child
|
||||
is the one that acquires an obligation.
|
||||
|
||||
`parentageOf` therefore grows from four checks to six, and the two new ones are
|
||||
small:
|
||||
|
||||
1. **All or none**, per pair. (Today's rule.)
|
||||
2. Each certificate parses. (Today's.)
|
||||
3. Each `certifies(certificate, child, itsParent)`. **`certifies` does not
|
||||
change** — it is six questions about one signer and one child and it stays
|
||||
that, so `SubgroupBirthCertificateEventTest`'s existing seven cases keep
|
||||
passing untouched.
|
||||
4. Each certificate's `subgroup_key` is the state's own threshold key. (Today's.)
|
||||
5. **No duplicate parents.** A set with a repeat in it is not a set.
|
||||
6. Any failure drops the whole state. (Today's.)
|
||||
|
||||
There is no `co_parent` tag and no cross-certificate agreement check. Both were
|
||||
needed only by the founding-time design, and both are gone.
|
||||
|
||||
### The footgun this creates
|
||||
|
||||
A key state is replaceable and the newest wins **wholesale**. So re-announcing to
|
||||
add P2 must re-emit P1's pair as well, or P1 is silently dropped.
|
||||
|
||||
That is a bad API to leave open. `propose` should take the *resulting* set and
|
||||
read the current one off the row, so that "add P2" is expressed as an addition
|
||||
rather than as a full replacement a caller has to remember to assemble. The same
|
||||
call then expresses removal, which is the next section.
|
||||
|
||||
## What this also unlocks
|
||||
|
||||
Two things fall out of the same mechanism, and neither needs extra work.
|
||||
|
||||
**Any existing robust group can become a subgroup.** Nothing in the two steps
|
||||
requires the child to already have a parent. A top-level group with a key state
|
||||
gets adopted exactly the same way, with the set going from empty to one. That is
|
||||
arguably worth more than multi-parent itself, and it is the same code path.
|
||||
|
||||
**A child can drop a parent.** Re-announce with the pair removed. This partly
|
||||
answers `subgroups.md`'s *"Removing a subgroup is not a thing"* — and only partly,
|
||||
so the limit is worth being precise about:
|
||||
|
||||
- The certificate is forever. Nothing revokes a signature and nothing should
|
||||
pretend to.
|
||||
- But a link is two signatures, and the child's half is replaceable. So the child
|
||||
can stop claiming a parent, and every device holding its key state will stop
|
||||
believing the link.
|
||||
- The parent's own list does **not** follow, because `SubgroupManager.subgroupsOf`
|
||||
reads the parent's certificates, not the child's key state. P1 keeps listing C.
|
||||
- That is fixable in the display without any new protocol: `Subgroup` already
|
||||
carries a `keyState` field, populated where the device holds the child's state.
|
||||
A parent device that also holds C's key state can see that C no longer names it
|
||||
and say so. A parent device that does not hold it cannot, and must not guess.
|
||||
|
||||
## The wire
|
||||
|
||||
### The key state carries pairs, in matched order
|
||||
|
||||
No new tag names. `parent_group` and `birth_certificate` repeat:
|
||||
|
||||
```
|
||||
["parent_group", <P1>]
|
||||
["birth_certificate", <P1's certificate, as JSON>]
|
||||
["parent_group", <P2>]
|
||||
["birth_certificate", <P2's certificate, as JSON>]
|
||||
```
|
||||
|
||||
`SubgroupParentTag.parse` and `SubgroupBirthCertificateTag.parse` already work
|
||||
per-tag; the readers change from `firstNotNullOfOrNull` to `mapNotNull`, and
|
||||
`assembleTags` takes a list instead of a nullable single. That is the entire wire
|
||||
change.
|
||||
|
||||
**The order is load-bearing, and only for old builds.**
|
||||
`GroupKeyStateEvent.parseParentChatRoomId` and `parseBirthCertificate` take the
|
||||
*first* of each, so a build predating this pairs `parent_group[0]` with
|
||||
`birth_certificate[0]` and runs `certifies` on them:
|
||||
|
||||
- emitted **pairwise** — P1, P1's cert, P2, P2's cert — it pairs P1 with P1's
|
||||
certificate, it verifies, and the device files a true but partial lineage.
|
||||
Correct, just less.
|
||||
- emitted **grouped** — both parents, then both certificates — it is coherent only
|
||||
while both lists happen to be in the same order, and silently wrong the moment
|
||||
they are not. Then `certifies` fails and the old build **drops the whole state**,
|
||||
losing the row that says which ceremony's share signs for that room. Recoverable
|
||||
through `completedKey`'s rederivation fallback, and only for default-path rooms.
|
||||
|
||||
So: emit pairwise, sorted by parent id so every device produces the same bytes.
|
||||
Free, and the difference between graceful degradation and an old device quietly
|
||||
losing its key state.
|
||||
|
||||
### The certificate does not change at all
|
||||
|
||||
Kind 30329 keeps its tags, its `certifies`, and its meaning. A certificate signed
|
||||
for a founding subgroup and one signed for an adoption are the same event — which
|
||||
is right, because they say the same thing.
|
||||
|
||||
## The schema
|
||||
|
||||
Much smaller than the founding-time version, because `DkgSession` is not involved.
|
||||
|
||||
**`GroupKeyState`: one-to-many.** The two columns `parentChatRoomId` and
|
||||
`birthCertificateJson` become a table:
|
||||
|
||||
```kotlin
|
||||
@Entity(primaryKeys = ["chatRoomId", "parentChatRoomId"], foreignKeys = [
|
||||
ForeignKey(entity = GroupKeyState::class, parentColumns = ["chatRoomId"],
|
||||
childColumns = ["chatRoomId"], onDelete = ForeignKey.CASCADE)
|
||||
])
|
||||
data class GroupKeyStateParent(
|
||||
val chatRoomId: String,
|
||||
val parentChatRoomId: HexKey,
|
||||
val birthCertificateJson: String,
|
||||
)
|
||||
```
|
||||
|
||||
The composite primary key *is* check (5) — one certificate per (child, parent),
|
||||
enforced by the store rather than by a reader remembering. The cascade is onto
|
||||
`GroupKeyState`, never onto `ChatRoom`, so `subgroups.md`'s argument against a
|
||||
self-referential cascade stands untouched: the parent side stays a plain column
|
||||
that routinely names a room this device does not have.
|
||||
|
||||
The cost is that filing a state stops being one write. `GroupKeyStateDao.replace`
|
||||
has newest-wins logic and would have to become a `@Transaction` that rewrites the
|
||||
parentage rows with the state, or a dropped parent survives the re-announcement
|
||||
that dropped it — which is exactly the operation this design adds.
|
||||
|
||||
**`DkgSession`: untouched.** Adoption opens no ceremony, so it needs no session, no
|
||||
discriminator and no migration there. Phase 10 has just added two columns to that
|
||||
table — `participantPublicKeys`, a sorted comma-joined set matched whole by
|
||||
`getLatestSubgroupSessionFor`, and `subject` — which is worth knowing for two
|
||||
reasons: it is the precedent for the canonical-string shape, and it is the change
|
||||
adoption is glad not to be entangled with.
|
||||
|
||||
**`ChatRoom.parentChatRoomId`: drop it.** It is write-only in production (see
|
||||
[found while reading](#found-while-reading)), and leaving it holding one of
|
||||
several parents makes it a cache that is wrong rather than merely unused.
|
||||
|
||||
Schema **20 → 21**, and the number moves under this note faster than the design does:
|
||||
19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`, and 20 was the
|
||||
several-profiles pull's, putting an owning identity on the queues. So take the version
|
||||
from `MantraDatabase` when this is built rather than from here. One new table, one
|
||||
dropped column, and the existing rows carried across as a single parentage row each.
|
||||
Not an `AutoMigration`; schema 9→10 already has the shape to copy.
|
||||
|
||||
### The zero-migration alternative, and why not
|
||||
|
||||
The parentage columns are already a *cache*: `stateFrom` derives them from the
|
||||
signed 30326 event, and `signedStateFor` can re-read the newest such event for any
|
||||
room at any time. So the lineage could be a derived read — parse the signed event,
|
||||
run the checks, return the set — with no schema change whatsoever.
|
||||
|
||||
Worth knowing it is available, and worth not taking. `signingPath` reads the
|
||||
`GroupKeyState` row on **every signature**, so the row stays either way; a design
|
||||
where the path comes off the row and the lineage comes off the event is two
|
||||
sources for one statement, which is the thing `subgroups.md` spends its appendix
|
||||
refusing. Cache the whole state or cache none of it.
|
||||
|
||||
## Who can actually do this
|
||||
|
||||
The one requirement that does not soften, and it should be stated before the UI is
|
||||
designed around it.
|
||||
|
||||
**Step 1** — proposing the certificate in P2 — needs a P2 admin holding a share of
|
||||
P2's key, *who also knows the child's threshold key*. The certificate's tags carry
|
||||
`subgroup_key`, and without it `certifies` fails at question 4. The child's
|
||||
threshold key lives on the child's `GroupKeyState`, which only the child's members
|
||||
hold. So the proposer is in both groups.
|
||||
|
||||
**Step 2** — proposing the key state in C — needs a member of C holding a share of
|
||||
C's key, *who has P2's certificate*. Certificates are filed as `GroupSignedEvent`
|
||||
rows by `recordSignedEvents`, keyed on the session's room, so only P2's members
|
||||
have one. So this proposer is also in both groups.
|
||||
|
||||
They need not be the same person, and their capabilities differ — step 1 needs
|
||||
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.
|
||||
|
||||
### 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
|
||||
like P2 to adopt us, here is our key" and for P2's admins to act on it. That is a
|
||||
real feature with its own consent questions, and it should not be smuggled in as
|
||||
a helper.
|
||||
|
||||
### The certificate rides on the key state, and needs no storage of its own
|
||||
|
||||
The obvious-looking alternative — file P2's certificate on the child's devices
|
||||
when it arrives — is closed, and the closure is load-bearing:
|
||||
`GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with
|
||||
no P2 room row **cannot** store a P2-signed event at all.
|
||||
|
||||
It does not need to. `GroupKeyStateManager.propose` takes the certificate as an
|
||||
`Event`, not as a row, and once the child's quorum signs the re-announced state
|
||||
the certificate travels inside it — verified standalone by `stateFrom` on every
|
||||
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
|
||||
designing the screen around the wrong list would be the easy mistake.
|
||||
|
||||
### The list is the selector's own rooms, and cannot be anything else
|
||||
|
||||
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 **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)
|
||||
filter { it is a Marmot room whose MarmotGroupData reads }
|
||||
filter { adminPubkeys.contains(userPublicKey) }
|
||||
filter { FrostSigningManager.canSign(database, it.id) }
|
||||
filter { it.id != C }
|
||||
filter { it.id !in C's current parents }
|
||||
filter { it.id is not a descendant of C }
|
||||
```
|
||||
|
||||
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
|
||||
a room this device created.
|
||||
|
||||
**A group cannot be offered as a parent by somebody who is not in it.** That is the
|
||||
overlap requirement from [who can actually do this](#who-can-actually-do-this) showing
|
||||
up as a list length. If nobody in C administers P2, P2 simply does not appear, and the
|
||||
remedy is a person rather than a button.
|
||||
|
||||
### Cost, and where it goes
|
||||
|
||||
`toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`,
|
||||
once per candidate room. `canSign` is cheap for exactly the rooms that pass —
|
||||
`completedKey`'s first branch is a key-state lookup and a session lookup by id, and
|
||||
its expensive fallback (`getKeyHoldingSessions` with a `marmotGroupId` derivation per
|
||||
session) only fires for rooms with no key state, which fail the filter anyway. Its last
|
||||
fallback is now `getLatestOwnSessionForChatRoom` — the same query scoped to
|
||||
`parentChatRoomId IS NULL`, so that a parent hosting its child's ceremony cannot
|
||||
resolve the child's key as its own.
|
||||
|
||||
So the filter is one MLS decode per room and two indexed reads. Fine at twenty rooms
|
||||
and worth building the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization`
|
||||
already builds its prefetch: off the main thread, filling in as it resolves, rather
|
||||
than blocking the screen on the whole list.
|
||||
|
||||
### The selection is an offer, not a decision
|
||||
|
||||
The ordering is forced — the key state carries the certificate, so the certificate is
|
||||
signed first — and that has a consequence worth stating in the UI's own words:
|
||||
|
||||
> **P2's quorum commits first. C's quorum decides second.**
|
||||
|
||||
Whoever picks P2 is not choosing on C's behalf. They are asking P2 to make an offer,
|
||||
and C's admins answer it at step 2 by signing the re-announced key state or not. The
|
||||
real decision point for C is the key-state proposal, which is why
|
||||
[the `ProposedEvent` gap](#consent-on-both-sides) is the most important small change
|
||||
in this design.
|
||||
|
||||
It also means P2's quorum can be spent on an offer that is declined. That is not new —
|
||||
founding has the same ordering, and `proposeBirthCertificate` runs before the child's
|
||||
quorum has signed anything either — but at founding the child's admins have at least
|
||||
*seen* the intent, because `DkgRitualEvents`' `parent_group` tag rides on the ceremony
|
||||
proposal and the ritual screen says which group it is for. Adoption has no ceremony
|
||||
and so no such hint.
|
||||
|
||||
### So C's admins need telling, and the arm to tell them already exists
|
||||
|
||||
Without one, the first thing an admin of C hears about the whole business is a
|
||||
proposal asking for their signature.
|
||||
|
||||
`ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch. Today it calls
|
||||
`SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` — the
|
||||
certificate's parent must be the room it arrived in — and drops anything else. A
|
||||
certificate that instead names *this* room as its subject, and verifies against the
|
||||
parent it names, is the arriving offer:
|
||||
|
||||
| the certificate | the line |
|
||||
|---|---|
|
||||
| signed by this room, about another | "The group made #Translators a subgroup…" (today's) |
|
||||
| about this room, signed by another | "#Ekklesia has offered to adopt this group" |
|
||||
|
||||
Both branches are fully verified and neither needs a lookup: `certifies` checks the
|
||||
signature against a room id, and a room id is a public key. Nothing is stored — the
|
||||
`GroupSignedEvent` foreign key onto `ChatRoom` forbids it, and nothing needs it,
|
||||
because the certificate reaches C's devices for real inside the key state at step 2.
|
||||
The line is notice, not evidence, and should read as an offer rather than as a fact
|
||||
about C's lineage.
|
||||
|
||||
Delivering the certificate into C is then one act by the member who is in both, and it
|
||||
is the same act that gives the step-2 proposer the event they need.
|
||||
|
||||
### Which screen it starts from
|
||||
|
||||
Two defensible entry points, and they produce an identical certificate:
|
||||
|
||||
- **C's detail screen**, beside the parent card: *"add a parent group"*. The child is
|
||||
the one acquiring the relationship, its quorum is the one that ultimately decides,
|
||||
and it mirrors the parent screen's existing *"add subgroup"*.
|
||||
- **P2's detail screen**, beside *"add subgroup"*: *"adopt an existing group"*. This is
|
||||
where the proposal actually lands, and where P2's admins would look for what their
|
||||
group has taken on.
|
||||
|
||||
C's screen is the better default, for the mirroring and because the intent usually
|
||||
originates with the group looking for a parent. P2's is worth adding later; the guards
|
||||
are the same set read in the other direction, and the candidate list becomes "Marmot
|
||||
rooms I hold a key state for", since that is where `subgroup_key` comes from.
|
||||
|
||||
## Consent, on both sides
|
||||
|
||||
The parent's side already works. A P2 admin sees `ProposedEvent`'s 30329 summary —
|
||||
the name and the admin count — and their device checks
|
||||
`marmotGroupId(subgroup_key, frost_path) == content` before they agree. Unchanged.
|
||||
|
||||
**The child's side does not, and this is the gap that has to close.**
|
||||
`ProposedEvent`'s 30326 arm reads:
|
||||
|
||||
```
|
||||
label: "This group's shared key"
|
||||
detail: "m/9420/0/0 · a1b2c3d4e5f6"
|
||||
```
|
||||
|
||||
A child admin approving an adoption would be shown the path and a ceremony id, and
|
||||
nothing whatever about the fact that they are accepting a parent. The proposal is
|
||||
their only chance to check — after it, a quorum has spoken — so the arm has to
|
||||
read the parentage off the tags it is already holding and say which groups are
|
||||
being accepted, and which are being dropped relative to the current row.
|
||||
|
||||
That is the single most important small change in this design. Everything else is
|
||||
plumbing; this is the one place where a member is asked a question and is
|
||||
currently not being told what it is.
|
||||
|
||||
## Guards
|
||||
|
||||
Two new refusal sets, both in the shape `SubgroupManager.refuseSubgroup`
|
||||
already has: checked before the button is offered, and again in the manager.
|
||||
|
||||
**Before proposing a certificate in P2** — the first three are its existing checks,
|
||||
retargeted from "the parent of a new subgroup" to "the adopting group":
|
||||
|
||||
| refused | because |
|
||||
|---|---|
|
||||
| P2 is not a Marmot room with readable group data | no admin list to consult; the same reason the subgroup button is hidden on a NIP-17 room |
|
||||
| this device is not an admin of P2 | a non-admin proposing the group's signature is a proposal the admins must decline by hand |
|
||||
| this device holds no share of P2's key | `proposeSigningBatch` throws, and throwing at a button is not a UI |
|
||||
| this device does not hold C's key state | there is no `subgroup_key` to certify, and a certificate without one fails `certifies` question 4 on every device including this one |
|
||||
| C **is** P2 | a group is not its own parent |
|
||||
| P2 is already among C's parents | that is a re-certification, not an adoption. `proposeBirthCertificate`'s `check(certificateFor(...) == null)` already refuses it; it should say so in those words |
|
||||
| P2 is a descendant of C, as far as this device can see | a cycle. Bounded walk, advisory only — see below |
|
||||
|
||||
**Before proposing the key state in C:**
|
||||
|
||||
| refused | because |
|
||||
|---|---|
|
||||
| this device holds no share of C's key | same reason |
|
||||
| any certificate in the proposed set fails `certifies` | already checked by `propose`; now per pair. Proposing a state this device would drop spends a quorum's attention on nothing |
|
||||
| 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
|
||||
`parentOf` returns a single id, so nothing was tempted to recurse. A cycle is
|
||||
constructible — C's quorum certifies P1 using P1's public threshold key, and P1's
|
||||
quorum re-announces its own key state accepting it, which this design is precisely
|
||||
what makes possible. It takes both quorums cooperating in something pointless, but
|
||||
any ancestor walk must be depth-bounded and visited-set guarded regardless, and
|
||||
the guard above is only as good as what this device can see. `subgroups.md` already
|
||||
notes depth 2 is untested; this makes it worth testing rather than merely noting.
|
||||
|
||||
**A half-adopted state exists between the two steps** — P2 has certified and C has
|
||||
not accepted — and every reader has to render it as an ordinary intermediate rather
|
||||
than as an error. Note it is *invisible* from C's side unless the certificate has
|
||||
been brought across, which means the child's screen cannot show "P2 has certified
|
||||
you" for a member who is not in P2.
|
||||
|
||||
**A re-announcement that forgets an existing parent drops it silently.** Addressed
|
||||
by the API shape above, and worth a test that names it.
|
||||
|
||||
## The UI
|
||||
|
||||
Two rungs on the child's `ChatRoomDetailScreen`, beside the parent card. No new
|
||||
screen, and no new approval UI at all — both proposals are ordinary
|
||||
`FrostSigningEvents.PROPOSAL`s that `ProposalListScreen` and `FrostSigningScreen`
|
||||
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. 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.
|
||||
|
||||
**"Accept #P2 as a parent"** appears once a certificate this device can see has
|
||||
been signed, and proposes the re-announced key state. Offered to any member of C
|
||||
holding a share — not only to whoever started, for the same reason `subgroups.md`
|
||||
gives for the ritual screen's rung. Its wording is the decision, not the mechanism:
|
||||
C is accepting an offer, and the transcript line from the arriving certificate is
|
||||
what it is accepting.
|
||||
|
||||
**The parent card becomes up to N cards.** It reads
|
||||
`ChatRoomDetailUIState.parentChatRoomId` ← `chatRepository.parentOf` ←
|
||||
`GroupKeyState`, so it is a straight plural, and it keeps the rule it has: absent
|
||||
rather than guessed where the state is not held.
|
||||
|
||||
**`SubgroupManager.Subgroup` gains a "still claims us" signal**, off the `keyState`
|
||||
field it already carries. Only where the device holds the child's state; silent
|
||||
otherwise.
|
||||
|
||||
## Tests
|
||||
|
||||
- `SubgroupBirthCertificateEventTest` — **unchanged, and that is the assertion**.
|
||||
`certifies` does not change, so all seven cases including the one whose job is
|
||||
to fail if a roster check is added must keep passing with no edits.
|
||||
- `GroupKeyStateTest` (commonTest, pure) — two parents both good; two parents with
|
||||
one certificate missing → drop; the same parent twice → drop; a single-parent
|
||||
state in today's shape still accepted, which is the compatibility case; and an
|
||||
empty set on a state carrying no parentage tags, which is every group made
|
||||
before this.
|
||||
- A pairing-order test, because the reason for pairwise emission is a build that
|
||||
does not exist yet: assemble a two-parent state, read it with the *old*
|
||||
first-tag-wins parsers, and assert the pair that comes out is coherent.
|
||||
- `SignedGroupKeyStateTest` (jvmTest) — the one that proves the flow. A real child
|
||||
key, a real P1 key and state, a real P2 key, a real certificate by P2 over the
|
||||
child, a real **re-announced** state carrying both, and the assertion that
|
||||
`stateFrom` accepts it and rejects each single-field mutation.
|
||||
- `SubgroupDaoJvmTest` — the new schema round-trips the table, a newer state replaces
|
||||
the parentage rows of an older one, and a re-announcement that drops a parent
|
||||
leaves no row behind.
|
||||
- `SubgroupManagerJvmTest` — the two guard tables, and the candidate filter: a room
|
||||
this device is in but does not administer is not offered as a parent, and neither
|
||||
is one it administers but holds no share of.
|
||||
- `ChatMessageJvmTest` — the 30329 arm's second branch: a certificate about this room,
|
||||
signed by another, writes the offer line; one signed by neither this room nor the
|
||||
parent it names writes nothing.
|
||||
|
||||
## Rollout
|
||||
|
||||
Additive apart from the migration.
|
||||
|
||||
`assembleTags` emits pairs only where a lineage is passed, so every state already
|
||||
signed verifies exactly as it did. The plural parsers read a single pair as a set
|
||||
of one. The new checks fire only on a state carrying more than one pair.
|
||||
`certifies` does not change. **The founding flow is not touched at all** — a
|
||||
subgroup is still founded with one parent, and multi-parent is reached by founding
|
||||
normally and then adopting.
|
||||
|
||||
An older build meeting a two-parent state files a true, partial lineage — one
|
||||
parent, verified — as long as the tags are pairwise. An older build meeting a
|
||||
re-announced *single*-parent state sees an ordinary key state and replaces its row,
|
||||
which is correct.
|
||||
|
||||
The migration is the only non-additive part, and the risk in it is the write path
|
||||
rather than the read: `replace` becoming transactional is where a bug would put a
|
||||
stale parent on a state that no longer claims it.
|
||||
|
||||
Phase 1 (the wire) and phase 2 (the schema) can ship dark. The rest is inert
|
||||
without the two rungs, so the flag, if one is wanted, is again the button.
|
||||
|
||||
## Considered and rejected
|
||||
|
||||
**Founding a subgroup with two parents at once.** The version this question
|
||||
replaced. An earlier draft rejected it on costs Phase 10 has since removed — the
|
||||
member-derived ceremony room and the parent set as a `DkgSession` discriminator — so
|
||||
the argument has to be made again, and it comes out the same way for a better reason.
|
||||
|
||||
**Phase 10 makes founding with two parents harder, not easier.** The ceremony now runs
|
||||
in *the parent's room*, and a child with two parents has two candidate rooms and no
|
||||
principled way to choose: `DkgSession.chatRoomId` is one column, the ceremony's
|
||||
messages ride that room's MLS transport, and the members of the other parent — who may
|
||||
be admins of the child — are not in it to receive them. Running it in both means two
|
||||
ceremonies and two keys, which is two children. Running it in one means the other
|
||||
parent's admins take part in a ceremony held somewhere they cannot see, which is not a
|
||||
thing MLS will do.
|
||||
|
||||
Adoption sidesteps the question entirely by not holding a ceremony, and what remains
|
||||
against founding-with-two is unchanged: it still needs a co-parent tag so certificates
|
||||
can agree on the set, a coordinator who administers both parents, and two quorums
|
||||
answering before the child room exists at all — and it still cannot adopt a group that
|
||||
already exists, which is the case people actually have. Founding with two parents stays
|
||||
reachable: found with one, adopt the second, and the room is a few minutes old instead
|
||||
of a few seconds.
|
||||
|
||||
**A separate adoption kind, leaving the key state alone.** Cleaner-sounding: a new
|
||||
event by the child's quorum saying "we accept P2", so the founding fact is never
|
||||
rewritten. It splits the lineage across a founding key state plus N adoption
|
||||
events, and then a device holding the state but missing adoption #2 has an
|
||||
incomplete lineage **and no way to tell that it is incomplete**. One replaceable
|
||||
statement is always whole or absent. That property is worth more than the
|
||||
tidiness of not re-signing.
|
||||
|
||||
**Filing foreign certificates on the child's devices.** Closed by
|
||||
`GroupSignedEvent`'s foreign key onto `ChatRoom`, and unnecessary: the certificate
|
||||
rides inside the key state.
|
||||
|
||||
**An `exclusive` flag on the certificate**, meaning "we certify C on condition it
|
||||
has no other parent", enforced by dropping any state whose set has more than one
|
||||
member when any certificate claims it. Cheap and checkable, and it hands a group
|
||||
that has gone quiet a permanent veto over a group that has not. If exclusivity is
|
||||
ever wanted it should be a social arrangement, not a tag.
|
||||
|
||||
**Requiring every certificate to agree on the parent set.** Impossible here, and
|
||||
the reason is the interesting part: P1 signed before P2 was a possibility. See
|
||||
[the invariant](#the-invariant-and-how-it-differs-from-founding-time-multi-parent).
|
||||
|
||||
## Found while reading
|
||||
|
||||
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
|
||||
`ChatRoomDao.observeByParentChatRoomId`, whose only callers are tests. So are
|
||||
`GroupKeyStateDao.getByParentChatRoomId`, its `observe` sibling, and
|
||||
`DkgSessionDao.getByParentChatRoomId`. The live reads both go elsewhere —
|
||||
`parentOf` off `GroupKeyState`, `subgroupsOf` off the certificates.
|
||||
|
||||
**`ProposedEvent`'s 30326 arm says nothing about parentage.** It shows the path
|
||||
and the ceremony id. Today that is harmless, because a key state carrying a
|
||||
parentage is only ever proposed at founding, where the proposer is the
|
||||
coordinator who chose it. It stops being harmless the moment a key state can be
|
||||
re-announced.
|
||||
|
||||
**`GroupSignedEvent` cannot hold another group's event**, by its foreign key onto
|
||||
`ChatRoom`. Worth knowing before designing anything that wants to keep evidence
|
||||
about a group this device is not in.
|
||||
|
||||
**The description hint from Phase 6 was never built.**
|
||||
[subgroups.md](./subgroups.md#the-description-carries-the-parent-too-as-a-hint)
|
||||
says `SharedKeyDerivation.describe` gains a `Parent group:` line so a member
|
||||
welcomed into a subgroup after its founding learns it is somebody's child.
|
||||
`describe(purpose, path)` has no such parameter and emits no such line — so the
|
||||
"one member and one problem" that section describes is unaddressed even for one
|
||||
parent. It is also unfixable by this design for a *later* parent, since the
|
||||
description is baked into the epoch-0 group context and adoption happens after it.
|
||||
|
||||
## Rough size
|
||||
|
||||
What it estimated, against what it took:
|
||||
|
||||
| | estimated | actual |
|
||||
|---|---|---|
|
||||
| wire | 1 | 2 — `GroupKeyStateEvent` plus the `SubgroupLineage` type beside it |
|
||||
| schema | 5 | 7 — two new files (`GroupKeyStateParent`, `LocalGroupKeyState`), the migration, two DAOs, `MantraDatabase`, the builder |
|
||||
| managers | 2 | 3 — `MarmotGroupCreation` as well, losing the parameter nothing fed any more |
|
||||
| repositories | 4 | 2 — one interface and one implementation; `DkgRepository`'s changes were return types the note did not count |
|
||||
| text | 2 | 2 |
|
||||
| UI | 5 | 10 — a route, a screen, a view model and a UI state for the picker, plus the detail screen's four and the strings |
|
||||
| tests | 8 | 7 files, 53 new cases |
|
||||
|
||||
39 files, and the founding flow is not among them: a subgroup is still founded with
|
||||
one parent, and the second arrives by adoption.
|
||||
|
||||
454 common tests, 962 jvm tests, `m3Audit` meets every budget.
|
||||
Reference in new issue
Block a user