Commit Graph

4 Commits

Author SHA1 Message Date
Kgothatso Ngako
113eda9f4d feat: sign a group's key state before its room exists, and put FROST on NIP-17
A room's `GroupKeyState` was the new #admins room's first application message:
the coordinator created the room, added the members, and only then asked the
group to agree what it signs with. The order is now reversed. The group agrees
it while it is still just a ceremony and a NIP-17 chat, and the room is created
already knowing.

**Two things were wrong with the old order, and neither was cosmetic.** The
room's founding fact was settled after the founding, so a session that never
reached a quorum left a live room whose every member fell back to rederiving --
which works, but only at the one path the constant names, and says nothing about
which ceremony a device should take its share from. And the members who had to
sign it were exactly the ones the room had just been created to hold: a member
whose key package could not be found was excluded from the room *and* from a
decision they held a share of, while `createAdminGroup` refuses to create the
room at all in that case. Agreeing first makes the state a precondition of the
room rather than an afterthought.

**Signing therefore has to work in a NIP-17 room, and `broadcast` is the only
place that knows.** In a Marmot room a signing message stays an ordinary inner
event, encrypted to the group and addressed to nobody, because who is in the
group is the MLS tree's business. In a NIP-17 room it goes out as one sealed
gift wrap per member and has to name them all, or the members it left out never
hear. Neither shape lets a recipient list decide anything -- the signer set
comes from the ceremony's host keys either way -- so tagging somebody does not
put them in it and failing to tag somebody only stops them hearing. Everything
above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds
off the gift-wrap path beside the DKG's, and the outbound path needed no change
because `sealGiftWrapPayload` already seals to the room's participants and
already refuses MLS rooms.

**`signingPath` gains the one case that cannot be self-checked.** Every other
candidate is right exactly when walking it reaches the room, which makes the
resolution self-checking rather than trusting. A NIP-17 room's id is an
aggregation of its members' keys, so no path reaches it and nothing can be
checked that way. What the group signs as there is the room it is about to make:
the ceremony's key at the app's admin path. That is admitted only when the
ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this
device's database -- and the path is the constant rather than anything off the
wire, so a proposer still chooses nothing. Naming some other ceremony this
device holds a share for gets no path at all, and `completedKey` will not even
find a key for a NIP-17 room that did not host one, so such a room cannot open a
session; both are tested.

**A state's subject is now its own `d` tag, not the room it arrived in.** Those
used to be required to agree, and a mismatch was dropped -- the right rule while
a state was made in the room it described, and the wrong one now that the two
differ by design. Nothing is given up. The check that drop was standing in for
is still made and made against the *named* room: `GroupKeyState.verifies` has to
rederive it, and `isSignedByGroup` has to find a signature by the key that
rederivation reaches. A state can therefore only ever be about a room it
derives, whatever room it turned up in, so nobody can point one room at another
room's key by putting it through the wrong door. The arrival room survives only
as the fallback for a state carrying no `d` tag at all.

**`record` holds what it cannot file; `adopt` files it when there is a room.**
`GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room
exists has nothing to hang on -- which is now the normal case rather than an
error. `record` says so and keeps the signed event; `adopt` reads it back off
`GroupSignedEvent` and files it the moment a room appears. Both ways into a room
end there: the member who creates it, in `createAdminGroup` and before the
members are added, since filing is local and doing it while the room is certain
to exist beats doing it after a step that can partly fail; and the member who
arrives on a Welcome, in `NostrDao`, off the same event they were already
holding because it was signed in the room they were already in. Nothing goes on
the wire in either case. A member who was not in the ceremony holds no such
event and gets nothing, which is right -- they hold no share either, so there is
nothing for them to pick the wrong one of.

**The screen watches the signed event, not a state row, and that is not
interchangeable.** There is no row until there is a room, so the only thing that
can say the agreement was reached is the event. `observeSignedGroupKeyState`
is a flow over `GroupSignedEvent` by kind for the same reason the button it
gates exists. Gating on the session's own items instead was rejected twice over:
`complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a
collector woken by the session row can read before the event lands; and an item
can hold a signature that has not been verified yet -- `complete` is where each
one is checked against its id and author, and throws if it is not.

**The button is one control and two steps, in the order they have to happen.**
"Agree the group's signing key" until a quorum has signed, "Create the #admins
group" after. Offering both at once would be the old order still available, and
`createAdminGroup` refuses it in the view model as well, since the screen not
drawing something is not a guard. A failed session re-offers the propose button
and nothing else does, because a retry has to be a *new* session: the failed
one's nonce seeds have already been published against an aggregate, and reusing
one produces two partial signatures under a single secret nonce, which is how a
share is extracted. `propose` mints a fresh session id every time, so tapping it
is the safe retry by construction.

**One bug found in review, which the tests now pin.** `replayStoredMessages`
read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before
the proposal it belongs to -- routine on a fresh sync, where a relay hands over a
backlog in whatever order it likes -- was stored in the gift-wrap payloads and
never read back. It now reads whichever store the room's transport writes to,
which has to be the same reading `broadcast` makes. `a nonce arriving before the
proposal is replayed out of the gift wraps` fails against the old code.

**One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a
signature was made in, which for every event but this one is also the room whose
key signed it. The key state is filed under the ceremony's room and authored by
the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row --
check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the
row cannot. Both columns are documented to say so. Re-filing the row under the
#admins room once it exists was the alternative and buys nothing: a key state is
not chroniclable, so no reader wants it there, and moving a row to keep one
helper honest is worse than saying where the helper stops.

`ChronicleManager` and `docs/member-chronicle.md` both argued for the
`isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its
first act", which is no longer true of any Marmot room. The filter stays and the
argument is restated: what it stops is a member replaying any group-signed
statement *about* the record as though it were work, and `applyPage` refuses the
same kinds coming the other way. The two are a pair and neither is safe to drop
on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state
on file by hand, which makes that test sharper rather than hypothetical.

`SignedGroupKeyStateTest`'s harness flattens the two transports into one
`Queued` shape and each device declares whether its room has MLS state, so every
existing test keeps testing the Marmot path and the seven new ones read the
same. `GroupKeyStateTest`'s "a state naming another group's key is dropped"
splits in two: one holding the room fixed and varying the key, which is still a
drop, and one varying both, which is another group's true statement and is now
attributed to that group's room rather than refused.

649 jvm tests and 373 common tests pass; `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:15:58 +02:00
Kgothatso Ngako
19d57ef3a5 Merge branch 'mantra' into claude/happy-gauss-dbe258
Brings in the Chronicle rename and the deprecation of the row rebuild, and
carries the supersession fix across into the new vocabulary.

Git followed every rename on its own -- `ArchiveManager` -> `ChronicleManager`,
the tests, the docs -- and auto-merged all three files my fix had touched. What
it could not do is rename identifiers inside the hunks it merged, so the fix
arrived speaking the old language: `ChronicleAssemblyJvmTest` still called
`ArchiveManager.assemble` and `ArchiveEvent.decodePage`, which does not compile,
and six doc comments in `ChronicleManager` and `GroupSignedEvent` still said
"archive" -- the exact ambiguity with archiving a chat that the rename exists to
remove.

One real conflict, in the design note, and it is the same sentence twice: my
correction of "a retranslated passage archives once" against the rename of the
uncorrected claim. Resolved to the correction, in the new vocabulary -- the
property still holds, it just stopped being free the moment the chronicle was
read from `GroupSignedEvent` rather than rebuilt from rows, and
`ChronicleManager.currentTranslationsOnly` is what holds it up.

`compileKotlinJvm` passes over a test file that does not compile, so it was no
evidence here; `compileTestKotlinJvm` is. And the filter was re-checked the way
it was written: removing it fails the same three tests, so the merge did not
quietly neuter them.

503 jvm tests and 297 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:37:12 +02:00
Kgothatso Ngako
4890906b24 Merge branch 'mantra' into claude/rename-archive-chronicle-a4a8e0
The rebuild deprecation landed on mantra while the rename was in flight, and it
touched the same files by their old names. Git matched the renames itself, so
the only conflict was `ChronicleRoundTripTest`'s header, where both sides had
rewritten the same paragraph: mantra's says this file is now the gate on a
deprecated fallback rather than on the only path, which is the newer and truer
claim, so it wins and the rename is applied on top of it.

Everything the merge brought in went through the same substitution as the rest:
the nine `@Deprecated` messages and the "Retiring the rebuild" checklist all name
`ChronicleManager`, `ChronicleRoundTripTest` and docs/member-chronicle.md, which
are the files that now exist.

797 tests pass -- 500 jvm, 297 android. The five new ones are the migration's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:29:23 +02:00
Kgothatso Ngako
ea11e8b233 refactor: call it a chronicle, and keep "archive" for what a user does to a chat
Archiving a chat is an ordinary thing a user will want to do to a conversation,
and it is not this. This is the group's signed record, handed to a member who
joined after the work was done so their room stops being empty. Two unrelated
meanings of one word in one app is a bug waiting to be written, and
`ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on
the chat row, named for the thing that is not the chat.

So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`,
`ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags,
`ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move;
only the words do.

**The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` ->
`chroniclePage`, which is free exactly once. Both kinds are new and there is no
old build to stay compatible with -- the design note says so in as many words --
so the alternative was carrying the old spelling on the wire forever to save a
rename that costs nothing today. The recipient tag stays `p`; it was never ours.

**Schema v14, because two things had the old word written into stored data.**

`ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than
dropped and re-added: while it is set it is the only record that a device with an
empty room has already asked the group for its history, and a device that lost it
mid-flight would ask again on its next launch, and the one after that.

The three `ChatMessage.messageType` strings become their `chronicle*` spellings,
rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was.
These lines cannot be regenerated -- a chronicle is announced once, when it is
requested, sent and applied -- and an unrecognised type is not skipped by the
transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items"
would come back attributed to a member as something they said.

`MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite
rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which
`getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is
in no index, no foreign key, and there is not a view or trigger in the database
-- so nothing has to move with it. Five tests hold the two halves apart: the
value survives, the column keeps its position, a room that never asked still
reads as never having asked, the three types are rewritten, and every other type
is left alone.

**`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps
its job unchanged: the allowlist that stands between a replayed
`GroupKeyStateEvent` and the apply path.

No behaviour change beyond the rename. 797 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:27:36 +02:00