From 26fef1f3319c566dfc2acc706d22ffc069e4481e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:42:59 +0200 Subject: [PATCH 01/15] docs: research a second parent, and find it is an adoption rather than a founding `docs/subgroups.md` never had to say that a lineage is settled at founding, because with one parent there was no other moment it could be settled at. This asks what it would cost to drop that, and the answer turns on which question is being asked. Asked as "found a subgroup with two parents at once", it is expensive: the ceremony room is derived from its admins, so the parent set has to become a canonical discriminator on `DkgSession`; the certificates are signed at the same moment so they can name each other, which needs a `co_parent` tag; one coordinator has to administer *both* parents and run the ChillDKG; and two quorums have to answer before the child room can be created at all. Around thirty files. Asked as "add a parent to a group that already exists", it is two FROST sessions. **The certificate step is unchanged, and the other three vanish.** Founding is four steps -- ChillDKG, certificate, key state, create the room. Adopting keeps only the middle one and follows it with a re-announcement of the child's key state, in the child's own Marmot room, carrying every certificate it accepts. The key already exists, so there is no ceremony; the room already exists, so there is nothing to create. Losing the ceremony is what makes this cheap: it is also what drags in the NIP-17 ceremony room, the member-derived id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator, and none of it is reachable from here. **Three existing code paths already permit it, unchanged.** `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)` -- the first branch holds for an existing child, so a room may already propose its own key state and simply never does. `FrostSigningManager.signingPath` resolves off the child's own key state, its first candidate, and so never reaches the `mlsGroupState == null` special case that `docs/subgroups.md` flags as the one un-self-checkable path in the app. `GroupKeyStateDao.replace` is newest-wins on the signed event's timestamp, so a re-announcement replaces the old lineage and an out-of-order arrival is dropped rather than applied. **A parent link is two signatures, one from each side, and the founding flow hides it.** The certificate is the parent's consent; the parentage tags on the key state are the child's. Neither alone is a link, which is why `parentageOf` drops a state whose certificate does not check out and why `proposeBirthCertificate` runs in the parent's room. At founding the two are bundled because the key state has to be signed anyway. For an existing group there is no founding fact left to settle, so the second signing is a key state signed purely as a carrier -- the one slightly odd thing here, and still better than the alternative, because splitting lineage across a founding state plus N adoption events leaves a device that missed one with an incomplete lineage and no way to know it is incomplete. **The invariant had to weaken, and the reason is the interesting part.** Certificates signed at the same moment can be required to agree on the whole parent set, which is what stops two unrelated certificates being stapled into a joint child. That check is not available here and cannot be made available: P1 signed before P2 was a possibility, and re-certifying in every existing parent on every adoption is not a cost worth paying. So the set is whatever the child's quorum most recently declared -- a parent has a veto on being added and none on who else joins. That is correct rather than a compromise. Nothing P1 signed becomes false, exclusivity was never expressible, and P2 can certify unilaterally regardless; what P2 cannot do is make the child accept it. The child's consent is the whole gate, and the `co_parent` tag disappears with the check that wanted it. **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, so `DkgSession`, `ChillDkgRitualManager`, `MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung are all outside this. Two dozen files rather than thirty, delivering strictly more -- because the founding-time design cannot adopt a group that already exists, which is the case people actually have. **Two things fall out of the same mechanism.** Any existing robust group can become a subgroup, the set going from empty to one. And a child can drop a parent by re-announcing without it -- which answers `docs/subgroups.md`'s "removing a subgroup is not a thing" only halfway, and the note says which half: the certificate is forever, and `subgroupsOf` reads the parent's own certificates, so the parent keeps listing a child that no longer claims it. Displayable without new protocol through the `keyState` field `SubgroupManager.Subgroup` already carries, and only on a device that holds it. **The one change that is not plumbing is `ProposedEvent`'s 30326 arm.** It shows the derivation path and a ceremony id. That is harmless today, because a key state carrying a parentage is only ever proposed at founding by the coordinator who chose it. The moment a key state can be re-announced, a child admin is being asked to accept a parent and is shown a path -- and the proposal is their only chance to check, since after it a quorum has spoken. The parent's side already reads correctly; the child's does not. **Four things found while reading, none caused by this.** `ChatRoom.parentChatRoomId` is write-only in production -- `MarmotGroupCreation` sets it and its only reader's only callers are tests, as is true of three other parent-indexed DAO reads, while the live answers go through `parentOf` and `subgroupsOf`. `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device cannot file a certificate signed by a group it is not in -- which closes the obvious "store the foreign certificate" approach and confirms the certificate must ride inside the key state, as it already does. The `ProposedEvent` gap above. And the description hint from Phase 6 of `docs/subgroups.md` was never built: `describe(purpose, path)` takes no parent and emits no `Parent group:` line, so the late-joiner problem that section describes is unaddressed even for one parent -- and is unfixable by adoption, since the description is baked into the epoch-0 group context and an adoption happens after it. The note also records what was rejected and why: founding with two parents at once (above), a separate adoption kind (splits the lineage), filing foreign certificates (closed by the foreign key), an `exclusive` flag on the certificate (hands a group that has gone quiet a permanent veto over one that has not), and requiring all certificates to agree on the set (impossible, per the invariant). Sizing is a dozen files across wire, schema, managers, repositories and UI, plus the `ProposedEvent` arm and six tests. `SubgroupBirthCertificateEventTest` is expected to pass **unchanged**, and that is itself the assertion: `certifies` stays six questions about one signer and one child, so the case whose job is to fail if a roster check is ever added must keep passing with no edits. `docs/README.md` gains the row and a line in the reading order, after the subgroups note it depends on. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 --- docs/README.md | 7 +- docs/subgroups-multiple-parents.md | 522 +++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 docs/subgroups-multiple-parents.md diff --git a/docs/README.md b/docs/README.md index 39176782..a1ab27f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 one screen that currently asks without saying what it is asking | | [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 | @@ -31,7 +32,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 research rather than a plan, 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. diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md new file mode 100644 index 00000000..a6453de8 --- /dev/null +++ b/docs/subgroups-multiple-parents.md @@ -0,0 +1,522 @@ +# Adding a parent to a group that already exists + +What it would take for a group to gain a second parent after it was founded. +Research, not a plan: nothing here has been built. + +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 `45cc80b5`. + +## 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 NIP-17 ceremony room | **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 what drags in the NIP-17 ceremony room, the member-derived room id, the +collision guard, and `DkgSession.parentChatRoomId` as a discriminator. None of +that is reachable from here. **The whole of `DkgSession`, `ChillDkgRitualManager`, +`MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung +is untouched by this design.** + +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 `mlsGroupState == null` special case — the one `subgroups.md` flags as the + only un-self-checkable path in the app — is **not** reached, because the child's + room exists. +- `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", ] +["birth_certificate", ] +["parent_group", ] +["birth_certificate", ] +``` + +`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.** No discriminator, no canonical joined string, no +migration. This is the largest single saving over founding-time multi-parent. + +**`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 **18 → 19**: 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. + +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 +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. + +## 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.refuseCeremonyRoom` +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 | + +## 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 a picker of candidate rooms: the Marmot rooms this +device administers and can sign in. Choosing one proposes the certificate there. +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. + +**"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. + +**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` — schema 19 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. + +## 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, and worth recording why the incremental framing wins. It needs the +ceremony's parent set to become a canonical discriminator on `DkgSession`, a +co-parent tag on the certificate so all certificates can agree on the set, a +coordinator who administers *both* parents, two quorums answering before the child +room can be created at all, and a widened `refuseCeremonyRoom`. Roughly thirty +files against this design's dozen — and it delivers strictly less, because it +cannot adopt a group that already exists, which is the case people actually have. +Founding with two parents remains reachable afterwards: 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 + +Four things, none caused by this, all worth knowing. + +**`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 + +| | files | notes | +|---|---|---| +| wire | 1 | `GroupKeyStateEvent` — list instead of nullable single, in three functions | +| lineage type | 2 | `SubgroupParentage` kept as one pair; a `SubgroupLineage` holding the set and its invariants | +| schema | 5 | `GroupKeyState`, a new entity, `GroupKeyStateDao` (transactional `replace`), `ChatRoomDao`, `MantraDatabase` + migration | +| managers | 2 | `GroupKeyStateManager` (`parentageOf`, `propose`), `SubgroupManager` (`parentsOf`, the two guard sets, adoption proposals) | +| repositories | 4 | two interfaces, two implementations, mostly signature plurals | +| text | 1 | `ProposedEvent`'s 30326 arm — the consent fix | +| UI | 5 | `ChatRoomDetailUIState`, its view model and screen, a parent picker, strings | +| tests | 6 | | + +Around two dozen files, and the founding flow is not among them. From 7d54fcdc34761f00c30e99cc5db0b07fc3feb001 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:46:40 +0200 Subject: [PATCH 02/15] docs: say which rooms an adoption picker can offer, and who is actually deciding The note said "add a parent group opens a picker of candidate rooms" and left the list unspecified, which is the easy place to design the wrong screen. It is not the groups that exist, and not the groups C's admins can name. **A certificate is proposed inside the adopting group, so the list is the selector's own rooms.** `FrostSigningManager.proposeSigning` runs in P2, so a candidate has to be a Marmot room this device already administers and holds a share of -- which is `SubgroupManager.refuseCeremonyRoom`'s first three checks with the target moved, plus C itself, C's existing parents and C's descendants removed. The admin test 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 on a room this device created it is null for everybody. So a group cannot be offered as a parent by somebody who is not in it. The overlap requirement the note already states turns up here as a list length, and an empty picker is a fact about the member rather than a failure -- the remedy is a person, not a button. **The filter costs one MLS decode per room and two indexed reads.** `toMlsGroup()` deserialises the whole group state out of hex to reach `adminPubkeys`. `canSign` is cheap for exactly the rooms that pass, because `completedKey`'s first branch is a key-state lookup and a session lookup by id; its `getKeyHoldingSessions` fallback, with a `marmotGroupId` derivation per session, fires only for rooms with no key state, which fail the filter anyway. Built the way `SelectChatRoomMembersViewModel.scheduleKeyPackageEventSynchronization` builds its prefetch -- off the main thread, filling in as it resolves. **Picking a parent is making an offer, not taking a decision.** The ordering is forced -- the key state carries the certificate -- so P2's quorum commits first and C's quorum answers second. Whoever picks P2 is not choosing on C's behalf; C's admins decide at step 2 by signing the re-announced key state or not. That is why the `ProposedEvent` 30326 arm is the most important small change in the design, and the note now says so from this direction too. **And it exposes a gap the founding flow does not have.** Founding has the same ordering -- `proposeBirthCertificate` runs before the child's quorum has signed anything -- but a subgroup's admins have at least *seen* the intent, because `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group. Adoption has no ceremony and so no hint, which means the first thing an admin of C would hear is a request for their signature. `ChatMessage.applyInnerEvent`'s 30329 arm should grow a second branch for it. Today it calls `SubgroupManager.record`, which asks `certifies(event, subgroup, chatRoomId)` -- the certificate's parent must be the room it arrived in -- and drops everything else. A certificate that instead names *this* room as its subject, and verifies against the parent it names, is the arriving offer and gets its own line: "#Ekklesia has offered to adopt this group", beside today's "The group made #Translators a subgroup". Both branches are fully verified and neither needs a lookup, since `certifies` checks a signature against a room id and a room id is a public key. Nothing is stored -- `GroupSignedEvent`'s 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 must read as an offer rather than as a fact about C's lineage. **Two entry points are defensible and they produce an identical certificate.** C's detail screen beside the parent card, mirroring the parent screen's "add subgroup"; or P2's beside that button, where the proposal actually lands. C's is the better default, because the intent originates with the group looking for a parent and its quorum is the one that decides. P2's is worth adding later, with the same guards read in the other direction and a candidate list of "Marmot rooms I hold a key state for", since that is where `subgroup_key` comes from. Two tests follow: the candidate filter in `SubgroupManagerJvmTest` -- a room this device is in but does not administer is not offered, and neither is one it administers but holds no share of -- and the new arm's branch in `ChatMessageJvmTest`. Sizing moves from six tests to eight and from one text file to two. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 --- docs/subgroups-multiple-parents.md | 134 +++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 8 deletions(-) diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index a6453de8..da4b2a9e 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -299,6 +299,115 @@ 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. +## 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 *this +device* can already act in: + +``` +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 are `SubgroupManager.refuseCeremonyRoom`'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. + +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 — @@ -377,15 +486,19 @@ 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 a picker of candidate rooms: the Marmot rooms this -device administers and can sign in. Choosing one proposes the certificate there. -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. +**"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 +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. +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` ← @@ -416,7 +529,12 @@ otherwise. - `SubgroupDaoJvmTest` — schema 19 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. +- `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 @@ -515,8 +633,8 @@ description is baked into the epoch-0 group context and adoption happens after i | schema | 5 | `GroupKeyState`, a new entity, `GroupKeyStateDao` (transactional `replace`), `ChatRoomDao`, `MantraDatabase` + migration | | managers | 2 | `GroupKeyStateManager` (`parentageOf`, `propose`), `SubgroupManager` (`parentsOf`, the two guard sets, adoption proposals) | | repositories | 4 | two interfaces, two implementations, mostly signature plurals | -| text | 1 | `ProposedEvent`'s 30326 arm — the consent fix | +| text | 2 | `ProposedEvent`'s 30326 arm — the consent fix — and `ChatMessage`'s 30329 arm, for the offer line | | UI | 5 | `ChatRoomDetailUIState`, its view model and screen, a parent picker, strings | -| tests | 6 | | +| tests | 8 | | Around two dozen files, and the founding flow is not among them. From 516fa5cc3c0428fed75056dc20029bd58ef1763b Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Wed, 9 Sep 2026 16:59:19 +0200 Subject: [PATCH 03/15] docs: rebuild the adoption note's case on what Phase 10 left standing Phase 10 moved the child's ceremony into the parent's Marmot room -- the thing `docs/subgroups.md` Phase 4 deferred and said to revisit -- and it landed between this note being written and being read. Four of its claims were about the world before that move. **Two of the costs the note claimed adoption avoids no longer exist, and a third changed shape.** It argued that skipping the ceremony also skips the NIP-17 sibling room, the member-derived room id, the collision guard and `DkgSession.parentChatRoomId` as a discriminator. The first two are gone from founding already, and the collision is now `DkgSessionDao.getLatestSubgroupSessionFor` over `(room, parent, participants)` rather than an address that can only be occupied once. So the case is remade on what is left -- the ChillDKG itself, still the biggest cost `subgroups.md` names, and the room creation -- which is enough on its own and did not need the padding. **`DkgSession` is still untouched by adoption, and that is now a stronger claim.** Phase 10 added two columns to that table, `participantPublicKeys` and `subject`, and adoption opens no ceremony so it needs neither. Worth noting for a second reason: the sorted comma-joined set matched whole by `getLatestSubgroupSessionFor` is precedent for the canonical-string shape this note reached for and then did not need. **Founding with two parents is now harder, and the rejected-alternatives section says so on new grounds.** It used to be rejected partly on the discriminator work Phase 10 has since done, which would have been a free win. The real objection is better: the ceremony 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 well be admins of the child -- are not in it to receive them. Both rooms means two ceremonies and two keys, which is two children. One room means the other parent's admins take part in a ceremony held somewhere they cannot see, which MLS will not do. Adoption sidesteps it by holding no ceremony at all. **`signingPath`'s un-self-checkable case has two arms now** -- `isCeremonyRoom && (mlsGroupState == null || isSubgroupCeremony)` rather than `mlsGroupState == null` -- and adoption enters neither, because the child's own room is neither the ceremony's room nor its claimed parent. The conclusion is unchanged; the sentence describing it was not. **`completedKey`'s last fallback is now `getLatestOwnSessionForChatRoom`**, the same query scoped to `parentChatRoomId IS NULL`, so a parent hosting its child's ceremony cannot resolve the child's key as its own. Noted where the picker's cost is discussed, since that is the branch a candidate room falls down. Schema **19 -> 20**: 19 is Phase 10's. `refuseCeremonyRoom` is `refuseSubgroup` at both sites that named it, and `ceremonyRoomIdFor` -- which the note did not reference -- is gone. Re-checked and unchanged: `GroupKeyStateManager.propose`'s `check(chatRoomId == signingRoomId || ...)`, which is what lets a room propose its own key state and so is the whole of step 2; `signingPath`'s first candidate, which is why adoption never reaches the special case above; and all four items under "found while reading" -- `ChatRoom.parentChatRoomId` is still write-only, the four parent-indexed DAO reads are still test-only, `ProposedEvent`'s 30326 arm still says nothing about parentage, and `describe(purpose, path)` still takes no parent. The `ChatMessage` 30329 arm this note wants to extend is untouched; what Phase 10 added beside it is a `DkgRitualEvents.ALL -> null` arm, for ceremony traffic now arriving in a Marmot room. Docs only -- no code changed, so there are no test counts to report. Co-Authored-By: Claude Opus 5 --- docs/subgroups-multiple-parents.md | 87 ++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index da4b2a9e..ccb82fac 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -7,7 +7,12 @@ 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 `45cc80b5`. +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 @@ -25,17 +30,24 @@ That is the whole flow. Compare it with founding, which is four steps: |---|---| | 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 NIP-17 ceremony room | **the same event, in the child's own Marmot room** | +| 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 what drags in the NIP-17 ceremony room, the member-derived room id, the -collision guard, and `DkgSession.parentChatRoomId` as a discriminator. None of -that is reachable from here. **The whole of `DkgSession`, `ChillDkgRitualManager`, -`MarmotGroupCreation`, `SelectSubgroupAdminsScreen` and the ritual screen's rung -is untouched by this design.** +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: @@ -46,9 +58,11 @@ Three things in the existing code make step 2 work as it stands: - `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 `mlsGroupState == null` special case — the one `subgroups.md` flags as the - only un-self-checkable path in the app — is **not** reached, because the child's - room exists. + 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. @@ -231,15 +245,20 @@ has newest-wins logic and would have to become a `@Transaction` that rewrites th 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.** No discriminator, no canonical joined string, no -migration. This is the largest single saving over founding-time multi-parent. +**`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 **18 → 19**: one new table, one dropped column, and the existing rows -carried across as a single parentage row each. Not an `AutoMigration`; schema +Schema **19 → 20** — 19 is Phase 10's, for `DkgSession.participantPublicKeys` and +`subject`: 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 @@ -321,7 +340,7 @@ getChatRoomListByUserPublicKey(user) filter { it.id is not a descendant of C } ``` -The first three are `SubgroupManager.refuseCeremonyRoom`'s existing checks with the +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 admin one reads `MarmotGroupData.adminPubkeys` rather than `Participant.adminAt`, for the reason that function already gives: the flag is written only by @@ -339,7 +358,10 @@ remedy is a person rather than a button. 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. +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` @@ -434,7 +456,7 @@ currently not being told what it is. ## Guards -Two new refusal sets, both in the shape `SubgroupManager.refuseCeremonyRoom` +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, @@ -526,7 +548,7 @@ otherwise. 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` — schema 19 round-trips the table, a newer state replaces +- `SubgroupDaoJvmTest` — schema 20 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 @@ -562,15 +584,26 @@ 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, and worth recording why the incremental framing wins. It needs the -ceremony's parent set to become a canonical discriminator on `DkgSession`, a -co-parent tag on the certificate so all certificates can agree on the set, a -coordinator who administers *both* parents, two quorums answering before the child -room can be created at all, and a widened `refuseCeremonyRoom`. Roughly thirty -files against this design's dozen — and it delivers strictly less, because it -cannot adopt a group that already exists, which is the case people actually have. -Founding with two parents remains reachable afterwards: found with one, adopt the -second, and the room is a few minutes old instead of a few seconds. +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 From acbf089378d88a8af861eadb9a046a63d919a79e Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:10:16 +0200 Subject: [PATCH 04/15] docs: stop the adoption note naming a schema version, since two landed under it Seventy-one commits of `mantra` merged in -- npub and nsec sign-in, several profiles on one device, three pulls from the Curated fork, a profile preview and a FAB sweep -- and none of it touches subgroups. `docs/subgroups.md`, `SubgroupManager`, `GroupKeyStateManager`, `GroupKeyStateEvent`, `nostr/subgroup/`, `GroupKeyState` and `ProposedEvent` are byte-identical, so the design stands as written. The only `FrostSigningManager` change is a comment, recording that putting a signed event on relays is now a member's separate act from `BroadcastGroupSignedEventScreen`. **The note said schema 19 -> 20, and 20 was taken while it sat there.** 19 was Phase 10's, for `DkgSession.participantPublicKeys` and `subject`; 20 is the several-profiles pull's, putting an owning identity on the queues. This is the second time the number has moved under a note that is not being built yet -- it was 18 -> 19 when first written three weeks ago -- which is twice too often for a figure nothing depends on. So it now says to read the version off `MantraDatabase` at build time, and the test line that named one does not. Everything else re-checked against the merged tree and unchanged: the four parent-indexed DAO reads are still test-only, `ChatRoom.parentChatRoomId` still has no production reader, `describe(purpose, path)` still takes no parent, and `GroupSignedEvent` still declares its foreign key onto `ChatRoom` -- the last of which is load-bearing for this design, since it is what forbids a child device storing a certificate signed by a group it is not in, and so forces the certificate to ride inside the key state. `docs/README.md` merged without a conflict: the row for this note sits where it was put, under the subgroups row, and the seven rows the sign-in and pull notes added append below it. Merged through the app's host sync rather than `git merge`, because the merge brings in a `.gitmodules` line -- `branch = master` on the lightning-kmp-app submodule -- which the sandbox is not meant to write. Docs only -- no code changed by this commit, so there are no test counts to report. The merged changes have not been built here. Co-Authored-By: Claude Opus 5 --- docs/subgroups-multiple-parents.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index ccb82fac..d6c16299 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -256,10 +256,12 @@ adoption is glad not to be entangled with. [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 **19 → 20** — 19 is Phase 10's, for `DkgSession.participantPublicKeys` and -`subject`: 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. +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 @@ -548,7 +550,7 @@ otherwise. 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` — schema 20 round-trips the table, a newer state replaces +- `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 From d97ba7228b00acae554999ddfadf32108e33e772 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:16:13 +0200 Subject: [PATCH 05/15] 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 --- docs/subgroups-multiple-parents.md | 114 ++++++++++++++++++++++++++--- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index d6c16299..3930e356 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -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 From 0690932252417ab4af4f468b359b39b91c65d4a6 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:31:24 +0200 Subject: [PATCH 06/15] feat(subgroups): a lineage is a set, and the tags that carry one in pairs Phase 1 of docs/subgroups-multiple-parents.md, the wire. It ships dark: the event can now state a lineage of any size and nothing in this build produces one of more than one, because the row that would hold it does not exist until the next phase. A room's lineage was never really a field. `GroupKeyState` carried one parent and one certificate because a group was founded with a parent and never gained another; the moment it can be adopted, "the parent" is a set that had only ever held one thing. **`SubgroupLineage` is that set, and it holds the two invariants a caller cannot be trusted to remember.** No parent twice -- two entries naming one group is one parent asked twice, and `of` refuses rather than folding them, because each carries its own certificate and picking one would be this device deciding which of the parent's statements counts. And one canonical order, sorted by parent id, so two devices assembling the same lineage from the same certificates emit the same bytes. `plus` and `minus` are the two edits an adoption and a disavowal make; `minus` returning null is "nobody's child" rather than a failure, and revokes nothing -- a certificate is a signature and exists forever, and what it removes is the child's half of the link, which is the half that decides. **The pairs are adjacent, and that is for other people's builds.** A reader that predates this takes the first `parent_group` and the first `birth_certificate` off the tags and checks them against each other. Emitted as `id, certificate, id, certificate` it gets one real parent and that parent's own certificate, verifies them, and files a true but partial lineage -- one parent, correctly. Emitted as all the ids and then all the certificates it would pair the first id with a certificate that may belong to another parent, fail `certifies`, and drop the **whole state** -- losing the row that says which ceremony's share signs for that room, which is a worse outcome than not knowing about the second parent. The sort is what makes the pairing deterministic rather than incidental. **`parseLineage` counts each half twice, by tag name and by successful parse.** A half that is present and broken reads as absent through a parser, and only the broken kind would slide the pairing along by one and pair an id with somebody else's certificate. So an uneven number of halves, a half that does not parse, and a repeated parent are all refused, and `claimsParentage` -- unchanged, reading tag names alone -- is still what tells "claims nothing" from "claims something unreadable". **Two places refuse a second parent for now, and say so rather than truncating.** `propose` checks the lineage is one before it builds a row that can only hold one, and `parentageOf` drops a state claiming more. Taking the first of two would file a lineage the group did not sign and leave the device believing a group has one parent while its own members see two -- the same reasoning the existing all-or-nothing rule is built on. Nothing emits a second yet, so neither fires except on a state from a newer build, which is exactly the case that wants refusing rather than guessing. `SubgroupParentage` is untouched: still one pair, still unchecked, still the thing a lineage is made of. Nineteen tests. `SubgroupLineageTest` is new and pure, with no crypto at all -- the certificates in it are deliberately nonsense, because whether one is good is `certifies`' question and is asked on every read rather than at the door; it pins the two invariants, case-insensitivity on both, and that a certificate rides along with its own parent through the sort. Five go to `GroupKeyStateTest`, and the one worth keeping if any of this is rewritten asserts the *tag order* and then that the first id and the first certificate an older reader would take are a matched pair. 443 common tests, 933 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../compose/managers/GroupKeyStateManager.kt | 54 ++++-- .../compose/managers/SubgroupManager.kt | 9 +- .../compose/nostr/frost/GroupKeyStateEvent.kt | 95 ++++++++--- .../compose/nostr/subgroup/SubgroupLineage.kt | 108 ++++++++++++ .../compose/managers/GroupKeyStateTest.kt | 146 +++++++++++++++- .../nostr/subgroup/SubgroupLineageTest.kt | 161 ++++++++++++++++++ .../managers/SignedGroupKeyStateTest.kt | 17 +- 7 files changed, 540 insertions(+), 50 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index 780d80e3..ff4613f6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -13,6 +13,7 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage /** @@ -112,7 +113,7 @@ object GroupKeyStateManager { userPublicKey: HexKey, key: DkgSession, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parent: SubgroupParentage? = null, + lineage: SubgroupLineage? = null, createdAt: Long = Clock.System.now().epochSeconds ): FrostSigningSession { val signingRoomId = localChatRoom.chatRoom.id @@ -121,6 +122,18 @@ object GroupKeyStateManager { "Ceremony ${key.id} has produced no key for $signingRoomId to propose" ) + // The event can carry a lineage of any size; the row cannot yet. Until + // `GroupKeyState` holds a set, a caller reaching here with two parents + // would have one of them silently dropped on the way to the columns, so + // it is refused outright rather than half-kept. The tags are already + // plural -- see `GroupKeyStateEvent.assembleTags` -- which is what lets + // the wire land before the storage does. + check(lineage == null || lineage.size == 1) { + "Room $signingRoomId cannot yet propose a key state naming " + + "${lineage?.size} parents" + } + val parent = lineage?.parents?.single() + // The room this is about: the group's key walked to the admin path, // which is that room's id. It need not exist yet, and in the flow this // was written for it does not. @@ -176,7 +189,7 @@ object GroupKeyStateManager { chatRoomId = chatRoomId, dkgSessionId = key.id, path = path, - parent = parent + lineage = lineage ), content = thresholdPublicKey, key = key, @@ -381,22 +394,39 @@ object GroupKeyStateManager { ): Parentage? { if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null) - // Both or neither. A parent named with no certificate is a claim with the - // checkable part removed; a certificate with no parent named beside it - // has nothing to be an index of. Either alone is refused rather than - // completed from the other, because completing it would be this device - // deciding what the group meant. - val parentChatRoomId = GroupKeyStateEvent.parseParentChatRoomId(innerEvent.tags) - val certificate = GroupKeyStateEvent.parseBirthCertificate(innerEvent.tags) + // Both or neither, per parent, and no parent twice -- all of which + // `parseLineage` decides. A parent named with no certificate is a claim + // with the checkable part removed; a certificate with no parent named + // beside it has nothing to be an index of. Either alone is refused rather + // than completed from the other, because completing it would be this + // device deciding what the group meant. + val lineage = GroupKeyStateEvent.parseLineage(innerEvent.tags) - if (parentChatRoomId == null || certificate == null) { + if (lineage == null) { logger.w( - "Key state for $announced claims a parent in a form that cannot be read " + - "(parent=${parentChatRoomId != null}, certificate=${certificate != null}); dropping" + "Key state for $announced claims a parentage in a form that cannot be read; " + + "dropping" ) return null } + // One parent is all a row can hold until `GroupKeyState` carries a set. + // Refused rather than truncated: taking the first of two would file a + // lineage the group did not sign and leave the device believing a group + // has one parent when its own members see two. Nothing in this build + // emits a second, so this fires only on a state from a newer one. + if (lineage.size > 1) { + logger.w( + "Key state for $announced claims ${lineage.size} parents " + + "(${lineage.parentChatRoomIds}), which this build cannot file; dropping" + ) + return null + } + + val parentage = lineage.parents.single() + val parentChatRoomId = parentage.parentChatRoomId + val certificate = parentage.certificate + // The parent's own quorum, over this very room's id. Everything that // makes a lineage worth anything is in here -- see // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 79c3ab7a..1e3b0083 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -11,6 +11,7 @@ import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage /** @@ -320,9 +321,11 @@ object SubgroupManager { userPublicKey = userPublicKey, key = key, path = path, - parent = SubgroupParentage( - parentChatRoomId = parentChatRoomId, - certificate = certificate.toEvent() + lineage = SubgroupLineage.of( + SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate.toEvent() + ) ) ) } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt index 2f2d9bd3..70ec49c9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/frost/GroupKeyStateEvent.kt @@ -11,6 +11,7 @@ import press.mantra.compose.managers.SharedKeyDerivation import press.mantra.compose.nostr.frost.tags.FrostDerivationPathTag import press.mantra.compose.nostr.frost.tags.FrostKeyTag import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag @@ -110,46 +111,94 @@ object GroupKeyStateEvent { chatRoomId: String, dkgSessionId: String, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parent: SubgroupParentage? = null + lineage: SubgroupLineage? = null ): Array> = arrayOf( DTag.assemble(chatRoomId), FrostKeyTag.assemble(dkgSessionId), FrostDerivationPathTag.assemble(path) ) + ( - parent?.let { - // Both or neither, which is why this arrives as one value rather - // than as two nullable parameters a caller could half-fill. A state - // carrying one without the other is dropped on read; emitting one is - // therefore only a way to make a state nobody will believe. - arrayOf( + lineage?.parents?.flatMap { + // Both or neither, and **adjacent**: one parent's id immediately + // followed by that parent's certificate, in the canonical order + // `SubgroupLineage` sorts them into. A state carrying one half + // without the other is dropped on read, so emitting one is only a + // way to make a state nobody will believe. + // + // The pairing is what a build predating several parents depends on. + // Such a reader takes the first tag of each name and checks them + // against each other: emitted in pairs it gets one real parent and + // one real certificate for it, verifies them, and files a true but + // partial lineage. Emitted as all the ids and then all the + // certificates it would pair the first id with a certificate that + // may belong to another parent, fail `certifies`, and drop the whole + // state -- losing the row that says which ceremony's share signs for + // this room, which is a worse outcome than not knowing about the + // second parent. See `SubgroupLineage`. + listOf( SubgroupParentTag.assemble(it.parentChatRoomId), SubgroupBirthCertificateTag.assemble(it.certificate) ) - } ?: emptyArray() + }?.toTypedArray() ?: emptyArray() ) /** - * The group this one claims to be a subgroup of, or null if it claims none. + * The lineage the tags claim, or null if they claim none or claim one that + * cannot be read. * - * A *claim* until [SubgroupBirthCertificateEvent.certifies] says otherwise. - * Read alongside [parseBirthCertificate] and never on its own -- see - * `GroupKeyStateManager.stateFrom`. + * The two outcomes share a null for the reason every parentage reader here + * does: a tag that is present and unparseable looks exactly like an absent + * one through a parser. [claimsParentage] is what tells them apart, and + * `GroupKeyStateManager.stateFrom` asks it first -- a state claiming a parent + * in a form nothing can check is refused rather than filed as top-level. + * + * Unreadable here means any of: an odd number of halves, a tag of either + * name that does not parse, or the same parent named twice. Each is a lineage + * this device cannot state back, and completing one from the other halves + * would be this device deciding what the group meant. + * + * **Pairing is positional.** The *n*th `parent_group` goes with the *n*th + * `birth_certificate`, which is how [assembleTags] writes them and how a + * reader that only ever looks at the first of each reads them. Nothing is + * matched up by looking inside the certificates: whether the pair agrees is + * `SubgroupBirthCertificateEvent.certifies`' question, asked once per pair, + * and a mismatched pair must fail it rather than be quietly re-paired into + * one that passes. */ - fun parseParentChatRoomId(tags: Array>): HexKey? = - tags.firstNotNullOfOrNull(SubgroupParentTag::parse)?.parentChatRoomId + fun parseLineage(tags: Array>): SubgroupLineage? { + val parentTagCount = tags.count { it.isNotEmpty() && it[0] == SubgroupParentTag.TAG_NAME } + val certificateTagCount = + tags.count { it.isNotEmpty() && it[0] == SubgroupBirthCertificateTag.TAG_NAME } - /** The parent's signed certificate, as it signed it, or null if it carries none. */ - fun parseBirthCertificate(tags: Array>): Event? = - tags.firstNotNullOfOrNull(SubgroupBirthCertificateTag::parse)?.certificate + if (parentTagCount == 0 || parentTagCount != certificateTagCount) return null + + val parentChatRoomIds = tags.mapNotNull(SubgroupParentTag::parse) + .map { it.parentChatRoomId } + val certificates = tags.mapNotNull(SubgroupBirthCertificateTag::parse) + .map { it.certificate } + + // Counted by tag name above and by successful parse here, so a half that + // is present and broken is told from one that was never written. Only + // the first would shift the pairing. + if (parentChatRoomIds.size != parentTagCount) return null + if (certificates.size != certificateTagCount) return null + + return SubgroupLineage.of( + parentChatRoomIds.zip(certificates) { parentChatRoomId, certificate -> + SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate + ) + } + ) + } /** - * Whether the tags carry either half of a parentage. + * Whether the tags carry any half of a parentage. * - * The question [parseParentChatRoomId] and [parseBirthCertificate] cannot - * answer between them: a tag that is present and unparseable reads as absent - * through both. A state claiming a parent in a form nothing can check must be - * refused rather than quietly filed as an ordinary top-level group, so this - * looks at the tag names alone. + * The question [parseLineage] cannot answer, because a tag that is present + * and unparseable reads as absent through it. A state claiming a parent in a + * form nothing can check must be refused rather than quietly filed as an + * ordinary top-level group, so this looks at the tag names alone. */ fun claimsParentage(tags: Array>): Boolean = tags.any { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt new file mode 100644 index 00000000..a4d7daa0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineage.kt @@ -0,0 +1,108 @@ +package press.mantra.compose.nostr.subgroup + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Every group a room is a child of, each with the signature saying so. + * + * A room's lineage is a *set*, and this is the type that makes it one. A group + * is founded with one parent and may be adopted by another later -- see + * `docs/subgroups-multiple-parents.md` -- so "the parent" was never a field so + * much as a set that had only ever held one thing. + * + * ### Why a type rather than a list + * + * Two invariants have to hold wherever a lineage is written, and neither is the + * sort a caller can be trusted to remember. + * + * **No parent appears twice.** Two entries naming one group is one parent asked + * twice, and a set with a repeat in it is not a set. [of] refuses rather than + * folding them, because the two entries carry two certificates and picking one + * would be this device deciding which of the parent's statements counts. + * + * **The order is canonical.** Sorted by parent id, so two devices assembling the + * same lineage from the same certificates emit the same bytes. That matters for + * one reason only, and it is a reason about other people's builds: a reader that + * predates several parents takes the *first* `parent_group` and the *first* + * `birth_certificate` off the tags and checks them against each other. Emitted + * in sorted pairs, such a reader files a true, partial lineage -- one parent, + * verified. Emitted in two unordered runs it would pair one parent's id with + * another's certificate, fail the check, and drop the whole state, losing the row + * that says which ceremony's share signs for the room. See + * `GroupKeyStateEvent.assembleTags`. + * + * ### Nothing here is evidence + * + * Holding one of these says only that somebody assembled it. Every certificate + * in it is checked by `SubgroupBirthCertificateEvent.certifies` on every read -- + * see `GroupKeyStateManager.stateFrom`, which drops the whole state when any one + * of them fails. This type holds the shape; the signatures hold the trust. + */ +class SubgroupLineage private constructor( + /** The parents, in canonical order. Never empty, never repeating. */ + val parents: List, +) { + /** The parents' room ids, in the same canonical order. */ + val parentChatRoomIds: List get() = parents.map { it.parentChatRoomId } + + /** How many groups claim this room as a child. At least one. */ + val size: Int get() = parents.size + + /** Whether [parentChatRoomId] is one of them. Hex, so case does not count. */ + fun names(parentChatRoomId: HexKey): Boolean = + parents.any { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) } + + /** + * This lineage with [parent] added, or null if it already names that group. + * + * An adoption, as the child's quorum will sign it. It takes the whole + * resulting set rather than the one addition because a key state is + * replaceable and the newest wins *wholesale* -- so a re-announcement that + * carried only the new parent would silently drop the old one. Expressing the + * addition as an addition is what stops a caller assembling that by hand and + * forgetting. + */ + fun plus(parent: SubgroupParentage): SubgroupLineage? = + if (names(parent.parentChatRoomId)) null else of(parents + parent) + + /** + * This lineage without [parentChatRoomId], or null when it was the last one. + * + * Null is "no lineage" rather than a failure, and is what a caller passes on + * to say a room is nobody's child. Dropping a parent that is not named + * returns this lineage unchanged, since the result is the same either way. + * + * It does not revoke anything. The certificate is a signature and exists + * forever; what this removes is the child's half of the link, which is the + * half that decides. The parent's own list is built from its certificates and + * will go on naming the child -- see `SubgroupManager.subgroupsOf`. + */ + fun minus(parentChatRoomId: HexKey): SubgroupLineage? = of( + parents.filterNot { it.parentChatRoomId.equals(parentChatRoomId, ignoreCase = true) } + ) + + companion object { + /** + * A lineage of [parents], or null if there are none or one repeats. + * + * Null for empty is deliberate and is the same null every parentage API + * here uses for "nobody's child": a room with no parents has no lineage + * rather than an empty one, so there is one way to say it instead of two. + */ + fun of(parents: List): SubgroupLineage? { + if (parents.isEmpty()) return null + + val distinct = parents.distinctBy { it.parentChatRoomId.lowercase() } + if (distinct.size != parents.size) return null + + return SubgroupLineage(distinct.sortedBy { it.parentChatRoomId.lowercase() }) + } + + /** + * A lineage of one, which cannot fail -- a single entry has nothing to + * repeat. The founding case, and the shape every state signed before + * several parents existed is read back as. + */ + fun of(parent: SubgroupParentage): SubgroupLineage = SubgroupLineage(listOf(parent)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index baf9a15e..fb4b7a5b 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -25,7 +25,10 @@ import press.mantra.compose.database.model.GroupKeyState import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage +import press.mantra.compose.nostr.subgroup.tags.SubgroupBirthCertificateTag +import press.mantra.compose.nostr.subgroup.tags.SubgroupParentTag import press.mantra.compose.nostr.nip30303.ArtifactEvent import press.mantra.compose.nostr.nip30303.ChapterEvent import press.mantra.compose.nostr.nip30303.DialectEvent @@ -786,7 +789,9 @@ class GroupKeyStateTest { chatRoomId = chatRoomId, dkgSessionId = "ceremony-1", path = path, - parent = SubgroupParentage(parentChatRoomId = parent, certificate = certificate) + lineage = SubgroupLineage.of( + SubgroupParentage(parentChatRoomId = parent, certificate = certificate) + ) ) ) @@ -892,19 +897,148 @@ class GroupKeyStateTest { chatRoomId = chatRoomId, dkgSessionId = "ceremony-1", path = path, - parent = SubgroupParentage(parentChatRoomId, certificate) + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) ) assertTrue(GroupKeyStateEvent.claimsParentage(tags)) - assertEquals(parentChatRoomId, GroupKeyStateEvent.parseParentChatRoomId(tags)) - assertEquals(certificate.toJson(), GroupKeyStateEvent.parseBirthCertificate(tags)?.toJson()) + + val lineage = GroupKeyStateEvent.parseLineage(tags) + + assertEquals(listOf(parentChatRoomId), lineage?.parentChatRoomIds) + assertEquals(certificate.toJson(), lineage?.parents?.single()?.certificate?.toJson()) // And the tags a top-level group is proposed on carry neither, so every // state signed before subgroups existed reads exactly as it did. val plain = GroupKeyStateEvent.assembleTags(chatRoomId, "ceremony-1", path) assertFalse(GroupKeyStateEvent.claimsParentage(plain)) - assertNull(GroupKeyStateEvent.parseParentChatRoomId(plain)) - assertNull(GroupKeyStateEvent.parseBirthCertificate(plain)) + assertNull(GroupKeyStateEvent.parseLineage(plain)) + } + + @Test + fun `two parents land as adjacent pairs, each id before its own certificate`() { + // The one thing in this design that exists for *other people's* builds. A + // reader that predates several parents takes the first `parent_group` and + // the first `birth_certificate` and checks them against each other, so + // the pairs have to be adjacent and in step. Were they emitted as all the + // ids and then all the certificates, such a reader would pair one + // parent's id with another's certificate, fail `certifies`, and drop the + // whole state -- losing the row that says which share signs for the room. + val second = birthCertificate(parent = chatRoomId, author = keyMaterial) + val tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of( + listOf( + SubgroupParentage(parentChatRoomId, birthCertificate()), + SubgroupParentage(chatRoomId, second) + ) + ) + ) + + val names = tags.map { it[0] }.filter { + it == SubgroupParentTag.TAG_NAME || it == SubgroupBirthCertificateTag.TAG_NAME + } + + assertEquals( + listOf( + SubgroupParentTag.TAG_NAME, + SubgroupBirthCertificateTag.TAG_NAME, + SubgroupParentTag.TAG_NAME, + SubgroupBirthCertificateTag.TAG_NAME + ), + names + ) + + // And the first pair an older reader would take is a matched one: the + // certificate beside the first id is the certificate *by* that parent. + val firstParent = tags.first { it[0] == SubgroupParentTag.TAG_NAME }[1] + val firstCertificate = tags.first { it[0] == SubgroupBirthCertificateTag.TAG_NAME }[1] + + assertEquals(firstParent, Event.fromJson(firstCertificate).pubKey) + } + + @Test + fun `a state claiming two parents is dropped rather than truncated`() { + // Until a row can hold a set. Taking the first of two would file a + // lineage the group did not sign, and leave this device believing a group + // has one parent while its own members see two. + val second = birthCertificate(parent = chatRoomId, author = keyMaterial) + val state = signedKeyState( + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of( + listOf( + SubgroupParentage(parentChatRoomId, birthCertificate()), + SubgroupParentage(chatRoomId, second) + ) + ) + ) + ) + + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, state)) + } + + @Test + fun `a state naming the same parent twice is dropped`() { + val certificate = birthCertificate() + val doubled = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) + ) + arrayOf( + SubgroupParentTag.assemble(parentChatRoomId), + SubgroupBirthCertificateTag.assemble(certificate) + ) + + assertTrue(GroupKeyStateEvent.claimsParentage(doubled)) + assertNull(GroupKeyStateEvent.parseLineage(doubled)) + assertNull(GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = doubled))) + } + + @Test + fun `an uneven number of halves is refused, not completed from the other`() { + val certificate = birthCertificate() + + val extraParent = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) + ) + arrayOf(SubgroupParentTag.assemble(chatRoomId)) + + assertNull(GroupKeyStateEvent.parseLineage(extraParent)) + + val extraCertificate = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, certificate)) + ) + arrayOf(SubgroupBirthCertificateTag.assemble(certificate)) + + assertNull(GroupKeyStateEvent.parseLineage(extraCertificate)) + } + + @Test + fun `a half that is present and broken shifts nothing, because it is counted`() { + // Counted by tag name as well as by successful parse, so a broken half is + // told from one that was never written. Only the first would slide the + // pairing along by one and pair an id with somebody else's certificate. + val broken = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = SubgroupLineage.of(SubgroupParentage(parentChatRoomId, birthCertificate())) + ) + arrayOf( + SubgroupParentTag.assemble(chatRoomId), + arrayOf(SubgroupBirthCertificateTag.TAG_NAME, "not an event") + ) + + assertTrue(GroupKeyStateEvent.claimsParentage(broken)) + assertNull(GroupKeyStateEvent.parseLineage(broken)) } } diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt new file mode 100644 index 00000000..7c8c31df --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupLineageTest.kt @@ -0,0 +1,161 @@ +package press.mantra.compose.nostr.subgroup + +import com.vitorpamplona.quartz.nip01Core.core.Event +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * The two invariants a lineage holds, and the three edits it allows. + * + * Nothing here signs anything. `SubgroupLineage` is about the *shape* of a + * parentage set -- no repeats, one canonical order -- and the certificates in it + * are deliberately nonsense, because whether a certificate is good is + * `SubgroupBirthCertificateEvent.certifies`' question and is asked on every read + * rather than at the door. A test here that needed a real signature would be + * testing the wrong class. + */ +class SubgroupLineageTest { + private val alpha = "a".repeat(64) + private val beta = "b".repeat(64) + private val gamma = "c".repeat(64) + + @Test + fun `no parents is no lineage rather than an empty one`() { + assertNull(SubgroupLineage.of(emptyList())) + } + + @Test + fun `one parent cannot fail, because it has nothing to repeat`() { + val lineage = SubgroupLineage.of(parentage(alpha)) + + assertEquals(1, lineage.size) + assertEquals(listOf(alpha), lineage.parentChatRoomIds) + assertTrue(lineage.names(alpha)) + assertFalse(lineage.names(beta)) + } + + @Test + fun `the same parent twice is refused rather than folded`() { + // One parent asked twice, carrying two certificates. Folding them would + // be this device picking which of the parent's statements counts. + assertNull(SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha)))) + } + + @Test + fun `a repeat is a repeat whatever case it is written in`() { + val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(alpha.uppercase()))) + + assertNull(lineage) + } + + @Test + fun `a room id is matched case-insensitively, because it is hex`() { + val lineage = SubgroupLineage.of(parentage(alpha)) + + assertTrue(lineage.names(alpha.uppercase())) + } + + @Test + fun `the order is the parents' ids, whatever order they arrived in`() { + // The whole reason this class sorts: two devices assembling one lineage + // from the same certificates have to emit the same bytes, or a reader + // that pairs the first tag of each name gets a mismatched pair. + val forwards = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta), parentage(gamma))) + val backwards = SubgroupLineage.of(listOf(parentage(gamma), parentage(beta), parentage(alpha))) + + assertEquals(listOf(alpha, beta, gamma), forwards?.parentChatRoomIds) + assertEquals(listOf(alpha, beta, gamma), backwards?.parentChatRoomIds) + } + + @Test + fun `sorting is on the id and not on the case it is written in`() { + val lineage = SubgroupLineage.of(listOf(parentage(beta.uppercase()), parentage(alpha))) + + // Sorted by the lowercased id, and the id itself is kept as it arrived -- + // the certificate is the authority on what the parent is called, and this + // tag is only an index into it. + assertEquals(listOf(alpha, beta.uppercase()), lineage?.parentChatRoomIds) + } + + @Test + fun `adding a parent is an addition, not a replacement`() { + // A key state is replaceable and the newest wins wholesale, so an + // adoption that carried only the new parent would silently drop the old + // one. Expressing it as an addition is what stops a caller assembling + // the resulting set by hand and forgetting half of it. + val adopted = SubgroupLineage.of(parentage(beta)).plus(parentage(alpha)) + + assertEquals(listOf(alpha, beta), adopted?.parentChatRoomIds) + } + + @Test + fun `adding a parent that is already there is refused`() { + assertNull(SubgroupLineage.of(parentage(alpha)).plus(parentage(alpha))) + } + + @Test + fun `dropping the last parent leaves no lineage at all`() { + // Null is "nobody's child", which is what a caller passes on. It revokes + // nothing: the certificate is a signature and exists forever, and what + // this removes is the child's half of the link. + assertNull(SubgroupLineage.of(parentage(alpha)).minus(alpha)) + } + + @Test + fun `dropping one of two leaves the other`() { + val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta))) + + assertEquals(listOf(beta), lineage?.minus(alpha)?.parentChatRoomIds) + assertEquals(listOf(alpha), lineage?.minus(beta)?.parentChatRoomIds) + } + + @Test + fun `dropping a parent that was never there changes nothing`() { + val lineage = SubgroupLineage.of(listOf(parentage(alpha), parentage(beta))) + + assertEquals(listOf(alpha, beta), lineage?.minus(gamma)?.parentChatRoomIds) + } + + @Test + fun `the certificates ride along with their own parents`() { + // The pairing is the point of the type: an id and the certificate beside + // it have to stay together through the sort, or `certifies` would be + // asked about a pair the group never signed. + val lineage = SubgroupLineage.of(listOf(parentage(gamma), parentage(alpha))) + val parents = lineage?.parents.orEmpty() + + assertEquals(alpha, parents[0].parentChatRoomId) + assertEquals(alpha, parents[0].certificate.content) + assertEquals(gamma, parents[1].parentChatRoomId) + assertEquals(gamma, parents[1].certificate.content) + } + + @Test + fun `a parentage is carried as it was handed over`() { + val parentage = parentage(alpha) + + assertSame(parentage, SubgroupLineage.of(parentage).parents.single()) + } + + /** + * A parentage whose certificate says only which parent it belongs to, so a + * test can tell two of them apart after a sort. Not a valid certificate and + * not meant to be one. + */ + private fun parentage(parentChatRoomId: String) = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = Event( + id = "d".repeat(64), + pubKey = parentChatRoomId, + createdAt = 1_700_000_000, + kind = SubgroupBirthCertificateEvent.KIND, + tags = emptyArray(), + content = parentChatRoomId, + sig = "e".repeat(128) + ) + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index 05c24f91..bb0eccc8 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -11,6 +11,7 @@ import fr.acinq.bitcoin.PrivateKey import fr.acinq.bitcoin.XonlyPublicKey import fr.acinq.bitcoin.crypto.frost.Frost import fr.acinq.bitcoin.crypto.frost.KeyMaterial +import press.mantra.compose.nostr.subgroup.SubgroupLineage import press.mantra.compose.nostr.subgroup.SubgroupParentage import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import fr.acinq.bitcoin.crypto.frost.Session @@ -1554,9 +1555,11 @@ class SignedGroupKeyStateTest { localChatRoom = creator.room, userPublicKey = creator.publicKey, key = ceremonyOn(creator), - parent = SubgroupParentage( - parentChatRoomId = parentRoomId, - certificate = certificate + lineage = SubgroupLineage.of( + SubgroupParentage( + parentChatRoomId = parentRoomId, + certificate = certificate + ) ) ) pump(creator, other) @@ -1591,9 +1594,11 @@ class SignedGroupKeyStateTest { localChatRoom = creator.room, userPublicKey = creator.publicKey, key = ceremonyOn(creator), - parent = SubgroupParentage( - parentChatRoomId = parentRoomId, - certificate = birthCertificate(subgroupChatRoomId = parentRoomId) + lineage = SubgroupLineage.of( + SubgroupParentage( + parentChatRoomId = parentRoomId, + certificate = birthCertificate(subgroupChatRoomId = parentRoomId) + ) ) ) } From eba973331b7e3d76ecce611cb6d840ba67d3a0b3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:50:57 +0200 Subject: [PATCH 07/15] feat(subgroups): schema 21 -- a parentage is a row, so a room can have two Phase 2 of docs/subgroups-multiple-parents.md, the storage. Still dark: a lineage of two now verifies and is filed, and nothing in the app creates one until the adoption flow lands. `GroupKeyState` carried `parentChatRoomId` and `birthCertificateJson` because a group could only be founded with a parent, never adopted by a second. Those are `GroupKeyStateParent` now, one row per parent, and nothing about what they mean changed in the move -- including that the parent side is never a foreign key onto `ChatRoom`, for the three reasons that entity's comment now carries. **The primary key is the rule.** `(chatRoomId, parentChatRoomId)` is "one certificate per child per parent", which `SubgroupLineage.of` already refuses to break; the key is what makes it a property of the store rather than of a reader remembering. The cascade points at `GroupKeyState`, which is the one cascade here that is wanted: a lineage must not outlive the state that claimed it, or a room recreated at the same derived id would inherit a parentage nobody signed for it. **`replace` rewrites the lineage rather than merging it, and that is why it is a transaction.** A key state is replaceable and the newest wins *wholesale*, so the set of parents it names is the set. That is what makes a disavowal expressible at all -- a room drops a parent by re-announcing without it -- and merged rows would leave the dropped parent surviving the statement that dropped it. Deleting before inserting inside one transaction also stops a reader seeing a state beside somebody else's parents: were the writes separate, a concurrent read could land between them and get a state with the old lineage, which looks exactly like a true answer. An older state still changes nothing, now including its parents. **`stateFrom` returns `LocalGroupKeyState`, because a state and its lineage are one signed statement.** `lineageOf` checks every parent and drops the whole state if any one certificate fails -- the all-or-nothing rule generalised, for the reason it was written: keeping the provable half would file a group as having one parent when its own members see two. Each link is checked on its own, which is what lets a lineage be assembled a parent at a time, and there is a comment where somebody will look for the check that is deliberately absent: nothing requires the certificates to agree on the set. An earlier certificate cannot name a later co-parent, so requiring agreement would make every adoption cost a fresh quorum in every group that had already certified the room. The child's key state is the sole authority. **`ChatRoom.parentChatRoomId` is dropped, unread.** It was a cache of the verified value, written by `MarmotGroupCreation` and read in production by nothing at all -- the live answer went through `GroupKeyState`. Keeping it would mean a column holding *one* of several parents, which is worse than unused: a cache that is wrong. Its one query, `ChatRoomDao.observeByParentChatRoomId`, had only tests for callers and goes with it; the comment left in its place says where the two live answers are. `create`'s `parentChatRoomId` parameter threaded through four files to reach the write that is now gone, so it goes too rather than sitting dead. `MIGRATION_20_21` creates, copies, then drops -- the shape MIGRATION_9_10 established, and for the same reason it gives: `DROP TABLE` fires cascades and would delete the rows the migration had just written, so `ALTER TABLE ... DROP COLUMN` is used instead. `BundledSQLiteDriver` is pinned on every platform, so 3.35 is ours rather than the host's. The `WHERE` takes both halves of a parentage: a half row should not exist, since `stateFrom` has always dropped such a state rather than filing it, but this is the one moment the data is reshaped rather than re-derived and a half row copied forward would become a parent whose certificate is the string "null". Better to leave it behind -- the state keeps saying what the room signs with, and loses a lineage it could never prove. `parentOf` is `parentsOf` and returns a list, through the repository to `ChatRoomDetailUIState`; the parent card becomes one card per parent, keyed, because they are a set rather than a path -- neither is "the" parent and the order says nothing about which came first. Thirteen tests in `SubgroupDaoJvmTest`, rewritten for the table: the two cascades in both directions, a child with two parents appearing in both their lists, a re-announcement that drops a parent and one that clears the lineage entirely, an older state changing neither the state nor its parents, and the primary key collapsing a repeat to one row. In `GroupKeyStateTest`, Phase 1's "two parents are dropped" becomes "two parents are kept, each with its own certificate" -- it needed a third real group, since the earlier draft's second parent was the child certifying itself -- beside a new case where one bad certificate of two drops the whole state. 444 common tests, 937 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../21.json | 5738 +++++++++++++++++ .../mantra/compose/database/MantraDatabase.kt | 13 +- .../builder/PlatformDatabaseBuilder.kt | 17 +- .../compose/database/dao/ChatRoomDao.kt | 27 +- .../compose/database/dao/GroupKeyStateDao.kt | 96 +- .../migrations/SubgroupLineageMigration.kt | 86 + .../mantra/compose/database/model/ChatRoom.kt | 23 - .../compose/database/model/GroupKeyState.kt | 41 +- .../database/model/GroupKeyStateParent.kt | 86 + .../model/intermdiate/LocalGroupKeyState.kt | 62 + .../repository/DatabaseChatRepository.kt | 10 +- .../repository/DatabaseDkgRepository.kt | 6 +- .../compose/managers/GroupKeyStateManager.kt | 188 +- .../compose/managers/MarmotGroupCreation.kt | 14 - .../compose/managers/SubgroupManager.kt | 28 +- .../compose/repository/ChatRepository.kt | 18 +- .../compose/repository/DkgRepository.kt | 10 +- .../ui/composable/ChatRoomDetailScreen.kt | 10 +- .../ui/view/model/ChatRoomDetailViewModel.kt | 2 +- .../ui/view/model/DkgRitualViewModel.kt | 6 +- .../ui/view/state/ChatRoomDetailUIState.kt | 2 +- .../compose/managers/GroupKeyStateTest.kt | 126 +- .../database/dao/SubgroupDaoJvmTest.kt | 291 +- .../managers/SignedGroupKeyStateTest.kt | 65 +- 24 files changed, 6556 insertions(+), 409 deletions(-) create mode 100644 composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt diff --git a/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json new file mode 100644 index 00000000..c387bc7a --- /dev/null +++ b/composeApp/schemas/press.mantra.compose.database.MantraDatabase/21.json @@ -0,0 +1,5738 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "ff296ccd92cdb4bd61b3e150565f59c4", + "entities": [ + { + "tableName": "BroadcastNostrEventReceipt", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `isAccepted` INTEGER NOT NULL, `isSync` INTEGER NOT NULL, `relayURL` TEXT NOT NULL, `messages` TEXT, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "isAccepted", + "columnName": "isAccepted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSync", + "columnName": "isSync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messages", + "columnName": "messages", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventReceipt_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventReceipt_isAccepted", + "unique": false, + "columnNames": [ + "isAccepted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventReceipt_isAccepted` ON `${TABLE_NAME}` (`isAccepted`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "BroadcastNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `nostrEventId` TEXT NOT NULL, `unsignedNostrEventId` INTEGER, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_BroadcastNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_BroadcastNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_BroadcastNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Connection", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourcePublicKey` TEXT NOT NULL, `destinationPublicKey` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`sourcePublicKey`, `destinationPublicKey`), FOREIGN KEY(`sourcePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`destinationPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sourcePublicKey", + "columnName": "sourcePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "destinationPublicKey", + "columnName": "destinationPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sourcePublicKey", + "destinationPublicKey" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sourcePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "destinationPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "ChatMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `senderPublicKey` TEXT NOT NULL, `isUserMessage` INTEGER NOT NULL, `giftWrapPayloadId` TEXT, `marmotGroupEventId` TEXT, `marmotInnerEventId` TEXT, `chatRoomId` TEXT NOT NULL, `replyToMessageId` INTEGER, `quotedMessageId` INTEGER, `content` TEXT NOT NULL, `messageType` TEXT NOT NULL, `directMessageRecipientPublicKey` TEXT, `frostSigningSessionId` TEXT, `dkgSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`giftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`marmotInnerEventId`) REFERENCES `MarmotInnerEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUserMessage", + "columnName": "isUserMessage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "giftWrapPayloadId", + "columnName": "giftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotInnerEventId", + "columnName": "marmotInnerEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToMessageId", + "columnName": "replyToMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "quotedMessageId", + "columnName": "quotedMessageId", + "affinity": "INTEGER" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageType", + "columnName": "messageType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_ChatMessage_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_ChatMessage_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MarmotInnerEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotInnerEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventRequestRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventRequestId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventRequestId`) REFERENCES `BroadcastNostrEventRequest`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventRequestId", + "columnName": "broadcastNostrEventRequestId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventRequest", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageBroadcastNostrEventReceiptRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `broadcastNostrEventReceiptId` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`broadcastNostrEventReceiptId`) REFERENCES `BroadcastNostrEventReceipt`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "broadcastNostrEventReceiptId", + "columnName": "broadcastNostrEventReceiptId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "BroadcastNostrEventReceipt", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "broadcastNostrEventReceiptId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatMessageNostrEventRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `chatMessageId` INTEGER NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, FOREIGN KEY(`chatMessageId`) REFERENCES `ChatMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chatMessageId", + "columnName": "chatMessageId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatMessageId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ChatRoom", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `subject` TEXT, `description` TEXT, `mlsGroupState` TEXT, `initialGiftWrapPayloadId` TEXT, `leftGroupAt` INTEGER, `joinedGroupAt` INTEGER, `chronicleRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`userPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`initialGiftWrapPayloadId`) REFERENCES `GiftWrapPayload`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "mlsGroupState", + "columnName": "mlsGroupState", + "affinity": "TEXT" + }, + { + "fieldPath": "initialGiftWrapPayloadId", + "columnName": "initialGiftWrapPayloadId", + "affinity": "TEXT" + }, + { + "fieldPath": "leftGroupAt", + "columnName": "leftGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "joinedGroupAt", + "columnName": "joinedGroupAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "chronicleRequestedAt", + "columnName": "chronicleRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "userPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "GiftWrapPayload", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "initialGiftWrapPayloadId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgParticipantMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `participantPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `participantPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `DkgSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "participantPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_DkgParticipantMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgParticipantMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "DkgSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "DkgSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `participantPublicKeys` TEXT, `subject` TEXT, `stage` TEXT NOT NULL, `hostPublicKey` TEXT NOT NULL, `round1Random` TEXT NOT NULL, `round2AuxRandom` TEXT NOT NULL, `coordinatorRound1` TEXT, `certificate` TEXT, `thresholdPublicKey` TEXT, `secretShare` TEXT, `publicShares` TEXT, `recoveryData` TEXT, `failureReason` TEXT, `hostKeyApprovedAt` INTEGER, `round1ApprovedAt` INTEGER, `round2ApprovedAt` INTEGER, `approvalRequestedThrough` TEXT, `parentChatRoomId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKeys", + "columnName": "participantPublicKeys", + "affinity": "TEXT" + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hostPublicKey", + "columnName": "hostPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round1Random", + "columnName": "round1Random", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "round2AuxRandom", + "columnName": "round2AuxRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorRound1", + "columnName": "coordinatorRound1", + "affinity": "TEXT" + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT" + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "secretShare", + "columnName": "secretShare", + "affinity": "TEXT" + }, + { + "fieldPath": "publicShares", + "columnName": "publicShares", + "affinity": "TEXT" + }, + { + "fieldPath": "recoveryData", + "columnName": "recoveryData", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "hostKeyApprovedAt", + "columnName": "hostKeyApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round1ApprovedAt", + "columnName": "round1ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "round2ApprovedAt", + "columnName": "round2ApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedThrough", + "columnName": "approvalRequestedThrough", + "affinity": "TEXT" + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_DkgSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_DkgSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSignerMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `signerPublicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, PRIMARY KEY(`sessionId`, `signerPublicKey`, `kind`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerPublicKey", + "columnName": "signerPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "payload", + "columnName": "payload", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "signerPublicKey", + "kind" + ] + }, + "indices": [ + { + "name": "index_FrostSignerMessage_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSignerMessage_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sessionId` TEXT NOT NULL, `itemIndex` INTEGER NOT NULL, `unsignedEventJson` TEXT NOT NULL, `eventId` TEXT NOT NULL, `nonceRandom` TEXT NOT NULL, `aggregatedNonce` TEXT, `signature` TEXT, PRIMARY KEY(`sessionId`, `itemIndex`), FOREIGN KEY(`sessionId`) REFERENCES `FrostSigningSession`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sessionId", + "columnName": "sessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemIndex", + "columnName": "itemIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unsignedEventJson", + "columnName": "unsignedEventJson", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventId", + "columnName": "eventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nonceRandom", + "columnName": "nonceRandom", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "aggregatedNonce", + "columnName": "aggregatedNonce", + "affinity": "TEXT" + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "sessionId", + "itemIndex" + ] + }, + "indices": [ + { + "name": "index_FrostSigningItem_sessionId", + "unique": false, + "columnNames": [ + "sessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningItem_sessionId` ON `${TABLE_NAME}` (`sessionId`)" + } + ], + "foreignKeys": [ + { + "table": "FrostSigningSession", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "sessionId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "FrostSigningSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `coordinatorPublicKey` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `threshold` INTEGER NOT NULL, `participantCount` INTEGER NOT NULL, `signerId` INTEGER NOT NULL, `derivationPath` TEXT, `stage` TEXT NOT NULL, `signerIds` TEXT, `failureReason` TEXT, `signApprovedAt` INTEGER, `approvalRequestedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "coordinatorPublicKey", + "columnName": "coordinatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threshold", + "columnName": "threshold", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantCount", + "columnName": "participantCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signerId", + "columnName": "signerId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "stage", + "columnName": "stage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signerIds", + "columnName": "signerIds", + "affinity": "TEXT" + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT" + }, + { + "fieldPath": "signApprovedAt", + "columnName": "signApprovedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvalRequestedAt", + "columnName": "approvalRequestedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_FrostSigningSession_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_FrostSigningSession_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_FrostSigningSession_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapMessage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `receiverPublicKey` TEXT NOT NULL, `receiverRelayHit` TEXT, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverRelayHit", + "columnName": "receiverRelayHit", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapSeal", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `giftWrapMessageId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapMessageId`) REFERENCES `GiftWrapMessage`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "giftWrapMessageId", + "columnName": "giftWrapMessageId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapMessage", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapMessageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GiftWrapPayload", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `giftWrapSealId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`giftWrapSealId`) REFERENCES `GiftWrapSeal`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "giftWrapSealId", + "columnName": "giftWrapSealId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "GiftWrapSeal", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "giftWrapSealId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyState", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `dkgSessionId` TEXT NOT NULL, `thresholdPublicKey` TEXT NOT NULL, `derivationPath` TEXT NOT NULL, `announcedBy` TEXT NOT NULL, `announcedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dkgSessionId", + "columnName": "dkgSessionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thresholdPublicKey", + "columnName": "thresholdPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedBy", + "columnName": "announcedBy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcedAt", + "columnName": "announcedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyState_dkgSessionId", + "unique": false, + "columnNames": [ + "dkgSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyState_dkgSessionId` ON `${TABLE_NAME}` (`dkgSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "GroupKeyStateParent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `parentChatRoomId` TEXT NOT NULL, `birthCertificateJson` TEXT NOT NULL, PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentChatRoomId", + "columnName": "parentChatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "birthCertificateJson", + "columnName": "birthCertificateJson", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "parentChatRoomId" + ] + }, + "indices": [ + { + "name": "index_GroupKeyStateParent_parentChatRoomId", + "unique": false, + "columnNames": [ + "parentChatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` ON `${TABLE_NAME}` (`parentChatRoomId`)" + } + ], + "foreignKeys": [ + { + "table": "GroupKeyState", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "chatRoomId" + ] + } + ] + }, + { + "tableName": "GroupSignedEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `signature` TEXT NOT NULL, `derivationPath` TEXT, `frostSigningSessionId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT" + }, + { + "fieldPath": "frostSigningSessionId", + "columnName": "frostSigningSessionId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_GroupSignedEvent_chatRoomId", + "unique": false, + "columnNames": [ + "chatRoomId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_chatRoomId` ON `${TABLE_NAME}` (`chatRoomId`)" + }, + { + "name": "index_GroupSignedEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_GroupSignedEvent_frostSigningSessionId", + "unique": false, + "columnNames": [ + "frostSigningSessionId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_GroupSignedEvent_frostSigningSessionId` ON `${TABLE_NAME}` (`frostSigningSessionId`)" + } + ], + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "InReplyToRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`replyingNostrEventId` TEXT NOT NULL, `inReplyToNostrEventId` TEXT NOT NULL, `inReplyToProfilePublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`replyingNostrEventId`, `inReplyToNostrEventId`), FOREIGN KEY(`inReplyToProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`inReplyToRootNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "replyingNostrEventId", + "columnName": "replyingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inReplyToProfilePublicKey", + "columnName": "inReplyToProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootProfilePublicKey", + "columnName": "inReplyToRootProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "replyingNostrEventId", + "inReplyToNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "inReplyToRootNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `visibility` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `license` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `versionLabel` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactId`) REFERENCES `MantraArtifact`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactId", + "columnName": "artifactId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionLabel", + "columnName": "versionLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifact", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `originalText` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalText", + "columnName": "originalText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `wordCount` INTEGER NOT NULL, `characterCount` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wordCount", + "columnName": "wordCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "characterCount", + "columnName": "characterCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraDialect", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `name` TEXT NOT NULL, `country` TEXT NOT NULL, `language` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "country", + "columnName": "country", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChunkId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `text` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChunkId`) REFERENCES `MantraTranslationChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChunkId", + "columnName": "translationChunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersion", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `artifactVersionId` TEXT NOT NULL, `dialectId` TEXT NOT NULL, `name` TEXT NOT NULL, `visibility` TEXT NOT NULL, `license` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`artifactVersionId`) REFERENCES `MantraArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dialectId`) REFERENCES `MantraDialect`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artifactVersionId", + "columnName": "artifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dialectId", + "columnName": "dialectId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraDialect", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dialectId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationArtifactVersionContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `editorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersionContributor`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "editorPublicKey", + "columnName": "editorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersionContributor", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapter", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chapterId` TEXT NOT NULL, `translationArtifactVersionId` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationArtifactVersionId`) REFERENCES `MantraTranslationArtifactVersion`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chapterId`) REFERENCES `MantraChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chapterId", + "columnName": "chapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationArtifactVersionId", + "columnName": "translationArtifactVersionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationArtifactVersion", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationArtifactVersionId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChapterContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `role` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationChunk", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chunkId` TEXT NOT NULL, `translationChapterId` TEXT NOT NULL, `text` TEXT NOT NULL, `index` INTEGER NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`chunkId`) REFERENCES `MantraChunk`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`translationChapterId`) REFERENCES `MantraTranslationChapter`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chunkId", + "columnName": "chunkId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationChapterId", + "columnName": "translationChapterId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraChunk", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chunkId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "MantraTranslationChapter", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationChapterId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MantraTranslationContributor", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `dTag` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `translationId` TEXT NOT NULL, `translatorPublicKey` TEXT NOT NULL, `signature` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `marmotGroupEventId` TEXT, `groupSignedEventId` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`translationId`) REFERENCES `MantraTranslation`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dTag", + "columnName": "dTag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationId", + "columnName": "translationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translatorPublicKey", + "columnName": "translatorPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "groupSignedEventId", + "columnName": "groupSignedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MantraTranslation", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "translationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotCommitResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `peerKeyPackageEventId` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `isOneMemberInitialGroupCreation` INTEGER NOT NULL, `commitBytes` BLOB NOT NULL, `welcomeBytes` BLOB, `groupInfoBytes` BLOB, `framedCommitBytes` BLOB NOT NULL, `preCommitExporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "peerKeyPackageEventId", + "columnName": "peerKeyPackageEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isOneMemberInitialGroupCreation", + "columnName": "isOneMemberInitialGroupCreation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "commitBytes", + "columnName": "commitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "welcomeBytes", + "columnName": "welcomeBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "groupInfoBytes", + "columnName": "groupInfoBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "framedCommitBytes", + "columnName": "framedCommitBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "preCommitExporterSecret", + "columnName": "preCommitExporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "MarmotGroupEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userPublicKey` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `signature` TEXT NOT NULL, `encryptedContent` TEXT NOT NULL, `expiresAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userPublicKey", + "columnName": "userPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedContent", + "columnName": "encryptedContent", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotInnerEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `quotedEventId` TEXT, `payloadEventId` TEXT, `marmotGroupEventId` TEXT, `directMessageRecipientPublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`marmotGroupEventId`) REFERENCES `MarmotGroupEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedEventId", + "columnName": "quotedEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "payloadEventId", + "columnName": "payloadEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "marmotGroupEventId", + "columnName": "marmotGroupEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "directMessageRecipientPublicKey", + "columnName": "directMessageRecipientPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "MarmotGroupEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "marmotGroupEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackage", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`publicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "publicKey" + ], + "referencedColumns": [ + "publicKey" + ] + } + ] + }, + { + "tableName": "MarmotKeyPackageBundle", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `publicKey` TEXT NOT NULL, `tlsEncodedMarmotKeyPackage` BLOB NOT NULL, `ncryptsecInitPrivateKey` TEXT NOT NULL, `ncryptsecEncryptionPrivateKey` TEXT NOT NULL, `ncryptsecSignaturePrivateKey` TEXT NOT NULL, `consumed` INTEGER NOT NULL, `rotated` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tlsEncodedMarmotKeyPackage", + "columnName": "tlsEncodedMarmotKeyPackage", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ncryptsecInitPrivateKey", + "columnName": "ncryptsecInitPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecEncryptionPrivateKey", + "columnName": "ncryptsecEncryptionPrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ncryptsecSignaturePrivateKey", + "columnName": "ncryptsecSignaturePrivateKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "consumed", + "columnName": "consumed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotated", + "columnName": "rotated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_MarmotKeyPackageBundle_publicKey", + "unique": false, + "columnNames": [ + "publicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_MarmotKeyPackageBundle_publicKey` ON `${TABLE_NAME}` (`publicKey`)" + } + ] + }, + { + "tableName": "MarmotRetainedEpochSecret", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chatRoomId` TEXT NOT NULL, `epoch` INTEGER NOT NULL, `senderDataSecret` BLOB NOT NULL, `encryptionSecret` BLOB NOT NULL, `leafCount` INTEGER NOT NULL, `exporterSecret` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, PRIMARY KEY(`chatRoomId`, `epoch`), FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "epoch", + "columnName": "epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderDataSecret", + "columnName": "senderDataSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptionSecret", + "columnName": "encryptionSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "leafCount", + "columnName": "leafCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exporterSecret", + "columnName": "exporterSecret", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chatRoomId", + "epoch" + ] + }, + "foreignKeys": [ + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Mention", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mentionedPublicKey` TEXT NOT NULL, `mentioningNostrEventId` TEXT, `relayUrl` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`mentionedPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`mentioningNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mentionedPublicKey", + "columnName": "mentionedPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mentioningNostrEventId", + "columnName": "mentioningNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentionedPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "mentioningNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `uuid` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilter` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilter", + "columnName": "synchronizationFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NegentropySynchronizeRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_NegentropySynchronizeRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NegentropySynchronizeRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NegentropySynchronizeResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `status` TEXT NOT NULL, `negentropySynchronizeRequestId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`negentropySynchronizeRequestId`) REFERENCES `SynchronizeNostrEventResult`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "negentropySynchronizeRequestId", + "columnName": "negentropySynchronizeRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "SynchronizeNostrEventResult", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "negentropySynchronizeRequestId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "NostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `content` TEXT NOT NULL, `sig` TEXT NOT NULL, `relayUrl` TEXT, `quotedNostrEventId` TEXT, `quotedAuthorPublicKey` TEXT, `inReplyToNostrEventId` TEXT, `inReplyToAuthorPublicKey` TEXT, `inReplyToRootNostrEventId` TEXT, `inReplyToRootAuthorPublicKey` TEXT, `repostedNostrEventId` TEXT, `repostedAuthorPublicKey` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sig", + "columnName": "sig", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayUrl", + "columnName": "relayUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "quotedAuthorPublicKey", + "columnName": "quotedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToNostrEventId", + "columnName": "inReplyToNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToAuthorPublicKey", + "columnName": "inReplyToAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootNostrEventId", + "columnName": "inReplyToRootNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "inReplyToRootAuthorPublicKey", + "columnName": "inReplyToRootAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "repostedAuthorPublicKey", + "columnName": "repostedAuthorPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_NostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + }, + { + "name": "index_NostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_NostrEvent_quotedNostrEventId", + "unique": false, + "columnNames": [ + "quotedNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_quotedNostrEventId` ON `${TABLE_NAME}` (`quotedNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToNostrEventId` ON `${TABLE_NAME}` (`inReplyToNostrEventId`)" + }, + { + "name": "index_NostrEvent_inReplyToRootNostrEventId", + "unique": false, + "columnNames": [ + "inReplyToRootNostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEvent_inReplyToRootNostrEventId` ON `${TABLE_NAME}` (`inReplyToRootNostrEventId`)" + } + ] + }, + { + "tableName": "NostrEventRelay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`relayURL` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`nostrEventId`, `relayURL`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nostrEventId", + "relayURL" + ] + }, + "indices": [ + { + "name": "index_NostrEventRelay_relayURL", + "unique": false, + "columnNames": [ + "relayURL" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_NostrEventRelay_relayURL` ON `${TABLE_NAME}` (`relayURL`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Participant", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `participantPublicKey` TEXT NOT NULL, `chatRoomId` TEXT NOT NULL, `relayHint` TEXT, `adminAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER, FOREIGN KEY(`participantPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`chatRoomId`) REFERENCES `ChatRoom`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "participantPublicKey", + "columnName": "participantPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatRoomId", + "columnName": "chatRoomId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayHint", + "columnName": "relayHint", + "affinity": "TEXT" + }, + { + "fieldPath": "adminAt", + "columnName": "adminAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "participantPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "ChatRoom", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "chatRoomId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Post", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `repostId` TEXT, `quote` TEXT, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostId", + "columnName": "repostId", + "affinity": "TEXT" + }, + { + "fieldPath": "quote", + "columnName": "quote", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Post_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Post_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Post_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + }, + { + "name": "index_Post_repostId", + "unique": false, + "columnNames": [ + "repostId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Post_repostId` ON `${TABLE_NAME}` (`repostId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `userName` TEXT, `displayName` TEXT, `picture` TEXT, `banner` TEXT, `website` TEXT, `about` TEXT, `bot` INTEGER, `pronouns` TEXT, `nip05` TEXT, `domain` TEXT, `lud06` TEXT, `lud16` TEXT, `twitter` TEXT, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`publicKey`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userName", + "columnName": "userName", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT" + }, + { + "fieldPath": "banner", + "columnName": "banner", + "affinity": "TEXT" + }, + { + "fieldPath": "website", + "columnName": "website", + "affinity": "TEXT" + }, + { + "fieldPath": "about", + "columnName": "about", + "affinity": "TEXT" + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER" + }, + { + "fieldPath": "pronouns", + "columnName": "pronouns", + "affinity": "TEXT" + }, + { + "fieldPath": "nip05", + "columnName": "nip05", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "lud06", + "columnName": "lud06", + "affinity": "TEXT" + }, + { + "fieldPath": "lud16", + "columnName": "lud16", + "affinity": "TEXT" + }, + { + "fieldPath": "twitter", + "columnName": "twitter", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey" + ] + }, + "indices": [ + { + "name": "index_Profile_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Profile_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "QuotedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`quotingNostrEventId` TEXT NOT NULL, `quotedNostrEventId` TEXT NOT NULL, `quotedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`quotingNostrEventId`, `quotedNostrEventId`), FOREIGN KEY(`quotedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`quotedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "quotingNostrEventId", + "columnName": "quotingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedNostrEventId", + "columnName": "quotedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "quotedProfilePublicKey", + "columnName": "quotedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "quotingNostrEventId", + "quotedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "quotedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Reaction", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `content` TEXT NOT NULL, `nostrEventId` TEXT NOT NULL, `replyToId` TEXT NOT NULL, `profilePublicKey` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`profilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`replyToId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToId", + "columnName": "replyToId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePublicKey", + "columnName": "profilePublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Reaction_profilePublicKey", + "unique": false, + "columnNames": [ + "profilePublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_profilePublicKey` ON `${TABLE_NAME}` (`profilePublicKey`)" + }, + { + "name": "index_Reaction_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Reaction_replyToId", + "unique": false, + "columnNames": [ + "replyToId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Reaction_replyToId` ON `${TABLE_NAME}` (`replyToId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "profilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "replyToId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "RecentSearch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `synchronizationFilters` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + } + }, + { + "tableName": "Relay", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`publicKey` TEXT NOT NULL, `type` TEXT NOT NULL, `url` TEXT NOT NULL, `read` INTEGER NOT NULL, `write` INTEGER NOT NULL, PRIMARY KEY(`publicKey`, `type`, `url`))", + "fields": [ + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "write", + "columnName": "write", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "publicKey", + "type", + "url" + ] + } + }, + { + "tableName": "RepostedRelation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repostingNostrEventId` TEXT NOT NULL, `repostedNostrEventId` TEXT NOT NULL, `repostedProfilePublicKey` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`repostingNostrEventId`, `repostedNostrEventId`), FOREIGN KEY(`repostedProfilePublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostedNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`repostingNostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repostingNostrEventId", + "columnName": "repostingNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedNostrEventId", + "columnName": "repostedNostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repostedProfilePublicKey", + "columnName": "repostedProfilePublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "repostingNostrEventId", + "repostedNostrEventId" + ] + }, + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedProfilePublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repostingNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventRequest", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `purpose` TEXT NOT NULL, `status` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `isRecommendedRelay` INTEGER NOT NULL, `level` INTEGER NOT NULL, `synchronizationFilters` TEXT NOT NULL, `ownerPublicKey` TEXT, `nostrEventId` TEXT, `unsignedNostrEventId` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`unsignedNostrEventId`) REFERENCES `UnsignedNostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isRecommendedRelay", + "columnName": "isRecommendedRelay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "synchronizationFilters", + "columnName": "synchronizationFilters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerPublicKey", + "columnName": "ownerPublicKey", + "affinity": "TEXT" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "unsignedNostrEventId", + "columnName": "unsignedNostrEventId", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventRequest_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_SynchronizeNostrEventRequest_status", + "unique": false, + "columnNames": [ + "status" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventRequest_status` ON `${TABLE_NAME}` (`status`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "UnsignedNostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "unsignedNostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SynchronizeNostrEventResult", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `relayURL` TEXT NOT NULL, `nostrEventId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relayURL", + "columnName": "relayURL", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SynchronizeNostrEventResult_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SynchronizeNostrEventResult_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + } + ], + "foreignKeys": [ + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "UnsignedNostrEvent", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `pubKey` TEXT NOT NULL, `kind` INTEGER NOT NULL, `tags` TEXT NOT NULL, `privateTags` TEXT, `content` TEXT NOT NULL, `signedAt` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `deletedAt` INTEGER, `broadcastedAt` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pubKey", + "columnName": "pubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "privateTags", + "columnName": "privateTags", + "affinity": "TEXT" + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signedAt", + "columnName": "signedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_UnsignedNostrEvent_pubKey", + "unique": false, + "columnNames": [ + "pubKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_pubKey` ON `${TABLE_NAME}` (`pubKey`)" + }, + { + "name": "index_UnsignedNostrEvent_kind", + "unique": false, + "columnNames": [ + "kind" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_UnsignedNostrEvent_kind` ON `${TABLE_NAME}` (`kind`)" + } + ] + }, + { + "tableName": "Zap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `postId` TEXT NOT NULL, `senderPublicKey` TEXT NOT NULL, `receiverPublicKey` TEXT NOT NULL, `message` TEXT, `invoice` TEXT, `amountInMillisatoshis` INTEGER, `nostrEventId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `savedAt` INTEGER NOT NULL, `viewedAt` INTEGER, `deletedAt` INTEGER, `broadcastedAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`senderPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`receiverPublicKey`) REFERENCES `Profile`(`publicKey`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`nostrEventId`) REFERENCES `NostrEvent`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`postId`) REFERENCES `Post`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "postId", + "columnName": "postId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderPublicKey", + "columnName": "senderPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "receiverPublicKey", + "columnName": "receiverPublicKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT" + }, + { + "fieldPath": "invoice", + "columnName": "invoice", + "affinity": "TEXT" + }, + { + "fieldPath": "amountInMillisatoshis", + "columnName": "amountInMillisatoshis", + "affinity": "INTEGER" + }, + { + "fieldPath": "nostrEventId", + "columnName": "nostrEventId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "savedAt", + "columnName": "savedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "deletedAt", + "columnName": "deletedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "broadcastedAt", + "columnName": "broadcastedAt", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Zap_senderPublicKey", + "unique": false, + "columnNames": [ + "senderPublicKey" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_senderPublicKey` ON `${TABLE_NAME}` (`senderPublicKey`)" + }, + { + "name": "index_Zap_nostrEventId", + "unique": false, + "columnNames": [ + "nostrEventId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_nostrEventId` ON `${TABLE_NAME}` (`nostrEventId`)" + }, + { + "name": "index_Zap_postId", + "unique": false, + "columnNames": [ + "postId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Zap_postId` ON `${TABLE_NAME}` (`postId`)" + } + ], + "foreignKeys": [ + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "senderPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "Profile", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "receiverPublicKey" + ], + "referencedColumns": [ + "publicKey" + ] + }, + { + "table": "NostrEvent", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "nostrEventId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Post", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "postId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff296ccd92cdb4bd61b3e150565f59c4')" + ] + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt index dcd795cd..6fb73be9 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/MantraDatabase.kt @@ -74,6 +74,7 @@ import press.mantra.compose.database.model.GiftWrapMessage import press.mantra.compose.database.model.GiftWrapPayload import press.mantra.compose.database.model.GiftWrapSeal import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.InReplyToRelation import press.mantra.compose.database.model.MantraArtifact @@ -139,6 +140,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) GiftWrapSeal::class, GiftWrapPayload::class, GroupKeyState::class, + GroupKeyStateParent::class, GroupSignedEvent::class, InReplyToRelation::class, MantraArtifact::class, @@ -177,7 +179,7 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) UnsignedNostrEvent::class, Zap::class ], - version = 20, + version = 21, autoMigrations = [ // v2 only adds the DkgSession/DkgParticipantMessage tables, so Room can // generate the migration itself — nothing existing changes shape. @@ -321,6 +323,15 @@ val GENESIS_AT = Instant.fromEpochMilliseconds(1231006505000L) // which is what they were, and any identity may drain them. The broadcast // queue deliberately gets no owner: a signed event is anyone's to carry. AutoMigration(from = 19, to = 20), + // v21 moves a room's parentage off two GroupKeyState columns onto the new + // GroupKeyStateParent table, so a room can be the child of more than one + // group -- founded with one and adopted by another later. It copies + // before it drops, which no AutoMigration can express, and it drops + // ChatRoom.parentChatRoomId as well: that column was a cache of the + // verified value which nothing in production ever read, and keeping it + // would mean a column holding *one* of several parents, which is worse + // than unused. See MIGRATION_20_21 and + // docs/subgroups-multiple-parents.md. ] ) @ColumnTypeConverters(MantraConverters::class) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt index 99ff7ece..212e6c8e 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/builder/PlatformDatabaseBuilder.kt @@ -3,6 +3,7 @@ package press.mantra.compose.database.builder import androidx.room3.RoomDatabase import androidx.sqlite.driver.bundled.BundledSQLiteDriver import press.mantra.compose.database.migrations.MIGRATION_3_4 +import press.mantra.compose.database.migrations.MIGRATION_20_21 import press.mantra.compose.database.migrations.MIGRATION_9_10 import press.mantra.compose.database.migrations.MIGRATION_13_14 import press.mantra.compose.database.migrations.MIGRATION_14_15 @@ -19,13 +20,21 @@ fun getRoomDatabase( builder: RoomDatabase.Builder ): press.mantra.compose.database.MantraDatabase { return builder - // Everything else Room generates itself. These four move data rather than + // Everything else Room generates itself. These five move data rather than // only changing shape, which an AutoMigration cannot express: 3->4 rewrites // chat rows, 9->10 copies a session's per-event columns onto the items // table before dropping them, 13->14 renames a column and rewrites the - // chat rows that named it the old way, and 14->15 adds a column and deletes - // the chat rows written for messages from before this device joined. - .addMigrations(MIGRATION_3_4, MIGRATION_9_10, MIGRATION_13_14, MIGRATION_14_15) + // chat rows that named it the old way, 14->15 adds a column and deletes + // the chat rows written for messages from before this device joined, and + // 20->21 copies each room's single parentage onto the table that can hold + // several before dropping the columns it came from. + .addMigrations( + MIGRATION_3_4, + MIGRATION_9_10, + MIGRATION_13_14, + MIGRATION_14_15, + MIGRATION_20_21 + ) .setDriver(BundledSQLiteDriver()) .setQueryCoroutineContext(Dispatchers.IO) .build() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt index c072cb30..c27e11bf 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/ChatRoomDao.kt @@ -66,25 +66,14 @@ interface ChatRoomDao { ) fun observeChatRoomListByUserPublicKey(userPublicKey: String): Flow> - /** - * The subgroups of [parentChatRoomId] this device actually holds rooms for. - * - * Deliberately not the answer to "what subgroups does this group have" -- - * that is `SubgroupManager.subgroupsOf`, which reads the parent's signed - * certificates and so can name a child whose room has not been created, or - * was created for somebody else. This names the ones there is something to - * open. - * - * Soft-deleted rooms are excluded like everywhere else: a room the user has - * cleared away should not reappear because its parent lists it. - */ - @Transaction - @Query( - CHAT_ROOM_WITH_LAST_MESSAGE + - "WHERE ChatRoom.parentChatRoomId = :parentChatRoomId AND ChatRoom.deletedAt IS NULL " + - ORDER_BY_LAST_ACTIVITY - ) - fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + // "The subgroups of this room" used to be a column filter here, over a + // ChatRoom.parentChatRoomId that production never read -- the live answer + // went through GroupKeyState, and SubgroupManager.subgroupsOf reads the + // parent's own certificates, which can name a child whose room this device + // does not have. The column is gone at v21, because a room can have more + // than one parent and a single column would have held one of them. The + // rooms a parent's children occupy are reached through + // GroupKeyStateDao.getByParentChatRoomId and resolved from there. @Upsert suspend fun upsert(chatRoom: ChatRoom) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt index 4a7f0b6f..f0076895 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/GroupKeyStateDao.kt @@ -6,6 +6,8 @@ import androidx.room3.Transaction import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState @Dao abstract class GroupKeyStateDao { @@ -15,6 +17,18 @@ abstract class GroupKeyStateDao { @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") abstract fun observeByChatRoomId(chatRoomId: String): Flow + /** + * A state with the groups that have certified it, which is how a lineage + * should always be read -- the two are one signed statement. + */ + @Transaction + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract suspend fun getLocalByChatRoomId(chatRoomId: String): LocalGroupKeyState? + + @Transaction + @Query("SELECT * FROM GroupKeyState WHERE chatRoomId = :chatRoomId") + abstract fun observeLocalByChatRoomId(chatRoomId: String): Flow + /** Every room that signs with one ceremony's key. One, today. */ @Query("SELECT * FROM GroupKeyState WHERE dkgSessionId = :dkgSessionId") abstract suspend fun getByDkgSessionId(dkgSessionId: String): List @@ -23,26 +37,52 @@ abstract class GroupKeyStateDao { * Every subgroup of [parentChatRoomId] this device holds a verified state * for. * - * Verified is the whole of what these rows are: a state only reaches the - * table with a parent on it once its birth certificate has been checked - * against that parent's signature, so this is a list of children the parent - * really did certify rather than a list of rooms claiming a parent. + * Verified is the whole of what these rows are: a parentage only reaches the + * table once its birth certificate has been checked against that parent's + * signature, so this is a list of children the parent really did certify + * rather than a list of rooms claiming a parent. * * It is not the whole list. A subgroup whose room this device does not have * leaves no state here at all, and the certificates in `GroupSignedEvent` are * the fuller answer -- see `SubgroupManager.subgroupsOf`. + * + * A child with two parents appears in both parents' answers, which is the + * point of the join: the row that makes it this parent's child says nothing + * about who else certified it. */ - @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC") - abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List + @Transaction + @Query( + "SELECT GroupKeyState.* FROM GroupKeyState " + + "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " + + "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " + + "ORDER BY GroupKeyState.announcedAt DESC" + ) + abstract suspend fun getByParentChatRoomId(parentChatRoomId: String): List - @Query("SELECT * FROM GroupKeyState WHERE parentChatRoomId = :parentChatRoomId ORDER BY announcedAt DESC") - abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + @Transaction + @Query( + "SELECT GroupKeyState.* FROM GroupKeyState " + + "JOIN GroupKeyStateParent ON GroupKeyStateParent.chatRoomId = GroupKeyState.chatRoomId " + + "WHERE GroupKeyStateParent.parentChatRoomId = :parentChatRoomId " + + "ORDER BY GroupKeyState.announcedAt DESC" + ) + abstract fun observeByParentChatRoomId(parentChatRoomId: String): Flow> + + /** The parents of one room, as rows. Prefer [getLocalByChatRoomId]. */ + @Query("SELECT * FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId") + abstract suspend fun getParentsByChatRoomId(chatRoomId: String): List @Upsert abstract suspend fun upsert(groupKeyState: GroupKeyState) + @Upsert + abstract suspend fun upsertParents(parents: List) + + @Query("DELETE FROM GroupKeyStateParent WHERE chatRoomId = :chatRoomId") + abstract suspend fun deleteParentsByChatRoomId(chatRoomId: String) + /** - * Files a state, keeping the newest one per room. + * Files a state and its whole lineage, keeping the newest one per room. * * This is where the event's replaceable semantics actually happen. Relays * never see a `GroupKeyStateEvent` -- it lives inside a Marmot group and is @@ -56,13 +96,36 @@ abstract class GroupKeyStateDao { * state agree by construction, since both derived it from the room they are * standing in. * - * Returns the state now on file. + * ### The lineage is replaced, not merged, and that is the whole reason this + * is a transaction + * + * A key state is replaceable and the newest wins **wholesale**, so the set of + * parents it names is the set -- not an addition to whatever was there. That + * is what makes a disavowal expressible at all: a room drops a parent by + * re-announcing without it, and if these rows were merged rather than + * rewritten the dropped parent would survive the statement that dropped it. + * + * Deleting before inserting inside one transaction is also what stops a + * reader seeing a state beside somebody else's parents. Were the two writes + * separate, a concurrent `getLocalByChatRoomId` could land between them and + * read a state with no lineage, or with the old one -- both of which look + * exactly like a true answer. + * + * Returns the state now on file, with the lineage now on file beside it. */ @Transaction - open suspend fun replace(groupKeyState: GroupKeyState): GroupKeyState { + open suspend fun replace( + groupKeyState: GroupKeyState, + parents: List = emptyList(), + ): LocalGroupKeyState { val known = getByChatRoomId(groupKeyState.chatRoomId) - if (known != null && known.announcedAt >= groupKeyState.announcedAt) return known + if (known != null && known.announcedAt >= groupKeyState.announcedAt) { + return LocalGroupKeyState( + keyState = known, + parents = getParentsByChatRoomId(known.chatRoomId) + ) + } val stamped = groupKeyState.copy( createdAt = known?.createdAt ?: groupKeyState.createdAt, @@ -70,6 +133,13 @@ abstract class GroupKeyStateDao { ) upsert(stamped) - return stamped + // The state's own row has to exist before its parents', because they + // reference it. Room turns foreign keys off around a migration but not + // around a transaction, so the order here is load-bearing rather than + // tidy. + deleteParentsByChatRoomId(stamped.chatRoomId) + if (parents.isNotEmpty()) upsertParents(parents) + + return LocalGroupKeyState(keyState = stamped, parents = parents) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt new file mode 100644 index 00000000..e7869173 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/migrations/SubgroupLineageMigration.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.database.migrations + +import androidx.room3.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +/** + * Moves a room's parentage off two `GroupKeyState` columns onto + * `GroupKeyStateParent`, so a room can be the child of more than one group. + * + * Not an `AutoMigration`. Room can create the table and it can drop the columns, + * but it cannot copy between them, and this migration is entirely about the copy. + * + * ### Every existing lineage is a set of one + * + * A group could only be founded with a parent, never adopted by a second, so + * every row that carries a parentage carries exactly one. Each becomes one row + * in the new table and nothing is lost -- and the rule the new primary key + * states, one certificate per child per parent, is already true of all of them. + * + * Rows with no parentage -- every group made before subgroups, and every + * top-level group made after -- contribute nothing, which is what "nobody's + * child" looks like in the new shape: no rows rather than a null. + * + * The `WHERE` takes both halves. A row with one and not the other should not + * exist, because `GroupKeyStateManager.stateFrom` has always dropped a state + * carrying a half-claim rather than filing it -- but this is the one moment the + * data is reshaped rather than re-derived, and a half row copied forward would + * become a parent whose certificate is the string "null". Better to leave such a + * row behind entirely: the state keeps saying what the room signs with, which is + * the part that matters, and loses a lineage it could never have proved. + * + * ### `ChatRoom.parentChatRoomId` goes with them, unread + * + * It was a cache of the verified value, written by `MarmotGroupCreation` and + * read -- in production -- by nothing at all; the live answer went through + * `GroupKeyState`. Keeping it would mean a column holding *one* of several + * parents, which is worse than unused: it is a cache that is wrong. So it is + * dropped rather than backfilled, and nothing is copied out of it. + * + * ### Why `DROP COLUMN` rather than a table rebuild + * + * The usual way to remove columns in SQLite -- create a new table, copy, drop + * the old one, rename -- is unsafe here and quietly so. `GroupKeyStateParent` + * references `GroupKeyState(chatRoomId)` `ON DELETE CASCADE`, and `DROP TABLE` + * fires cascades: with foreign keys enforced it would delete every parent row + * this migration had just written. Dropping `ChatRoom` would be far worse, since + * most of the schema cascades off it. + * + * `ALTER TABLE ... DROP COLUMN` avoids the question. It needs SQLite 3.35 and + * the columns must be free of indices and constraints -- all three are, and + * `getRoomDatabase` pins `BundledSQLiteDriver` on every platform, so the version + * is ours rather than the host's. Both tables keep their keys, their foreign keys + * and their indices. + */ +val MIGRATION_20_21 = object : Migration(20, 21) { + override suspend fun migrate(connection: SQLiteConnection) { + connection.execSQL( + "CREATE TABLE IF NOT EXISTS `GroupKeyStateParent` (" + + "`chatRoomId` TEXT NOT NULL, " + + "`parentChatRoomId` TEXT NOT NULL, " + + "`birthCertificateJson` TEXT NOT NULL, " + + "PRIMARY KEY(`chatRoomId`, `parentChatRoomId`), " + + "FOREIGN KEY(`chatRoomId`) REFERENCES `GroupKeyState`(`chatRoomId`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE )" + ) + connection.execSQL( + "CREATE INDEX IF NOT EXISTS `index_GroupKeyStateParent_parentChatRoomId` " + + "ON `GroupKeyStateParent` (`parentChatRoomId`)" + ) + + connection.execSQL( + "INSERT OR IGNORE INTO `GroupKeyStateParent` " + + "(`chatRoomId`, `parentChatRoomId`, `birthCertificateJson`) " + + "SELECT `chatRoomId`, `parentChatRoomId`, `birthCertificateJson` " + + "FROM `GroupKeyState` " + + "WHERE `parentChatRoomId` IS NOT NULL AND `birthCertificateJson` IS NOT NULL" + ) + + listOf("parentChatRoomId", "birthCertificateJson").forEach { column -> + connection.execSQL("ALTER TABLE `GroupKeyState` DROP COLUMN `$column`") + } + + connection.execSQL("ALTER TABLE `ChatRoom` DROP COLUMN `parentChatRoomId`") + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt index 20dc9cc5..8babc6ff 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt @@ -112,29 +112,6 @@ data class ChatRoom( */ val chronicleRequestedAt: Instant? = null, - /** - * The group this room is a subgroup of, or null for a room that is nobody's - * child. - * - * A copy of the verified `GroupKeyState.parentChatRoomId`, kept here so the - * room list and the room's own screen can answer "whose child is this" - * without a second read. It is written as the room is created or adopts its - * key state, and only from a state whose certificate passed - * `SubgroupBirthCertificateEvent.certifies`. - * - * **Never a foreign key onto `ChatRoom`.** Every other reference to a room in - * this schema cascades, and a self-referential cascade would mean deleting a - * parent silently deletes its subgroups -- and then, by their own cascades, - * those rooms' messages, participants, key states, signing sessions and - * signed events. A user tidying up a group they left would destroy a group - * they are still in. `RESTRICT` would be no better: it would make a parent - * undeletable while a child row exists, which is a foreign key deciding a - * product question. This routinely names a room this device does not have -- - * see `GroupKeyState.parentChatRoomId` -- so a dangling value is the normal, - * expected state. - */ - val parentChatRoomId: HexKey? = null, - override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt index 05800bf5..a3524da5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyState.kt @@ -94,41 +94,16 @@ data class GroupKeyState( val announcedAt: Instant, /** - * The group this one is a subgroup of, or null for a group that is nobody's - * child -- which is every group made before subgroups existed. + * A lineage used to live here, as a parent id and a certificate. It is + * `GroupKeyStateParent` now, one row per parent, because a room can have + * more than one -- see `docs/subgroups-multiple-parents.md`. Nothing about + * what it means changed in the move, including that the parent side is never + * a foreign key onto `ChatRoom`; that entity's comment carries the argument. * - * **Not a foreign key, and that is deliberate.** The three tables around this - * one cascade off `ChatRoom`, and a self-referential cascade here would mean - * deleting a parent room deletes every subgroup's state beneath it. It could - * not be `RESTRICT` either: this routinely names a room the device does not - * have at all -- a member of a subgroup who was never in its parent holds the - * id and nothing else -- so a dangling value is the normal state and readers - * resolve it with a lookup allowed to return null. - * - * Written only from a state whose [birthCertificateJson] passed - * `SubgroupBirthCertificateEvent.certifies`, so a row carrying this is a row - * where the parent's quorum really did sign for this room. See - * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than - * keeping a parentage it could not check. + * Read the two together through `LocalGroupKeyState`, never apart: a state + * and the lineage it claims are one signed statement, and + * `GroupKeyStateManager.stateFrom` believes all of it or drops all of it. */ - val parentChatRoomId: HexKey? = null, - - /** - * The parent's signed certificate, whole, as JSON -- or null when this group - * has no parent. - * - * The claim itself, where [parentChatRoomId] is only an index into it. Kept - * as the event rather than as its signature so that a reader can check it - * again from the row alone: a signature plus a rule for rebuilding the event - * it covers is a rule that breaks silently the first time the certificate's - * shape changes, and a rebuild differing by one byte hashes to an id whose - * signature fails and is indistinguishable from a forgery. - * - * Always set together with [parentChatRoomId]. One without the other is a - * state that never reached this table. - */ - val birthCertificateJson: String? = null, - override val createdAt: Instant = Clock.System.now(), override val updatedAt: Instant = createdAt, override val savedAt: Instant = createdAt, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt new file mode 100644 index 00000000..8dda692d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/GroupKeyStateParent.kt @@ -0,0 +1,86 @@ +package press.mantra.compose.database.model + +import androidx.room3.Entity +import androidx.room3.ForeignKey +import androidx.room3.Index +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * One group that has certified a room as its child, and the signature saying so. + * + * A row per parent, because a room can have more than one: it is founded with + * one and may be adopted by another later, and an adoption is the parent + * certifying plus the child re-announcing its key state with the pair added. See + * `docs/subgroups-multiple-parents.md`. + * + * These rows were two nullable columns on [GroupKeyState] while a lineage could + * only ever be one link. Nothing about their meaning changed in the move. + * + * ### The primary key is the rule + * + * `(chatRoomId, parentChatRoomId)` -- one certificate per child per parent. A + * second entry for a parent already in the set is one parent asked twice and is + * refused by `SubgroupLineage.of` before it gets here; the key is what makes + * that a property of the store rather than of a reader remembering. + * + * ### Verified before it is written, every time + * + * A row exists only where `SubgroupBirthCertificateEvent.certifies` has said + * that this parent's quorum really signed for this room -- see + * `GroupKeyStateManager.stateFrom`, which drops the **whole** state when any one + * parent's certificate fails. So the presence of a row is itself the claim that + * the lineage was checked, and there is no state in which some parents are + * believed and others are not. + * + * [birthCertificateJson] is the certificate whole rather than its signature, + * so a reader can check it again from the row alone. A signature plus a rule for + * rebuilding the event it covers is a rule that breaks silently the first time + * the certificate's shape changes: a rebuild differing by one byte hashes to an + * id whose signature fails and is indistinguishable from a forgery. + * + * ### The cascade points at the state, never at the parent's room + * + * `ON DELETE CASCADE` from [GroupKeyState] is what keeps a lineage from + * outliving the state that claimed it -- and it is what lets a re-announcement + * that drops a parent actually drop it, since `GroupKeyStateDao.replace` + * rewrites these rows inside the same transaction. + * + * [parentChatRoomId] is deliberately **not** a foreign key onto `ChatRoom`. A + * self-referential cascade would mean deleting a parent room deletes its + * children's key states, and then by their own cascades those rooms' messages, + * participants, signing sessions and signed events -- a user tidying up a group + * they left would destroy a group they are still in. `RESTRICT` would be no + * better: it would make a parent undeletable while a child row exists, which is + * a foreign key deciding a product question. And this routinely names a room the + * device does not have at all, since a member of a subgroup need never have been + * in its parent, so a dangling value is the normal, expected state and readers + * resolve it with a lookup allowed to return null. + */ +@Entity( + primaryKeys = ["chatRoomId", "parentChatRoomId"], + foreignKeys = [ + ForeignKey( + entity = GroupKeyState::class, + parentColumns = ["chatRoomId"], + childColumns = ["chatRoomId"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [ + // The composite primary key already indexes chatRoomId as its leftmost + // column, so only the other direction needs one: "which rooms has this + // group certified", which is how a parent finds the children whose state + // this device holds. + Index("parentChatRoomId"), + ], +) +data class GroupKeyStateParent( + /** The child: the room whose key state carries this parentage. */ + val chatRoomId: String, + + /** The parent's room id, which is also the key its certificate verifies against. */ + val parentChatRoomId: HexKey, + + /** The parent's signed certificate, whole, as JSON. */ + val birthCertificateJson: String, +) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt new file mode 100644 index 00000000..30991c56 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/intermdiate/LocalGroupKeyState.kt @@ -0,0 +1,62 @@ +package press.mantra.compose.database.model.intermdiate + +import androidx.room3.Embedded +import androidx.room3.Relation +import com.vitorpamplona.quartz.nip01Core.core.Event +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage + +/** + * A room's key state with every group that has certified it as a child. + * + * The two always travel together, because a state and the lineage it claims are + * one signed statement: `GroupKeyStateManager.stateFrom` either believes all of + * it or drops all of it, so a reader holding the state without the parents would + * be holding half of something that has no halves. + * + * Also the shape `stateFrom` returns. It is a Room read model -- `@Embedded` + * over `@Relation`, like `LocalChatRoom` -- and a plain data class besides, so + * the pure verifier can build one with no database anywhere near it. + */ +data class LocalGroupKeyState( + @Embedded + val keyState: GroupKeyState, + + @Relation( + entity = GroupKeyStateParent::class, + parentColumns = ["chatRoomId"], + entityColumns = ["chatRoomId"], + ) + val parents: List = emptyList(), +) { + /** + * The lineage these rows amount to, or null for a room that is nobody's + * child. + * + * Null also where the rows cannot be read back as a lineage -- a certificate + * whose JSON no longer parses, or a repeat the store should have made + * impossible. Both are the same answer here, and it is the safe one: a + * lineage that cannot be stated is not half-stated, for the reason + * `stateFrom` drops a whole state rather than keeping a parentage it could + * not check. + * + * Rebuilt rather than cached, so the canonical order and the no-repeats rule + * come from `SubgroupLineage` on every read instead of from whatever order + * the rows came back in. + */ + fun lineage(): SubgroupLineage? = SubgroupLineage.of( + parents.mapNotNull { parent -> + Event.fromJsonOrNull(parent.birthCertificateJson)?.let { certificate -> + SubgroupParentage( + parentChatRoomId = parent.parentChatRoomId, + certificate = certificate + ) + } + }.takeIf { it.size == parents.size } ?: return null + ) + + /** The groups that have certified this room, in canonical order. */ + fun parentChatRoomIds(): List = lineage()?.parentChatRoomIds.orEmpty() +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 6b71b5e8..915fdec8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -55,7 +55,6 @@ class DatabaseChatRepository( userPublicKey: HexKey, keyPair: KeyPair, path: List, - parentChatRoomId: HexKey?, ): MarmotGroupCreation.Outcome = MarmotGroupCreation.create( database = database, chatRepository = this, @@ -66,7 +65,6 @@ class DatabaseChatRepository( userPublicKey = userPublicKey, keyPair = keyPair, path = path, - parentChatRoomId = parentChatRoomId, ) override suspend fun observeChatRoomListByPublicKey(publicKey: String): Flow> { return database.chatRoomDao().observeChatRoomListByUserPublicKey(publicKey) @@ -113,11 +111,11 @@ class DatabaseChatRepository( emptyList() } - override suspend fun parentOf(chatRoomId: String): HexKey? = try { - SubgroupManager.parentOf(database, chatRoomId) + override suspend fun parentsOf(chatRoomId: String): List = try { + SubgroupManager.parentsOf(database, chatRoomId) } catch (e: Throwable) { - logger.e("Error reading the parent of $chatRoomId", e) - null + logger.e("Error reading the parents of $chatRoomId", e) + emptyList() } override suspend fun groupSignedEvent(chatRoomId: String, id: HexKey): GroupSignedEvent? = try { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index e1674fc5..1353970d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -5,7 +5,7 @@ import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.types.DkgRitualStage import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @@ -110,7 +110,7 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) - override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> @@ -132,7 +132,7 @@ class DatabaseDkgRepository( subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } } - override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = try { + override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try { GroupKeyStateManager.adopt(database = database, chatRoomId = chatRoomId) } catch (e: Throwable) { // The room is made and usable either way; what it loses is the shortcut diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt index ff4613f6..757221c8 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/GroupKeyStateManager.kt @@ -9,8 +9,10 @@ import press.mantra.compose.database.MantraDatabase import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent import press.mantra.compose.nostr.subgroup.SubgroupLineage @@ -122,18 +124,6 @@ object GroupKeyStateManager { "Ceremony ${key.id} has produced no key for $signingRoomId to propose" ) - // The event can carry a lineage of any size; the row cannot yet. Until - // `GroupKeyState` holds a set, a caller reaching here with two parents - // would have one of them silently dropped on the way to the columns, so - // it is refused outright rather than half-kept. The tags are already - // plural -- see `GroupKeyStateEvent.assembleTags` -- which is what lets - // the wire land before the storage does. - check(lineage == null || lineage.size == 1) { - "Room $signingRoomId cannot yet propose a key state naming " + - "${lineage?.size} parents" - } - val parent = lineage?.parents?.single() - // The room this is about: the group's key walked to the admin path, // which is that room's id. It need not exist yet, and in the flow this // was written for it does not. @@ -150,9 +140,7 @@ object GroupKeyStateManager { thresholdPublicKey = thresholdPublicKey, derivationPath = SharedKeyDerivation.formatPath(path), announcedBy = userPublicKey, - announcedAt = Instant.fromEpochSeconds(createdAt), - parentChatRoomId = parent?.parentChatRoomId, - birthCertificateJson = parent?.certificate?.toJson() + announcedAt = Instant.fromEpochSeconds(createdAt) ) check(state.verifies()) { @@ -160,10 +148,14 @@ object GroupKeyStateManager { } // Refused here rather than discovered by the group. Every device that - // receives this state runs the same check in `parentageOf` and drops the - // state when it fails, so proposing one this device would not believe - // spends a quorum's attention on a statement nobody will keep. - if (parent != null) { + // receives this state runs the same checks in `lineageOf` and drops the + // state when any of them fails, so proposing one this device would not + // believe spends a quorum's attention on a statement nobody will keep. + // + // Every parent, because the receiving check is all-or-nothing: one bad + // certificate in a set of two drops the whole state, so a proposal is + // only worth making if every link in it holds. + lineage?.parents?.forEach { parent -> check( SubgroupBirthCertificateEvent.certifies( event = parent.certificate, @@ -214,18 +206,19 @@ object GroupKeyStateManager { database: MantraDatabase, chatRoomId: String, innerEvent: Event - ): GroupKeyState? { - val state = stateFrom(chatRoomId, innerEvent) ?: return null + ): LocalGroupKeyState? { + val local = stateFrom(chatRoomId, innerEvent) ?: return null + val announced = local.keyState.chatRoomId - if (database.chatRoomDao().findChatRoomById(state.chatRoomId) == null) { + if (database.chatRoomDao().findChatRoomById(announced) == null) { logger.i( - "The group signed a key state for ${state.chatRoomId}, which does not exist " + + "The group signed a key state for $announced, which does not exist " + "yet; holding it until it does" ) return null } - return database.groupKeyStateDao().replace(state) + return database.groupKeyStateDao().replace(local.keyState, local.parents) } /** @@ -248,12 +241,15 @@ object GroupKeyStateManager { * Idempotent: `replace` keeps the newest state per room, so adopting twice * settles on the same row. */ - suspend fun adopt(database: MantraDatabase, chatRoomId: String): GroupKeyState? { - val state = signedStateFor(database, chatRoomId) ?: return null + suspend fun adopt(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? { + val local = signedStateFor(database, chatRoomId) ?: return null - logger.i("Room $chatRoomId adopts the key state its group signed at ${state.announcedAt}") + logger.i( + "Room $chatRoomId adopts the key state its group signed at " + + "${local.keyState.announcedAt}, naming ${local.parents.size} parents" + ) - return database.groupKeyStateDao().replace(state) + return database.groupKeyStateDao().replace(local.keyState, local.parents) } /** @@ -266,7 +262,7 @@ object GroupKeyStateManager { * the ritual screen gates the "create the room" button on, and what [adopt] * files once there is a room to file it against. */ - suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): GroupKeyState? = + suspend fun signedStateFor(database: MantraDatabase, chatRoomId: String): LocalGroupKeyState? = stateAmong(database.groupSignedEventDao().getByKind(GroupKeyStateEvent.KIND), chatRoomId) /** @@ -295,7 +291,7 @@ object GroupKeyStateManager { * arrive can ask the same question of each emission -- which is how the * ceremony screen knows the group has finished agreeing. */ - fun stateAmong(signedEvents: List, chatRoomId: String): GroupKeyState? = + fun stateAmong(signedEvents: List, chatRoomId: String): LocalGroupKeyState? = signedAmong(signedEvents, chatRoomId)?.second /** @@ -310,7 +306,7 @@ object GroupKeyStateManager { private fun signedAmong( signedEvents: List, chatRoomId: String - ): Pair? = + ): Pair? = signedEvents .asSequence() .filter { it.kind == GroupKeyStateEvent.KIND } @@ -319,7 +315,7 @@ object GroupKeyStateManager { // every room at once, so a state that names no room names nothing. .filter { GroupKeyStateEvent.parseChatRoomId(it.tags) == chatRoomId } .mapNotNull { signed -> stateFrom(chatRoomId, signed.toEvent())?.let { signed to it } } - .maxByOrNull { (_, state) -> state.announcedAt } + .maxByOrNull { (_, local) -> local.keyState.announcedAt } /** * The state an event amounts to, or null if it amounts to none. @@ -358,20 +354,21 @@ object GroupKeyStateManager { * names no room at all, and appears in the logs. */ /** - * A parentage that was checked, wrapping a value that is allowed to be + * A lineage that was checked, wrapping a value that is allowed to be * absent. * * Three outcomes have to be told apart and a bare nullable can only carry * two. A state may claim no parent, which is every group made before - * subgroups and every top-level group made after; it may claim one and prove - * it; or it may claim one it cannot prove, which is not a state with a field - * missing but a device asserting a relationship the parent never agreed to. - * The third has to stop [stateFrom] rather than be flattened into the first. + * subgroups and every top-level group made after; it may claim some and + * prove them all; or it may claim some it cannot prove, which is not a state + * with a field missing but a device asserting a relationship a parent never + * agreed to. The third has to stop [stateFrom] rather than be flattened into + * the first. * * So `null` here means refused, and a present [value] of `null` means none * claimed. */ - private class Parentage(val value: SubgroupParentage?) + private class Lineage(val value: SubgroupLineage?) /** * The parentage an event actually establishes, or null if it claims one it @@ -387,12 +384,12 @@ object GroupKeyStateManager { * not a state with one field wrong, and half-believing it would file a group * as top-level that its own members will see as a subgroup. */ - private fun parentageOf( + private fun lineageOf( announced: String, thresholdPublicKey: HexKey, innerEvent: Event - ): Parentage? { - if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Parentage(null) + ): Lineage? { + if (!GroupKeyStateEvent.claimsParentage(innerEvent.tags)) return Lineage(null) // Both or neither, per parent, and no parent twice -- all of which // `parseLineage` decides. A parent named with no certificate is a claim @@ -410,55 +407,57 @@ object GroupKeyStateManager { return null } - // One parent is all a row can hold until `GroupKeyState` carries a set. - // Refused rather than truncated: taking the first of two would file a - // lineage the group did not sign and leave the device believing a group - // has one parent when its own members see two. Nothing in this build - // emits a second, so this fires only on a state from a newer one. - if (lineage.size > 1) { - logger.w( - "Key state for $announced claims ${lineage.size} parents " + - "(${lineage.parentChatRoomIds}), which this build cannot file; dropping" - ) - return null + // Every parent, or none of them. One bad certificate drops the whole + // state rather than the one pair, for the reason the all-or-nothing rule + // was written: a state that claims a lineage it cannot back is a device + // asserting a relationship somebody never agreed to, and keeping the + // provable half would file a group as having one parent when its own + // members see two. A set that is partly believed is not a weaker set. + lineage.parents.forEach { parentage -> + val parentChatRoomId = parentage.parentChatRoomId + val certificate = parentage.certificate + + // The parent's own quorum, over this very room's id. Everything that + // makes a lineage worth anything is in here -- see + // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup, + // no key it has to be told, and no knowledge of the other parents. + // Each link is checked on its own, which is why a lineage can be + // assembled a parent at a time. + if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) { + logger.w( + "Key state for $announced carries no certificate by $parentChatRoomId for " + + "that room; dropping" + ) + return null + } + + // Belt and braces: the id already derives from this key, and the + // certificate's id already derives from the key it names, so two + // certificates for one room cannot name two keys. Stated anyway + // because the two facts live in different places and a future change + // to either should have to notice this. + val certifiedKey = + SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags) + if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) { + logger.w( + "Key state for $announced names $thresholdPublicKey and the certificate " + + "by $parentChatRoomId names $certifiedKey; dropping" + ) + return null + } } - val parentage = lineage.parents.single() - val parentChatRoomId = parentage.parentChatRoomId - val certificate = parentage.certificate - - // The parent's own quorum, over this very room's id. Everything that - // makes a lineage worth anything is in here -- see - // `SubgroupBirthCertificateEvent.certifies`, which needs no lookup and no - // key it has to be told. - if (!SubgroupBirthCertificateEvent.certifies(certificate, announced, parentChatRoomId)) { - logger.w( - "Key state for $announced carries no certificate by $parentChatRoomId for " + - "that room; dropping" - ) - return null - } - - // Belt and braces: the id already derives from this key, and the - // certificate's id already derives from the key it names, so two - // certificates for one room cannot name two keys. Stated anyway because - // the two facts live in different places and a future change to either - // should have to notice this. - val certifiedKey = SubgroupBirthCertificateEvent.parseThresholdPublicKey(certificate.tags) - if (!thresholdPublicKey.equals(certifiedKey, ignoreCase = true)) { - logger.w( - "Key state for $announced names $thresholdPublicKey and its certificate names " + - "$certifiedKey; dropping" - ) - return null - } - - return Parentage( - SubgroupParentage(parentChatRoomId = parentChatRoomId, certificate = certificate) - ) + // Deliberately not checked: whether the parents know about each other. + // An earlier certificate cannot name a later co-parent -- the first + // parent signed before the second was a possibility -- so requiring them + // to agree on the set would make every adoption cost a fresh quorum in + // every group that had already certified this room. The child's key state + // is the sole authority on the set; each parent has a veto on being added + // and none on who else is in it. See docs/subgroups-multiple-parents.md. + return Lineage(lineage) } - fun stateFrom(chatRoomId: String, innerEvent: Event): GroupKeyState? { + fun stateFrom(chatRoomId: String, innerEvent: Event): LocalGroupKeyState? { val announced = GroupKeyStateEvent.parseChatRoomId(innerEvent.tags) ?: chatRoomId val thresholdPublicKey = GroupKeyStateEvent.parseThresholdPublicKey(innerEvent.content) @@ -491,7 +490,7 @@ object GroupKeyStateManager { return null } - val parent = parentageOf(announced, thresholdPublicKey, innerEvent) ?: return null + val lineage = lineageOf(announced, thresholdPublicKey, innerEvent) ?: return null val state = GroupKeyState( chatRoomId = announced, @@ -499,9 +498,7 @@ object GroupKeyStateManager { thresholdPublicKey = thresholdPublicKey, derivationPath = SharedKeyDerivation.formatPath(path), announcedBy = innerEvent.pubKey, - announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt), - parentChatRoomId = parent.value?.parentChatRoomId, - birthCertificateJson = parent.value?.certificate?.toJson() + announcedAt = Instant.fromEpochSeconds(innerEvent.createdAt) ) // Half the trust model, in one line, and the half that does not care who @@ -514,6 +511,15 @@ object GroupKeyStateManager { return null } - return state + return LocalGroupKeyState( + keyState = state, + parents = lineage.value?.parents?.map { + GroupKeyStateParent( + chatRoomId = announced, + parentChatRoomId = it.parentChatRoomId, + birthCertificateJson = it.certificate.toJson() + ) + }.orEmpty() + ) } } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt index c25a8c11..841bbff4 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/MarmotGroupCreation.kt @@ -93,10 +93,6 @@ object MarmotGroupCreation { * appended to it, because MIP-01 has no field for one and the path is what * rebuilds the `TweakCache` a signing session needs. * - * [parentChatRoomId] is written onto the room when this is a subgroup. It is - * the verified value off the child's own key state -- never a claim off a - * proposal. - * * [name] is put under [MarmotGroupName] here rather than trusted from the * caller. Every Marmot room on a device is a `#name`, and this is the funnel * both flows that make one already go through, so it is the one place the @@ -112,7 +108,6 @@ object MarmotGroupCreation { userPublicKey: HexKey, keyPair: KeyPair, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parentChatRoomId: HexKey? = null, keyPackageLookupTimeout: Long = KEY_PACKAGE_LOOKUP_TIMEOUT ): Outcome { chatRepository.getChatRoomByIdentifier(groupId)?.let { return Outcome.Existing(it) } @@ -163,15 +158,6 @@ object MarmotGroupCreation { mlsGroupState = group.saveState().encodeTls().toHex() ) ?: return Outcome.Failed("Couldn't create the group. Please try again.") - if (parentChatRoomId != null) { - // Written after the room exists rather than passed into its creation, - // because `getOrCreateChatRoom` is shared with every other way a room - // appears and none of them has a parent to hand it. - database.chatRoomDao().upsert( - localChatRoom.chatRoom.copy(parentChatRoomId = parentChatRoomId) - ) - } - // The room comes into existence already knowing what it signs with. The // group agreed that before any of this ran and this device has been // holding the signed statement since; this is simply the first moment diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 1e3b0083..42e57358 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -410,18 +410,26 @@ object SubgroupManager { .sortedByDescending { it.certificate.createdAt } /** - * The group a room is a subgroup of, verified, or null if it is nobody's - * child. + * Every group that has certified a room as its child, verified, in canonical + * order -- empty for a room that is nobody's child. * - * Off the `GroupKeyState`, which only carries a parent whose certificate was - * checked -- see `GroupKeyStateManager.stateFrom`. A room whose state this - * device does not hold answers null even when it really is a subgroup, which - * is the position a member welcomed in after the founding is in; the - * unverified hint in the room's own description is the most they get, and it - * must never be written here. + * Off `GroupKeyStateParent`, whose rows exist only where the certificate was + * checked against that parent's own signature -- see + * `GroupKeyStateManager.stateFrom`, which drops the whole state rather than + * keeping a lineage it could not check. So every id here is a group that + * really did certify this room. + * + * More than one is the ordinary case now, not a corner: a group is founded + * with one parent and may be adopted by another later. The order is the + * lineage's own and says nothing about which came first. + * + * A room whose state this device does not hold answers empty even when it + * really is a subgroup, which is the position a member welcomed in after the + * founding is in. Nothing unverified may be written here. */ - suspend fun parentOf(database: MantraDatabase, chatRoomId: String): HexKey? = - database.groupKeyStateDao().getByChatRoomId(chatRoomId)?.parentChatRoomId + suspend fun parentsOf(database: MantraDatabase, chatRoomId: String): List = + database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() + .orEmpty() /** * Files a certificate that arrived, or drops it and says why. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 42794c40..9453a9e7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -70,14 +70,16 @@ interface ChatRepository { suspend fun subgroupsOf(chatRoomId: String): List /** - * The group this room is a subgroup of, verified, or null if it is nobody's - * child. + * Every group that has certified this room as its child, verified, in + * canonical order -- empty if it is nobody's child. * - * Off the room's `GroupKeyState`, which only carries a parent whose - * certificate was checked. A member welcomed in after the founding holds no - * state and gets null here even when the room really is a subgroup. + * Off `GroupKeyStateParent`, whose rows exist only where the certificate was + * checked. More than one is ordinary: a group is founded with one parent and + * may be adopted by another later. A member welcomed in after the founding + * holds no state and gets an empty list even when the room really is a + * subgroup. */ - suspend fun parentOf(chatRoomId: String): HexKey? + suspend fun parentsOf(chatRoomId: String): List /** * One event the group signed, as the group signed it, or null when this @@ -203,7 +205,6 @@ interface ChatRepository { userPublicKey: HexKey, keyPair: KeyPair, path: List = SharedKeyDerivation.MARMOT_ADMIN_GROUP_PATH, - parentChatRoomId: HexKey? = null, ): MarmotGroupCreation.Outcome /** @@ -279,7 +280,7 @@ interface ChatRepository { override suspend fun subgroupsOf(chatRoomId: String): List = emptyList() - override suspend fun parentOf(chatRoomId: String): HexKey? = null + override suspend fun parentsOf(chatRoomId: String): List = emptyList() override suspend fun groupSignedEvent( chatRoomId: String, @@ -386,7 +387,6 @@ interface ChatRepository { userPublicKey: HexKey, keyPair: KeyPair, path: List, - parentChatRoomId: HexKey?, ): MarmotGroupCreation.Outcome = MarmotGroupCreation.Outcome.Failed("No database") diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index cb4da4eb..1e6c16d6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -3,7 +3,7 @@ package press.mantra.compose.repository import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.FrostSigningSession -import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.intermdiate.LocalGroupKeyState import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession @@ -149,10 +149,10 @@ interface DkgRepository { * a finished subgroup with its key state signed, its certificate signed, and * no way to create the room -- the lookup found no ceremony at all. */ - fun observeSignedGroupKeyState(dkgSessionId: String): Flow + fun observeSignedGroupKeyState(dkgSessionId: String): Flow /** Files the state the group signed for a room that now exists. */ - suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? + suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? /** * Asks the parent's quorum to certify the subgroup this ceremony produced. @@ -242,10 +242,10 @@ interface DkgRepository { chatRoomId: String ): Flow> = flowOf(emptyList()) - override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = flowOf(null) - override suspend fun adoptGroupKeyState(chatRoomId: String): GroupKeyState? = null + override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = null override suspend fun proposeBirthCertificate( parentChatRoomId: String, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 0689fb34..9c9efbae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -259,8 +259,14 @@ fun ChatRoomDetailScreen( // nobody's child, and absent -- rather than guessed -- // on a subgroup this device holds no key state for, // since an unverified lineage is not one worth showing. - chatRoomDetailUIState.parentChatRoomId?.let { parentChatRoomId -> - item { + // + // One card per parent, because a room can have more + // than one: founded with one and adopted by another + // later. They are a set rather than a path -- neither + // is "the" parent and the order says nothing about + // which came first -- so each gets an equal row. + chatRoomDetailUIState.parentChatRoomIds.forEach { parentChatRoomId -> + item(key = "parent-$parentChatRoomId") { Card( modifier = Modifier.fillMaxWidth(), onClick = { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 3bfb3db2..4500b9ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -81,7 +81,7 @@ class ChatRoomDetailViewModel( groupKeyState = chatRepository.groupKeyState(chatRoomId), signedGroupKeyStateEvent = chatRepository.signedGroupKeyStateEvent(chatRoomId), subgroups = chatRepository.subgroupsOf(chatRoomId), - parentChatRoomId = chatRepository.parentOf(chatRoomId), + parentChatRoomIds = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), ) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index d369fd14..76d61b2b 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -607,11 +607,7 @@ class DkgRitualViewModel( }, adminPublicKeys = members, userPublicKey = activeUserPublicKey, - keyPair = keyPair, - // Verified by the time it is written: the key state this room was - // gated on carries the certificate, and `stateFrom` refused it - // otherwise. - parentChatRoomId = subgroupParent + keyPair = keyPair ) isActionPending.value = false diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 80f08814..7082d6d8 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -49,7 +49,7 @@ sealed interface ChatRoomDetailUIState { * founding holds no state and so sees nothing here, even in a room that * really is a subgroup. */ - val parentChatRoomId: HexKey? = null, + val parentChatRoomIds: List = emptyList(), /** * Whether this device could open a subgroup here at all. * diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt index fb4b7a5b..48892ee3 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/managers/GroupKeyStateTest.kt @@ -75,6 +75,15 @@ class GroupKeyStateTest { threshold = threshold ) + /** A third group, for the lineages that name two parents. */ + private val secondParentKeyMaterial: KeyMaterial = Frost.trustedDealerKeygen( + thresholdSecretKey = PrivateKey( + ByteVector32("3c0c0a0000000000000000000000000000000000000000000000000000000000c3") + ), + nParticipants = participants, + threshold = threshold + ) + private val thresholdPublicKey = keyMaterial.thresholdPublicKey.value.toHex() /** The other group's key, for the states that name the wrong one. */ @@ -307,15 +316,15 @@ class GroupKeyStateTest { fun `a state the group signed about the room it arrives in is taken`() { val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState()) - assertEquals(chatRoomId, state?.chatRoomId) - assertEquals("ceremony-1", state?.dkgSessionId) - assertEquals(thresholdPublicKey, state?.thresholdPublicKey) - assertEquals("m/9420/0/0", state?.derivationPath) + assertEquals(chatRoomId, state?.keyState?.chatRoomId) + assertEquals("ceremony-1", state?.keyState?.dkgSessionId) + assertEquals(thresholdPublicKey, state?.keyState?.thresholdPublicKey) + assertEquals("m/9420/0/0", state?.keyState?.derivationPath) // Attribution and ordering come off the event, not off the clock. The // author is the room itself now, which is the whole point: nobody in // particular said this. - assertEquals(chatRoomId, state?.announcedBy) - assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.announcedAt) + assertEquals(chatRoomId, state?.keyState?.announcedBy) + assertEquals(Instant.fromEpochSeconds(1_700_000_000), state?.keyState?.announcedAt) } @Test @@ -412,8 +421,8 @@ class GroupKeyStateTest { ) ) - assertNotEquals(chatRoomId, state?.chatRoomId) - assertEquals(siblingRoom, state?.chatRoomId) + assertNotEquals(chatRoomId, state?.keyState?.chatRoomId) + assertEquals(siblingRoom, state?.keyState?.chatRoomId) } @Test @@ -475,7 +484,8 @@ class GroupKeyStateTest { assertEquals( chatRoomId, - GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected))?.chatRoomId + GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState(tags = undirected)) + ?.keyState?.chatRoomId ) } @@ -760,6 +770,23 @@ class GroupKeyStateTest { /** The parent group's room, which is `otherKeyMaterial` wearing a second hat. */ private val parentChatRoomId get() = identityOf(otherKeyMaterial).hex + /** A second, unrelated parent's room -- the group that adopts the child later. */ + private val secondParentChatRoomId get() = identityOf(secondParentKeyMaterial).hex + + /** A lineage of both parents, each with its own group's real signature. */ + private fun bothParents() = SubgroupLineage.of( + listOf( + SubgroupParentage(parentChatRoomId, birthCertificate()), + SubgroupParentage( + secondParentChatRoomId, + birthCertificate( + parent = secondParentChatRoomId, + author = secondParentKeyMaterial + ) + ) + ) + ) + /** A certificate by [author]'s room saying [subgroupChatRoomId] is its child. */ private fun birthCertificate( subgroupChatRoomId: String = chatRoomId, @@ -799,18 +826,26 @@ class GroupKeyStateTest { fun `a state whose parent certified it keeps the parent and the certificate`() { val state = GroupKeyStateManager.stateFrom(chatRoomId, keyStateWithParent()) - assertEquals(parentChatRoomId, state?.parentChatRoomId) - assertEquals(birthCertificate().toJson(), state?.birthCertificateJson) + assertEquals(listOf(parentChatRoomId), state?.parentChatRoomIds()) + assertEquals( + birthCertificate().toJson(), + state?.parents?.single()?.birthCertificateJson + ) + // And the row points back at the child, which is what makes it a row + // rather than a column. + assertEquals(chatRoomId, state?.parents?.single()?.chatRoomId) } @Test - fun `a state claiming no parent keeps both fields null`() { + fun `a state claiming no parent carries no parent rows at all`() { // Every group made before subgroups existed, and every top-level group - // made after. Null here is the ordinary case, not missing data. + // made after. No rows is the ordinary case, not missing data -- and it is + // one way of saying "nobody's child" rather than two, which is why a + // lineage of none is null rather than empty. val state = GroupKeyStateManager.stateFrom(chatRoomId, signedKeyState()) - assertNull(state?.parentChatRoomId) - assertNull(state?.birthCertificateJson) + assertEquals(emptyList(), state?.parents) + assertNull(state?.lineage()) } @Test @@ -924,17 +959,11 @@ class GroupKeyStateTest { // ids and then all the certificates, such a reader would pair one // parent's id with another's certificate, fail `certifies`, and drop the // whole state -- losing the row that says which share signs for the room. - val second = birthCertificate(parent = chatRoomId, author = keyMaterial) val tags = GroupKeyStateEvent.assembleTags( chatRoomId = chatRoomId, dkgSessionId = "ceremony-1", path = path, - lineage = SubgroupLineage.of( - listOf( - SubgroupParentage(parentChatRoomId, birthCertificate()), - SubgroupParentage(chatRoomId, second) - ) - ) + lineage = bothParents() ) val names = tags.map { it[0] }.filter { @@ -960,11 +989,43 @@ class GroupKeyStateTest { } @Test - fun `a state claiming two parents is dropped rather than truncated`() { - // Until a row can hold a set. Taking the first of two would file a - // lineage the group did not sign, and leave this device believing a group - // has one parent while its own members see two. - val second = birthCertificate(parent = chatRoomId, author = keyMaterial) + fun `a state claiming two parents keeps both, each with its own certificate`() { + // Two groups, two quorums, two signatures over the same child's id. The + // second one signed later and could not have named the first as a + // co-parent, which is why nothing requires the certificates to agree on + // the set -- the child's key state is the sole authority on it. + val state = signedKeyState( + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = chatRoomId, + dkgSessionId = "ceremony-1", + path = path, + lineage = bothParents() + ) + ) + + val local = GroupKeyStateManager.stateFrom(chatRoomId, state) + + assertEquals( + listOf(parentChatRoomId, secondParentChatRoomId).sorted(), + local?.parentChatRoomIds()?.sorted() + ) + assertEquals(2, local?.parents?.size) + // Each row carries the certificate of the parent it names, not whichever + // one came first off the tags. + local?.parents?.forEach { parent -> + assertEquals( + parent.parentChatRoomId, + Event.fromJson(parent.birthCertificateJson).pubKey, + "each parentage row keeps its own parent's certificate" + ) + } + } + + @Test + fun `one bad certificate in a set of two drops the whole state`() { + // All or nothing, generalised. Keeping the provable half would file a + // group as having one parent when its own members see two, and a set + // that is partly believed is not a weaker set. val state = signedKeyState( tags = GroupKeyStateEvent.assembleTags( chatRoomId = chatRoomId, @@ -973,7 +1034,16 @@ class GroupKeyStateTest { lineage = SubgroupLineage.of( listOf( SubgroupParentage(parentChatRoomId, birthCertificate()), - SubgroupParentage(chatRoomId, second) + // Really signed by the second parent, but for a different + // child -- so it certifies something, just not this. + SubgroupParentage( + secondParentChatRoomId, + birthCertificate( + subgroupChatRoomId = otherKey, + parent = secondParentChatRoomId, + author = secondParentKeyMaterial + ) + ) ) ) ) diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt index cb040477..1e1a9e9f 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/database/dao/SubgroupDaoJvmTest.kt @@ -16,31 +16,38 @@ import press.mantra.compose.database.builder.getRoomDatabase import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.types.DkgRitualStage /** - * What schema 17 has to survive, and the one thing it must refuse to do. + * What schema 21 has to survive, and the two things it must refuse to do. * - * Four nullable columns say a group is somebody's child. Three properties of - * them are invisible to the compiler and each one is a way subgroups could break - * quietly. + * A room's parentage is a table now rather than two nullable columns, because a + * room can be the child of more than one group -- founded with one and adopted by + * another later. Four properties of that are invisible to the compiler and each + * one is a way a lineage could break quietly. * * A parent pointer **may name a room this device does not have**. A member of a * subgroup who was never in its parent holds the id and nothing else, so a - * dangling value is the normal state rather than an error -- and if any of these - * columns were ever given a foreign key, that member's rows would stop being + * dangling value is the normal state rather than an error -- and if the parent + * side were ever given a foreign key, that member's rows would stop being * writable at all. * * Deleting a parent **must not touch its children**. Every other reference to a - * room in this schema cascades. A self-referential cascade here would mean a user + * room in this schema cascades. A self-referential cascade would mean a user * tidying away a group they had left silently destroying a group they are still * in, together with its messages, participants, key state and signed work. * - * And the two `GroupKeyState` columns **travel together**. One without the other - * is a state whose parentage cannot be rechecked from the row, which is the whole - * reason the certificate is stored whole rather than as a signature. + * A state's lineage **is replaced wholesale, never merged**. A key state is + * replaceable and the newest wins, so the set of parents it names is the set. + * That is what makes a disavowal expressible at all: a room drops a parent by + * re-announcing without it, and merged rows would leave the dropped parent + * surviving the statement that dropped it. + * + * And a lineage **does not outlive the state that claimed it**, which is the one + * cascade here that is wanted. */ class SubgroupDaoJvmTest { @@ -59,83 +66,72 @@ class SubgroupDaoJvmTest { /** A parent this device has never held a room for. */ private val absentParent = "33".repeat(32) - private val certificate = - """{"id":"aa","pubkey":"bb","created_at":1700000000,"kind":30329,"tags":[],"content":"cc","sig":"dd"}""" + private fun certificate(by: String) = + """{"id":"aa","pubkey":"$by","created_at":1700000000,"kind":30329,""" + + """"tags":[],"content":"cc","sig":"dd"}""" @Test - fun `a key state keeps its parent and the certificate behind it`() = runBlocking { + fun `a key state keeps every parent and the certificate behind each`() = runBlocking { seedRooms() - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(absentParent)) ) - val stored = db.groupKeyStateDao().getByChatRoomId(childRoom) + val stored = db.groupKeyStateDao().getLocalByChatRoomId(childRoom) - assertEquals(parentRoom, stored?.parentChatRoomId) - assertEquals(certificate, stored?.birthCertificateJson) + assertEquals( + listOf(parentRoom, absentParent).sorted(), + stored?.parents?.map { it.parentChatRoomId }?.sorted() + ) + assertEquals( + certificate(parentRoom), + stored?.parents?.single { it.parentChatRoomId == parentRoom }?.birthCertificateJson + ) } @Test - fun `a group that is nobody's child keeps both columns null`() = runBlocking { + fun `a group that is nobody's child carries no parent rows`() = runBlocking { seedRooms() - db.groupKeyStateDao().upsert(keyState(chatRoomId = parentRoom)) + db.groupKeyStateDao().replace(keyState(chatRoomId = parentRoom)) - val stored = db.groupKeyStateDao().getByChatRoomId(parentRoom) + val stored = db.groupKeyStateDao().getLocalByChatRoomId(parentRoom) // Every group made before subgroups existed reads back this way, and so - // does every top-level group made after. Null here is not missing data. - assertNull(stored?.parentChatRoomId) - assertNull(stored?.birthCertificateJson) + // does every top-level group made after. No rows is not missing data, and + // it is one way of saying "nobody's child" rather than two. + assertEquals(emptyList(), stored?.parents) + assertNull(stored?.lineage()) } @Test - fun `a key state may name a parent this device holds no room for`() = runBlocking { + fun `a parentage may name a parent this device holds no room for`() = runBlocking { seedRooms() // The position a subgroup member who was never in the parent is in: they - // hold the id off a certificate and nothing else. If this column were a - // foreign key their state could not be written at all. - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = absentParent, - birthCertificateJson = certificate - ) + // hold the id off a certificate and nothing else. Were the parent side a + // foreign key their lineage could not be written at all. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(absentParent)) ) - assertEquals(absentParent, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId) - assertNull(db.chatRoomDao().findChatRoomById(absentParent)) - } - - @Test - fun `a room may name a parent this device holds no room for`() = runBlocking { - seedRooms() - - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = absentParent)) - assertEquals( - absentParent, - db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId + listOf(absentParent), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } ) + assertNull(db.chatRoomDao().findChatRoomById(absentParent)) } @Test fun `deleting a parent room leaves its subgroup standing`() = runBlocking { seedRooms() - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom)) ) db.chatRoomDao().delete(room(parentRoom)) @@ -146,10 +142,27 @@ class SubgroupDaoJvmTest { // them owns. assertNull(db.chatRoomDao().findChatRoomById(parentRoom)) assertEquals( - parentRoom, - db.chatRoomDao().findChatRoomById(childRoom)?.chatRoom?.parentChatRoomId + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } ) - assertEquals(parentRoom, db.groupKeyStateDao().getByChatRoomId(childRoom)?.parentChatRoomId) + } + + @Test + fun `deleting the child takes its lineage with it`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom)) + ) + + // The one cascade that is wanted: a lineage must not outlive the state + // that claimed it, or a room recreated at the same derived id would + // inherit a parentage nobody signed for it. + db.chatRoomDao().delete(room(childRoom)) + + assertNull(db.groupKeyStateDao().getByChatRoomId(childRoom)) + assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom)) } @Test @@ -159,73 +172,130 @@ class SubgroupDaoJvmTest { val second = "44".repeat(32) db.chatRoomDao().upsert(room(second)) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = childRoom, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate, - announcedAt = Instant.fromEpochSeconds(1_000) - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom)) ) - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = second, - parentChatRoomId = parentRoom, - birthCertificateJson = certificate, - announcedAt = Instant.fromEpochSeconds(2_000) - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = second, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom, child = second)) ) // Another group's child, to prove the filter is on the parent rather // than on having a parent at all. - db.groupKeyStateDao().upsert( - keyState( - chatRoomId = parentRoom, - parentChatRoomId = absentParent, - birthCertificateJson = certificate - ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = parentRoom), + listOf(parentage(absentParent, child = parentRoom)) ) assertEquals( listOf(second, childRoom), - db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.chatRoomId } + db.groupKeyStateDao().getByParentChatRoomId(parentRoom).map { it.keyState.chatRoomId } ) assertEquals( listOf(second, childRoom), - db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first().map { it.chatRoomId } + db.groupKeyStateDao().observeByParentChatRoomId(parentRoom).first() + .map { it.keyState.chatRoomId } ) } @Test - fun `a parent lists only the subgroup rooms this device actually holds`() = runBlocking { + fun `a child with two parents appears in both their lists`() = runBlocking { seedRooms() - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) + db.chatRoomDao().upsert(room(absentParent)) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(absentParent)) + ) - val rooms = db.chatRoomDao().observeByParentChatRoomId(parentRoom).first() - - assertEquals(listOf(childRoom), rooms.map { it.chatRoom.id }) - - // A child that was certified but whose room was never created here shows - // up nowhere in this query. That is the difference between it and - // SubgroupManager.subgroupsOf, which reads the parent's certificates. - assertTrue(db.chatRoomDao().observeByParentChatRoomId(absentParent).first().isEmpty()) - } - - @Test - fun `a soft-deleted subgroup drops out of its parent's list`() = runBlocking { - seedRooms() - - db.chatRoomDao().upsert(room(childRoom).copy(parentChatRoomId = parentRoom)) - db.chatRoomDao().upsert( - room(childRoom).copy( - parentChatRoomId = parentRoom, - deletedAt = Instant.fromEpochSeconds(9_000) + // The row that makes it this parent's child says nothing about who else + // certified it, which is the whole point of the join. + listOf(parentRoom, absentParent).forEach { parent -> + assertEquals( + listOf(childRoom), + db.groupKeyStateDao().getByParentChatRoomId(parent).map { it.keyState.chatRoomId }, + "$parent should list the child it certified" ) + } + } + + @Test + fun `a newer state replaces the lineage rather than adding to it`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom), parentage(absentParent)) ) - // A room the user has cleared away must not come back because its parent - // still lists it. - assertTrue(db.chatRoomDao().observeByParentChatRoomId(parentRoom).first().isEmpty()) + // A disavowal: the child re-announces without one of them. Were these + // rows merged, the dropped parent would survive the statement dropping + // it -- and a parent nobody can drop is a parent for life. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom)) + ) + + assertEquals( + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } + ) + assertTrue(db.groupKeyStateDao().getByParentChatRoomId(absentParent).isEmpty()) + } + + @Test + fun `a re-announcement to nobody's child clears the lineage`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(parentRoom)) + ) + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)) + ) + + assertEquals(emptyList(), db.groupKeyStateDao().getParentsByChatRoomId(childRoom)) + } + + @Test + fun `an older state changes neither the state nor the lineage on file`() = runBlocking { + seedRooms() + + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(2_000)), + listOf(parentage(parentRoom)) + ) + + // A session replayed after a crash must not walk the room back to a + // state it has already moved past -- and must not walk its lineage back + // either, which is new: the parents are part of the statement now. + val kept = db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom, announcedAt = Instant.fromEpochSeconds(1_000)), + listOf(parentage(absentParent)) + ) + + assertEquals(Instant.fromEpochSeconds(2_000), kept.keyState.announcedAt) + assertEquals(listOf(parentRoom), kept.parents.map { it.parentChatRoomId }) + assertEquals( + listOf(parentRoom), + db.groupKeyStateDao().getLocalByChatRoomId(childRoom)?.parents?.map { it.parentChatRoomId } + ) + } + + @Test + fun `one parent gets one row, whatever a caller tries`() = runBlocking { + seedRooms() + + // The primary key is the rule "one certificate per child per parent". + // `SubgroupLineage.of` refuses a repeat long before here; this is the + // store saying so too, so the rule does not rest on a reader remembering. + db.groupKeyStateDao().replace( + keyState(chatRoomId = childRoom), + listOf(parentage(parentRoom), parentage(parentRoom)) + ) + + assertEquals(1, db.groupKeyStateDao().getParentsByChatRoomId(childRoom).size) } @Test @@ -271,10 +341,15 @@ class SubgroupDaoJvmTest { mlsGroupState = null, ) + private fun parentage(parentChatRoomId: String, child: String = childRoom) = + GroupKeyStateParent( + chatRoomId = child, + parentChatRoomId = parentChatRoomId, + birthCertificateJson = certificate(parentChatRoomId), + ) + private fun keyState( chatRoomId: String, - parentChatRoomId: String? = null, - birthCertificateJson: String? = null, announcedAt: Instant = Instant.fromEpochSeconds(1_700_000_000) ) = GroupKeyState( chatRoomId = chatRoomId, @@ -283,8 +358,6 @@ class SubgroupDaoJvmTest { derivationPath = "m/9420/0/0", announcedBy = user, announcedAt = announcedAt, - parentChatRoomId = parentChatRoomId, - birthCertificateJson = birthCertificateJson, ) private fun session( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt index bb0eccc8..90bd88d6 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SignedGroupKeyStateTest.kt @@ -194,7 +194,7 @@ class SignedGroupKeyStateTest { /** The one event a session of one signs. */ suspend fun item(sessionId: String) = items(sessionId).single() - suspend fun keyState() = db.groupKeyStateDao().getByChatRoomId(roomId) + suspend fun keyState() = db.groupKeyStateDao().getLocalByChatRoomId(roomId) /** Everything the group has signed here, as the group signed it. */ suspend fun signedEvents() = db.groupSignedEventDao().getByChatRoomId(roomId) @@ -515,13 +515,13 @@ class SignedGroupKeyStateTest { listOf(creator, other).forEach { device -> val state = device.keyState() assertNotNull(state, "every signer should hold the state the group signed") - assertEquals(thresholdPublicKey, state.thresholdPublicKey) - assertEquals(ceremonyId, state.dkgSessionId) - assertEquals(SharedKeyDerivation.formatPath(), state.derivationPath) + assertEquals(thresholdPublicKey, state.keyState.thresholdPublicKey) + assertEquals(ceremonyId, state.keyState.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), state.keyState.derivationPath) // Signed by the room, so attributed to the room -- nobody in // particular said this. - assertEquals(adminRoomId, state.announcedBy) - assertTrue(state.verifies()) + assertEquals(adminRoomId, state.keyState.announcedBy) + assertTrue(state.keyState.verifies()) } } @@ -589,7 +589,7 @@ class SignedGroupKeyStateTest { absent.keyState(), "a member the quorum did not need still learns what the room signs with" ) - assertEquals(adminRoomId, absent.keyState()?.announcedBy) + assertEquals(adminRoomId, absent.keyState()?.keyState?.announcedBy) } // ---- The key state, signed before the room exists ---------------------- @@ -722,22 +722,22 @@ class SignedGroupKeyStateTest { val adopted = GroupKeyStateManager.adopt(device.db, adminRoomId) assertNotNull(adopted, "a room should adopt the state its group signed for it") - assertEquals(adminRoomId, adopted.chatRoomId) - assertEquals(thresholdPublicKey, adopted.thresholdPublicKey) - assertEquals(ceremonyId, adopted.dkgSessionId) - assertEquals(SharedKeyDerivation.formatPath(), adopted.derivationPath) - assertEquals(adminRoomId, adopted.announcedBy) - assertTrue(adopted.verifies()) + assertEquals(adminRoomId, adopted.keyState.chatRoomId) + assertEquals(thresholdPublicKey, adopted.keyState.thresholdPublicKey) + assertEquals(ceremonyId, adopted.keyState.dkgSessionId) + assertEquals(SharedKeyDerivation.formatPath(), adopted.keyState.derivationPath) + assertEquals(adminRoomId, adopted.keyState.announcedBy) + assertTrue(adopted.keyState.verifies()) // On file, not merely returned. Compared field by field because the // store keeps whole seconds and the row in hand does not. val stored = device.db.groupKeyStateDao().getByChatRoomId(adminRoomId) assertNotNull(stored) - assertEquals(adopted.chatRoomId, stored.chatRoomId) - assertEquals(adopted.thresholdPublicKey, stored.thresholdPublicKey) - assertEquals(adopted.dkgSessionId, stored.dkgSessionId) - assertEquals(adopted.derivationPath, stored.derivationPath) - assertEquals(adopted.announcedAt, stored.announcedAt) + assertEquals(adopted.keyState.chatRoomId, stored.chatRoomId) + assertEquals(adopted.keyState.thresholdPublicKey, stored.thresholdPublicKey) + assertEquals(adopted.keyState.dkgSessionId, stored.dkgSessionId) + assertEquals(adopted.keyState.derivationPath, stored.derivationPath) + assertEquals(adopted.keyState.announcedAt, stored.announcedAt) } } @@ -763,14 +763,14 @@ class SignedGroupKeyStateTest { assertNotNull(first) assertNotNull(second) - assertEquals(first.thresholdPublicKey, second.thresholdPublicKey) - assertEquals(first.dkgSessionId, second.dkgSessionId) - assertEquals(first.derivationPath, second.derivationPath) - assertEquals(first.announcedAt, second.announcedAt) + assertEquals(first.keyState.thresholdPublicKey, second.keyState.thresholdPublicKey) + assertEquals(first.keyState.dkgSessionId, second.keyState.dkgSessionId) + assertEquals(first.keyState.derivationPath, second.keyState.derivationPath) + assertEquals(first.keyState.announcedAt, second.keyState.announcedAt) // The second adopt is a no-op rather than a rewrite: `replace` keeps the // state already on file when nothing newer has been signed. To whole // seconds, which is all the store keeps. - assertEquals(first.createdAt.epochSeconds, second.createdAt.epochSeconds) + assertEquals(first.keyState.createdAt.epochSeconds, second.keyState.createdAt.epochSeconds) } @Test @@ -1380,9 +1380,9 @@ class SignedGroupKeyStateTest { val state = creator.keyState() assertNotNull(state, "a room off the default path must still reach a signed state") - assertEquals(SharedKeyDerivation.formatPath(sibling), state.derivationPath) - assertEquals(siblingRoomId, state.announcedBy) - assertTrue(state.verifies()) + assertEquals(SharedKeyDerivation.formatPath(sibling), state.keyState.derivationPath) + assertEquals(siblingRoomId, state.keyState.announcedBy) + assertTrue(state.keyState.verifies()) } @Test @@ -1572,10 +1572,11 @@ class SignedGroupKeyStateTest { // The whole join. Neither device was sent a row; both derived the // event from their own items, both re-ran `certifies` against the - // parent's id, and both wrote the same two columns. - assertEquals(parentRoomId, state.parentChatRoomId) - assertEquals(certificate.toJson(), state.birthCertificateJson) - assertTrue(state.verifies()) + // parent's id, and both wrote the same parentage row. + assertEquals(listOf(parentRoomId), state.parentChatRoomIds()) + assertEquals(certificate.toJson(), state.parents.single().birthCertificateJson) + assertEquals(adminRoomId, state.parents.single().chatRoomId) + assertTrue(state.keyState.verifies()) } } @@ -1626,8 +1627,8 @@ class SignedGroupKeyStateTest { // does every top-level group made after. listOf(creator, other).forEach { device -> val state = assertNotNull(device.keyState()) - assertNull(state.parentChatRoomId) - assertNull(state.birthCertificateJson) + assertEquals(emptyList(), state.parents) + assertNull(state.lineage()) } } From e81659bf3be585cde5d1da3c6a99b272740c1d2c Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 17:57:49 +0200 Subject: [PATCH 08/15] feat(subgroups): say what a key state proposal is asking, and tell a child it has been offered a parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of docs/subgroups-multiple-parents.md, the two consent surfaces. The note calls the first of these the one change in the design that is not plumbing, and it is the only one that would have shipped a member a question they could not read. **`ProposedEvent`'s 30326 arm showed a derivation path and a ceremony id.** That was right while a key state was proposed once, at founding, by the coordinator who chose its lineage: the only question was which key the room signs with. A key state can be re-announced now, and that is exactly how a group is adopted -- its own quorum accepts a parent by signing a state that names one. A member approving that is deciding whether their group should answer to another, and `m/9420/0/0 · a1b2c3d4e5f6` answers a question nobody asked. The proposal is their only chance to check, since after it a quorum has spoken. So the parents lead when there are any, counted when there is more than one, in the lineage's canonical order so two devices describe one proposal the same way. The certificates are already in the tags the signature covers, so this costs a reader nothing it does not hold. What the summary cannot say is which parents are being *dropped*: that is a difference against the row this device holds and a summary is pure, so it belongs to the screen, and there is a comment where somebody will look for it. **And the child's room now hears the offer.** `ChatMessage`'s 30329 arm assumed a certificate in a room was that room's own act, because at founding it always is. An adoption signs the certificate first, in the adopting group, and the child's admins had no notice of it at all -- the first thing they would see is a proposal wanting their signature. Founding at least shows them the intent, since `DkgRitualEvents` puts `parent_group` on the ceremony proposal and the ritual screen names the group; adoption has no ceremony and so no hint. `SubgroupManager.classify` is the decision, pure and needing no database -- `certifies` checks a signature against a room id, and a room id is a public key, so an offer can be believed by a device that has never held the offering group's room. Two true things a certificate can be, and the room it arrived in is the whole of the difference: this room signed it about another, or another signed it about this one. A third room's business is nothing here whoever signed it. `TYPE_SUBGROUP_ADOPTION_OFFERED` writes a line and files nothing. It cannot file anything -- `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device with no row for the parent could not store the event -- and it does not need to, because the certificate reaches these devices for real inside the key state that accepts it. The wording carries that: a subgroup of theirs *only once this group agrees what it signs with again, naming them*. An offer rendered as a fact about the lineage would be the whole gap reopened from the other end. **A test found a real one while writing this.** A group can sign, truthfully by every check `certifies` makes, that it is its own parent: author, subject and named parent one room, the id deriving from the key, the signature verifying. The self-parent guard started out 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 put a group in its own list of subgroups. It is refused ahead of both branches now, and the test is named for what it pins rather than for the branch it was written against. That case predates this commit: `subgroupsOf` would have listed it today. Eleven tests. Six for `classify` in `SubgroupBirthCertificateEventTest`, which is where the three real quorums already are and where every case it decides is a question about a certificate; four for the summary in `ProposedEventTest`, including a state claiming a lineage it cannot state, which must read as the key alone rather than guess. One icon arm in `ChatTranscript`, because a subgroup type missing from that mapping renders as a chat bubble and looks exactly like a member having said it. 454 common tests, 947 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../compose/database/model/ChatMessage.kt | 95 +++++++++++++++---- .../compose/managers/SubgroupManager.kt | 73 ++++++++++++++ .../mantra/compose/text/ProposedEvent.kt | 40 +++++++- .../composable/widgets/chat/ChatTranscript.kt | 3 +- .../SubgroupBirthCertificateEventTest.kt | 86 +++++++++++++++++ .../mantra/compose/text/ProposedEventTest.kt | 89 +++++++++++++++++ 6 files changed, 360 insertions(+), 26 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt index efa2b704..0cd98645 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt @@ -383,6 +383,26 @@ data class ChatMessage( */ const val TYPE_SUBGROUP_CERTIFIED = "subgroupCertified" + /** + * Another group has certified **this** room as its child. + * + * The other side of the line above, and it appears in the *child's* room. + * A group is founded with a parent and may be adopted by another later -- + * see docs/subgroups-multiple-parents.md -- and an adoption is two + * signings: the new parent certifies, then this room's own quorum + * re-announces its key state accepting it. The parent commits first, so + * this line is the offer, and it is the only notice the child's admins + * get before a proposal asks for their signature. + * + * **An offer rather than a fact about the lineage.** Nothing is adopted + * until this room's quorum says so, and nothing is filed: a child device + * cannot store an event signed by a group it is not in, and does not need + * to. The certificate itself travels inside the key state at the second + * signing. The wording has to carry that, or a member reads it as a + * relationship that already exists. + */ + const val TYPE_SUBGROUP_ADOPTION_OFFERED = "subgroupAdoptionOffered" + /** * Every subgroup line, for the one check the transcript dispatches on. * @@ -392,6 +412,7 @@ data class ChatMessage( */ val SUBGROUP_TYPES = setOf( TYPE_SUBGROUP_CERTIFIED, + TYPE_SUBGROUP_ADOPTION_OFFERED, ) /** @@ -1336,29 +1357,61 @@ data class ChatMessage( // that was ever put to this room -- the child may since have been // renamed, and this line is a record of what was agreed rather // than a view of what is. - SubgroupBirthCertificateEvent.KIND -> { - SubgroupManager.record( - database = database, - chatRoomId = groupId, - innerEvent = event - ) ?: return null + // + // Two true things a certificate can be, and the room it arrived + // in is what tells them apart -- see `SubgroupManager.classify`. + // This room signed it about another, or another signed it about + // this one, which is a parent offering to adopt a group that + // already exists. Anything else is nobody's business here. + SubgroupBirthCertificateEvent.KIND -> + when (val arriving = SubgroupManager.classify(groupId, event)) { + is SubgroupManager.ArrivingCertificate.OurChild -> { + SubgroupManager.record( + database = database, + chatRoomId = groupId, + innerEvent = event + ) ?: return null - val name = SubgroupBirthCertificateEvent.parseName(event.tags) - val admins = SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size + val name = SubgroupBirthCertificateEvent.parseName(event.tags) + val admins = + SubgroupBirthCertificateEvent.parseAdminPublicKeys(event.tags).size - ChatMessage( - giftWrapPayloadId = null, - messageType = TYPE_SUBGROUP_CERTIFIED, - marmotGroupEventId = marmotGroupEventId, - marmotInnerEventId = marmotInnerEventId, - senderPublicKey = senderPublicKey, - isUserMessage = isUserMessage, - chatRoomId = groupId, - createdAt = createdAt, - content = "The group made ${name ?: "a subgroup"} a subgroup, " + - "run by $admins of its members" - ) - } + ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_CERTIFIED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "The group made ${name ?: "a subgroup"} a subgroup, " + + "run by $admins of its members" + ) + } + + // Deliberately writes a line and files nothing. The offer + // is real and verified -- that group's quorum signed it -- + // but this room is not adopted until its own quorum says + // so, and the certificate reaches these devices for real + // inside the key state that says it. + is SubgroupManager.ArrivingCertificate.OfferToUs -> ChatMessage( + giftWrapPayloadId = null, + messageType = TYPE_SUBGROUP_ADOPTION_OFFERED, + marmotGroupEventId = marmotGroupEventId, + marmotInnerEventId = marmotInnerEventId, + senderPublicKey = senderPublicKey, + isUserMessage = isUserMessage, + chatRoomId = groupId, + createdAt = createdAt, + content = "${arriving.parentChatRoomId.take(16)} has offered to " + + "adopt this group as a subgroup. It is a subgroup of theirs " + + "only once this group agrees what it signs with again, " + + "naming them." + ) + + null -> return null + } else -> { ChatMessage( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 42e57358..26993788 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -431,6 +431,79 @@ object SubgroupManager { database.groupKeyStateDao().getLocalByChatRoomId(chatRoomId)?.parentChatRoomIds() .orEmpty() + /** + * What a certificate arriving in a room is, or null if it is nothing this + * room should believe. + * + * Pure, and the decision the 30329 dispatch arm cannot make for itself. Two + * true things a certificate can be, and the room it arrived in is what tells + * them apart: + * + * - [OurChild] -- **this** room signed it, about another room. The founding + * case, and the one that was always here. + * - [OfferToUs] -- it is about **this** room, signed by the group it names. + * A parent offering to adopt an existing group, which is the half of an + * adoption the child's members would otherwise never hear about until a + * proposal asked for their signature. + * + * Both are fully verified before they are returned, and neither needs a + * lookup: `certifies` checks a signature against a room id, and a room id is + * a public key. So an offer can be believed on its own merits by a device that + * has never held the offering group's room. + * + * **Neither is stored as the offering group's event.** `GroupSignedEvent` + * declares a foreign key onto `ChatRoom`, so a child device with no row for + * the parent could not file one if it wanted to. It does not need to: the + * certificate reaches the child's devices for real inside the key state the + * child's own quorum signs, and what an offer buys here is notice, not + * evidence. + */ + sealed interface ArrivingCertificate { + /** This room certified [subgroupChatRoomId] as its child. */ + data class OurChild(val subgroupChatRoomId: String) : ArrivingCertificate + + /** [parentChatRoomId] has certified this room as *its* child. */ + data class OfferToUs(val parentChatRoomId: HexKey) : ArrivingCertificate + } + + fun classify(chatRoomId: String, innerEvent: Event): ArrivingCertificate? { + val subgroupChatRoomId = + SubgroupBirthCertificateEvent.parseSubgroupChatRoomId(innerEvent.tags) + ?: return null + + // A certificate names a parent or it is not one; `certifies` would refuse + // it either way, so reading the tag first costs nothing and lets the next + // check come before anything else. + val parentChatRoomId = + SubgroupBirthCertificateEvent.parseParentChatRoomId(innerEvent.tags) ?: return null + + // **A group is not its own child**, and this is the one self-referential + // certificate `certifies` waves through: a group can sign, truthfully by + // every check that function makes, that it is its own parent -- the + // author, the subject and the named parent are one room, the id derives + // from the key, and the signature verifies. It means nothing, and it has + // to be refused before either branch below rather than inside one, or the + // first branch reads it as an ordinary child and the group appears in its + // own list of subgroups. + if (subgroupChatRoomId.equals(parentChatRoomId, ignoreCase = true)) return null + + if (SubgroupBirthCertificateEvent.certifies(innerEvent, subgroupChatRoomId, chatRoomId)) { + return ArrivingCertificate.OurChild(subgroupChatRoomId) + } + + // Only a certificate about *this* room can be an offer to it. Checked + // before the signature so that a certificate about some third room, + // signed by some fourth, cannot be read as anything at all -- it is + // nobody's business in this room whoever signed it. + if (!subgroupChatRoomId.equals(chatRoomId, ignoreCase = true)) return null + + if (!SubgroupBirthCertificateEvent.certifies(innerEvent, chatRoomId, parentChatRoomId)) { + return null + } + + return ArrivingCertificate.OfferToUs(parentChatRoomId) + } + /** * Files a certificate that arrived, or drops it and says why. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt index 7b11f466..be902063 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ProposedEvent.kt @@ -129,13 +129,45 @@ object ProposedEvent { // its work, and the only one a member sees before the room has done // anything. Shown as the path and the ceremony rather than as the key: // the key is the room's own id, which the member is already looking at. - GroupKeyStateEvent.KIND -> Summary( - label = "This group's shared key", - detail = listOfNotNull( + // + // ### A state naming parents is a different question, and must read like one + // + // A key state used to be proposed once, at founding, by the coordinator + // who chose its lineage -- so the path and a ceremony id were the whole of + // what a signer needed. A state can be **re-announced** now, and that is + // how a group is adopted: its own quorum accepts a parent by signing a + // state that names one. A member asked to approve that is being asked + // whether this group should answer to another, and showing them a + // derivation path answers a question nobody asked. + // + // So the parents lead when there are any. The proposal is their only + // chance to check -- after it a quorum has spoken -- and the certificates + // are right there in the tags the signature covers, so this costs a + // reader nothing it does not already hold. + // + // What this cannot say is which parents are being *dropped*, because a + // summary is pure and a disavowal is a difference against the row this + // device holds. That belongs to the screen, which has the room. + GroupKeyStateEvent.KIND -> { + val parents = GroupKeyStateEvent.parseLineage(event.tags)?.parentChatRoomIds + val shared = listOfNotNull( GroupKeyStateEvent.parsePath(event.tags)?.let(SharedKeyDerivation::formatPath), GroupKeyStateEvent.parseDkgSessionId(event.tags)?.take(12) ).joinToString(" · ") - ) + + if (parents.isNullOrEmpty()) { + Summary(label = "This group's shared key", detail = shared) + } else { + Summary( + label = if (parents.size == 1) { + "This group's shared key, and the group it answers to" + } else { + "This group's shared key, and the ${parents.size} groups it answers to" + }, + detail = (parents.map { it.take(16) } + shared).joinToString(" · ") + ) + } + } // The other thing a group signs that is about groups rather than about // work, and the only one where the *proposal* is the last chance to diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index ca9c4bc6..bfefd1b6 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -812,7 +812,8 @@ private fun RitualNotice( // An invite made and an invite sent are two separate steps on the deferred // path, so they get separate icons -- the whole reason both lines exist is // to be able to see that the first happened and the second did not. - ChatMessage.TYPE_SUBGROUP_CERTIFIED -> Icons.Default.AccountTree + ChatMessage.TYPE_SUBGROUP_CERTIFIED, + ChatMessage.TYPE_SUBGROUP_ADOPTION_OFFERED -> Icons.Default.AccountTree ChatMessage.TYPE_MEMBER_INVITED -> Icons.Default.PersonAdd ChatMessage.TYPE_MEMBER_INVITE_SENT -> Icons.Default.Upload ChatMessage.TYPE_MEMBER_INVITE_FAILED -> Icons.Default.ErrorOutline diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index 8fc32ffd..a625bb39 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -18,6 +18,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import press.mantra.compose.extensions.toHex import press.mantra.compose.managers.SharedKeyDerivation +import press.mantra.compose.managers.SubgroupManager /** * What a birth certificate is allowed to convince a member of. @@ -350,6 +351,91 @@ class SubgroupBirthCertificateEventTest { * way to test that both are checked. [signature] replaces the real one * outright, for the cases where what arrives is not a signature at all. */ + // ---- What an arriving certificate is ------------------------------------ + // + // `SubgroupManager.classify` lives in a manager and is tested here, because + // every case it decides is a question about a certificate and this is where + // the three real quorums are. It needs no database: `certifies` checks a + // signature against a room id, and a room id is a public key. + + @Test + fun `in the parent's room, a certificate is about the room's own child`() { + assertEquals( + SubgroupManager.ArrivingCertificate.OurChild(subgroupChatRoomId), + SubgroupManager.classify(parentChatRoomId, certificate()) + ) + } + + @Test + fun `in the child's room, the same certificate is an offer to adopt it`() { + // The half of an adoption the child's members would otherwise never hear + // about until a proposal asked for their signature. One event, two + // meanings, and the room it arrived in is the whole of the difference. + assertEquals( + SubgroupManager.ArrivingCertificate.OfferToUs(parentChatRoomId), + SubgroupManager.classify(subgroupChatRoomId, certificate()) + ) + } + + @Test + fun `in a third party's room it is nothing at all`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + + // Neither this room's act nor about this room. Whoever signed it, it is + // nobody's business here, and a line in this transcript would be noise + // about two groups the reader may not be in. + assertNull(SubgroupManager.classify(strangerRoom, certificate())) + } + + @Test + fun `an offer has to be signed by the group it names`() { + // The `parent_group` tag says the parent certified this room; the author + // says who actually signed. A stranger putting another group's name on + // its own signature is the forgery this check exists for. + val forged = certificate(author = strangerMaterial) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, forged)) + } + + @Test + fun `a group is not its own child, however truthfully it says so`() { + // The one self-referential certificate `certifies` waves through: author, + // subject and named parent all one room, the id deriving from the key, + // the signature verifying. Refused before either branch -- read as an + // ordinary child it would put the group in its own list of subgroups. + val itself = certificate( + content = parentChatRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = parentChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(parentChatRoomId, itself)) + } + + @Test + fun `an offer must be about this room and not merely arrive in it`() { + val strangerRoom = SharedKeyDerivation.marmotGroupId(strangerKey, path) + val aboutSomebodyElse = certificate( + content = strangerRoom, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = strangerRoom, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt index 9ecddf7e..e8ff4ecf 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ProposedEventTest.kt @@ -3,6 +3,10 @@ package press.mantra.compose.text import com.vitorpamplona.quartz.nip01Core.core.Event import kotlin.test.Test import kotlin.test.assertEquals +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.nostr.subgroup.SubgroupLineage +import press.mantra.compose.nostr.subgroup.SubgroupParentage /** * How an event a group signs is said to the member signing it. @@ -23,6 +27,91 @@ class ProposedEventTest { assertEquals("An article", summary.detail) } + @Test + fun `a key state with no lineage reads as the key alone`() { + // Every group made before subgroups, and every top-level group made + // after. The path and the ceremony are what a signer needs, because the + // only question is which key the room signs with. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + ) + ) + + assertEquals("This group's shared key", summary.label) + assertEquals("m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state that names a parent says so first`() { + // The one change in this mapping that is not cosmetic. A key state can be + // re-announced now, and that is how a group is adopted -- so a member + // approving this is deciding whether their group should answer to + // another, and a derivation path answers a question nobody asked. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(parentage(parent)) + ) + ) + ) + + assertEquals("This group's shared key, and the group it answers to", summary.label) + assertEquals("${parent.take(16)} · m/9420/0/0 · ceremony-1", summary.detail) + } + + @Test + fun `a key state naming two parents counts them`() { + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = room, + dkgSessionId = "ceremony-1", + lineage = SubgroupLineage.of(listOf(parentage(parent), parentage(second))) + ) + ) + ) + + assertEquals("This group's shared key, and the 2 groups it answers to", summary.label) + // In the lineage's canonical order, so two devices describe one proposal + // the same way. + assertEquals( + "${parent.take(16)} · ${second.take(16)} · m/9420/0/0 · ceremony-1", + summary.detail + ) + } + + @Test + fun `a key state claiming a lineage it cannot state reads as the key alone`() { + // A half-claim, which `GroupKeyStateManager.stateFrom` would drop whole. + // The summary cannot say "and one group" about a parent it could not + // read, so it says nothing about lineage rather than guessing. + val summary = ProposedEvent.summarize( + event( + GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags(room, "ceremony-1") + + arrayOf(arrayOf("parent_group", parent)) + ) + ) + + assertEquals("This group's shared key", summary.label) + } + + private val room = "1".repeat(64) + private val parent = "a".repeat(64) + private val second = "b".repeat(64) + + /** A parentage whose certificate is nonsense; this mapping checks nothing. */ + private fun parentage(parentChatRoomId: String) = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = event(SubgroupBirthCertificateEvent.KIND, content = parentChatRoomId) + ) + private fun event( kind: Int, tags: Array> = emptyArray(), From b54ff61eb6ea2831e1a91b58122b92fa8823af90 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:05:15 +0200 Subject: [PATCH 09/15] feat(subgroups): adopt an existing group, accept a parent, and stop claiming one Phase 4 of docs/subgroups-multiple-parents.md, the operations. Two signings and no ceremony, which is the whole of why this is cheap: the key exists, so there is no ChillDKG, and the room exists, so there is nothing to create. Still unreachable -- no screen offers any of it yet. ``` 1. birth certificate, in the adopting group -> its quorum signs the child's id 2. key state, in the child itself -> the child's quorum re-announces, carrying every certificate it accepts ``` **The order is forced and it decides who is deciding.** The key state carries the certificate, so the certificate has to exist first -- which means the parent commits first and the child answers second. Step 1 is an offer; step 2 is the acceptance, and it is the only half that settles anything. Picking a parent is not choosing on the child's behalf, and the wording of every message here says so. **`proposeAdoption` is the founding certificate with the child already there.** Same kind, same six checks on the other side, same room it has always been proposed in. What differs is that the child's id and threshold key are read off its key state rather than derived from a ceremony this device may not hold. The roster and the name are the child's *current* ones -- on a founding certificate they are the founding roster because there is nothing else they could be, and here the adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now. Still frozen the moment they are signed, and still never read as a live roster. **`proposeAcceptance` carries the resulting set, not the addition.** The footgun is real: a key state is replaceable and the newest wins *wholesale*, so a state naming only the new parent would silently drop every existing one. The current lineage is read off the row here rather than taken from the caller, and `SubgroupLineage.plus` is what makes an addition an addition. It signs in the child's **own** room, which it can because the room exists -- `propose`'s first branch holds and `signingPath` resolves off the child's own key state, neither of which founding can do. **`proposeDisavowal` is the same act with the set the other way**, and it revokes nothing. A certificate is a signature and exists forever; what this removes is the child's half of the link, which is the half that decides, since a lineage is what the child's quorum most recently declared. The parent's own list does not follow -- `subgroupsOf` reads that group's certificates -- and the comment says so where somebody will wonder. **`refuseAdoption` is seven refusals and the picker is the same function.** `adoptingCandidatesFor` filters the active profile's rooms through the guard itself rather than through a copy of its rules, so screen and manager cannot drift. The list is the profile's own rooms and cannot be anything else: a certificate is proposed inside the adopting group, so a candidate has to be a room the key in the chair can act in, and `getChatRoomListByUserPublicKey` is keyed by identity. An empty answer is a fact about the member rather than a failure -- the remedy is a person, not a button. Two refusals are new in kind. "This group has not agreed what it signs with" is the overlap requirement surfacing: the certificate carries the child's threshold key, which only the child's members hold, so a would-be parent with no sight of it cannot certify anything. And a cycle check, because a lineage is a DAG now rather than a tree -- with one parent nothing was ever tempted to recurse. `isAncestorOf` is depth-bounded and visited-set guarded; it is advisory, since a device sees only the lineages it holds states for, and the real protection is that an adoption needs the child's own quorum. Fifteen tests. Every refusal once, including the cycle and the picker's filter, and then the three operations by reading the proposed event back out of the signing session -- which is what catches the wholesale-replacement footgun: accepting a second parent has to carry the first, and disavowing the only one has to leave a state that claims none rather than one that claims nothing readable. `parentWith` is now one case of a `marmotRoom` helper taking the id and the key, since adoption needs two real group rooms at once rather than one; it keeps its own doc comment and the warning it carries about `nostrGroupId` lengths, which is still the trap it was. `certificate` gains a parent id so a sibling group can sign one. 454 common tests, 962 jvm tests, `m3Audit` meets every budget. Co-Authored-By: Claude Opus 5 --- .../compose/managers/SubgroupManager.kt | 384 ++++++++++++++++++ .../managers/SubgroupManagerJvmTest.kt | 375 ++++++++++++++++- 2 files changed, 746 insertions(+), 13 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 26993788..0d68a0f7 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -72,6 +72,18 @@ object SubgroupManager { */ const val MINIMUM_ADMINS: Int = 3 + /** + * How far an ancestor walk goes before it gives up. + * + * A lineage is a DAG rather than a tree once a room can have two parents, so + * "is this group already beneath that one" is a graph walk. Bounded because a + * cycle is constructible: a child's quorum can certify its own parent -- the + * parent's threshold key is public -- and the parent's quorum can re-announce + * a state accepting it. Both have to cooperate in something pointless, which + * is no reason to let a reader loop forever. + */ + const val MAXIMUM_LINEAGE_DEPTH: Int = 16 + /** * Why these admins cannot hold a ceremony together, or null when they can. * @@ -330,6 +342,378 @@ object SubgroupManager { ) } + // ---- Adoption: a group that already exists gaining a parent ------------- + // + // Two signings and no ceremony, which is the whole of why this is cheap: the + // key exists, so there is no ChillDKG, and the room exists, so there is + // nothing to create. + // + // ``` + // 1. birth certificate, in the adopting group -> its quorum signs the child's id + // 2. key state, in the child itself -> the child's quorum re-announces, + // carrying every certificate it accepts + // ``` + // + // The order is forced, not chosen: the key state carries the certificate, so + // the certificate has to exist first. Which means **the parent commits first + // and the child decides second** -- step 1 is an offer, and step 2 is the + // acceptance. Picking a parent is not choosing on the child's behalf. + // + // Both steps need somebody in both groups, and there is no way around it: + // step 1 needs the child's threshold key, which only the child's members + // hold, and step 2 needs the certificate, which `recordSignedEvents` files + // only for the adopting group's members. They need not be the same person and + // their capabilities differ. See docs/subgroups-multiple-parents.md. + + /** + * Why [adoptingRoom] cannot offer to adopt [childChatRoomId], or null when it + * can. + * + * Read by the picker so nothing is offered that could only fail, and again + * before anything is published, because a screen not drawing something is not + * a guard. Each message says what to do rather than what is wrong. + * + * "This device" throughout means **the profile in the chair**, and + * `FrostSigningManager.canSign` does not currently tell the two apart -- see + * the note under docs/subgroups-multiple-parents.md. These refusals are + * honest about capability and silent about persona. + */ + suspend fun refuseAdoption( + database: MantraDatabase, + adoptingRoom: LocalChatRoom, + childChatRoomId: String, + userPublicKey: HexKey + ): String? { + val adoptingChatRoomId = adoptingRoom.chatRoom.id + + // The certificate is signed by the adopting group's key, so a device + // holding no share of it cannot open the session at all -- + // `proposeSigningBatch` throws outright, and throwing at a button is not + // a UI. First, because it is the one refusal the user cannot fix by + // picking differently. + if (!FrostSigningManager.canSign(database, adoptingChatRoomId)) { + return "Only a member who holds a share of that group's key can adopt a group into it." + } + + // Read off the room's own `MarmotGroupData` rather than off + // `Participant.adminAt`, for the reason `refuseSubgroup` gives: that + // column is written only by `processGroupMembershipChanges` on an + // arriving commit, so on a room this device created it is null for + // everybody, including the creator. + val adoptingAdmins = runCatching { + adoptingRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys + }.getOrNull() + ?: return "A group can only adopt another if it has admins." + + if (!adoptingAdmins.contains(userPublicKey)) { + return "Only an admin of that group can adopt a group into it." + } + + if (adoptingChatRoomId.equals(childChatRoomId, ignoreCase = true)) { + return "A group cannot be its own parent." + } + + // The certificate carries the child's threshold key, and without it + // `certifies` fails its fourth question on every device including this + // one. That key lives on the child's own key state, which only the + // child's members hold -- so this is the overlap requirement showing up + // as a refusal. + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: return "This group has not agreed what it signs with, so there is nothing to certify." + + if (child.lineage()?.names(adoptingChatRoomId) == true) { + return "That group has already adopted this one." + } + + // One certificate per child per parent. A second is a `d`-tag replacement + // of the first rather than a second adoption, and spending a quorum's + // attention to restate something they have already signed is worse than + // doing nothing. + if (certificateFor(database, childChatRoomId, adoptingChatRoomId) != null) { + return "That group has already offered to adopt this one." + } + + // A cycle, as far as this device can see -- and only as far as that. The + // walk is bounded and visited-set guarded because a lineage is a DAG now + // rather than a tree: with one parent nothing was ever tempted to recurse. + if (isAncestorOf(database, childChatRoomId, adoptingChatRoomId)) { + return "That group is already a subgroup of this one." + } + + return null + } + + /** + * Whether [candidate] is [chatRoomId] or anything beneath it, as far as this + * device's own verified lineages reach. + * + * Bounded and visited-set guarded. A cycle is constructible -- two quorums + * cooperating in something pointless -- and more to the point a lineage is a + * DAG now, so the fan-out makes an unbounded walk worth refusing to write. + * + * Advisory. A device sees only the lineages it holds states for, so a false + * answer here is "I cannot see one", never "there is none". The real + * protection is that an adoption needs the child's own quorum. + */ + private suspend fun isAncestorOf( + database: MantraDatabase, + chatRoomId: String, + candidate: String, + depth: Int = MAXIMUM_LINEAGE_DEPTH + ): Boolean { + val seen = mutableSetOf(chatRoomId.lowercase()) + var frontier = listOf(chatRoomId) + + repeat(depth) { + if (frontier.isEmpty()) return false + + val children = frontier.flatMap { parent -> + database.groupKeyStateDao().getByParentChatRoomId(parent) + .map { it.keyState.chatRoomId } + } + + if (children.any { it.equals(candidate, ignoreCase = true) }) return true + + frontier = children.filter { seen.add(it.lowercase()) } + } + + return false + } + + /** + * The groups this profile could offer [childChatRoomId] to, in room order. + * + * **The active profile's own rooms, and it cannot be anything else.** A + * certificate is proposed *inside* the adopting group, so a candidate has to + * be a room the key in the chair can already act in. Seeded from + * `getChatRoomListByUserPublicKey`, which is keyed by identity: 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 acting. + * + * A group cannot be offered as a parent by somebody who is not in it. That is + * the overlap requirement showing up as a list length, and an empty answer is + * a fact about this member rather than a failure -- the remedy is a person, + * not a button. + * + * Filtered through [refuseAdoption] itself rather than through a copy of its + * rules, so the picker and the manager cannot drift. The cost is one MLS + * decode per room, to reach `adminPubkeys`, plus two indexed reads; call it + * off the main thread. + */ + suspend fun adoptingCandidatesFor( + database: MantraDatabase, + childChatRoomId: String, + userPublicKey: HexKey + ): List = + database.chatRoomDao().getChatRoomListByUserPublicKey(userPublicKey) + .filter { candidate -> + refuseAdoption( + database = database, + adoptingRoom = candidate, + childChatRoomId = childChatRoomId, + userPublicKey = userPublicKey + ) == null + } + + /** + * Step 1: asks [adoptingRoom]'s quorum to certify an existing group as its + * child. + * + * The same event kind, the same six checks on the other side, and the same + * room it has always been proposed in -- what differs from founding is only + * that the child is already there, so its id and key are read off its key + * state rather than derived from a ceremony this device may not hold. + * + * **The roster and the name are the child's current ones**, and that is the + * honest value rather than a compromise. On a founding certificate they are + * the founding roster, frozen, because there is nothing else they could be. + * Here the adopting group's admins are being asked about a group that exists, + * and what they can weigh is who runs it now and what it is called now. They + * are still frozen the moment they are signed, and still never a live roster: + * `certifies` does not check either, for the reason it never did. + */ + suspend fun proposeAdoption( + database: MantraDatabase, + adoptingRoom: LocalChatRoom, + childRoom: LocalChatRoom, + userPublicKey: HexKey + ): FrostSigningSession { + val adoptingChatRoomId = adoptingRoom.chatRoom.id + val childChatRoomId = childRoom.chatRoom.id + + refuseAdoption(database, adoptingRoom, childChatRoomId, userPublicKey)?.let { + throw IllegalStateException(it) + } + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException("$childChatRoomId has agreed no key state to certify") + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + val childAdmins = runCatching { + childRoom.chatRoom.toMlsGroup()?.currentMarmotData()?.adminPubkeys + }.getOrNull().orEmpty() + + val childName = MarmotGroupName.of(childRoom.chatRoom.subject.orEmpty().ifBlank { "a group" }) + + logger.i("Asking $adoptingChatRoomId to adopt $childChatRoomId as its subgroup") + + return FrostSigningManager.proposeSigning( + database = database, + localChatRoom = adoptingRoom, + userPublicKey = userPublicKey, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = childChatRoomId, + parentChatRoomId = adoptingChatRoomId, + thresholdPublicKey = child.keyState.thresholdPublicKey, + adminPublicKeys = childAdmins, + name = childName, + path = path + ), + content = childChatRoomId + ) + } + + /** + * Step 2: asks the child's own quorum to accept [parentChatRoomId], by + * re-announcing its key state with that parentage added. + * + * The acceptance, and the only half of an adoption that decides anything. The + * certificate is an offer until this is signed, which is why + * `ProposedEvent`'s 30326 arm leads with the parents -- this proposal is + * where a member of the child answers it. + * + * **Carries the resulting set, not the addition.** A key state is replaceable + * and the newest wins wholesale, so a state naming only the new parent would + * silently drop every existing one. The current lineage is read off the row + * here rather than taken from the caller, and `SubgroupLineage.plus` is what + * makes adding an addition. + * + * Signed in the child's **own** room, which it can be because the room + * exists: `GroupKeyStateManager.propose`'s first branch holds, and + * `FrostSigningManager.signingPath` resolves off the child's own key state. + * Founding could not do either, which is why its key state is signed where + * its ceremony ran. + */ + suspend fun proposeAcceptance( + database: MantraDatabase, + childRoom: LocalChatRoom, + userPublicKey: HexKey, + parentChatRoomId: HexKey + ): FrostSigningSession { + val childChatRoomId = childRoom.chatRoom.id + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException( + "$childChatRoomId has agreed no key state, so it has none to re-announce" + ) + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + // The certificate this device holds for the pair. Only a member of the + // adopting group has one -- `recordSignedEvents` files it against that + // group's room -- so this is the overlap requirement again, from the + // other side. + val certificate = certificateFor(database, childChatRoomId, parentChatRoomId) + ?: throw IllegalStateException( + "$parentChatRoomId has not certified $childChatRoomId, so there is nothing to accept" + ) + + val parentage = SubgroupParentage( + parentChatRoomId = parentChatRoomId, + certificate = certificate.toEvent() + ) + + val lineage = child.lineage()?.plus(parentage) ?: SubgroupLineage.of(parentage) + checkNotNull(lineage) { "$childChatRoomId already names $parentChatRoomId as a parent" } + + val key = FrostSigningManager.completedKey(database, childChatRoomId) + ?: throw IllegalStateException( + "This device holds no share of $childChatRoomId's key to propose with" + ) + + logger.i( + "Asking $childChatRoomId to accept $parentChatRoomId, making " + + "${lineage.size} parents in all" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = childRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + lineage = lineage + ) + } + + /** + * Asks the child's own quorum to stop claiming [parentChatRoomId], by + * re-announcing its key state without that parentage. + * + * The same act as [proposeAcceptance] with the set the other way, and the + * reason `SubgroupLineage.minus` exists. It **revokes nothing**: a certificate + * is a signature and exists forever, and what this removes is the child's half + * of the link -- which is the half that decides, since a lineage is what the + * child's quorum most recently declared. + * + * The parent's own list does not follow. `subgroupsOf` reads that group's + * certificates, so it will go on naming this room; a parent device that also + * holds this room's key state can see that it no longer names them, and one + * that does not cannot and must not guess. + */ + suspend fun proposeDisavowal( + database: MantraDatabase, + childRoom: LocalChatRoom, + userPublicKey: HexKey, + parentChatRoomId: HexKey + ): FrostSigningSession { + val childChatRoomId = childRoom.chatRoom.id + + val child = database.groupKeyStateDao().getLocalByChatRoomId(childChatRoomId) + ?: throw IllegalStateException( + "$childChatRoomId has agreed no key state, so it has none to re-announce" + ) + + val path = child.keyState.pathIndices() + ?: throw IllegalStateException( + "$childChatRoomId names ${child.keyState.derivationPath}, which is not a path" + ) + + val current = child.lineage() + check(current?.names(parentChatRoomId) == true) { + "$childChatRoomId does not name $parentChatRoomId as a parent" + } + + val key = FrostSigningManager.completedKey(database, childChatRoomId) + ?: throw IllegalStateException( + "This device holds no share of $childChatRoomId's key to propose with" + ) + + val remaining = current.minus(parentChatRoomId) + + logger.i( + "Asking $childChatRoomId to stop claiming $parentChatRoomId, leaving " + + "${remaining?.size ?: 0} parents" + ) + + return GroupKeyStateManager.propose( + database = database, + localChatRoom = childRoom, + userPublicKey = userPublicKey, + key = key, + path = path, + lineage = remaining + ) + } + /** * A subgroup as the parent's own record has it, plus whatever this device * happens to hold about it. diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt index 5953e669..32ee1bd3 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/managers/SubgroupManagerJvmTest.kt @@ -34,6 +34,8 @@ import press.mantra.compose.database.model.ChatRoom import press.mantra.compose.database.model.DkgParticipantMessage import press.mantra.compose.database.model.DkgSession import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.GroupKeyState +import press.mantra.compose.database.model.GroupKeyStateParent import press.mantra.compose.database.model.GroupSignedEvent import press.mantra.compose.database.model.NostrEvent import press.mantra.compose.database.model.Participant @@ -41,6 +43,7 @@ import press.mantra.compose.database.model.Profile import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.extensions.toHex import press.mantra.compose.nostr.dkg.DkgRitualEvents +import press.mantra.compose.nostr.frost.GroupKeyStateEvent import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent /** @@ -501,6 +504,326 @@ class SubgroupManagerJvmTest { * room first, because `DkgSession.chatRoomId` is a foreign key onto it. */ /** A ceremony held in the parent's room to make a subgroup run by [admins]. */ + // ---- Adoption: a group that already exists gaining a parent ------------- + + @Test + fun `a group with a key state and an admin in both can be adopted`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + assertNull(refuseAdoptionOf(adopting)) + } + + @Test + fun `a group that has agreed no key state cannot be certified`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + // The room exists; it has never said what it signs with. The certificate + // carries the child's threshold key and there is none to carry, so + // `certifies` would fail its fourth question on every device. + seedRoom(childRoomId) + + assertEquals( + "This group has not agreed what it signs with, so there is nothing to certify.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a member who holds no share of the adopting group's key is refused first`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob), canSign = false) + childWithKeyState() + + assertEquals( + "Only a member who holds a share of that group's key can adopt a group into it.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a non-admin of the adopting group is refused`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob), isAdmin = false) + childWithKeyState() + + assertEquals( + "Only an admin of that group can adopt a group into it.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a group cannot be its own parent`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + assertEquals( + "A group cannot be its own parent.", + SubgroupManager.refuseAdoption( + database = db, + adoptingRoom = adopting, + childChatRoomId = adopting.chatRoom.id, + userPublicKey = user + ) + ) + } + + @Test + fun `a parent already in the lineage is not offered again`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState(parents = listOf(parentRoomId)) + + assertEquals("That group has already adopted this one.", refuseAdoptionOf(adopting)) + } + + @Test + fun `an offer that already stands is not made twice`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // One certificate per child per parent: a second is a `d`-tag + // replacement of the first rather than a second adoption. + record(certificate()) + + assertEquals( + "That group has already offered to adopt this one.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `a group already beneath this one cannot adopt it`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // The adopting group is already the child's child, so adopting it back + // would close a cycle. Advisory -- this device sees only the lineages it + // holds states for -- and the real protection is the child's own quorum. + db.groupKeyStateDao().replace( + GroupKeyState( + chatRoomId = parentRoomId, + dkgSessionId = "parent-ceremony", + thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = parentRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000), + ), + listOf( + GroupKeyStateParent( + chatRoomId = parentRoomId, + parentChatRoomId = childRoomId, + birthCertificateJson = certificate().toJson(), + ) + ) + ) + + assertEquals( + "That group is already a subgroup of this one.", + refuseAdoptionOf(adopting) + ) + } + + @Test + fun `the picker offers only the rooms this profile can act in`() = runBlocking { + val adopting = parentWith(members = listOf(alice, bob)) + childWithKeyState() + + // A room this device is in but holds no share of, and one it is in but + // does not administer. Neither can carry the signature, so neither is + // offered -- and the filter runs the guard itself rather than a copy of + // its rules. + marmotRoom( + chatRoomId = siblingRoomId, + material = siblingMaterial, + ceremonyId = "sibling-ceremony", + name = "Elders", + members = listOf(alice), + canSign = false, + ) + + assertEquals( + listOf(adopting.chatRoom.id), + SubgroupManager.adoptingCandidatesFor(db, childRoomId, user).map { it.chatRoom.id } + ) + } + + @Test + fun `accepting a parent re-announces the key state with it added`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState() + record(certificate()) + + val session = SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + // Signed in the child's own room, which founding cannot do: the room + // exists, so `propose`'s first branch holds. + assertEquals(childRoomId, session.chatRoomId) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals(GroupKeyStateEvent.KIND, proposed.kind) + assertEquals( + listOf(parentRoomId), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds + ) + } + + @Test + fun `accepting a second parent carries the first one too`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(siblingRoomId)) + record(certificate()) + + val session = SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + // The footgun this guards: a key state is replaceable and the newest wins + // wholesale, so a state naming only the new parent would silently drop + // the one already there. + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals( + listOf(parentRoomId, siblingRoomId).sorted(), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds?.sorted() + ) + } + + @Test + fun `accepting a parent that has not certified this group is refused`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState() + + // No certificate on file. Only a member of the adopting group has one, so + // this is the overlap requirement from the other side. + assertFailsWith { + SubgroupManager.proposeAcceptance( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + } + Unit + } + + @Test + fun `disavowing a parent re-announces the key state without it`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(parentRoomId, siblingRoomId)) + + val session = SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + assertEquals( + listOf(siblingRoomId), + GroupKeyStateEvent.parseLineage(proposed.tags)?.parentChatRoomIds + ) + } + + @Test + fun `disavowing the only parent leaves a state claiming none`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(parentRoomId)) + + val session = SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + + val proposed = Event.fromJson( + assertNotNull(db.frostSigningSessionDao().getItems(session.id).singleOrNull()) + .unsignedEventJson + ) + // Nobody's child, which is a state rather than an absence of one. It + // revokes nothing: the certificate is a signature and exists forever. + assertFalse(GroupKeyStateEvent.claimsParentage(proposed.tags)) + assertNull(GroupKeyStateEvent.parseLineage(proposed.tags)) + } + + @Test + fun `disavowing a parent this group never claimed is refused`() = runBlocking { + parentWith(members = listOf(alice, bob)) + val child = childWithKeyState(parents = listOf(siblingRoomId)) + + assertFailsWith { + SubgroupManager.proposeDisavowal( + database = db, + childRoom = child, + userPublicKey = user, + parentChatRoomId = parentRoomId + ) + } + Unit + } + + private suspend fun refuseAdoptionOf(adopting: LocalChatRoom): String? = + SubgroupManager.refuseAdoption( + database = db, + adoptingRoom = adopting, + childChatRoomId = childRoomId, + userPublicKey = user + ) + + /** + * The child as an adoption finds it: a real Marmot room of its own, with a + * key state saying what it signs with, and whatever lineage it already has. + */ + private suspend fun childWithKeyState( + parents: List = emptyList() + ): LocalChatRoom { + val room = marmotRoom( + chatRoomId = childRoomId, + material = childMaterial, + ceremonyId = "child-ceremony", + name = "Translators", + members = listOf(alice, bob), + ) + + db.groupKeyStateDao().replace( + GroupKeyState( + chatRoomId = childRoomId, + dkgSessionId = "child-ceremony", + thresholdPublicKey = childMaterial.thresholdPublicKey.value.toHex(), + derivationPath = SharedKeyDerivation.formatPath(path), + announcedBy = childRoomId, + announcedAt = Instant.fromEpochSeconds(1_700_000_000), + ), + parents.map { parent -> + GroupKeyStateParent( + chatRoomId = childRoomId, + parentChatRoomId = parent, + birthCertificateJson = certificate( + author = if (parent == parentRoomId) parentMaterial else siblingMaterial, + parentChatRoomId = parent, + ).toJson(), + ) + } + ) + + return room + } + private fun subgroupCeremony(id: String, admins: Set) = ownCeremony(parentRoomId).copy( id = id, @@ -584,15 +907,40 @@ class SubgroupManagerJvmTest { members: List, canSign: Boolean = true, isAdmin: Boolean = true, + ): LocalChatRoom = marmotRoom( + chatRoomId = parentRoomId, + material = parentMaterial, + ceremonyId = "parent-ceremony", + name = "Ekklesia", + members = members, + canSign = canSign, + isAdmin = isAdmin, + ) + + /** + * Any of this app's group rooms, as what one *is*: the admin room derived + * from the group's own key, with its `MarmotGroupData` in the epoch-0 + * extensions. Adoption needs two of them at once -- the adopting group and + * the child -- so this takes the id and the key rather than closing over + * the parent's. + */ + private suspend fun marmotRoom( + chatRoomId: String, + material: KeyMaterial, + ceremonyId: String, + name: String, + members: List, + canSign: Boolean = true, + isAdmin: Boolean = true, ): LocalChatRoom { (members + user).forEach { seedProfile(it) } val admins = if (isAdmin) members + user else members val metadata = MarmotGroupData( - nostrGroupId = parentRoomId, - name = "Ekklesia", - description = SharedKeyDerivation.describe("Admins of Ekklesia."), + nostrGroupId = chatRoomId, + name = name, + description = SharedKeyDerivation.describe("Admins of $name."), adminPubkeys = admins, relays = Relays.DefaultDMRelayList.map { it.url } ) @@ -604,9 +952,9 @@ class SubgroupManagerJvmTest { db.chatRoomDao().upsert( ChatRoom( - id = parentRoomId, + id = chatRoomId, userPublicKey = user, - subject = "Ekklesia", + subject = name, description = metadata.description, mlsGroupState = mlsGroup.saveState().encodeTls().toHex(), ) @@ -620,7 +968,7 @@ class SubgroupManagerJvmTest { (members + user).map { Participant( participantPublicKey = it, - chatRoomId = parentRoomId, + chatRoomId = chatRoomId, relayHint = null, ) } @@ -631,8 +979,8 @@ class SubgroupManagerJvmTest { // matches, which is exactly how a real admin room resolves its key. db.dkgSessionDao().upsert( DkgSession( - id = "parent-ceremony", - chatRoomId = parentRoomId, + id = ceremonyId, + chatRoomId = chatRoomId, coordinatorPublicKey = user, userPublicKey = user, threshold = 2, @@ -641,7 +989,7 @@ class SubgroupManagerJvmTest { hostPublicKey = user, round1Random = "aa".repeat(32), round2AuxRandom = "bb".repeat(32), - thresholdPublicKey = parentMaterial.thresholdPublicKey.value.toHex(), + thresholdPublicKey = material.thresholdPublicKey.value.toHex(), secretShare = "cc".repeat(32), ) ) @@ -653,7 +1001,7 @@ class SubgroupManagerJvmTest { (members + user).forEachIndexed { index, member -> db.dkgSessionDao().upsert( DkgParticipantMessage( - sessionId = "parent-ceremony", + sessionId = ceremonyId, participantPublicKey = member, kind = DkgRitualEvents.HOST_KEY, payload = "0${index + 1}".repeat(33) @@ -662,7 +1010,7 @@ class SubgroupManagerJvmTest { } } - return assertNotNull(db.chatRoomDao().findChatRoomById(parentRoomId)) + return assertNotNull(db.chatRoomDao().findChatRoomById(chatRoomId)) } private suspend fun seedProfile(publicKey: String) { @@ -716,12 +1064,13 @@ class SubgroupManagerJvmTest { createdAt: Long = 1_700_000_000, author: KeyMaterial = parentMaterial, signer: KeyMaterial = author, - signature: String? = null + signature: String? = null, + parentChatRoomId: String = roomOf(author) ): Event { val subgroupChatRoomId = roomOf(subgroupMaterial) val eventTags = tags ?: SubgroupBirthCertificateEvent.assembleTags( subgroupChatRoomId = subgroupChatRoomId, - parentChatRoomId = parentRoomId, + parentChatRoomId = parentChatRoomId, thresholdPublicKey = subgroupMaterial.thresholdPublicKey.value.toHex(), adminPublicKeys = adminPublicKeys, name = name, From e15d31b8325c82ac68f4f5a5330ab924e303e5f7 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:16:58 +0200 Subject: [PATCH 10/15] feat(subgroups): ask a group to adopt this one, and accept the offer when it comes Phase 5 of docs/subgroups-multiple-parents.md, the UI. Phases 1-4 were dark; this is the flag, and it is the button. A group can now be given a second parent from the app. **Two affordances, on the child's own detail screen, under the parent cards.** Both belong there and not on the adopting group's screen: the child is what acquires the relationship, its quorum is what decides, and it mirrors the parent screen's existing "add subgroup". `SelectAdoptingGroupScreen` is the only new screen, because the acceptance needs none -- by then there is something to accept and a card does it. Its list is the **profile's own rooms** and cannot be anything else: a certificate is proposed inside the adopting group, so a candidate has to be a room the key in the chair can act in. `adoptingCandidatesFor` filters through `refuseAdoption` itself rather than through a copy of its rules, so screen and manager cannot drift. An empty list is a fact about this member rather than a failure, and the empty state says the reason outright -- a group cannot be offered as a parent by somebody who is not in it, so the remedy is a person rather than a button. **Tapping a row asks; it does not adopt, and the words have to carry that.** The snackbar says "asked that group to adopt this one", and the screen hands straight back to the room rather than going anywhere new. The offer card then says a group is a subgroup of another *only once its own quorum says so*, because rendered as a fact about the lineage it would be the consent gap reopened from the other end -- the one Phase 3 closed in the transcript. **The accept button is offered to whoever opens the screen and can act**, not only to the member who asked, for the reason docs/subgroups.md gives for the ritual screen's rung: every step after a ceremony is open to any admin holding a share. After it, the screen reloads rather than crossing the offer off -- the offer leaves the list when the state is signed and filed, which is a quorum away, so the card stays until the group has actually agreed. `canAddParent` wants a key state as well as a share, which `canAddSubgroup` does not: the certificate carries this group's threshold key and the acceptance re-announces that very state, so a room that has agreed nothing has neither half to work with. Hidden rather than disabled, the way the subgroup entry is. The picker is a pushed screen, so it takes an `onNavigateBack` and puts `NavigateBackButton` in its app bar's leading slot -- the rule `CLAUDE.md` gained while this was still a note, and the desktop target has neither a system back nor an edge swipe to fall back on. `offersTo` reads every certificate this device holds in **any** room rather than asking this one, because a certificate about this group is signed in the other group's room and filed against it -- asking this room would never find it. Each is checked by `classify`, and parents already in the lineage drop out, since an offer that has been accepted is not an offer. Ten strings in the catalogue in sentence case, spacing from `MaterialTheme.spacing`, colour from roles, `Decorative` on every icon beside a label, and the notifier read outside the handler with the caller's scope -- an acceptance is shown while the screen reloads around it. `inComparableGroups` stays private to `ChatRoomDetailScreen`: the upstream commit that moved it to the key extensions is in the fork's tree and not this one, and a second copy to add three spaces to an id is not worth having. **What is reachable and what is not.** Adding a parent and accepting one are both wired. `SubgroupManager.proposeDisavowal` is built, 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 rather than bolted onto this one. 454 common tests, 962 jvm tests, `m3Audit` meets every budget with 0 title-case strings and 0 dp literals. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 10 + .../repository/DatabaseChatRepository.kt | 81 ++++++++ .../compose/managers/SubgroupManager.kt | 27 +++ .../compose/repository/ChatRepository.kt | 68 ++++++ .../ui/composable/ChatRoomDetailScreen.kt | 106 ++++++++++ .../composable/SelectAdoptingGroupScreen.kt | 195 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 14 ++ .../routes/SelectAdoptingGroupRoute.kt | 22 ++ .../ui/view/model/ChatRoomDetailViewModel.kt | 41 ++++ .../model/SelectAdoptingGroupViewModel.kt | 138 +++++++++++++ .../ui/view/state/ChatRoomDetailUIState.kt | 23 +++ .../view/state/SelectAdoptingGroupUIState.kt | 24 +++ 12 files changed, 749 insertions(+) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45be57b2..771819ef 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -491,4 +491,14 @@ You already have a chat with %1$s. Checking whether %1$s can receive messages here. %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. + Who adopts this group? + Which group should adopt this one? + No group you administer can adopt this one. A group can only be offered as a parent by somebody who is in it. + Asked that group to adopt this one + Parent groups + Add a parent group + %1$s has offered to adopt this group + Accept as a parent + A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them. + Asked this group to accept that parent diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt index 915fdec8..3463a4e5 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseChatRepository.kt @@ -111,6 +111,87 @@ class DatabaseChatRepository( emptyList() } + override suspend fun adoptionOffersFor(chatRoomId: String): List = try { + SubgroupManager.offersTo(database, chatRoomId) + } catch (e: Throwable) { + logger.e("Error reading the adoption offers for $chatRoomId", e) + emptyList() + } + + override suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List = try { + SubgroupManager.adoptingCandidatesFor( + database = database, + childChatRoomId = chatRoomId, + userPublicKey = userPublicKey + ) + } catch (e: Throwable) { + logger.e("Error reading the groups that could adopt $chatRoomId", e) + emptyList() + } + + override suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? = try { + val adopting = database.chatRoomDao().findChatRoomById(adoptingChatRoomId) + ?: return "Couldn't load that group" + + SubgroupManager.refuseAdoption( + database = database, + adoptingRoom = adopting, + childChatRoomId = chatRoomId, + userPublicKey = userPublicKey + ) + } catch (e: Throwable) { + logger.e("Error checking whether $adoptingChatRoomId can adopt $chatRoomId", e) + // A refusal rather than a pass. A guard that could not run has not said + // yes, and the manager checks again anyway. + "Couldn't check whether that group can adopt this one" + } + + override suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean = try { + val adopting = checkNotNull(database.chatRoomDao().findChatRoomById(adoptingChatRoomId)) + val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId)) + + SubgroupManager.proposeAdoption( + database = database, + adoptingRoom = adopting, + childRoom = child, + userPublicKey = userPublicKey + ) + true + } catch (e: Throwable) { + logger.e("Error asking $adoptingChatRoomId to adopt $chatRoomId", e) + false + } + + override suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean = try { + val child = checkNotNull(database.chatRoomDao().findChatRoomById(chatRoomId)) + + SubgroupManager.proposeAcceptance( + database = database, + childRoom = child, + userPublicKey = userPublicKey, + parentChatRoomId = parentChatRoomId + ) + true + } catch (e: Throwable) { + logger.e("Error asking $chatRoomId to accept $parentChatRoomId as a parent", e) + false + } + override suspend fun parentsOf(chatRoomId: String): List = try { SubgroupManager.parentsOf(database, chatRoomId) } catch (e: Throwable) { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt index 0d68a0f7..a10c237c 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/managers/SubgroupManager.kt @@ -480,6 +480,33 @@ object SubgroupManager { return false } + /** + * Groups that have offered to adopt [chatRoomId] and are not its parents yet, + * newest offer first. + * + * Read off every certificate this device holds, in any room, rather than off + * the room this one is -- a certificate about this group is signed in the + * *other* group's room, so it is filed against that room and would never be + * found by asking this one. Each is checked by [classify], which verifies the + * signature against the group it names and needs no lookup to do it. + * + * Already-parents are dropped, because an offer that has been accepted is + * not an offer any more; the lineage is where that shows. + */ + suspend fun offersTo(database: MantraDatabase, chatRoomId: String): List { + val accepted = parentsOf(database, chatRoomId).map { it.lowercase() }.toSet() + + return database.groupSignedEventDao() + .getByKind(SubgroupBirthCertificateEvent.KIND) + .sortedByDescending { it.createdAt } + .mapNotNull { signed -> + (classify(chatRoomId, signed.toEvent()) + as? ArrivingCertificate.OfferToUs)?.parentChatRoomId + } + .filterNot { it.lowercase() in accepted } + .distinctBy { it.lowercase() } + } + /** * The groups this profile could offer [childChatRoomId] to, in room order. * diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt index 9453a9e7..319e3782 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/ChatRepository.kt @@ -116,6 +116,49 @@ interface ChatRepository { coordinatorPublicKey: HexKey, ): String? + /** + * Groups that have offered to adopt this room and are not its parents yet. + * + * An offer is a certificate signed in the *other* group's room, so only a + * member of that group holds one -- which is the overlap an adoption needs. + */ + suspend fun adoptionOffersFor(chatRoomId: String): List + + /** The groups this profile could offer [chatRoomId] to, for the picker. */ + suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List + + /** Why [adoptingChatRoomId] cannot adopt [chatRoomId], or null when it can. */ + suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? + + /** + * Asks [adoptingChatRoomId]'s quorum to certify this room as its child. + * + * The offer, and only that: the child's own quorum answers it with + * [acceptParent], and nothing is adopted until it does. + */ + suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean + + /** + * Asks this room's quorum to accept [parentChatRoomId], by re-announcing its + * key state with that parentage added to whatever it already claims. + */ + suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean + suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List /** @@ -307,6 +350,31 @@ interface ChatRepository { return null } + override suspend fun adoptionOffersFor(chatRoomId: String): List = emptyList() + + override suspend fun adoptingCandidatesFor( + chatRoomId: String, + userPublicKey: HexKey + ): List = emptyList() + + override suspend fun refuseAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): String? = "No database" + + override suspend fun proposeAdoption( + chatRoomId: String, + adoptingChatRoomId: String, + userPublicKey: HexKey + ): Boolean = false + + override suspend fun acceptParent( + chatRoomId: String, + parentChatRoomId: HexKey, + userPublicKey: HexKey + ): Boolean = false + override suspend fun getAllParticipantsWithPubKey(publicKey: HexKey): List { TODO("Not yet implemented") } diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index 9c9efbae..b490e05d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -88,12 +88,18 @@ import press.mantra.compose.ui.composable.navigation.routes.ArtifactDetailRoute import press.mantra.compose.ui.theme.spacing import mantra.composeapp.generated.resources.run_by_members import mantra.composeapp.generated.resources.parent_group +import mantra.composeapp.generated.resources.accept_as_a_parent +import mantra.composeapp.generated.resources.add_a_parent_group +import mantra.composeapp.generated.resources.a_group_is_a_subgroup_once_its_own_quorum_says_so +import mantra.composeapp.generated.resources.asked_this_group_to_accept_that_parent +import mantra.composeapp.generated.resources.s_has_offered_to_adopt_this_group import mantra.composeapp.generated.resources.a_subgroup import mantra.composeapp.generated.resources.created_you_are_not_a_member import mantra.composeapp.generated.resources.certified_not_yet_created import mantra.composeapp.generated.resources.no_subgroups_have_been_made_by_this_group import mantra.composeapp.generated.resources.add_subgroup import mantra.composeapp.generated.resources.subgroups +import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute @@ -166,6 +172,13 @@ fun ChatRoomDetailScreen( ) ) + // Both read here because both are composable and an onClick lambda is not. + // The notifier takes the caller's scope on purpose: an acceptance is shown + // while the screen reloads around it, and a message launched in a departing + // composable's scope would be cancelled with it. + val notify = rememberNotifier(rememberCoroutineScope()) + val acceptedParent = stringResource(Res.string.asked_this_group_to_accept_that_parent) + ScreenStateTransition(chatRoomDetailViewModel.chatRoomDetailUIState) { uiState -> when (val chatRoomDetailUIState = uiState) { is ChatRoomDetailUIState.Error -> { @@ -310,6 +323,99 @@ fun ChatRoomDetailScreen( } } + // An offer, and it has to read like one. The other + // group's quorum has signed; this group has not, and + // until it does the lineage is unchanged. Rendered as + // a fact about the lineage it would be the consent gap + // reopened from the other end. + chatRoomDetailUIState.adoptionOffers.forEach { offeringChatRoomId -> + item(key = "offer-$offeringChatRoomId") { + Card(modifier = Modifier.fillMaxWidth()) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + headlineContent = { + Text( + text = stringResource( + Res.string.s_has_offered_to_adopt_this_group, + offeringChatRoomId.take(16) + ) + ) + }, + supportingContent = { + Text( + text = stringResource( + Res.string + .a_group_is_a_subgroup_once_its_own_quorum_says_so + ), + style = MaterialTheme.typography.bodySmall + ) + }, + trailingContent = { + // Offered to whoever opens the + // screen and can act, not only to + // the member who asked -- every + // step after the ceremony is open + // to any admin holding a share. + if (chatRoomDetailUIState.canAddParent) { + TextButton( + onClick = { + chatRoomDetailViewModel.acceptParent( + offeringChatRoomId + ) { message -> + notify(message ?: acceptedParent) + } + } + ) { + Text( + stringResource( + Res.string.accept_as_a_parent + ) + ) + } + } + } + ) + } + } + } + + // Hidden rather than disabled, the way the subgroup + // entry is: a room with no key state has neither half + // of an adoption to work with, and a device with no + // share cannot sign either of them. + if (chatRoomDetailUIState.canAddParent) { + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + SelectAdoptingGroupRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + relayHint = relayHint + ) + ) + } + ) { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + + Spacer( + modifier = Modifier + .width(MaterialTheme.spacing.relatedGap) + ) + + Text(stringResource(Res.string.add_a_parent_group)) + } + } + } + item { chatRoomDetailUIState.localChatRoom.chatRoom.description?.let { Text( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt new file mode 100644 index 00000000..16313ef2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SelectAdoptingGroupScreen.kt @@ -0,0 +1,195 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccountTree +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.launch +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.asked_that_group_to_adopt_this_one +import mantra.composeapp.generated.resources.no_group_here_can_adopt_this_one +import mantra.composeapp.generated.resources.which_group_should_adopt_this_one +import mantra.composeapp.generated.resources.who_adopts_this_group +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.EmptyState +import press.mantra.compose.ui.composable.widgets.ErrorState +import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.composable.widgets.ScreenStateTransition +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.SelectAdoptingGroupViewModel +import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState + +/** + * Which group to ask to adopt this one. + * + * Tapping a row asks that group's quorum to certify this room as its child. That + * is an **offer** and nothing more: this room becomes their subgroup only once + * its own quorum re-announces its key state naming them, which is a card back on + * the room's own screen. So this hands back rather than going anywhere new, and + * the snackbar says what was asked rather than what was done. + * + * The list is the profile's own rooms -- see `SelectAdoptingGroupViewModel` for + * why it can be nothing else -- so empty is an ordinary answer and the empty + * state explains it rather than apologising. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SelectAdoptingGroupScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading, + chatRepository: ChatRepository, + onNavigateBack: () -> Unit, +) { + val viewModel: SelectAdoptingGroupViewModel = viewModel( + factory = SelectAdoptingGroupViewModel.factory( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + initialUIState = initialUIState, + chatRepository = chatRepository + ) + ) + + LaunchedEffect(chatRoomId) { viewModel.initiate() } + + val snackbarHostState = LocalSnackbarHostState.current + val scope = rememberCoroutineScope() + + // Read outside the handler: both of these are composable and an onClick + // lambda is not. The notifier takes the caller's scope because the message is + // shown as this screen navigates away, and one launched in the departing + // composable's scope would be cancelled with it. + val asked = stringResource(Res.string.asked_that_group_to_adopt_this_one) + + ScreenStateTransition(viewModel.uiState) { uiState -> + when (val state = uiState) { + is SelectAdoptingGroupUIState.Error -> ErrorState( + message = state.message, + // Retrying reloads the group and the candidates, which is what + // failed. + onRetry = { viewModel.initiate() } + ) + + SelectAdoptingGroupUIState.Loading -> LoadingDataIndicator() + + is SelectAdoptingGroupUIState.Loaded -> Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text(stringResource(Res.string.who_adopts_this_group)) }, + navigationIcon = { NavigateBackButton(onNavigateBack) }, + ) + } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .readableContent() + .fillMaxSize() + .padding(MaterialTheme.spacing.containerPadding), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap) + ) { + if (state.candidates.isEmpty()) { + item { + // The message carries the reason, because the + // reason is the whole content of this absence: a + // group cannot be offered as a parent by somebody + // who is not in it, so the remedy is a person. + EmptyState( + message = stringResource( + Res.string.no_group_here_can_adopt_this_one + ) + ) + } + } else { + item { + Text( + text = stringResource( + Res.string.which_group_should_adopt_this_one + ), + style = MaterialTheme.typography.labelMedium + ) + } + + items( + items = state.candidates, + key = { candidate -> candidate.chatRoom.id } + ) { candidate -> + Card( + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + viewModel.offerTo(candidate.chatRoom.id) { refusal -> + scope.launch { + snackbarHostState.showSnackbar(refusal ?: asked) + } + if (refusal == null) onNavigateBack() + } + } + } + ) { + ListItem( + leadingContent = { + Icon( + Icons.Default.AccountTree, + contentDescription = Decorative + ) + }, + trailingContent = { + Icon( + Icons.Default.ChevronRight, + contentDescription = Decorative + ) + }, + headlineContent = { + Text( + text = candidate.chatRoom.subject + ?: candidate.chatRoom.id.take(16), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Text( + text = candidate.chatRoom.id.take(16), + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis + ) + } + ) + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index 92f022d0..ae13c349 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -99,7 +99,9 @@ import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToC import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute import press.mantra.compose.ui.composable.navigation.routes.SearchRoute import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomMembersRoute +import press.mantra.compose.ui.composable.navigation.routes.SelectAdoptingGroupRoute import press.mantra.compose.ui.composable.navigation.routes.SelectSubgroupAdminsRoute +import press.mantra.compose.ui.composable.SelectAdoptingGroupScreen import press.mantra.compose.ui.composable.SelectSubgroupAdminsScreen import press.mantra.compose.ui.composable.navigation.routes.SelectChatRoomTypeRoute import press.mantra.compose.ui.composable.navigation.routes.ShareProfileRoute @@ -599,6 +601,18 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + SelectAdoptingGroupScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + onNavigateBack = { + navController.popBackStack() + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt new file mode 100644 index 00000000..5c018ebc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/SelectAdoptingGroupRoute.kt @@ -0,0 +1,22 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Which group to ask to adopt this one. + * + * The first of an adoption's two signings, and the only one that needs a screen + * of its own: the acceptance is a card on the child's own detail screen, because + * by then there is something to accept. + * + * Carries only the child's id. The candidates are not on the route and must not + * be: they are the rooms the **profile in the chair** can act in, which a route + * cannot know and which changes while the screen is open -- see + * `SubgroupManager.adoptingCandidatesFor`. + */ +@Serializable +data class SelectAdoptingGroupRoute( + val activeUserPublicKey: String, + val chatRoomId: String, + val relayHint: String? = null +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt index 4500b9ac..9d7b7ade 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatRoomDetailViewModel.kt @@ -84,11 +84,52 @@ class ChatRoomDetailViewModel( parentChatRoomIds = chatRepository.parentsOf(chatRoomId), canAddSubgroup = localChatRoom.chatRoom.mlsGroupState != null && chatRepository.canSign(chatRoomId), + adoptionOffers = chatRepository.adoptionOffersFor(chatRoomId), + // A key state as well as a share: the certificate carries + // this group's threshold key, and the acceptance re-announces + // that very state, so a room that has agreed nothing has + // neither half to work with. + canAddParent = localChatRoom.chatRoom.mlsGroupState != null && + chatRepository.groupKeyState(chatRoomId) != null && + chatRepository.canSign(chatRoomId), ) } } } + /** + * Asks this group's quorum to accept [parentChatRoomId] as a parent. + * + * The acceptance, and the half that decides: the certificate is an offer + * until a quorum of *this* group has re-announced its key state naming them. + * `SubgroupManager.proposeAcceptance` carries the whole resulting set, so a + * group that already has a parent keeps it. + * + * [onDone] is called with null on success and a message otherwise, so the + * caller can show it from a scope that outlives this screen. + */ + fun acceptParent(parentChatRoomId: HexKey, onDone: (String?) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val accepted = chatRepository.acceptParent( + chatRoomId = chatRoomId, + parentChatRoomId = parentChatRoomId, + userPublicKey = activeUserPublicKey + ) + + if (!accepted) { + logger.e("Failed to ask $chatRoomId to accept $parentChatRoomId") + onDone("Couldn't ask this group to accept that parent. Please try again.") + return@launch + } + + // The offer leaves the list only when the state is signed and filed, + // which is a quorum away -- so this reloads rather than crossing it + // off, and the card stays until the group has actually agreed. + initiateChatRoomDetail() + onDone(null) + } + } + fun leaveGroup( localChatRoom: LocalChatRoom, onPopBackToRoute: (Route) -> Unit diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt new file mode 100644 index 00000000..a46ef417 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SelectAdoptingGroupViewModel.kt @@ -0,0 +1,138 @@ +package press.mantra.compose.ui.view.model + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.launch +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.ui.view.state.SelectAdoptingGroupUIState + +/** + * Which group to ask to adopt this one. + * + * The first of an adoption's two signings. Confirming proposes a birth + * certificate in the **chosen** group's room, and that is all it does: the + * certificate is an **offer**, and this room is not that group's subgroup until + * its own quorum re-announces its key state naming them. So this screen hands + * back to the room it came from rather than anywhere new -- the acceptance is a + * card there, and the answer belongs to whoever opens it. + * + * ### The candidates cannot be anything but this profile's own rooms + * + * A certificate is proposed *inside* the adopting group, so a candidate has to be + * a room the key in the chair can act in: a Marmot room this profile administers + * and holds a share of, which is not this room, is not already a parent, has not + * already offered, and is not already beneath this one. Every one of those is + * `SubgroupManager.refuseAdoption`'s, run through that function rather than + * through a copy of its rules, so the screen and the manager cannot drift. + * + * An empty list is therefore a fact about this member rather than a failure, and + * the screen says so: a group cannot be offered as a parent by somebody who is + * not in it, and the remedy is a person rather than a button. + */ +class SelectAdoptingGroupViewModel( + val activeUserPublicKey: HexKey, + val chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState, + val chatRepository: ChatRepository, +): ViewModel() { + + var uiState: SelectAdoptingGroupUIState by mutableStateOf(initialUIState) + private set + + private val logger = Logger.withTag(TAG) + + val isActionPending: MutableState = mutableStateOf(false) + + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val childRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (childRoom == null) { + uiState = SelectAdoptingGroupUIState.Error("Couldn't load this group") + return@launch + } + + uiState = SelectAdoptingGroupUIState.Loaded( + childRoom = childRoom, + candidates = chatRepository.adoptingCandidatesFor( + chatRoomId = chatRoomId, + userPublicKey = activeUserPublicKey + ) + ) + } + } + + /** + * Asks [adoptingChatRoomId]'s quorum to certify this room as its child, then + * hands back. + * + * Checked again here rather than trusted from the list, because the screen + * may have been open a while and a screen not drawing something is not a + * guard. The manager checks a third time; this one exists to say *why* rather + * than to throw. + */ + fun offerTo(adoptingChatRoomId: String, onDone: (String?) -> Unit) { + if (isActionPending.value) return + isActionPending.value = true + + viewModelScope.launch(Dispatchers.IO) { + val refusal = chatRepository.refuseAdoption( + chatRoomId = chatRoomId, + adoptingChatRoomId = adoptingChatRoomId, + userPublicKey = activeUserPublicKey + ) + + if (refusal != null) { + isActionPending.value = false + onDone(refusal) + return@launch + } + + val proposed = chatRepository.proposeAdoption( + chatRoomId = chatRoomId, + adoptingChatRoomId = adoptingChatRoomId, + userPublicKey = activeUserPublicKey + ) + + isActionPending.value = false + + if (!proposed) { + logger.e("Failed to ask $adoptingChatRoomId to adopt $chatRoomId") + onDone("Couldn't ask that group to adopt this one. Please try again.") + return@launch + } + + onDone(null) + } + } + + companion object { + private const val TAG = "SelectAdoptingGroupViewModel" + + fun factory( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialUIState: SelectAdoptingGroupUIState = SelectAdoptingGroupUIState.Loading, + chatRepository: ChatRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + SelectAdoptingGroupViewModel( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + initialUIState = initialUIState, + chatRepository = chatRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt index 7082d6d8..d357797e 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/ChatRoomDetailUIState.kt @@ -64,6 +64,29 @@ sealed interface ChatRoomDetailUIState { * missing and what to do about it. */ val canAddSubgroup: Boolean = false, + /** + * Groups that have offered to adopt this one and are not parents yet. + * + * An offer is a certificate signed in the *other* group's room, so only a + * member of that group holds one -- which is the overlap an adoption + * needs, showing up here as a list that is empty for everybody else. + * + * Each is verified: the signature was checked against the group it names, + * which needs no lookup. What is unverified is nothing; what is + * *unsettled* is everything, because an offer is not a lineage until this + * group's own quorum says so. + */ + val adoptionOffers: List = emptyList(), + /** + * Whether this device could put a parent to this group at all. + * + * The room has to be a Marmot one with a key state -- the certificate + * carries the child's threshold key and the acceptance re-announces that + * state -- and this device has to hold a share, because both halves are + * signatures by this group. The rest of the refusals are the picker's, + * where they can name which group and why. + */ + val canAddParent: Boolean = false, ): ChatRoomDetailUIState data class Error( diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt new file mode 100644 index 00000000..9255519b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/SelectAdoptingGroupUIState.kt @@ -0,0 +1,24 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.intermdiate.LocalChatRoom + +sealed interface SelectAdoptingGroupUIState { + data class Loaded( + val childRoom: LocalChatRoom, + /** + * The groups this profile could ask, in room order. + * + * **The profile's own rooms, and nothing else is possible.** A + * certificate is proposed *inside* the adopting group, so a candidate has + * to be a room the key in the chair can already act in. Empty is a fact + * about this member rather than a failure -- a group cannot be offered as + * a parent by somebody who is not in it, and the remedy is a person + * rather than a button. + */ + val candidates: List = emptyList(), + ): SelectAdoptingGroupUIState + + data class Error(val message: String): SelectAdoptingGroupUIState + + data object Loading: SelectAdoptingGroupUIState +} From 7a28b227d9d9e75396166f4b0cf0b46ef30c5ea3 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:18:17 +0200 Subject: [PATCH 11/15] docs: record what the adoption plan built, and the eight places it chose differently The note shipped as research and is now five commits of code, so it says so, in the shape docs/subgroups.md uses: the phases kept as written because they are the reasoning, and a table of where the build disagreed with them. Eight entries, and two are worth more than the rest. **A group can sign, truthfully by all six of `certifies`' questions, that it is its own parent** -- author, subject and named parent one room, the id deriving from the key, the signature verifying. The note never considered it. The self-parent guard began inside the offer branch, where it never ran, because the child branch catches such a certificate first and reads it as an ordinary child. A test caught it, and the gap predates this work: `subgroupsOf` would list the group as its own subgroup today. **The certificate's roster.** The note was careful that a founding certificate's name and `p` tags are the founding roster, frozen, and said nothing about adoption -- where there is no founding. The adopting group's admins are being asked about a group that exists, so what they can weigh is who runs it now, and the current roster is the honest value rather than a compromise. Still frozen the moment it is signed, still never read as live. The rest are smaller and all one shape, which is a note counting a thing and missing its thread: dropping `ChatRoom.parentChatRoomId` also drops the parameter that travelled through four files to reach its only writer; `stateFrom` returning "a state and a lineage" wanted a named type, and `LocalGroupKeyState` is a Room read model that is also a plain data class, so the pure verifier builds one with no database near it. One thing the note asked for is **not** done and cannot be: the key-state summary saying which parents are being *dropped*. A summary is pure and a disavowal is a difference against the row this device holds, so it belongs to the screen. The rough-size table becomes estimated against actual. It guessed about two dozen files and it was 39, with the UI three times its estimate -- a picker needs a route, a screen, a view model and a state, which the note had as one line -- and the repositories half of it, because `DkgRepository`'s changes were return types it never counted. Tests: 7 files, 53 new cases. `docs/README.md` moves the note from research to a phased plan that has been built, and its one-line summary now ends on the self-certificate rather than on the consent gap, which is closed. Docs only -- no code changed, so there are no test counts of this commit's own. The five it describes: 454 common tests, 962 jvm tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- docs/README.md | 6 ++-- docs/subgroups-multiple-parents.md | 58 +++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/docs/README.md b/docs/README.md index dfdf4a2c..63ba242c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,7 +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 one screen that currently asks without saying what it is asking | +| [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 | @@ -40,8 +40,8 @@ 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. The -multiple-parents note is research rather than a plan, and reads only after it: -it takes the one assumption that note never had to state — that a lineage is +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 diff --git a/docs/subgroups-multiple-parents.md b/docs/subgroups-multiple-parents.md index 3930e356..e3efe414 100644 --- a/docs/subgroups-multiple-parents.md +++ b/docs/subgroups-multiple-parents.md @@ -1,7 +1,35 @@ # Adding a parent to a group that already exists -What it would take for a group to gain a second parent after it was founded. -Research, not a plan: nothing here has been built. +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 @@ -757,15 +785,19 @@ description is baked into the epoch-0 group context and adoption happens after i ## Rough size -| | files | notes | -|---|---|---| -| wire | 1 | `GroupKeyStateEvent` — list instead of nullable single, in three functions | -| lineage type | 2 | `SubgroupParentage` kept as one pair; a `SubgroupLineage` holding the set and its invariants | -| schema | 5 | `GroupKeyState`, a new entity, `GroupKeyStateDao` (transactional `replace`), `ChatRoomDao`, `MantraDatabase` + migration | -| managers | 2 | `GroupKeyStateManager` (`parentageOf`, `propose`), `SubgroupManager` (`parentsOf`, the two guard sets, adoption proposals) | -| repositories | 4 | two interfaces, two implementations, mostly signature plurals | -| text | 2 | `ProposedEvent`'s 30326 arm — the consent fix — and `ChatMessage`'s 30329 arm, for the offer line | -| UI | 5 | `ChatRoomDetailUIState`, its view model and screen, a parent picker, strings | -| tests | 8 | | +What it estimated, against what it took: -Around two dozen files, and the founding flow is not among them. +| | 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. From 4e6ca0a08032b0405fea7ca87a54420557476a09 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:45:28 +0200 Subject: [PATCH 12/15] fix(dkg): stop the key ceremony screen offering to start the ceremony it is showing "Start key ceremony" is not a button, it is the whole no-ceremony screen -- the explainer, the quorum picker and the FAB together -- so anything that leaves `DkgRitualUIState.Loaded` saying there is no session puts that offer in front of a member watching one run. Two unrelated things did, and they fail differently: one flashes, one sticks. **The state went up before the rows were read.** `initiate()` published a bare `Loaded(localChatRoom)` -- session null, `roomHasCeremony` false -- and *then* started the two flows that answer both questions. A cold open is the only time those queries are slow, and it is exactly when a member arrives from the transcript's notice, so the offer was drawn over a running ceremony for as long as they took. Both answers are a row lookup, so they are now read with `first()` before anything is published and the state goes up already correct. The flows still run; they only keep it current. **Seven flows were folding into one `var ... by mutableStateOf`.** The ceremony, the room's other ceremonies, the ceremony's messages, the key state, whether the key state is signed, the certificate and the certificate's signing -- each of them a read, a `copy` and an assignment, every one emitting on Room's own executor. Two emissions arriving together silently dropped one: whichever read first wrote last, over the top of the other's change. The field that went missing most visibly was `session`, copied back to null by a watcher that had read the state before the ceremony landed in it. That is not a flash. The session flow only re-emits when the `DkgSession` table changes, and a ceremony waiting on a member who has not opened the app changes nothing, so the screen sat there offering to start the ceremony it was showing. `MutableStateFlow` with every write through `update { }` -- a compare-and-set that retries on a loss, so a change can only ever be folded into the state that is current. The one rule it imposes is that the block re-runs, so it must be pure: `pendingApproval` is a suspending database lookup that used to sit *inside* the `copy`, and it now happens above the call. `updateLoaded` carries the `as? Loaded ?: return` that all twenty call sites used to write by hand. **Two knock-ons.** `canStartRitual()` and `quorumRange()` were read from the composable and are now passed the state, because reading a flow's `.value` inside composition observes nothing -- the offer would have been decided once and never revised. Passing the rendered state is also more correct while `ScreenStateTransition` still holds the outgoing one. `DkgRitualScreen` and `DkgApprovalScaffold` collect with `collectAsState()`. **The button itself is right and stays.** Creating a robust group already opens the ceremony -- `SelectChatRoomTypeViewModel.createNip17ChatRoom` calls `proposeRitual` immediately after `createNip17ChatRoom` -- and this screen's offer is the deliberate fallback for the case where that failed (`keyCeremonyNotStarted`) and for rooms created before it existed. What was broken is when it appeared, not that it exists. **Tests.** `DkgRitualStartOfferJvmTest`, two cases, and each was checked against its own regression reinstated rather than only against the fix. The first asserts the *first* `Loaded` already knows the room is running a ceremony, with a deliberately slow query so the window is proved gone rather than hidden. The second forces the lost update to happen rather than racing into it: the session watcher is parked inside `pendingApproval` while the key-state watcher writes, and the write that follows must not carry the stale copy over it. **Not done.** `HomeViewModel`, `ChatMessageListViewModel`, `ProposalListViewModel` and others still declare `by mutableStateOf` in a view model constructor. They are only at risk where more than one flow folds into one state object; none was audited here. 964 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../ui/composable/DkgApprovalScaffold.kt | 3 +- .../compose/ui/composable/DkgRitualScreen.kt | 7 +- .../ui/view/model/DkgRitualViewModel.kt | 271 +++++++++++------- .../view/model/DkgRitualStartOfferJvmTest.kt | 247 ++++++++++++++++ 4 files changed, 428 insertions(+), 100 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt index cc358aa5..d7513e87 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgApprovalScaffold.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -100,7 +101,7 @@ internal fun DkgApprovalScaffold( ) } ) { padding -> - when (val state = dkgRitualViewModel.dkgRitualUIState) { + when (val state = dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { is DkgRitualUIState.Loading -> Column( modifier = Modifier.fillMaxWidth().padding(padding).readableContent().padding(MaterialTheme.spacing.space250), horizontalAlignment = Alignment.CenterHorizontally diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt index f669285c..3ea26e01 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -154,7 +155,7 @@ fun DkgRitualScreen( ) ) - ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState) { uiState -> + ScreenStateTransition(dkgRitualViewModel.dkgRitualUIState.collectAsState().value) { uiState -> when (val dkgRitualUIState = uiState) { is DkgRitualUIState.Error -> { Column( @@ -168,7 +169,7 @@ fun DkgRitualScreen( is DkgRitualUIState.Loaded -> { val session = dkgRitualUIState.session val participantCount = dkgRitualUIState.participantCount - val canStartRitual = dkgRitualViewModel.canStartRitual() + val canStartRitual = dkgRitualViewModel.canStartRitual(dkgRitualUIState) val isActionPending = dkgRitualViewModel.isActionPending.value Scaffold( @@ -286,7 +287,7 @@ fun DkgRitualScreen( QuorumStepper( threshold = dkgRitualViewModel.threshold.value, participantCount = participantCount, - quorumRange = dkgRitualViewModel.quorumRange(), + quorumRange = dkgRitualViewModel.quorumRange(dkgRitualUIState), onThresholdChange = dkgRitualViewModel::setThreshold ) } else { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index 76d61b2b..feb4316f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -1,9 +1,7 @@ package press.mantra.compose.ui.view.model import androidx.compose.runtime.MutableState -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -36,7 +34,10 @@ import press.mantra.compose.identity.Identity import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch /** @@ -60,8 +61,51 @@ class DkgRitualViewModel( val dkgRepository: DkgRepository, ): ViewModel() { - var dkgRitualUIState: DkgRitualUIState by mutableStateOf(initialDkgRitualUIState) - private set + private val _dkgRitualUIState = MutableStateFlow(initialDkgRitualUIState) + + /** + * What the screen draws -- a flow rather than a `mutableStateOf`, because + * seven flows write it. + * + * The ceremony, the room's other ceremonies, the ceremony's messages, the + * key state, whether the key state is signed, the certificate and the + * certificate's signing all fold into this one object, and every one of + * them emits on Room's own executor. Held + * as a `var ... by mutableStateOf`, each of those was a read, a `copy` and + * an assignment with a suspension point in the middle, so two emissions + * arriving together silently dropped one: whichever read first wrote last, + * over the top of the other's change. + * + * The change that went missing most visibly was [DkgRitualUIState.Loaded.session], + * copied back to null by a watcher that had read the state before the + * ceremony landed in it -- and a screen with no session is the screen that + * offers to start one. It stayed that way until some other table changed and + * the session flow emitted again, which for a ceremony waiting on an absent + * member is never. + * + * [MutableStateFlow.update] retries on a losing compare-and-set, so a change + * can only be folded into the state that is actually current. Every write + * below goes through it -- see [updateLoaded]. It is the same rule the + * wallet view models already keep, for the same reason. + */ + val dkgRitualUIState: StateFlow = _dkgRitualUIState.asStateFlow() + + /** + * Folds a change into the loaded state, atomically, and does nothing when + * the screen is not in it -- the `as? Loaded ?: return` that every one of + * these call sites used to write by hand. + * + * [block] is re-run when it loses the race, so it has to be a pure function + * of the state it is handed: read nothing else out of the view model inside + * it, and do the suspending work above the call. + */ + private inline fun updateLoaded( + block: (DkgRitualUIState.Loaded) -> DkgRitualUIState.Loaded + ) { + _dkgRitualUIState.update { current -> + if (current is DkgRitualUIState.Loaded) block(current) else current + } + } private val logger = Logger.withTag(TAG) @@ -101,9 +145,15 @@ class DkgRitualViewModel( * Any member may: ChillDKG gives the coordinator no say in the outcome, and a * NIP-17 room records no creator to reserve the job for. What rules it out is * a ceremony already running, or a group too small to split a key across. + * + * [state] is passed in by the screen rather than read off the view model, + * because the state is a flow now and reading `.value` inside composition + * observes nothing -- the offer would be decided once and never revised. The + * screen hands in the state it is drawing, which is also the only state the + * answer should be about while a transition still holds the outgoing one. */ - fun canStartRitual(): Boolean { - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return false + fun canStartRitual(state: DkgRitualUIState = dkgRitualUIState.value): Boolean { + val loaded = state as? DkgRitualUIState.Loaded ?: return false if (loaded.session != null && loaded.session.stage != DkgRitualStage.FAILED) return false @@ -116,8 +166,8 @@ class DkgRitualViewModel( return ChillDkgRitualManager.canRunRitual(loaded.localChatRoom) } - fun quorumRange(): IntRange { - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded + fun quorumRange(state: DkgRitualUIState = dkgRitualUIState.value): IntRange { + val loaded = state as? DkgRitualUIState.Loaded ?: return ChatRoomType.quorumRange(ChatRoomType.MINIMUM_QUORUM) // Below the minimum the range runs backwards and is empty, which `coerceIn` @@ -137,17 +187,10 @@ class DkgRitualViewModel( viewModelScope.launch(Dispatchers.IO) { val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) if (localChatRoom == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't load this chat") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't load this chat") return@launch } - dkgRitualUIState = DkgRitualUIState.Loaded(localChatRoom = localChatRoom) - threshold.value = ChatRoomType - .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) - .coerceIn(quorumRange()) - - observeKeyState() - // Room-scoped, deliberately. Only the member who opened a subgroup // arrives here knowing it is one -- everybody else reaches this screen // from the room, through the transcript's ritual notice or the group's @@ -171,30 +214,65 @@ class DkgRitualViewModel( // Whether the room has any ceremony at all, which is a different // question from which one is being shown -- see // `DkgRitualUIState.roomHasCeremony`. - launch { - dkgRepository.observeLatestSessionForChatRoom(chatRoomId).collect { newest -> - val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + val roomSessions = dkgRepository.observeLatestSessionForChatRoom(chatRoomId) - dkgRitualUIState = current.copy( - roomHasCeremony = newest != null && newest.stage != DkgRitualStage.FAILED - ) + // Both answers are read here, before anything is published, rather + // than awaited after a bare `Loaded` has gone up. A `Loaded` with no + // session and no ceremony *is* the screen that offers to start one, + // so publishing it first flashed "start key ceremony" over a ceremony + // already running -- for as long as the two queries took, which is + // long enough to read and long enough to tap. The flows below keep + // both current from here on; this only settles what the first frame + // says. + val shownSession = sessions.first() + val newestSession = roomSessions.first() + + _dkgRitualUIState.value = DkgRitualUIState.Loaded( + localChatRoom = localChatRoom, + session = shownSession, + parentChatRoomId = shownSession?.parentChatRoomId, + pendingApproval = shownSession?.let { dkgRepository.pendingApproval(it) }, + roomHasCeremony = newestSession != null && + newestSession.stage != DkgRitualStage.FAILED + ) + + threshold.value = ChatRoomType + .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) + .coerceIn(quorumRange()) + + observeKeyState() + + launch { + roomSessions.collect { newest -> + updateLoaded { current -> + current.copy( + roomHasCeremony = newest != null && + newest.stage != DkgRitualStage.FAILED + ) + } } } sessions.collect { session -> - val loaded = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom) - // The session is the only thing that knows what the ceremony is // for. The route used to carry it too, which was right for the one // member who opened the subgroup and null for everybody else. val parent = session?.parentChatRoomId - dkgRitualUIState = loaded.copy( - session = session, - parentChatRoomId = parent, - pendingApproval = session?.let { dkgRepository.pendingApproval(it) } - ) + // Above the update, not inside it: `update` re-runs its block when + // it loses the race, and a block that re-runs cannot suspend. + val pending = session?.let { dkgRepository.pendingApproval(it) } + + _dkgRitualUIState.update { current -> + val loaded = current as? DkgRitualUIState.Loaded + ?: DkgRitualUIState.Loaded(localChatRoom = localChatRoom) + + loaded.copy( + session = session, + parentChatRoomId = parent, + pendingApproval = pending + ) + } // Re-point the per-ceremony watchers whenever the ceremony or its // purpose changes, which for a member arriving cold is once. @@ -212,23 +290,32 @@ class DkgRitualViewModel( if (session != null) { messageObserver = viewModelScope.launch(Dispatchers.IO) { dkgRepository.observeMessages(session.id).collect { messages -> - val current = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - fun senders(kind: Kind) = messages .filter { it.kind == kind } .map { it.participantPublicKey } .toSet() - dkgRitualUIState = current.copy( - hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY), - round1Participants = senders(DkgRitualEvents.ROUND_1), - round2Participants = senders(DkgRitualEvents.ROUND_2), - // Recomputed here as well as on the session, because - // whether round 1 can be asked for depends on how many - // host keys have arrived, which only this flow sees. - pendingApproval = current.session - ?.let { dkgRepository.pendingApproval(it) } - ) + // Recomputed here as well as on the session, because + // whether round 1 can be asked for depends on how many + // host keys have arrived, which only this flow sees. + val pending = dkgRepository.pendingApproval(session) + + updateLoaded { current -> + // This watcher belongs to one ceremony, and + // cancelling it does not reach into a body already + // running -- so say which ceremony these counts are + // for rather than letting them land on the next. + if (current.session?.id != session.id) { + return@updateLoaded current + } + + current.copy( + hostKeyParticipants = senders(DkgRitualEvents.HOST_KEY), + round1Participants = senders(DkgRitualEvents.ROUND_1), + round2Participants = senders(DkgRitualEvents.ROUND_2), + pendingApproval = pending + ) + } } } } @@ -257,17 +344,15 @@ class DkgRitualViewModel( // before there is a room -- which is the whole point of asking it. launch { dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + val keyState = sessions.firstOrNull { local -> + local.items.any { item -> + runCatching { + Event.fromJson(item.unsignedEventJson).kind + }.getOrNull() == GroupKeyStateEvent.KIND + } + }?.session - dkgRitualUIState = loaded.copy( - keyStateSession = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == GroupKeyStateEvent.KIND - } - }?.session - ) + updateLoaded { it.copy(keyStateSession = keyState) } } } @@ -293,9 +378,9 @@ class DkgRitualViewModel( certificateObserver?.cancel() if (dkgSessionId == null) { - dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null) - ?: dkgRitualUIState + updateLoaded { + it.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null) + } return } @@ -306,41 +391,33 @@ class DkgRitualViewModel( // about this one's key. launch { dkgRepository.observeSignedGroupKeyState(dkgSessionId).collect { state -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - - dkgRitualUIState = loaded.copy(isKeyStateSigned = state != null) + updateLoaded { it.copy(isKeyStateSigned = state != null) } } } if (parentChatRoomId == null) { - dkgRitualUIState = (dkgRitualUIState as? DkgRitualUIState.Loaded) - ?.copy(isCertified = false, certificateSession = null) - ?: dkgRitualUIState + updateLoaded { it.copy(isCertified = false, certificateSession = null) } return@launch } launch { dkgRepository.observeBirthCertificate(dkgSessionId, parentChatRoomId) .collect { certificate -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect - - dkgRitualUIState = loaded.copy(isCertified = certificate != null) + updateLoaded { it.copy(isCertified = certificate != null) } } } launch { dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions -> - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return@collect + val certificate = sessions.firstOrNull { local -> + local.items.any { item -> + runCatching { + Event.fromJson(item.unsignedEventJson).kind + }.getOrNull() == SubgroupBirthCertificateEvent.KIND + } + }?.session - dkgRitualUIState = loaded.copy( - certificateSession = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == SubgroupBirthCertificateEvent.KIND - } - }?.session - ) + updateLoaded { it.copy(certificateSession = certificate) } } } } @@ -359,7 +436,7 @@ class DkgRitualViewModel( fun proposeKeyState() { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return if (session.thresholdPublicKey == null) return @@ -398,7 +475,7 @@ class DkgRitualViewModel( if (signing == null) { logger.e("Failed to propose the key state for $chatRoomId") - dkgRitualUIState = DkgRitualUIState.Error( + _dkgRitualUIState.value = DkgRitualUIState.Error( "Couldn't ask the group to agree its signing key. Please try again." ) } @@ -421,7 +498,7 @@ class DkgRitualViewModel( fun proposeBirthCertificate() { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return // The state's, not the route's. Every step after the ceremony is open to // any of the subgroup's admins -- see `SubgroupManager` -- and they reach // this screen from the room, with no route to have told them anything. @@ -459,7 +536,7 @@ class DkgRitualViewModel( if (signing == null) { logger.e("Failed to propose a birth certificate in $parent") - dkgRitualUIState = DkgRitualUIState.Error( + _dkgRitualUIState.value = DkgRitualUIState.Error( "Couldn't ask the group to certify this subgroup. Please try again." ) } @@ -470,11 +547,11 @@ class DkgRitualViewModel( fun startRitual() { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return } @@ -492,7 +569,7 @@ class DkgRitualViewModel( if (session == null) { logger.e("Failed to start DKG ritual for $chatRoomId") - dkgRitualUIState = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't start the key ceremony. Please try again.") } // On success the session flow above delivers the new row; no need to // set it here and risk racing the observer. @@ -509,12 +586,12 @@ class DkgRitualViewModel( fun approve(step: DkgApprovalStep) { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return } @@ -558,7 +635,7 @@ class DkgRitualViewModel( fun createAdminGroup(onNavigateToRoute: (Route) -> Unit) { if (isActionPending.value) return - val loaded = dkgRitualUIState as? DkgRitualUIState.Loaded ?: return + val loaded = dkgRitualUIState.value as? DkgRitualUIState.Loaded ?: return val session = loaded.session ?: return val thresholdPublicKey = session.thresholdPublicKey ?: return @@ -568,7 +645,7 @@ class DkgRitualViewModel( val nostrPrivateKey = activeIdentityStateFlow.value?.nostrPrivateKey if (nostrPrivateKey == null) { - dkgRitualUIState = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") + _dkgRitualUIState.value = DkgRitualUIState.Error("Couldn't read your keys. Please try again.") return } @@ -594,7 +671,7 @@ class DkgRitualViewModel( ?: if (subgroupParent != null) "Subgroup" else "Group" isActionPending.value = true - dkgRitualUIState = loaded.copy(adminGroupBlockedOn = emptyList()) + updateLoaded { it.copy(adminGroupBlockedOn = emptyList()) } viewModelScope.launch(Dispatchers.IO) { val outcome = chatRepository.createMarmotGroup( @@ -617,19 +694,21 @@ class DkgRitualViewModel( // Named rather than counted. A member cannot publish a key // package on somebody else's behalf, so the only useful thing // to say is whose door to knock on. - dkgRitualUIState = loaded.copy( - adminGroupBlockedOn = outcome.missing.map { publicKey -> - loaded.ritualMembers - .firstOrNull { it.participant.participantPublicKey == publicKey } - ?.profile - ?.humanReadableNameOrPubkey() - ?: publicKey.take(12) - } - ) + updateLoaded { current -> + current.copy( + adminGroupBlockedOn = outcome.missing.map { publicKey -> + current.ritualMembers + .firstOrNull { it.participant.participantPublicKey == publicKey } + ?.profile + ?.humanReadableNameOrPubkey() + ?: publicKey.take(12) + } + ) + } } is MarmotGroupCreation.Outcome.Failed -> { - dkgRitualUIState = DkgRitualUIState.Error(outcome.reason) + _dkgRitualUIState.value = DkgRitualUIState.Error(outcome.reason) } is MarmotGroupCreation.Outcome.Created -> withContext(Main) { diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt new file mode 100644 index 00000000..e82b816e --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt @@ -0,0 +1,247 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +/** + * The two ways the key-ceremony screen came to offer to start the ceremony it + * was already showing. + * + * "Start key ceremony" is the whole no-ceremony screen -- the explainer, the + * quorum picker and the button -- so anything that leaves the state saying + * there is no ceremony puts that offer in front of a member watching one run. + * Both of these did, and both are invisible to a test that only asks the view + * model what it settles on: the state was right before and right after, and + * wrong in between for long enough to read and to tap. + * + * Neither is about ChillDKG. They are about one `DkgRitualUIState` being + * assembled by seven flows at once, which is why they are tested here rather + * than against the ritual. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualStartOfferJvmTest { + private val chatRoomId = "a1b2c3d4".repeat(8) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * The first loaded state already knows the room is running a ceremony. + * + * The screen used to publish a bare `Loaded` and then wait for the rows, + * and a `Loaded` with no session and no ceremony *is* the offer to start + * one. On a cold open -- the only time the queries are slow -- that is + * exactly when it was shown. + */ + @Test + fun `the offer to start is not published while the rows are still being read`() = runBlocking { + val session = session(DkgRitualStage.COLLECTING_HOST_KEYS) + + val viewModel = viewModel( + object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + // Slow on purpose: a Room query answers in microseconds and + // would hide the window rather than prove it is gone. + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flow { + delay(50) + emit(session) + } + } + ) + + viewModel.initiate() + + val firstLoaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } + } as DkgRitualUIState.Loaded + + assertEquals(session.id, firstLoaded.session?.id, "the first loaded state missed the ceremony") + assertFalse( + viewModel.canStartRitual(firstLoaded), + "offered to start a ceremony on a room already running one" + ) + } + + /** + * A write that suspends between reading the state and writing it back does + * not carry the stale copy over somebody else's change. + * + * Every flow folding into this state used to read it, build a `copy` and + * assign -- and the session watcher asks the database what this member owes + * in the middle of that. The key-state watcher writing during that pause had + * its change thrown away by the assignment that followed. The same pause in + * the other direction is what put `session` back to null and offered to + * start the ceremony on screen; this is the half of it that can be forced to + * happen rather than raced into. + */ + @Test + fun `a change written during a suspension survives the write that follows it`() = runBlocking { + val session = session(DkgRitualStage.COLLECTING_ROUND_1) + val signingSessions = MutableStateFlow>(emptyList()) + + // Held open until the test has had the key-state watcher write. The + // seeding call runs before anything else is collecting, so it is let + // through; the session watcher's call is the one that waits. + val gate = CompletableDeferred() + var calls = 0 + + val viewModel = viewModel( + object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(session) + + override fun observeSigningSessions( + chatRoomId: String + ): Flow> = signingSessions + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? { + calls += 1 + if (calls == 1) return null + + gate.await() + return DkgApprovalStep.ROUND_1 + } + } + ) + + viewModel.initiate() + + withTimeout(5_000) { viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } } + + // The watcher is now parked inside `pendingApproval`, holding a copy of + // the state as it was before this line. + signingSessions.value = listOf(keyStateSigningSession()) + + withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.keyStateSession != null + } + } + + gate.complete(Unit) + + val settled = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.pendingApproval == DkgApprovalStep.ROUND_1 + } + } as DkgRitualUIState.Loaded + + assertNotNull( + settled.keyStateSession, + "the session watcher wrote back a copy taken before the key state arrived" + ) + assertEquals(session.id, settled.session?.id) + } + + private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = me, + initialDkgRitualUIState = DkgRitualUIState.Loading, + activeIdentityStateFlow = MutableStateFlow(null), + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = dkgRepository + ) + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) + + private fun session(stage: DkgRitualStage) = DkgSession( + id = "ceremony", + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) + + private fun keyStateSigningSession() = LocalFrostSigningSession( + session = FrostSigningSession( + id = "signing", + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + dkgSessionId = "ceremony", + threshold = 2, + participantCount = 2, + signerId = 0, + ), + unorderedItems = listOf( + FrostSigningItem( + sessionId = "signing", + itemIndex = 0, + unsignedEventJson = """{"id":"${"33".repeat(32)}","pubkey":"$me",""" + + """"created_at":0,"kind":30326,"tags":[],"content":"","sig":"${"44".repeat(64)}"}""", + eventId = "33".repeat(32), + nonceRandom = "55".repeat(32), + ) + ), + ) +} From a14ea3d9d3cbf6293efac012447424841efb7f49 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 19:46:06 +0200 Subject: [PATCH 13/15] feat(dkg): every key ceremony a room holds, pending first A room holds more than one ceremony as a matter of course, and has done since a subgroup's ChillDKG moved into its parent's Marmot room: the parent's own, long finished, plus one for every subgroup the group has ever made. Nothing in the app could name the second of them. `DkgRitualRoute` without a session id shows the room's *newest*, which is routinely the finished one with the subgroup's request buried underneath it. The group's details reached only that. The transcript could name one, by scrolling to the line it was about -- which for a subgroup means scrolling past whatever else the room has been used for, and is impossible for a ceremony whose line never arrived. **`DkgRitualListScreen`, deliberately the same shape as `ProposalListScreen`.** One row per ceremony off `observeSessionsForChatRoom`, split into "waiting for you" and "everything else". Split rather than sorted, for the reason `ProposalListUIState` gives: the rest of the list is what the room has done, these are what it is waiting on the reader for, and they are a different kind of thing to read. The finished ones are kept, because how this group came to hold the keys it holds is the other half of what the screen is for. **What "waiting on you" means is two things, and they are owed by different people.** An approval gate this member cannot delegate, and a finished ceremony whose room only its coordinator can create -- a ceremony reaching COMPLETE finishes nothing on its own, and nothing else in the app would tell the coordinator so. Both are asked of `DkgRepository` rather than re-derived, the same readings the transcript's standing notice and the ceremony screen make, so the three cannot disagree. An abandoned ceremony is asked neither: there is no step left to approve and no key to build a room from, and a row claiming otherwise would send a member to a screen with nothing on it. **A row says what the key is for before it says where it has got to**, because a room holding two holds them for different groups and a row that only reported progress would leave a member guessing which. A subgroup is named by its proposal, so there is a third branch for the member who arrives before that name has -- it still says which of the two kinds of key this is. The quorum is on the row too: a ceremony fixes it and it cannot be changed afterwards. **Three entry points, two of which were gaps.** `ChatRoomDetailScreen` gets a "Key ceremonies" row in *every* room, including the Marmot ones the `mlsGroupState` check keeps "Shared key" out of. The two are different questions and both are kept: "Shared key" offers a room a key of its own, which only a NIP-17 room can want; this lists the ceremonies a room *holds*. A parent's Marmot room has never had a door to its subgroups' ceremonies at all. The transcript's `CeremoniesAwaitingYouNotice` says "3 key ceremonies need you" and then opened `ceremonies.first()` -- answering a different question from the one it asked, and leaving the other two exactly as hard to find as before. It now opens the list whenever it stands for more than one, which is what the signing notice beside it already did. A ceremony line in the transcript routes to the list when it cannot name a session -- a line written before chat rows carried one -- or when the ceremony it names is not the only one waiting on this member. `hidesOtherCeremonies` mirrors `hidesOtherDecisions` exactly, including its restraint: only for a ceremony that is itself waiting on them, because a line about a finished one is history and a reader who taps it is asking to see that, not to be handed a queue. **An empty list offers the way out of itself**, rather than ending in a blank page that sends a member back to the group's details to find the other door. It only offers to ask; the ceremony screen still decides whether the group is in a position to hold one. `DkgRitualListViewModel` is a `MutableStateFlow` from the start rather than a `by mutableStateOf`, for the reason the commit before this one found. **Tests, 6 cases in 2 files.** `DkgRitualListViewModelJvmTest` covers the three decisions nothing else can check: that a ceremony wanting something is at the top even when its date is not, that only the coordinator is told to finish setting up a finished one, and that an abandoned ceremony is waiting on nobody even when both lookups say it is. `DkgRitualListScreenJvmTest` renders it and checks the thing that is only true once drawn -- that a row opens the ceremony it names rather than the room's newest, which is the whole reason the screen exists -- plus the two headings and the empty state's offer. **Not done.** Nobody has looked at this screen running; the render tests prove it composes, sections and routes, which is not the same thing. 970 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../composeResources/values/strings.xml | 13 + .../ui/composable/ChatRoomDetailScreen.kt | 35 ++ .../ui/composable/ChatRoomMessagingScreen.kt | 36 +- .../ui/composable/DkgRitualListScreen.kt | 504 ++++++++++++++++++ .../ui/composable/navigation/MantraNavHost.kt | 18 + .../navigation/routes/DkgRitualListRoute.kt | 23 + .../composable/widgets/chat/ChatTranscript.kt | 9 +- .../ui/view/model/ChatMessageListViewModel.kt | 19 + .../ui/view/model/DkgRitualListViewModel.kt | 140 +++++ .../ui/view/state/DkgRitualListUIState.kt | 68 +++ .../composable/DkgRitualListScreenJvmTest.kt | 157 ++++++ .../model/DkgRitualListViewModelJvmTest.kt | 198 +++++++ 12 files changed, 1213 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 771819ef..2f5f0b2d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -501,4 +501,17 @@ Accept as a parent A group is a subgroup of another only once its own quorum says so. Accepting asks this group to agree what it signs with again, naming them. Asked this group to accept that parent + + Key ceremonies + This group has not held a key ceremony. + The group's shared key + A subgroup's key + A subgroup: %1$s + Waiting for members to join. + Waiting for everyone to commit their part. + Waiting for everyone to confirm the result. + The group has a key from this ceremony. + Abandoned. No key was created. + %1$s of %2$s members will sign with it + Set up a shared key diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt index b490e05d..9125dfbb 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomDetailScreen.kt @@ -70,6 +70,7 @@ import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendin import press.mantra.compose.ui.composable.navigation.routes.ProfilePreviewRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute import press.mantra.compose.extensions.toFormattedTimeAndDateString @@ -123,6 +124,7 @@ import mantra.composeapp.generated.resources.no_projects_exists_in_this_group import mantra.composeapp.generated.resources.projects import mantra.composeapp.generated.resources.proposals import mantra.composeapp.generated.resources.reindex_events +import mantra.composeapp.generated.resources.key_ceremonies import mantra.composeapp.generated.resources.shared_key import mantra.composeapp.generated.resources.signing_key import mantra.composeapp.generated.resources.the_key_this_room_signs_as @@ -742,6 +744,39 @@ fun ChatRoomDetailScreen( } } + // Every room, including the Marmot ones the `mlsGroupState` + // check above keeps "Shared key" out of. That button offers + // a room a key of its own, which only a NIP-17 room can + // want; this one lists the ceremonies a room *holds*, and a + // parent's Marmot room holds one per subgroup the group has + // ever made. Until this there was no way to reach the second + // of them except by scrolling the transcript to its request + // line, and no way at all to reach one whose line had never + // arrived. + item { + TextButton( + onClick = { + onNavigateToRoute.invoke( + DkgRitualListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + } + ) { + Icon( + Icons.Default.Key, + contentDescription = "Key ceremonies" + ) + + Spacer( + modifier = Modifier.width(MaterialTheme.spacing.space125) + ) + + Text(stringResource(Res.string.key_ceremonies)) + } + } + // Every room, not only the one that holds the key. Most // of what a group signs it signs in its Marmot room -- // its subgroups' certificates and key states included -- diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt index e678a623..e5b842ae 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ChatRoomMessagingScreen.kt @@ -49,6 +49,7 @@ import press.mantra.compose.repository.NostrRepository import press.mantra.compose.ui.composable.navigation.routes.ChatRoomDetailRoute import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.Route import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator @@ -176,12 +177,37 @@ fun ChatRoomMessagingScreen( ChatTranscript( viewModel = chatMessageListViewModel, onOpenSharedKey = { dkgSessionId -> + // The same two reasons the signing lines open + // the queue instead of one proposal. A line + // written before chat rows named their ceremony + // cannot say which it is about, and the room may + // hold several -- the list is the honest answer + // there, showing all of them with their own + // state rather than guessing at the newest, + // which since subgroups moved into the parent's + // room is routinely the finished one. + // + // The other is that the ceremony they tapped is + // not the only one waiting on them, in which + // case it is not the whole of what is being + // asked and a screen showing only it would say + // it was. onNavigateToRoute.invoke( - DkgRitualRoute( - activeUserPublicKey = activeUserPublicKey, - chatRoomId = chatRoomId, - dkgSessionId = dkgSessionId - ) + if (dkgSessionId == null || + chatMessageListViewModel + .hidesOtherCeremonies(dkgSessionId) + ) { + DkgRitualListRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + } else { + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + dkgSessionId = dkgSessionId + ) + } ) }, onOpenSigning = { sessionId -> diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt new file mode 100644 index 00000000..aa7121b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreen.kt @@ -0,0 +1,504 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Key +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import mantra.composeapp.generated.resources.Res +import mantra.composeapp.generated.resources.a_subgroup_named_s +import mantra.composeapp.generated.resources.a_subgroups_key +import mantra.composeapp.generated.resources.abandoned_no_key_was_created +import mantra.composeapp.generated.resources.check_the_combined_result +import mantra.composeapp.generated.resources.everything_else +import mantra.composeapp.generated.resources.join_the_ceremony_by_publishing_your_key +import mantra.composeapp.generated.resources.key_ceremonies +import mantra.composeapp.generated.resources.open +import mantra.composeapp.generated.resources.review +import mantra.composeapp.generated.resources.s_of_s_members_will_sign_with_it +import mantra.composeapp.generated.resources.send_your_contribution_to_the_key +import mantra.composeapp.generated.resources.set_up_a_shared_key +import mantra.composeapp.generated.resources.the_group_has_a_key_from_this_ceremony +import mantra.composeapp.generated.resources.the_group_has_its_key_finish_setting_it_up +import mantra.composeapp.generated.resources.the_groups_shared_key +import mantra.composeapp.generated.resources.this_group_has_held_no_key_ceremony +import mantra.composeapp.generated.resources.waiting_for_everyone_to_commit_their_part +import mantra.composeapp.generated.resources.waiting_for_everyone_to_confirm_the_result +import mantra.composeapp.generated.resources.waiting_for_members_to_join +import mantra.composeapp.generated.resources.waiting_for_you +import org.jetbrains.compose.resources.stringResource +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.toFormattedTimeAndDateString +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.Decorative +import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.NavigateBackButton +import press.mantra.compose.ui.theme.ConformancePreviews +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.theme.readableContent +import press.mantra.compose.ui.theme.spacing +import press.mantra.compose.ui.view.model.DkgRitualListViewModel +import press.mantra.compose.ui.view.state.DkgRitualListUIState + +/** + * Every key ceremony a room holds. + * + * The transcript shows a ceremony as it happens, in among everything else that + * was said, and the standing notice above the composer says when one is waiting + * on the reader. Neither can say *which*, once there are two -- and since a + * subgroup's ChillDKG moved into its parent's room there are routinely two: the + * group's own, long finished, and whatever subgroup is being made this week. + * + * So: one row per ceremony, each carrying its own state, the finished ones kept + * rather than dropped. The ones still waiting on the reader come first, because + * those are the only ones with anything to do in them; the rest are the record + * of how this group came to hold the keys it holds, which is the other half of + * what this screen is for. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DkgRitualListScreen( + activeUserPublicKey: HexKey, + chatRoomId: String, + initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading, + chatRepository: ChatRepository, + dkgRepository: DkgRepository, + onNavigateBack: () -> Unit, + onNavigateToRoute: (Route) -> Unit, +) { + val dkgRitualListViewModel: DkgRitualListViewModel = viewModel( + factory = DkgRitualListViewModel.factory( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + initialDkgRitualListUIState = initialDkgRitualListUIState, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + ) + + LaunchedEffect(true) { + if (initialDkgRitualListUIState == DkgRitualListUIState.Loading) { + dkgRitualListViewModel.initiate() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) }, + topBar = { + TopAppBar( + title = { + Text( + text = stringResource(Res.string.key_ceremonies), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { NavigateBackButton(onNavigateBack) } + ) + } + ) { padding -> + when (val state = dkgRitualListViewModel.dkgRitualListUIState.collectAsState().value) { + is DkgRitualListUIState.Loading -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).readableContent() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + CircularProgressIndicator() + } + + is DkgRitualListUIState.Error -> Column( + modifier = Modifier.fillMaxWidth().padding(padding).readableContent() + .padding(MaterialTheme.spacing.space250), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(MaterialTheme.spacing.space600)) + Text(text = state.message, textAlign = TextAlign.Center) + } + + is DkgRitualListUIState.Loaded -> { + val open: (DkgRitualListUIState.Ceremony) -> Unit = { ceremony -> + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId, + dkgSessionId = ceremony.session.id + ) + ) + } + + LazyColumn( + modifier = Modifier.padding(padding).readableContent().fillMaxSize() + .padding(MaterialTheme.spacing.space250), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space125) + ) { + if (state.ceremonies.isEmpty()) { + item { + Column( + modifier = Modifier.fillMaxWidth() + .padding(top = MaterialTheme.spacing.space600), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy( + MaterialTheme.spacing.sectionGap + ) + ) { + Text( + text = stringResource( + Res.string.this_group_has_held_no_key_ceremony + ), + textAlign = TextAlign.Center + ) + + // The way out of an empty list, rather than a + // dead end that sends a member back to the + // group's details to find the other door. The + // ceremony screen decides whether the group is + // in a position to hold one; this only offers + // to ask it. + FilledTonalButton(onClick = { + onNavigateToRoute.invoke( + DkgRitualRoute( + activeUserPublicKey = activeUserPublicKey, + chatRoomId = chatRoomId + ) + ) + }) { + Icon(Icons.Default.Key, contentDescription = Decorative) + + Spacer( + modifier = Modifier.width( + MaterialTheme.spacing.relatedGap + ) + ) + + Text(stringResource(Res.string.set_up_a_shared_key)) + } + } + } + } + + if (state.waitingForYou.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.waiting_for_you), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + + items( + items = state.waitingForYou, + key = { it.session.id } + ) { ceremony -> + CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) }) + } + } + + if (state.rest.isNotEmpty()) { + // Headed only when there is a section above to tell it + // apart from; on its own it is the whole screen, which + // the title already says. + if (state.waitingForYou.isNotEmpty()) { + item { + Text( + text = stringResource(Res.string.everything_else), + style = MaterialTheme.typography.labelMedium + ) + } + } + + items( + items = state.rest, + key = { it.session.id } + ) { ceremony -> + CeremonyCard(ceremony = ceremony, onClick = { open(ceremony) }) + } + } + } + } + } + } +} + +/** + * One ceremony, named after the key it is making. + * + * What a ceremony is *for* is the first thing to say about it, because a room + * holding two holds them for different groups: one is this room's own key and + * the other belongs to a subgroup that does not exist yet. A row that only said + * how far along it was would leave a member guessing which of the two they were + * looking at. + */ +@Composable +private fun CeremonyCard( + ceremony: DkgRitualListUIState.Ceremony, + onClick: () -> Unit, +) { + val session = ceremony.session + + // Every colour inside this card is derived from the card rather than reached + // for independently, because ListItem does not inherit LocalContentColor -- + // its headline comes from `onSurface` whatever the container is. See + // ProposalListScreen, where that combination drew #1B1B1B on #1B1B1B. + val cardColors = if (ceremony.awaitsYou) { + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ) + } else { + CardDefaults.cardColors() + } + val cardContentColor = cardColors.contentColor + + // A subgroup is named by its proposal, and a member who did not open it may + // be looking at the ceremony before that name has arrived -- hence the third + // branch, which still says which of the two kinds of key this is. + val subject = session.subject + + val label = when { + !ceremony.isSubgroup -> stringResource(Res.string.the_groups_shared_key) + subject != null -> stringResource(Res.string.a_subgroup_named_s, subject) + else -> stringResource(Res.string.a_subgroups_key) + } + + Card(onClick = onClick, colors = cardColors) { + ListItem( + colors = ListItemDefaults.colors( + containerColor = Color.Transparent, + headlineColor = cardContentColor, + overlineColor = cardContentColor, + supportingColor = cardContentColor, + leadingIconColor = cardContentColor, + trailingIconColor = cardContentColor, + ), + leadingContent = { + Icon( + imageVector = when { + ceremony.awaitsYou -> Icons.Default.Key + session.stage == DkgRitualStage.COMPLETE -> Icons.Default.CheckCircle + session.stage == DkgRitualStage.FAILED -> Icons.Default.ErrorOutline + else -> Icons.Default.HourglassEmpty + }, + // Carries the stage on its own, and carries it alone on the + // highlighted card where the failure state has no colour to + // spare. + contentDescription = when { + ceremony.awaitsYou -> "Waiting on you" + session.stage == DkgRitualStage.COMPLETE -> "Finished" + session.stage == DkgRitualStage.FAILED -> "Abandoned" + else -> "Waiting on others" + }, + tint = when { + // `error` is 2.67:1 on the highlighted card, so there the + // failure is carried by the icon's shape and by the status + // line rather than by colour. + session.stage == DkgRitualStage.FAILED && + !ceremony.awaitsYou -> MaterialTheme.colorScheme.error + else -> cardContentColor + } + ) + }, + trailingContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + // The same word the transcript's notice uses for the same + // thing, so a member who came here looking for the card they + // tapped finds it saying what it said there. + Text( + text = if (ceremony.awaitsYou) { + stringResource(Res.string.review) + } else { + stringResource(Res.string.open) + }, + style = MaterialTheme.typography.labelLarge, + color = cardContentColor + ) + + Icon( + Icons.Default.ChevronRight, + contentDescription = "Open this ceremony" + ) + } + }, + overlineContent = { Text(text = label) }, + headlineContent = { + Text( + text = statusOf(ceremony), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + }, + supportingContent = { + Column( + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.space25) + ) { + // The quorum a ceremony settles is fixed by it and cannot be + // changed afterwards, so it belongs on the row rather than + // only on the screen behind it. + Text( + text = stringResource( + Res.string.s_of_s_members_will_sign_with_it, + session.threshold, + session.participantCount + ) + ) + + Text( + text = "${ceremony.coordinatorName} · " + + session.createdAt.toFormattedTimeAndDateString(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) + } +} + +/** + * Where a ceremony has got to, in a line. + * + * Shorter than the ceremony screen's ladder, and it has to be: this is one row + * of a list somebody is scanning, not the page they are acting on. What it must + * never do is call a ceremony finished when it is not, or waiting when there is + * nothing left to wait for. + * + * What it owes this member outranks the stage, in the words the approval screens + * use for the same step, so nobody is asked twice in two vocabularies. + */ +@Composable +private fun statusOf(ceremony: DkgRitualListUIState.Ceremony): String { + ceremony.pendingApproval?.let { + return when (it) { + DkgApprovalStep.HOST_KEY -> + stringResource(Res.string.join_the_ceremony_by_publishing_your_key) + DkgApprovalStep.ROUND_1 -> + stringResource(Res.string.send_your_contribution_to_the_key) + DkgApprovalStep.ROUND_2 -> + stringResource(Res.string.check_the_combined_result) + } + } + + if (ceremony.roomAwaitingCreation != null) { + return stringResource(Res.string.the_group_has_its_key_finish_setting_it_up) + } + + return when (ceremony.session.stage) { + DkgRitualStage.COLLECTING_HOST_KEYS -> + stringResource(Res.string.waiting_for_members_to_join) + + DkgRitualStage.COLLECTING_ROUND_1 -> + stringResource(Res.string.waiting_for_everyone_to_commit_their_part) + + DkgRitualStage.COLLECTING_ROUND_2 -> + stringResource(Res.string.waiting_for_everyone_to_confirm_the_result) + + DkgRitualStage.COMPLETE -> + stringResource(Res.string.the_group_has_a_key_from_this_ceremony) + + // The reason is the whole content of a failure -- "a member refused" and + // "the group could not agree" are different things to have happened -- so + // it stands in for the bare word whenever there is one. + DkgRitualStage.FAILED -> + ceremony.session.failureReason + ?: stringResource(Res.string.abandoned_no_key_was_created) + } +} + +@ConformancePreviews +@Composable +private fun DkgRitualListScreenPreview() { + val session = DkgSession( + id = "sessionId", + chatRoomId = "chatRoomId", + coordinatorPublicKey = "c".repeat(64), + userPublicKey = "u".repeat(64), + threshold = 2, + participantCount = 3, + hostPublicKey = "h".repeat(64), + round1Random = "1".repeat(64), + round2AuxRandom = "2".repeat(64), + ) + + MantraTheme { + Surface(modifier = Modifier.fillMaxSize()) { + DkgRitualListScreen( + activeUserPublicKey = "u".repeat(64), + chatRoomId = "chatRoomId", + initialDkgRitualListUIState = DkgRitualListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = "chatRoomId", + userPublicKey = "u".repeat(64), + subject = "#Ekklesia", + description = null, + initialGiftWrapPayloadId = "sdfaer", + mlsGroupState = null + ) + ), + ceremonies = listOf( + DkgRitualListUIState.Ceremony( + session = session.copy( + id = "subgroupSessionId", + parentChatRoomId = "parentChatRoomId", + subject = "Translators", + stage = DkgRitualStage.COLLECTING_ROUND_1 + ), + coordinatorName = "Ada", + pendingApproval = DkgApprovalStep.ROUND_1 + ), + DkgRitualListUIState.Ceremony( + session = session.copy(stage = DkgRitualStage.COMPLETE), + coordinatorName = "You" + ) + ) + ), + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateBack = {}, + onNavigateToRoute = {} + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt index ae13c349..a5d67444 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt @@ -38,6 +38,7 @@ import press.mantra.compose.ui.composable.ChatRoomDetailScreen import press.mantra.compose.ui.composable.ChatRoomMessagingScreen import press.mantra.compose.ui.composable.CreateProfileScreen import press.mantra.compose.ui.composable.DkgJoinApprovalScreen +import press.mantra.compose.ui.composable.DkgRitualListScreen import press.mantra.compose.ui.composable.DkgRitualScreen import press.mantra.compose.ui.composable.DkgRound1ApprovalScreen import press.mantra.compose.ui.composable.DkgRound2ApprovalScreen @@ -140,6 +141,7 @@ import press.mantra.compose.ui.composable.navigation.routes.AddArtifactRoute import press.mantra.compose.ui.composable.navigation.routes.AddDialectRoute import press.mantra.compose.ui.composable.navigation.routes.BroadcastGroupSignedEventRoute import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualListRoute import press.mantra.compose.ui.composable.navigation.routes.ProposalListRoute import press.mantra.compose.ui.composable.navigation.routes.AddChapterRoute import press.mantra.compose.ui.composable.navigation.routes.AddTranslationRoute @@ -1198,6 +1200,22 @@ fun MantraNavHost( } ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + + DkgRitualListScreen( + activeUserPublicKey = route.activeUserPublicKey, + chatRoomId = route.chatRoomId, + chatRepository = databaseChatRepository, + dkgRepository = databaseDkgRepository, + onNavigateBack = { + navController.popBackStack() + }, + onNavigateToRoute = { ceremonyRoute -> + navController.navigate(route = ceremonyRoute) + } + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt new file mode 100644 index 00000000..e23efb54 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/routes/DkgRitualListRoute.kt @@ -0,0 +1,23 @@ +package press.mantra.compose.ui.composable.navigation.routes + +import kotlinx.serialization.Serializable + +/** + * Every key ceremony a room holds. + * + * The room's list, not the reader's: a ceremony is run by the whole group, and + * which of them are waiting on this member is a status on each row rather than + * a different list -- the same shape as [ProposalListRoute], for the same + * reason. + * + * A room holds more than one as a matter of course since a subgroup's ChillDKG + * moved into its parent's room, and until this there was no screen that could + * name the second: [DkgRitualRoute] without a session id shows the newest, and + * the newest is routinely the group's own, long finished, with the subgroup's + * request buried underneath it. + */ +@Serializable +data class DkgRitualListRoute( + val activeUserPublicKey: String, + val chatRoomId: String +): Route() diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt index bfefd1b6..a69c8b8d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/chat/ChatTranscript.kt @@ -578,7 +578,8 @@ fun ChatTranscript( @Composable private fun CeremoniesAwaitingYouNotice( ceremonies: List, - onClick: (dkgSessionId: String) -> Unit, + /** Null where the notice stands for more than one, which no single screen can show. */ + onClick: (dkgSessionId: String?) -> Unit, ) { val single = ceremonies.singleOrNull() @@ -602,7 +603,11 @@ private fun CeremoniesAwaitingYouNotice( } Card( - onClick = { onClick(ceremonies.first().sessionId) }, + // The one it names when it names one, and the list otherwise. A notice + // reading "3 key ceremonies need you" that opened the first of them -- + // which is what this did -- answered a different question from the one + // it asked, and left the other two exactly as hard to find as before. + onClick = { onClick(single?.sessionId) }, modifier = Modifier.fillMaxWidth().padding(horizontal = MaterialTheme.spacing.space50), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.primaryContainer, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt index 7ef1bdb0..16eb4c5c 100755 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/ChatMessageListViewModel.kt @@ -156,6 +156,25 @@ class ChatMessageListViewModel( proposalsAwaitingYou.size > 1 && proposalsAwaitingYou.any { it.sessionId == sessionId } + /** + * Whether opening [sessionId] by itself would hide other ceremonies the room + * is waiting on this member for. + * + * [hidesOtherDecisions]'s counterpart, and the same argument: a ceremony line + * names one ceremony, and opening it is right while that is the only one + * asking. A room holds more than one as a matter of course since a subgroup's + * ChillDKG moved into its parent's room, and the second is not in the + * transcript beside the first -- so answering the one they tapped and leaving + * would look exactly like being done. + * + * Only for a ceremony that is itself waiting on them. A line about a ceremony + * the group finished is history, and a reader who taps one is asking to see + * that, not to be handed the queue. + */ + fun hidesOtherCeremonies(sessionId: String): Boolean = + ceremoniesAwaitingYou.size > 1 && + ceremoniesAwaitingYou.any { it.sessionId == sessionId } + fun startDirectMessage(participant: Participant) { logger.d("Arming a direct message to ${participant.participantPublicKey}") directMessageRecipient = participant diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt new file mode 100644 index 00000000..38677d1c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModel.kt @@ -0,0 +1,140 @@ +package press.mantra.compose.ui.view.model + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import co.touchlab.kermit.Logger +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.extensions.memberName +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualListUIState + +/** + * Every key ceremony a room holds. + * + * A room routinely holds more than one: a parent's Marmot room hosts the + * ceremony of every subgroup the group ever makes, alongside whatever it holds + * of its own. Watching the whole list rather than the newest is the point -- + * which of them is waiting on the reader is a fact about each one, and the + * newest is routinely the finished one. + * + * The counterpart to [ProposalListViewModel], and deliberately the same shape. + */ +class DkgRitualListViewModel( + val chatRoomId: String, + val activeUserPublicKey: HexKey, + initialDkgRitualListUIState: DkgRitualListUIState, + val chatRepository: ChatRepository, + val dkgRepository: DkgRepository, +): ViewModel() { + + private val _dkgRitualListUIState = MutableStateFlow(initialDkgRitualListUIState) + + /** + * A flow rather than a `mutableStateOf`: this is written from + * `Dispatchers.IO`, and a `mutableStateOf` created in a view model's + * constructor silently drops a write that lands during the composition that + * created it. See [DkgRitualViewModel.dkgRitualUIState]. + */ + val dkgRitualListUIState: StateFlow = + _dkgRitualListUIState.asStateFlow() + + private val logger = Logger.withTag(TAG) + + /** + * Loads the room, then watches its ceremonies for as long as the screen + * lives. + * + * Every one of them moves on messages arriving from other members, so a + * screen that read the list once would go stale the moment somebody else + * took their step -- which is exactly when a member is likely to be looking + * at it. + */ + fun initiate() { + viewModelScope.launch(Dispatchers.IO) { + val localChatRoom = chatRepository.getChatRoomByIdentifier(chatRoomId) + if (localChatRoom == null) { + _dkgRitualListUIState.value = + DkgRitualListUIState.Error("Couldn't find the group") + return@launch + } + + dkgRepository.observeSessionsForChatRoom(chatRoomId).collect { sessions -> + logger.d("${sessions.size} key ceremonies in $chatRoomId") + + _dkgRitualListUIState.value = DkgRitualListUIState.Loaded( + localChatRoom = localChatRoom, + ceremonies = sessions.map { asCeremony(it, localChatRoom) } + ) + } + } + } + + /** + * One ceremony as a row. + * + * What it wants is asked of the repository rather than worked out here, so + * this list, the transcript's standing notice and the ceremony screen cannot + * disagree about which ceremonies still have something in them -- the same + * rule the proposal list keeps. + * + * An abandoned ceremony is asked nothing. It can want nothing: there is no + * step left to approve and no key to build a room from, and a row claiming + * otherwise would send a member to a screen with nothing on it. + */ + private suspend fun asCeremony( + session: DkgSession, + localChatRoom: LocalChatRoom + ): DkgRitualListUIState.Ceremony { + val live = session.takeIf { it.stage != DkgRitualStage.FAILED } + + return DkgRitualListUIState.Ceremony( + session = session, + coordinatorName = session.coordinatorPublicKey.memberName( + members = localChatRoom.localParticipants, + activeUserPublicKey = activeUserPublicKey + ), + pendingApproval = live?.let { dkgRepository.pendingApproval(it) }, + // Only of the member who opened it: creating the room is the + // coordinator's job, and a row telling everybody else to do it would + // be telling them to do something they cannot. + roomAwaitingCreation = live + ?.takeIf { it.isCoordinator() } + ?.let { dkgRepository.roomAwaitingCreation(it) } + ) + } + + companion object { + private const val TAG = "DkgRitualListViewModel" + + fun factory( + chatRoomId: String, + activeUserPublicKey: HexKey, + initialDkgRitualListUIState: DkgRitualListUIState = DkgRitualListUIState.Loading, + chatRepository: ChatRepository, + dkgRepository: DkgRepository, + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + DkgRitualListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = activeUserPublicKey, + initialDkgRitualListUIState = initialDkgRitualListUIState, + chatRepository = chatRepository, + dkgRepository = dkgRepository + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt new file mode 100644 index 00000000..0e9e533e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/state/DkgRitualListUIState.kt @@ -0,0 +1,68 @@ +package press.mantra.compose.ui.view.state + +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep + +sealed interface DkgRitualListUIState { + data class Loaded( + val localChatRoom: LocalChatRoom, + + /** Every ceremony the room holds, newest first. */ + val ceremonies: List = emptyList(), + ): DkgRitualListUIState { + /** + * The ones that cannot move until this member acts. + * + * Split out rather than sorted to the top, the way the proposal list + * splits its own -- the rest of the list is what the room has done, + * these are what it is waiting on the reader for. A room can have two + * at once the moment a subgroup's admins are the whole group, and + * burying the second under the first one's history is the whole reason + * this screen exists. + */ + val waitingForYou: List = ceremonies.filter { it.awaitsYou } + + val rest: List = ceremonies.filterNot { it.awaitsYou } + } + + /** + * One ceremony, as a row. + * + * Both reasons a ceremony can want something are carried separately rather + * than folded into one flag, because they are owed by different people and + * read differently: an approval is this member's gate and nobody else can + * pass it, while finishing the setup is work the coordinator alone can come + * back to. The row says which. + */ + data class Ceremony( + val session: DkgSession, + + /** Whoever opened it, as a name to read. */ + val coordinatorName: String, + + /** What the ceremony is waiting on this member for, if anything. */ + val pendingApproval: DkgApprovalStep? = null, + + /** + * The room this finished ceremony's key derives, when it does not exist + * yet and this member is the one who would make it. + * + * Reaching COMPLETE finishes nothing on its own: the group has a key and + * somebody still has to get its state signed and create the room. The + * same reading the transcript's standing notice makes. + */ + val roomAwaitingCreation: String? = null, + ) { + /** Whether this ceremony is making a subgroup rather than the room's own key. */ + val isSubgroup: Boolean get() = session.parentChatRoomId != null + + val awaitsYou: Boolean get() = pendingApproval != null || roomAwaitingCreation != null + } + + data class Error( + val message: String + ): DkgRitualListUIState + + data object Loading: DkgRitualListUIState +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt new file mode 100644 index 00000000..7d49853d --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/composable/DkgRitualListScreenJvmTest.kt @@ -0,0 +1,157 @@ +package press.mantra.compose.ui.composable + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runDesktopComposeUiTest +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.composable.navigation.routes.DkgRitualRoute +import press.mantra.compose.ui.composable.navigation.routes.Route +import press.mantra.compose.ui.composable.widgets.ProvideSnackbarHost +import press.mantra.compose.ui.theme.MantraTheme +import press.mantra.compose.ui.view.state.DkgRitualListUIState +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * What the ceremony list is for, read off the rendered screen. + * + * The ordering is settled in [press.mantra.compose.ui.view.model.DkgRitualListViewModel] + * and tested there. What is only true once it is drawn is that a row goes to + * the ceremony it names -- the whole reason this screen exists is that the + * screens before it could not say which of a room's ceremonies they meant -- + * and that a room with none offers the way to hold one rather than ending in a + * blank page. + */ +@OptIn(ExperimentalTestApi::class) +class DkgRitualListScreenJvmTest { + private val me = "11".repeat(32) + private val chatRoomId = "22".repeat(32) + + @Test + fun `a row opens the ceremony it names, not the room's newest`() = + runDesktopComposeUiTest(500, 1200) { + var opened: Route? = null + + setContent { + Screen( + state = loaded( + ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy( + pendingApproval = DkgApprovalStep.HOST_KEY + ), + ceremony("newest", DkgRitualStage.COMPLETE) + ), + onNavigateToRoute = { opened = it } + ) + } + + onNodeWithText("Join it by publishing your device's key") + .assertIsDisplayed() + .performClick() + + assertEquals( + DkgRitualRoute( + activeUserPublicKey = me, + chatRoomId = chatRoomId, + dkgSessionId = "subgroup" + ), + opened + ) + } + + /** The two sections, so a ceremony owing nothing cannot be mistaken for one that does. */ + @Test + fun `the ones waiting on you are headed apart from the rest`() = + runDesktopComposeUiTest(500, 1200) { + setContent { + Screen( + state = loaded( + ceremony("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS).copy( + pendingApproval = DkgApprovalStep.HOST_KEY + ), + ceremony("newest", DkgRitualStage.COMPLETE) + ) + ) + } + + onNodeWithText("Waiting for you").assertIsDisplayed() + onNodeWithText("Everything else").assertIsDisplayed() + onNodeWithText("The group has a key from this ceremony.").assertIsDisplayed() + } + + @Test + fun `a room that has held none offers to hold one`() = + runDesktopComposeUiTest(500, 1200) { + var opened: Route? = null + + setContent { Screen(state = loaded(), onNavigateToRoute = { opened = it }) } + + onNodeWithText("This group has not held a key ceremony.").assertIsDisplayed() + onNodeWithText("Set up a shared key").assertIsDisplayed().performClick() + + // No session id: there is no ceremony to name, and the ceremony + // screen is what decides whether the group can hold one at all. + assertEquals( + DkgRitualRoute(activeUserPublicKey = me, chatRoomId = chatRoomId), + opened + ) + } + + @Composable + private fun Screen( + state: DkgRitualListUIState, + onNavigateToRoute: (Route) -> Unit = {}, + ) { + MantraTheme { + ProvideSnackbarHost { + DkgRitualListScreen( + activeUserPublicKey = me, + chatRoomId = chatRoomId, + initialDkgRitualListUIState = state, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + dkgRepository = DkgRepository.NO_OP_DKG_REPOSITORY, + onNavigateBack = {}, + onNavigateToRoute = onNavigateToRoute, + ) + } + } + } + + private fun loaded(vararg ceremonies: DkgRitualListUIState.Ceremony) = + DkgRitualListUIState.Loaded( + localChatRoom = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ) + ), + ceremonies = ceremonies.toList() + ) + + private fun ceremony(id: String, stage: DkgRitualStage) = DkgRitualListUIState.Ceremony( + session = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ), + coordinatorName = "You", + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt new file mode 100644 index 00000000..80798701 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualListViewModelJvmTest.kt @@ -0,0 +1,198 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.Profile +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualListUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What the key-ceremony list decides that nothing else can check. + * + * **Which ceremonies come first.** A room holds one per subgroup the group has + * ever made plus its own, and the newest is routinely the finished one -- so + * the list is only worth having if the two that want something from the reader + * are at the top of it rather than wherever their dates put them. + * + * **What counts as wanting something.** Two different things do, owed by + * different people: an approval gate this member cannot delegate, and a + * finished ceremony whose room only its coordinator can create. An abandoned + * ceremony wants neither, whatever the rows underneath it say. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualListViewModelJvmTest { + private val chatRoomId = "a1b2c3d4".repeat(8) + private val me = "11".repeat(32) + private val ada = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the ceremonies waiting on this member are separated from the rest`() = runBlocking { + val mine = session("mine", DkgRitualStage.COLLECTING_ROUND_1) + val finished = session("finished", DkgRitualStage.COMPLETE) + val subgroup = session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS) + .copy(parentChatRoomId = "parent", subject = "Translators") + + val loaded = load( + // Newest first, the way the query returns them -- and the one that + // wants something is deliberately not the newest. + sessions = listOf(finished, subgroup, mine), + pending = mapOf( + "mine" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ) + ) + + assertEquals( + listOf("subgroup", "mine"), + loaded.waitingForYou.map { it.session.id }, + "a ceremony waiting on this member was left down the list" + ) + assertEquals(listOf("finished"), loaded.rest.map { it.session.id }) + assertTrue(loaded.waitingForYou.single { it.session.id == "subgroup" }.isSubgroup) + } + + /** + * A finished ceremony still owes its coordinator a room, and owes everybody + * else nothing -- a row telling the rest of the group to create it would be + * telling them to do something they cannot. + */ + @Test + fun `only the coordinator is told to finish setting up a finished ceremony`() = runBlocking { + val theirs = session("theirs", DkgRitualStage.COMPLETE).copy(coordinatorPublicKey = ada) + val ours = session("ours", DkgRitualStage.COMPLETE) + + val loaded = load( + sessions = listOf(theirs, ours), + roomAwaitingCreation = "theRoomItDerives" + ) + + assertEquals(listOf("ours"), loaded.waitingForYou.map { it.session.id }) + assertEquals("Ada", loaded.rest.single().coordinatorName) + assertEquals("You", loaded.waitingForYou.single().coordinatorName) + } + + /** + * An abandoned ceremony is asked nothing at all. There is no step left to + * approve and no key to build a room from, so a row that claimed otherwise + * would send a member to a screen with nothing on it. + */ + @Test + fun `an abandoned ceremony is never waiting on anybody`() = runBlocking { + val loaded = load( + sessions = listOf(session("abandoned", DkgRitualStage.FAILED)), + // Both answers say yes, and both have to be ignored on a failure. + pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1), + roomAwaitingCreation = "theRoomItDerives" + ) + + assertTrue(loaded.waitingForYou.isEmpty()) + + val row = loaded.rest.single() + assertNull(row.pendingApproval) + assertNull(row.roomAwaitingCreation) + } + + private suspend fun load( + sessions: List, + pending: Map = emptyMap(), + roomAwaitingCreation: String? = null, + ): DkgRitualListUIState.Loaded { + val viewModel = DkgRitualListViewModel( + chatRoomId = chatRoomId, + activeUserPublicKey = me, + initialDkgRitualListUIState = DkgRitualListUIState.Loading, + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeSessionsForChatRoom( + chatRoomId: String + ): Flow> = flowOf(sessions) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = + pending[session.id] + + override suspend fun roomAwaitingCreation(session: DkgSession): String? = + roomAwaitingCreation + } + ) + + viewModel.initiate() + + return withTimeout(5_000) { + viewModel.dkgRitualListUIState.first { it is DkgRitualListUIState.Loaded } + } as DkgRitualListUIState.Loaded + } + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me to null, ada to "Ada").map { (publicKey, name) -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = name?.let { + Profile( + publicKey = publicKey, + displayName = it, + nostrEventId = "ee".repeat(32), + ) + }, + ) + }, + ) + + private fun session(id: String, stage: DkgRitualStage) = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) +} From 26d305bc143a94d41ae8957ad99659e7c294d8a5 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:12:43 +0200 Subject: [PATCH 14/15] fix(subgroups): a parent's second subgroup can be certified, and sign its own key state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both rungs after a subgroup's ceremony run in the **parent's** room. The birth certificate is the parent's signature, so it is signed where the parent's key is; the key state is signed over the parent's room because the room it is *about* does not exist yet -- that is the whole point of signing it first. So a parent making its second subgroup has two certificate signings and two key-state signings in one room, and the ceremony screen read them out by event kind alone. `observeSessionsForChatRoom` is `ORDER BY createdAt DESC`, so `firstOrNull` on a kind match is "the newest of that kind in this room" -- which is some other subgroup's. The screen for one child followed another child's progress. **The display was not the damage.** Both rungs disable their button while a signing of that kind is running and un-failed: ```kotlin enabled = … && (certificateSession == null || certificateFailed) ``` and a *completed* signing is un-failed forever. So once a parent had certified one subgroup, it could never be asked to certify another: the rung sat there disabled with no way out, and `proposeBirthCertificate` returned early on the same reading. The key state blocked the same way, and `createAdminGroup` is gated behind it, so the second subgroup could not be made at all. **It did not need two subgroups.** Adoption signs a key state in the child's own room (`proposeAcceptance`, `localChatRoom = childRoom`), so a group that has ever been adopted already holds a `GroupKeyStateEvent` session in its room -- enough to block the first subgroup it tries to make. What makes this a half-finished job rather than an oversight is that the *signed* facts were already right. `isCertified` goes through `observeBirthCertificate`, scoped by session id, and `isKeyStateSigned` through `observeSignedGroupKeyState`, whose own comment records this exact lesson being learned once already: "A ceremony room holds more than one ceremony the moment a subgroup's admins are the whole group." The in-flight lookups never got the same treatment, and the comment above the key-state watcher still asserted the premise that had stopped being true -- "the room a ceremony runs in signs nothing else". **The key state is matched on the ceremony the proposal names.** `GroupKeyStateEvent` carries `FrostKeyTag` from the moment the proposal is assembled, so `parseDkgSessionId(tags) == dkgSessionId` discriminates before the ceremony has a key and before anybody signs. The watcher moves out of `observeKeyState` -- deleted, with its `Job` -- and into `observeForCeremony`, which is already cancelled and re-pointed per ceremony and is where every other per-ceremony watcher lives. `observeForCeremony`'s null branch now clears `keyStateSession` too, which it could not before because the field was not its to clear. **The certificate is matched on the child it names, through a new `addresses`.** `certifies` could not be reused: it ends on `GroupKeyStateEvent.isSignedByRoom`, a real signature check by the parent's quorum, and the thing being matched here is a proposal that quorum has not signed yet -- that is what the rung is waiting for. `SubgroupBirthCertificateEvent.addresses` is the structural half (kind, content, the `d` tag, the parent tag), `certifies` now calls it so the two cannot drift, and its doc says outright that nothing may decide a lineage on it. The three values it reads come out of `assembleTags` together, so they agree by construction rather than by agreement. Correctness is not at stake in either: these two fields only decide which progress bar is drawn and which button is live. Whether the parent actually agreed is `isCertified`, which does check the signature. **`DkgRepository.roomDerivedBy(dkgSessionId)`** is the session-id-to-room derivation that `observeBirthCertificate` and `observeSignedGroupKeyState` each carried inline. Three callers now, one of them new, and the no-op repository answers null -- which is also the honest answer for a ceremony that has not finished, and the reason all three resolve it per emission rather than once. **Tests: `DkgRitualSecondSubgroupJvmTest`, 3 cases, each checked against the bug reinstated rather than only against the fix.** Two assert that the screen follows its own ceremony's signing when a sibling's is newer. The third is the permanent block, and it is driven in two emissions on a hot flow for a reason worth recording: written as a single emission it asserted "still null" and **passed against the bug**, because at that moment the watcher had not started. Nothing is proved by a field that is null before anybody has looked at it. **One test from the commit before this had to be rewritten, and the change is this commit's fault.** `DkgRitualStartOfferJvmTest`'s lost-update case forced its interleaving by parking the session watcher inside `pendingApproval` while the key-state watcher wrote. Those two are no longer concurrent -- the key-state watcher now starts *from* the session collector, after the park -- so the setup could not happen. The claim was untouched by that, so it is now made against the session watcher versus the room watcher, which are genuinely concurrent: both collect before either writes, neither waits on the other, and the field they contend over is `roomHasCeremony`, which is the one that decides whether the offer to start appears. Re-confirmed failing against the lost update reinstated. 973 jvm tests, 454 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../repository/DatabaseDkgRepository.kt | 44 ++- .../subgroup/SubgroupBirthCertificateEvent.kt | 42 ++- .../compose/repository/DkgRepository.kt | 15 + .../ui/view/model/DkgRitualViewModel.kt | 126 ++++--- .../model/DkgRitualSecondSubgroupJvmTest.kt | 322 ++++++++++++++++++ .../view/model/DkgRitualStartOfferJvmTest.kt | 79 +++-- 6 files changed, 511 insertions(+), 117 deletions(-) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt index 1353970d..fcb53e1f 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseDkgRepository.kt @@ -110,26 +110,27 @@ class DatabaseDkgRepository( ): Flow> = database.frostSigningSessionDao().observeSessionsForChatRoom(chatRoomId) + /** + * By session id, not by room. A ceremony room holds more than one ceremony + * the moment a subgroup's admins are the whole group, and a lookup by room + * answered about whichever the room's rule picked -- for a room holding only + * a subgroup's ceremony, asking for the room's *own* found nothing at all, + * and a finished subgroup with a signed key state could never be created. + */ + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + database.dkgSessionDao() + .getSessionById(dkgSessionId) + ?.thresholdPublicKey + ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } + override fun observeSignedGroupKeyState(dkgSessionId: String): Flow = database.groupSignedEventDao() .observeByKind(GroupKeyStateEvent.KIND) .map { signedEvents -> - // The room this ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - // - // By session id, not by room. A ceremony room holds more than one - // ceremony the moment a subgroup's admins are the whole group, and - // a lookup by room answered about whichever the room's rule picked - // -- for a room holding only a subgroup's ceremony, asking for the - // room's *own* found nothing at all, and a finished subgroup with - // a signed key state could never be created. - val subjectRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - - subjectRoomId?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + roomDerivedBy(dkgSessionId) + ?.let { GroupKeyStateManager.stateAmong(signedEvents, it) } } override suspend fun adoptGroupKeyState(chatRoomId: String): LocalGroupKeyState? = try { @@ -176,14 +177,9 @@ class DatabaseDkgRepository( database.groupSignedEventDao() .observeByKind(SubgroupBirthCertificateEvent.KIND) .map { signedEvents -> - // The room the ceremony's key derives, resolved per emission - // rather than once: the ceremony has no key until it finishes, - // and this flow is running before it does. - val subgroupChatRoomId = database.dkgSessionDao() - .getSessionById(dkgSessionId) - ?.thresholdPublicKey - ?.let { runCatching { SharedKeyDerivation.marmotGroupId(it) }.getOrNull() } - ?: return@map null + // Resolved per emission rather than once: the ceremony has no key + // until it finishes, and this flow is running before it does. + val subgroupChatRoomId = roomDerivedBy(dkgSessionId) ?: return@map null signedEvents .filter { diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt index 9c1f8155..0554151d 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEvent.kt @@ -170,15 +170,7 @@ object SubgroupBirthCertificateEvent { subgroupChatRoomId: String, parentChatRoomId: String ): Boolean = runCatching { - if (event.kind != KIND) return false - - if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false - - val addressed = parseSubgroupChatRoomId(event.tags) ?: return false - if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false - - val named = parseParentChatRoomId(event.tags) ?: return false - if (!named.equals(parentChatRoomId, ignoreCase = true)) return false + if (!addresses(event, subgroupChatRoomId, parentChatRoomId)) return false val thresholdPublicKey = parseThresholdPublicKey(event.tags) ?: return false val path = parsePath(event.tags) ?: return false @@ -189,6 +181,38 @@ object SubgroupBirthCertificateEvent { GroupKeyStateEvent.isSignedByRoom(event, parentChatRoomId) }.getOrDefault(false) + /** + * Whether this event is *addressed to* that child by that parent -- the + * half of [certifies] that can be asked of a proposal nobody has signed. + * + * **Not a weaker [certifies].** It says who the event is about, not that + * the parent's quorum agreed to it: no derivation and no signature, so an + * event that merely claims the right pair passes. Nothing may decide a + * lineage on this. + * + * What it is for is telling one *in-flight* certificate signing from + * another in the same room. A parent certifies every subgroup it ever makes + * from one room, so the ceremony screen has several to choose between and + * no signature to choose by -- the proposal is what the quorum has not yet + * signed. The three values it reads are assembled together by + * [assembleTags], so they agree by construction. + */ + fun addresses( + event: Event, + subgroupChatRoomId: String, + parentChatRoomId: String + ): Boolean = runCatching { + if (event.kind != KIND) return false + + if (!event.content.trim().equals(subgroupChatRoomId, ignoreCase = true)) return false + + val addressed = parseSubgroupChatRoomId(event.tags) ?: return false + if (!addressed.equals(subgroupChatRoomId, ignoreCase = true)) return false + + val named = parseParentChatRoomId(event.tags) ?: return false + named.equals(parentChatRoomId, ignoreCase = true) + }.getOrDefault(false) + /** * The name tag. A bare two-element tag with no class of its own, because * nothing but the proposal screen and the subgroup list ever reads it and diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt index 1e6c16d6..c61a9e85 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/repository/DkgRepository.kt @@ -65,6 +65,19 @@ interface DkgRepository { suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? + /** + * The id of the room a ceremony's key derives, or null while it has no key. + * + * The one name a ceremony and the room it makes have in common, and the only + * way to tell two of a parent's subgroups apart in anything written about + * the room rather than about the ceremony -- a birth certificate names the + * child, not the ceremony that produced it. + * + * Null is the ordinary answer rather than a failure: a ceremony has no key + * until it finishes, and every caller here is watching from before that. + */ + suspend fun roomDerivedBy(dkgSessionId: String): String? + /** * Opens a ritual in [localChatRoom], making this device the coordinator. * @@ -213,6 +226,8 @@ interface DkgRepository { override suspend fun getLatestSessionForChatRoom(chatRoomId: String): DkgSession? = null + override suspend fun roomDerivedBy(dkgSessionId: String): String? = null + override suspend fun proposeRitual( localChatRoom: LocalChatRoom, userPublicKey: HexKey, diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt index feb4316f..5f6858ac 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/DkgRitualViewModel.kt @@ -114,9 +114,6 @@ class DkgRitualViewModel( /** Watches the current session's messages; replaced whenever the session changes. */ private var messageObserver: Job? = null - /** Watches the room's signing sessions, which is where the key state is agreed. */ - private var keyStateObserver: Job? = null - /** Watches the parent certifying this subgroup; re-pointed as the purpose is learned. */ private var certificateObserver: Job? = null @@ -240,8 +237,6 @@ class DkgRitualViewModel( .defaultQuorum(ChillDkgRitualManager.memberPublicKeys(localChatRoom).size) .coerceIn(quorumRange()) - observeKeyState() - launch { roomSessions.collect { newest -> updateLoaded { current -> @@ -323,43 +318,6 @@ class DkgRitualViewModel( } } - /** - * Follows the group agreeing what its admin room will sign with. - * - * Nothing here watches a [GroupKeyState] row, because there is no room yet - * for one to belong to -- being able to follow this before the room exists - * is the point. What is watched instead is the session, for how the signing - * is going, and the signed event, for whether it finished. - * - * The room a ceremony runs in signs nothing else, so the newest session in - * it is the key state's -- but it is matched on the kind it carries anyway, - * so a room that one day signs something else does not confuse the two. - */ - private fun observeKeyState() { - keyStateObserver?.cancel() - keyStateObserver = viewModelScope.launch(Dispatchers.IO) { - // Two flows because they answer two questions off two tables. How - // the signing is going is the session row; whether it finished is - // the signed event, and only that second one can be asked at all - // before there is a room -- which is the whole point of asking it. - launch { - dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> - val keyState = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == GroupKeyStateEvent.KIND - } - }?.session - - updateLoaded { it.copy(keyStateSession = keyState) } - } - } - - - } - } - /** * Follows one ceremony: whether its key state has been signed, and -- for a * subgroup -- whether its parent has certified it. @@ -379,12 +337,50 @@ class DkgRitualViewModel( if (dkgSessionId == null) { updateLoaded { - it.copy(isKeyStateSigned = false, isCertified = false, certificateSession = null) + it.copy( + isKeyStateSigned = false, + keyStateSession = null, + isCertified = false, + certificateSession = null + ) } return } certificateObserver = viewModelScope.launch(Dispatchers.IO) { + // How the agreement is going, as opposed to whether it finished -- + // two questions off two tables, and only the second can be asked + // before there is a room at all, which is the point of asking it. + // + // By the ceremony the proposal names, not by kind in this room. The + // room a ceremony runs in used to sign nothing else, and that is + // what this read assumed; a parent's Marmot room now hosts the + // ceremony of every subgroup the group makes, so it holds one of + // these per subgroup. Matching on kind alone handed the screen the + // newest of them -- which is some other subgroup's -- and with it a + // progress bar for a signing this ceremony is not in and a button + // disabled on the strength of it. Permanently, once that other one + // completed: the guard only reopens on a failure. + // + // `FrostKeyTag` is on the proposal from the moment it is made, so + // this works before the ceremony has a key and before anybody signs. + launch { + dkgRepository.observeSigningSessions(chatRoomId).collect { sessions -> + val keyState = sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + event.kind == GroupKeyStateEvent.KIND && + GroupKeyStateEvent.parseDkgSessionId(event.tags) == dkgSessionId + } + }?.session + + updateLoaded { it.copy(keyStateSession = keyState) } + } + } + // Whether the group has agreed what the room this ceremony's key // derives will sign with. Asked by ceremony rather than by room: the // room holds however many ceremonies it holds, and the answer is @@ -408,14 +404,43 @@ class DkgRitualViewModel( } launch { + // The child this ceremony is making, which is what a certificate + // names. Resolved per emission rather than once, because the + // ceremony has no key until it finishes and this flow is running + // before it does -- the same reason `observeBirthCertificate` + // resolves it per emission. + // + // Scoped to that child, not to the kind. A parent certifies every + // subgroup it ever makes from the same room, so by kind alone this + // found the newest certificate signing in the parent -- some other + // child's -- and read its progress as this one's. Worse, the rung + // disables "ask the group to certify" while a certificate session + // is running and un-failed, so once a parent had certified one + // subgroup it could never be asked to certify another. dkgRepository.observeSigningSessions(parentChatRoomId).collect { sessions -> - val certificate = sessions.firstOrNull { local -> - local.items.any { item -> - runCatching { - Event.fromJson(item.unsignedEventJson).kind - }.getOrNull() == SubgroupBirthCertificateEvent.KIND - } - }?.session + val subgroupChatRoomId = dkgRepository.roomDerivedBy(dkgSessionId) + + val certificate = subgroupChatRoomId?.let { child -> + sessions.firstOrNull { local -> + local.items.any { item -> + val event = runCatching { + Event.fromJson(item.unsignedEventJson) + }.getOrNull() ?: return@any false + + // `addresses`, not `certifies`: the thing being + // matched is a proposal, and `certifies` ends on + // a signature by the parent's quorum, which is + // precisely what has not happened yet. Whether + // the parent actually agreed is `isCertified`, + // which does check it. + SubgroupBirthCertificateEvent.addresses( + event = event, + subgroupChatRoomId = child, + parentChatRoomId = parentChatRoomId, + ) + } + }?.session + } updateLoaded { it.copy(certificateSession = certificate) } } @@ -736,7 +761,6 @@ class DkgRitualViewModel( override fun onCleared() { messageObserver?.cancel() - keyStateObserver?.cancel() certificateObserver?.cancel() super.onCleared() } diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt new file mode 100644 index 00000000..84815488 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualSecondSubgroupJvmTest.kt @@ -0,0 +1,322 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.FrostSigningItem +import press.mantra.compose.database.model.FrostSigningSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalFrostSigningSession +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.database.model.types.FrostSigningStage +import press.mantra.compose.nostr.frost.GroupKeyStateEvent +import press.mantra.compose.nostr.subgroup.SubgroupBirthCertificateEvent +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.ui.view.state.DkgRitualUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * A parent's room holds every one of its subgroups' signings, and the ceremony + * screen has to pick out the one it is showing. + * + * Since a subgroup's ChillDKG moved into its parent's Marmot room, both of the + * rungs after the ceremony run there too: the birth certificate is the parent's + * signature, and the key state is signed over the parent's room because the + * subgroup's own room does not exist yet. So a parent making a second subgroup + * has two of each in one room, told apart by nothing but the ceremony they + * belong to. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DkgRitualSecondSubgroupJvmTest { + private val parentChatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + // Real points, in the compressed form `SharedKeyDerivation` reads: it derives + // a room id off the curve, and a made-up 32 bytes is not on it. + private val firstKey = "02" + KeyPair().pubKey.toHexKey() + private val secondKey = "02" + KeyPair().pubKey.toHexKey() + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `the certificate shown is this subgroup's, not the parent's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + // Newest first, the way the query returns them. + certificateSigning("forSecond", subgroupOf(secondKey)), + certificateSigning("forFirst", subgroupOf(firstKey)), + ), + until = { it.certificateSession != null } + ) + + assertEquals( + "forFirst", + loaded.certificateSession?.id, + "the screen for one subgroup is following another subgroup's certificate" + ) + } + + @Test + fun `the key state shown is this ceremony's, not the room's newest`() = runBlocking { + val loaded = load( + signingSessions = listOf( + keyStateSigning("forSecond", dkgSessionId = "secondCeremony"), + keyStateSigning("forFirst", dkgSessionId = "firstCeremony"), + ), + until = { it.keyStateSession != null } + ) + + assertEquals( + "forFirst", + loaded.keyStateSession?.id, + "the screen for one ceremony is following another ceremony's key state" + ) + } + + /** + * The damaging half, and the reason this is a block rather than a cosmetic + * mix-up. Both rungs disable their button while a signing of that kind is + * running and un-failed, and a *completed* signing is un-failed forever -- + * so by kind alone, a parent that had certified one subgroup could never be + * asked to certify another, and the rung sat there disabled with no way out. + */ + @Test + fun `a sibling's finished signings leave this ceremony's rungs open`() = runBlocking { + // Driven in two emissions rather than one, so the assertion is about a + // watcher that demonstrably ran. Asserting "still null" against a single + // emission passes before the watcher has started, which is how a version + // of this test passed against the bug it was written for. + val signingSessions = MutableStateFlow( + listOf( + certificateSigning("mine", subgroupOf(firstKey)), + keyStateSigning("myKeyState", dkgSessionId = "firstCeremony"), + ) + ) + + val viewModel = viewModel(signingSessions) + viewModel.initiate() + + awaitLoaded(viewModel) { + it.certificateSession?.id == "mine" && it.keyStateSession?.id == "myKeyState" + } + + // Now the room holds only a sibling's, both finished -- which is every + // parent that has already made one subgroup and is making its second. + signingSessions.value = listOf( + certificateSigning( + "siblingCertificate", + subgroupOf(secondKey), + stage = FrostSigningStage.COMPLETE + ), + // A key state naming another ceremony: the sibling's, or this parent's + // own from an adoption, which is signed in this room too. + keyStateSigning( + "siblingKeyState", + dkgSessionId = "secondCeremony", + stage = FrostSigningStage.COMPLETE + ), + ) + + val loaded = awaitLoaded(viewModel) { + it.certificateSession == null && it.keyStateSession == null + } + + // Both rungs gate on these being null or failed, and a completed signing + // is never failed -- so a sibling left in either of them shuts the rung + // for good. + assertNull(loaded.certificateSession) + assertNull(loaded.keyStateSession) + } + + private suspend fun load( + signingSessions: List, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded { + val viewModel = viewModel(MutableStateFlow(signingSessions)) + viewModel.initiate() + return awaitLoaded(viewModel, until) + } + + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + + /** The screen is always showing the FIRST subgroup's ceremony. */ + private fun viewModel( + signingSessions: MutableStateFlow> + ): DkgRitualViewModel { + val ceremony = DkgSession( + id = "firstCeremony", + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = me, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = DkgRitualStage.COMPLETE, + thresholdPublicKey = firstKey, + parentChatRoomId = parentChatRoomId, + subject = "Translators", + ) + + return DkgRitualViewModel( + chatRoomId = parentChatRoomId, + activeUserPublicKey = me, + dkgSessionId = ceremony.id, + initialDkgRitualUIState = DkgRitualUIState.Loading, + activeIdentityStateFlow = MutableStateFlow(null), + chatRepository = object : ChatRepository by ChatRepository.NO_OP_CHAT_REPOSITORY { + override suspend fun getChatRoomByIdentifier(id: HexKey): LocalChatRoom? = room() + }, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override suspend fun roomDerivedBy(dkgSessionId: String): String? = + if (dkgSessionId == ceremony.id) subgroupOf(firstKey) else null + + override fun observeSessionById(sessionId: String): Flow = + flowOf(ceremony) + + override fun observeLatestSessionForChatRoom( + chatRoomId: String, + parentChatRoomId: String?, + ): Flow = flowOf(ceremony) + + override fun observeSigningSessions( + chatRoomId: String + ): Flow> = signingSessions + } + ) + } + + /** The room id a ceremony's key derives, which is what a certificate names. */ + private fun subgroupOf(thresholdPublicKey: HexKey): String = + press.mantra.compose.managers.SharedKeyDerivation.marmotGroupId(thresholdPublicKey) + + private fun certificateSigning( + id: String, + subgroupChatRoomId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = SubgroupBirthCertificateEvent.KIND, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = firstKey, + adminPublicKeys = listOf(me, peer), + name = "Translators", + ), + content = subgroupChatRoomId, + sig = "ff".repeat(64), + ) + ) + + private fun keyStateSigning( + id: String, + dkgSessionId: String, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = signing( + id = id, + stage = stage, + event = Event( + id = "ee".repeat(32), + pubKey = me, + createdAt = 0, + kind = GroupKeyStateEvent.KIND, + tags = GroupKeyStateEvent.assembleTags( + chatRoomId = subgroupOf(firstKey), + dkgSessionId = dkgSessionId, + ), + content = firstKey, + sig = "ff".repeat(64), + ) + ) + + private fun signing( + id: String, + event: Event, + stage: FrostSigningStage = FrostSigningStage.COLLECTING_NONCES, + ) = LocalFrostSigningSession( + session = FrostSigningSession( + id = id, + stage = stage, + chatRoomId = parentChatRoomId, + coordinatorPublicKey = me, + userPublicKey = me, + dkgSessionId = "firstCeremony", + threshold = 2, + participantCount = 2, + signerId = 0, + ), + unorderedItems = listOf( + FrostSigningItem( + sessionId = id, + itemIndex = 0, + unsignedEventJson = event.toJson(), + eventId = event.id, + nonceRandom = "55".repeat(32), + ) + ), + ) + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = parentChatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = "state", + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = parentChatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) +} diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt index e82b816e..e811acb0 100644 --- a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/DkgRitualStartOfferJvmTest.kt @@ -111,21 +111,30 @@ class DkgRitualStartOfferJvmTest { * * Every flow folding into this state used to read it, build a `copy` and * assign -- and the session watcher asks the database what this member owes - * in the middle of that. The key-state watcher writing during that pause had - * its change thrown away by the assignment that followed. The same pause in - * the other direction is what put `session` back to null and offered to - * start the ceremony on screen; this is the half of it that can be forced to - * happen rather than raced into. + * in the middle of that. Whoever wrote during that pause had their change + * thrown away by the assignment that followed. The same pause in the other + * direction is what put `session` back to null and offered to start the + * ceremony on screen; this is the half of it that can be forced to happen + * rather than raced into. + * + * The pair used here is the session watcher against the room watcher, which + * are the two that are genuinely concurrent: both are collecting before + * either has written, and neither waits on the other. `roomHasCeremony` is + * the field they fight over, and it is the one that decides the offer. */ @Test fun `a change written during a suspension survives the write that follows it`() = runBlocking { - val session = session(DkgRitualStage.COLLECTING_ROUND_1) - val signingSessions = MutableStateFlow>(emptyList()) + val live = session(DkgRitualStage.COLLECTING_ROUND_1) + val abandoned = session(DkgRitualStage.FAILED) - // Held open until the test has had the key-state watcher write. The - // seeding call runs before anything else is collecting, so it is let - // through; the session watcher's call is the one that waits. + val roomSessions = MutableStateFlow(live) + + // Opened once the test has had the room watcher write. The seeding call + // runs before anything else is collecting, so it is let through; the + // session watcher's call is the one that waits, and `parked` says when + // it is in there. val gate = CompletableDeferred() + val parked = CompletableDeferred() var calls = 0 val viewModel = viewModel( @@ -133,16 +142,16 @@ class DkgRitualStartOfferJvmTest { override fun observeLatestSessionForChatRoom( chatRoomId: String, parentChatRoomId: String?, - ): Flow = flowOf(session) + ): Flow = roomSessions - override fun observeSigningSessions( - chatRoomId: String - ): Flow> = signingSessions + override fun observeSessionById(sessionId: String): Flow = + flowOf(live) override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? { calls += 1 if (calls == 1) return null + parked.complete(Unit) gate.await() return DkgApprovalStep.ROUND_1 } @@ -151,33 +160,37 @@ class DkgRitualStartOfferJvmTest { viewModel.initiate() - withTimeout(5_000) { viewModel.dkgRitualUIState.first { it is DkgRitualUIState.Loaded } } + awaitLoaded(viewModel) { it.roomHasCeremony } - // The watcher is now parked inside `pendingApproval`, holding a copy of - // the state as it was before this line. - signingSessions.value = listOf(keyStateSigningSession()) - - withTimeout(5_000) { - viewModel.dkgRitualUIState.first { - (it as? DkgRitualUIState.Loaded)?.keyStateSession != null - } - } + // The session watcher is now inside `pendingApproval`, holding -- in the + // shape this used to be written in -- a copy of the state as it was + // before the next two lines. + withTimeout(5_000) { parked.await() } + roomSessions.value = abandoned + awaitLoaded(viewModel) { !it.roomHasCeremony } gate.complete(Unit) - val settled = withTimeout(5_000) { - viewModel.dkgRitualUIState.first { - (it as? DkgRitualUIState.Loaded)?.pendingApproval == DkgApprovalStep.ROUND_1 - } - } as DkgRitualUIState.Loaded + val settled = awaitLoaded(viewModel) { + it.pendingApproval == DkgApprovalStep.ROUND_1 + } - assertNotNull( - settled.keyStateSession, - "the session watcher wrote back a copy taken before the key state arrived" + assertFalse( + settled.roomHasCeremony, + "the session watcher wrote back a copy taken before the room watcher's change" ) - assertEquals(session.id, settled.session?.id) + assertEquals(live.id, settled.session?.id) } + private suspend fun awaitLoaded( + viewModel: DkgRitualViewModel, + until: (DkgRitualUIState.Loaded) -> Boolean, + ): DkgRitualUIState.Loaded = withTimeout(5_000) { + viewModel.dkgRitualUIState.first { + (it as? DkgRitualUIState.Loaded)?.let(until) == true + } + } as DkgRitualUIState.Loaded + private fun viewModel(dkgRepository: DkgRepository) = DkgRitualViewModel( chatRoomId = chatRoomId, activeUserPublicKey = me, From b6c3e3ddc0073c24d7c958f941d8128318e7e572 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 20:22:17 +0200 Subject: [PATCH 15/15] test(subgroups): pin what `addresses` is worth, and when one ceremony is the whole question Two surfaces the last three commits added and left resting on indirect coverage. Both are decisions rather than plumbing, and neither had a test that would notice it changing. **`SubgroupBirthCertificateEvent.addresses` had none at all.** Its sibling `certifies` has nineteen, several of them real FROST forgeries, because a certificate is the whole of what a subgroup relationship is. `addresses` was then added *beside* it as a deliberately weaker reading -- no derivation, no signature -- and exercised only through a view model, which is the wrong place to learn what it is worth. The invariant worth having is the one a future reader can get wrong: **an event can pass `addresses` and fail `certifies`.** Two cases say so in the two ways it happens -- an unsigned proposal, which is exactly what the ceremony screen holds and asks about, and a forgery authored and signed by the child's own quorum, which is anybody at all. A third asserts `certifies` implies `addresses`, so the weaker one cannot drift into being the stricter one while both are called "which child is this about". The other two are the case the predicate exists for: two subgroups of one parent. A sibling's well-formed proposal is not addressed to this child, *and is* addressed to the sibling -- told apart rather than both refused, which a predicate that merely failed closed would also satisfy. Checked by giving `addresses` a signature check: the two "and certifies nothing" cases fail, the other three do not. **`hidesOtherCeremonies` had none either**, and it decides where a tap in the transcript goes. `CeremonyQueueRoutingJvmTest` pins both of its guards separately, which is the point of four cases rather than two: - one ceremony owed -> open it. Drop the `size > 1` and this fails. - two owed -> the list, from either tap, because neither is the whole of what the room is waiting on this member for. - a *finished* ceremony's line -> open it, even while two others are owed. This is the restraint, and dropping the "is itself awaiting" clause is what it catches: a reader tapping history is asking to see history, not to be handed somebody else's queue. - an abandoned one is never owed, even when `pendingApproval` says it is. Each regression was reinstated separately and each is caught by exactly one case. One thing the empty expectation needed care over, and it is the same trap a test in `26d305bc` fell into: a watcher that writes once cannot be waited for by its own empty value, because empty is indistinguishable from not having run. The abandoned case waits on a clock instead and says so. **`hidesOtherDecisions`, the proposal counterpart this mirrors, still has no test.** It predates all of this and is left alone, but the asymmetry now runs the other way and is worth knowing about. **Still untested, and deliberately so rather than overlooked:** the three entry points into the ceremony list -- the "Key ceremonies" row on the group's details, which exists to be reachable in a *Marmot* room where "Shared key" is not, the standing notice handing back null for several, and the transcript line's routing. Those are desktop compose-ui tests over `ChatRoomDetailScreen` and `ChatTranscript`, both of which stand up considerably more than anything here does. 982 jvm tests, 459 common tests, `m3Audit` meeting every budget. Co-Authored-By: Claude Opus 5 --- .../SubgroupBirthCertificateEventTest.kt | 146 ++++++++++++ .../view/model/CeremonyQueueRoutingJvmTest.kt | 216 ++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt index a625bb39..ada04fb2 100644 --- a/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/nostr/subgroup/SubgroupBirthCertificateEventTest.kt @@ -436,6 +436,152 @@ class SubgroupBirthCertificateEventTest { assertNull(SubgroupManager.classify(subgroupChatRoomId, aboutSomebodyElse)) } + // ---- `addresses`: the half that can be asked of a proposal ------------ + // + // The ceremony screen has to tell one *in-flight* certificate signing from + // another in the parent's room, and a parent certifies every subgroup it + // ever makes from that one room. `certifies` cannot answer it: it ends on a + // signature by the parent's quorum, which is exactly what the screen is + // waiting for. `addresses` is the structural half, and the only thing worth + // asserting about it is where it stops. + + @Test + fun `an unsigned proposal addresses its child, and certifies nothing`() { + // What the ceremony screen is actually holding: the event as it was + // assembled, before anybody signed it. Any string in `sig` serves -- + // the point is that no quorum has put one there. + val proposal = certificate(signature = "0".repeat(128)) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "the screen could not tell which subgroup its own proposal is about" + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = proposal, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "an unsigned proposal is being read as a parent's certificate" + ) + } + + @Test + fun `a forgery by another group addresses the pair it names, and certifies nothing`() { + // The reason `addresses` must never decide a lineage: anybody can author + // an event naming any pair, and this says yes to it. The guard is that + // nothing downstream of a lineage asks `addresses` -- only the screen + // deciding which of its own room's signings to draw does. + val forged = certificate(author = childMaterial) + + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.certifies( + event = forged, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a sibling's proposal is not addressed to this child`() { + // Two subgroups of one parent, which is the case the screen exists to + // tell apart. The sibling's proposal is a real, well-formed certificate + // request; it is simply about somebody else. + val siblingRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path) + + val sibling = certificate( + content = siblingRoomId, + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId, + thresholdPublicKey = strangerMaterial.thresholdPublicKey.value.toHex(), + adminPublicKeys = admins, + name = "Another team", + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ), + "one subgroup's rung is following a sibling's proposal" + ) + + // And it is addressed to the sibling, so the sibling's own screen finds + // it -- the two are told apart rather than both being refused. + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = sibling, + subgroupChatRoomId = siblingRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `a proposal naming another parent is not addressed by this one`() { + val fromElsewhere = certificate( + tags = SubgroupBirthCertificateEvent.assembleTags( + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = SharedKeyDerivation + .marmotGroupId(strangerMaterial.thresholdPublicKey.value.toHex(), path), + thresholdPublicKey = childKey, + adminPublicKeys = admins, + name = name, + path = path + ) + ) + + assertFalse( + SubgroupBirthCertificateEvent.addresses( + event = fromElsewhere, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + + @Test + fun `everything certifies addresses, so the weaker one cannot be the stricter`() { + // `certifies` calls `addresses` and adds to it, and this is what keeps + // that true: a change that let one pass where the other refuses would + // mean two different answers to "which child is this about". + val valid = certificate() + + assertTrue( + SubgroupBirthCertificateEvent.certifies( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + assertTrue( + SubgroupBirthCertificateEvent.addresses( + event = valid, + subgroupChatRoomId = subgroupChatRoomId, + parentChatRoomId = parentChatRoomId + ) + ) + } + private fun certificate( content: String = subgroupChatRoomId, tags: Array> = SubgroupBirthCertificateEvent.assembleTags( diff --git a/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt new file mode 100644 index 00000000..f6c876cb --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/press/mantra/compose/ui/view/model/CeremonyQueueRoutingJvmTest.kt @@ -0,0 +1,216 @@ +package press.mantra.compose.ui.view.model + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.delay +import press.mantra.compose.database.model.ChatRoom +import press.mantra.compose.database.model.DkgSession +import press.mantra.compose.database.model.Participant +import press.mantra.compose.database.model.intermdiate.LocalChatRoom +import press.mantra.compose.database.model.intermdiate.LocalParticipant +import press.mantra.compose.database.model.types.DkgApprovalStep +import press.mantra.compose.database.model.types.DkgRitualStage +import press.mantra.compose.repository.ChatRepository +import press.mantra.compose.repository.DkgRepository +import press.mantra.compose.repository.FrostSigningRepository +import press.mantra.compose.repository.NostrRepository +import press.mantra.compose.ui.view.state.ChatMessageListUIState +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * When tapping one ceremony in the transcript is answering the whole question, + * and when it is answering a third of it. + * + * The transcript names one ceremony per line and its standing notice stands for + * however many are owed. Opening the named one is right while it is the only + * thing being asked; it stops being right the moment there are two, because the + * second is not on screen beside the first -- it may be pages up, or have + * arrived while the member was reading -- so answering the one they tapped and + * leaving would look exactly like being done. + * + * `hidesOtherDecisions` has made this call for proposals since before subgroups + * existed. [ChatMessageListViewModel.hidesOtherCeremonies] is its counterpart, + * and a room holds several ceremonies as a matter of course now that a + * subgroup's ChillDKG runs in its parent's room. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CeremonyQueueRoutingJvmTest { + private val chatRoomId = "aa".repeat(32) + private val me = "11".repeat(32) + private val peer = "22".repeat(32) + + @BeforeTest + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `one ceremony owed is answered by opening it`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("only", DkgRitualStage.COLLECTING_ROUND_1)), + pending = mapOf("only" to DkgApprovalStep.ROUND_1), + expected = 1 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("only"), + "the one ceremony owed was sent to a queue of itself" + ) + } + + @Test + fun `two ceremonies owed send the member to the list instead`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS) + .copy(parentChatRoomId = chatRoomId, subject = "Translators"), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + // Either tap: the queue is the honest answer to both, because neither is + // the whole of what the room is waiting on this member for. + assertTrue(viewModel.hidesOtherCeremonies("ours")) + assertTrue(viewModel.hidesOtherCeremonies("subgroup")) + } + + /** + * The restraint, and the reason this is not just "does the room owe two". + * A line about a ceremony the group finished is history, and a reader who + * taps one is asking to see that -- not to be handed a queue of other + * people's business. + */ + @Test + fun `a finished ceremony is opened on its own even while others are owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf( + session("done", DkgRitualStage.COMPLETE), + session("ours", DkgRitualStage.COLLECTING_ROUND_1), + session("subgroup", DkgRitualStage.COLLECTING_HOST_KEYS), + ), + pending = mapOf( + "ours" to DkgApprovalStep.ROUND_1, + "subgroup" to DkgApprovalStep.HOST_KEY + ), + expected = 2 + ) + + assertFalse( + viewModel.hidesOtherCeremonies("done"), + "a finished ceremony's line was answered with somebody else's queue" + ) + } + + /** + * An abandoned one is not owed either, which matters because the room's + * watcher filters on it and a row that slipped through would put a dead + * ceremony at the top of the notice. + */ + @Test + fun `an abandoned ceremony is never counted as owed`() = runBlocking { + val viewModel = awaiting( + sessions = listOf(session("abandoned", DkgRitualStage.FAILED)), + // Says yes, and has to be ignored on a failure. + pending = mapOf("abandoned" to DkgApprovalStep.ROUND_1), + expected = 0 + ) + + assertTrue(viewModel.ceremoniesAwaitingYou.isEmpty()) + assertFalse(viewModel.hidesOtherCeremonies("abandoned")) + } + + /** Builds the view model and waits until its watcher has settled on [expected]. */ + private suspend fun awaiting( + sessions: List, + pending: Map, + expected: Int, + ): ChatMessageListViewModel { + val viewModel = ChatMessageListViewModel( + initialChatMessageListUIState = ChatMessageListUIState.Loading, + localChatRoom = room(), + nostrRepository = NostrRepository.NO_OP_NOSTR_REPOSITORY, + chatRepository = ChatRepository.NO_OP_CHAT_REPOSITORY, + frostSigningRepository = FrostSigningRepository.NO_OP_FROST_SIGNING_REPOSITORY, + dkgRepository = object : DkgRepository by DkgRepository.NO_OP_DKG_REPOSITORY { + override fun observeSessionsForChatRoom( + chatRoomId: String + ): Flow> = flowOf(sessions) + + override suspend fun pendingApproval(session: DkgSession): DkgApprovalStep? = + pending[session.id] + } + ) + + viewModel.initiate() + + // The watcher writes once, so an empty expectation cannot be waited for + // by its own value -- it is indistinguishable from not having run. Wait + // for a non-empty one, and give an empty one a moment to be wrong in. + withTimeout(5_000) { + if (expected == 0) { + delay(200) + } else { + while (viewModel.ceremoniesAwaitingYou.size != expected) delay(5) + } + } + + assertEquals(expected, viewModel.ceremoniesAwaitingYou.size) + return viewModel + } + + private fun room() = LocalChatRoom( + chatRoom = ChatRoom( + id = chatRoomId, + userPublicKey = me, + subject = "Ekklesia", + description = null, + mlsGroupState = null, + ), + localParticipants = listOf(me, peer).map { publicKey -> + LocalParticipant( + participant = Participant( + participantPublicKey = publicKey, + chatRoomId = chatRoomId, + relayHint = null, + ), + profile = null, + ) + }, + ) + + private fun session(id: String, stage: DkgRitualStage) = DkgSession( + id = id, + chatRoomId = chatRoomId, + coordinatorPublicKey = peer, + userPublicKey = me, + threshold = 2, + participantCount = 2, + hostPublicKey = peer, + round1Random = "66".repeat(32), + round2AuxRandom = "77".repeat(32), + stage = stage, + ) +}