ea11e8b233f57e51960a51a4108f976a98e3d9d0
623 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
a69d80d38f |
feat: archive the events the group signed, not rebuilds of its rows
`assemble` read the archive out of `Mantra*` rows, rebuilding each payload with `toXEvent()` and standing or falling on that rebuild being byte-identical to what was signed. It had to: nothing kept the events. `GroupSignedEvent` keeps them now, so `signedEventsOf` reads the record first and rebuilds only what the record does not hold. **The rebuild stays, as the fallback, keyed by id.** A room whose work predates v13 has no events on file, and dropping the walk would silently empty its archive -- the failure mode being that a member asks for the history, a member answers, and nobody notices the answer was blank. So both sources are read and unioned by event id, which is also what a half-upgraded room needs: older work only the rows remember, newer work on file, and neither half complete on its own. The fallback can go once no install still carries pre-v13 work, and `ArchiveRoundTripTest` is what holds it up until then. **The allowlist does real work on the way out now, and this is the part that would have bitten.** The rebuild could only ever produce document kinds, because those are the only rows it walks. The record holds every kind the group has ever signed -- and every room signs a `GroupKeyStateEvent` as its first act, so one is on file in every room that has signed anything at all. `ArchiveEvent.build` refuses a non-archivable kind with `require`, so an unfiltered read does not quietly ship a key state: it throws, and the room's entire archive fails on the one event every room has. `signedEventsOf` therefore filters on `isArchivable` before anything else, which is the same rule `applyPage` applies on the way in. Removing that one line fails two tests with exactly that exception, which is how I know they are load-bearing rather than passing for the reason I expected. **An artifact whose initial version row is missing now archives.** The rebuild has to recover the version label from that row -- `fromArtifactEvent` drops it, so it is not on the artifact -- and logs and gives up without it, which is a hole in the archive for any device that applied half a batch. Read from the record there is nothing to recover: the label never left the event. That is the case that makes the record the better source rather than merely the faster one, and it has a test of its own. **One verify filter over both sources**, because the rule is per event and not per source: nothing leaves that the recipient could not check for themselves. A drop still means different things on each side -- a member's own rumor sitting in the same table as the group's work, versus a row that has drifted from the event it recorded -- and the comment now says so, since the log line cannot. **Ordering is unchanged where it matters and looser where it does not.** `inApplyOrder` is a stable sort by dependency rank, so the union only affects order *within* a rank: a room holding some work both ways can order two chapters differently from a member holding one way only. Pages are idempotent and applied payload by payload, and two members already differed by the order their rows were written in, so this costs nothing. `rebuiltEventsOf` still runs on every archive even where it contributes nothing, because there is no way to tell a complete record from a partial one without doing the walk, and it is a handful of indexed queries against a room's own rows. 495 jvm tests and 297 android unit tests pass. Five new cases in `ArchiveAssemblyJvmTest`, which seeds through the real inbound path and now records the same batch the way `FrostSigningManager.complete` does: payloads compared byte-for-byte against what was signed, work held both ways travelling exactly once, a genuinely room-signed key state left behind, a signed kind the archive has no arm for left behind, and the artifact the rebuild has to leave out archiving from the record. The existing assembly and end-to-end tests seed without recording, so they go on covering the rebuild fallback unchanged -- which is why they all still pass, and why that is evidence rather than luck. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8f9e4de82e |
feat: keep what the group signed, and the path it signed as
A quorum signing something is the most expensive thing this app does and, until now, the least recorded. `FrostSigningManager.complete` verified the signature, handed the event to `ChatMessage.applyInnerEvent`, and let it go. What survived was whatever the row it became happened to keep -- an artifact keeps its `signature` and `publicKey`, a translation contributor list keeps nothing at all because that arm is still a TODO, and a kind this build has no arm for keeps nothing anywhere. The signature is the group's statement; the rows are one reading of it. `GroupSignedEvent` is where the statement itself now lives, at schema v13 behind an `AutoMigration(12, 13)`. **The columns are `NostrEvent`'s, not a summary of one.** `id`, `publicKey`, `kind`, `tags`, `content`, `signature` and the event's own `created_at` as `createdAt`, so what is stored is an event rather than a description of one. That is what makes `verifies()` answerable from the row alone: it delegates to `GroupKeyStateEvent.isSignedByRoom`, which asks whether the author is the room, whether the id is the hash of the fields sitting next to it, and whether the signature checks out. No ceremony, no key state and no path have to be on hand first -- which is exactly the position a member added after the ceremony is in. **The derivation path is the point of the exercise.** `publicKey` is the group's threshold key walked to `derivationPath`, and for a room that walk is also the room id -- see docs/shared-key-derivation.md, where those are one value. Without the path there is no way back from a signature to the ceremony behind it: a threshold key alone does not say which of a group's rooms signed, and a room id alone cannot be walked backwards. `GroupKeyState` records the path for the room; this records it for the event, so an event stays checkable after the room's state is gone or was never known. Null means the untweaked threshold key, the same meaning it carries on `FrostSigningSession.derivationPath`, which is where the signing path is copied from -- resolved from the room by `signingPath`, never from a proposer. **Two writers, and both file only what they have already checked.** `FrostSigningManager.recordSignedEvents` files a whole batch in one write, after every item's signature has verified and before any of them is applied -- a session's events are one decision by one quorum, so half a batch on file is a state no reader should have to reason about. `ArchiveManager.applyPage` files each payload it accepts, after the allowlist and `GroupKeyStateEvent.isSignedByRoom`, reading the room's path once per page from `GroupKeyState` rather than once per payload. Neither failure is the caller's: recording throws are logged and swallowed, because a ceremony that succeeded must not be reported as failed over a row this device could not write down. **The archive half is what makes a recipient more than a dead end.** A member handed their history used to end up holding the rows and none of the events -- able to read the group's work, unable to prove any of it, and unable to build a page for the next member to arrive. Now the events land too. **`record` merges rather than overwrites, and that direction is deliberate.** The same event reaches a device twice by design: once when the session that made it completes, once from any archive page carrying it. The second arrival is the poorer one -- an archive knows no session, and on a member who joined after the ceremony no derivation path either -- so the incoming row fills gaps and never empties them. The event's own fields are not merged because they cannot disagree: the id is the hash of them, so two rows under one id either hold the same event or one of them is not the event it claims to be. **Every `Mantra*` row points back at it.** `groupSignedEventId` on all twelve entities that carry `marmotGroupEventId`, stamped by `ChatMessage.applyInnerEvent` through a new defaulted parameter. On a group-signed row it is the only provenance there is: both Marmot ids are null, because there is no group event and no inner event behind one -- a signed event authored by the threshold key cannot travel as an inner event at all, since the outbound pipeline re-authors rumors as their sender and would strip the signature off. The column is only set when the record actually landed, so a row never points at an event that is not there. **`ArchiveManager`'s own doc said something that is no longer true.** It opened with "signed events are not stored as events", stated as present-tense fact and load-bearing for the paragraph under it. Corrected there and noted at the head of the same section in docs/member-archive.md, which is a phase history and so gets a note rather than a rewrite. Assembly still rebuilds payloads from rows via `toXEvent()` and the round-trip gate still holds it up: a room whose work predates v13 has no events on file, and rebuilding is the only way to reach it. Reading assembled events from the table is worth doing once that fallback can be dropped. **Two things this deliberately does not touch.** `ChatMessage` gets no such column -- it is not a `Mantra*` row and already carries `frostSigningSessionId` for the lines that need to name a session. `MantraTranslationChunkProposal` has a `marmotGroupEventId` but is not a `@Database` entity and nothing in `composeApp/src` references it, so it was left as the dead code it is rather than grown a column. Rows are not backfilled by the migration. The events they came from are gone, and minting an id for one would point a row at a signature nobody can produce; null reads as "this device does not hold the event behind this row", which is true of every row written before today. 490 jvm tests and 297 android unit tests pass. `GroupSignedEventDaoJvmTest` is eight cases against a real 2-of-3 quorum rather than a stub signature, because a fake one would satisfy every column assertion and prove nothing -- it covers the round trip, the path walking back to the row's own author, the merge in both directions, batch ordering, and a row edited after the fact no longer verifying. `SignedGroupKeyStateTest` adds the end-to-end claim over two devices: a batch of three signed in one session lands as three events on both, each at `m/9420/0/0` that neither device was told and both derived from the room they stand in. `ArchiveApplyJvmTest` asserts the receiver ends up holding the events and not only the rows, and that the four forgeries in its adversarial page become no signed-event rows either -- a forgery filed there is one the recipient goes on to hand to everybody else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
47aa79ebc7 |
feat: archive the translated text too, now that the group signs it
The merge brought in two commits that close the gap this feature was written around, so the allowlist grows from six kinds to eight. `feat: sign an artifact's first version with it, not derive it after` makes the version the second item of the artifact's own signing batch. `feat: ask the group to sign a chunk's translation, not just save it` puts a quorum behind the prose. Both were done for their own reasons and neither was about the archive, but they are exactly what the archive was missing: an archive can only carry what its recipient can check, so a derived version and a member-authored translation could not travel. A new member got the whole structure and none of the words. **30301 and 30309 do not go on the end of the list.** The order is the foreign keys: a version sits between its artifact and the chapters hanging off it, and a translated chunk hangs off both a source chunk and a translation chapter, so that one really is last. **`toArtifactVersionEvent` had the bug this predicted it would.** It emitted [artifactId, alt] where `build` emits [alt, artifactId], so the id did not round-trip -- the same fault fixed on `MantraArtifact.toArtifactEvent` in Phase 3, in the second of the three unused rebuilds, and for the same reason: nothing had ever called it, so the "tag order matches build" claim in its comment was never checked. `toTranslationChunkEvent` was already correct. Both now have a round-trip case, which is what makes the difference between a rebuild that is right and one that has not been contradicted yet. **`signedEventsOf` walks two steps further**, emitting each version and the translation chunks under each translation chapter. A retranslated passage archives once: the arm that applies a translation chunk drops the one it supersedes -- newest by the timestamp the group signed at, id breaking a tie -- so what a sender holds, and therefore what travels, is the group's current answer to each passage rather than its drafts. **The seeds had to change with it.** Both database tests derived the artifact's first version by applying the artifact, which is exactly what stopped happening; they now sign it through `ArtifactVersionEvent.initialVersionOf`, the way the batch does. That also removes the one exception in the end-to-end assertion: every archived row is now authored by the room and carries a signature, where the artifact version used to have to be excused for having neither. 480 tests pass. The plan's Phase 3 table, its built-vs-plan table and its "what this does not do" section are updated -- what an archive cannot do is down from two things to one, and the remaining one is that it still cannot make its recipient able to sign. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
54091099a9 | Merge branch 'mantra' into claude/happy-gauss-dbe258 | ||
|
|
f3984e838c |
test: prove the catch-up row by row, and say what an old build makes of a page
Phases 8 and 9 of docs/member-archive.md. The tests ran in the phases where the code they cover first existed -- the way the batch-signing note's did -- so this is what was missing from them, plus the rollout note, plus the plan marked built. **Compared row by row, not by count.** The end-to-end test asserted the two databases held the same *number* of artifacts, chapters and chunks. That is not the claim: two databases can hold the same counts and disagree about every row, and a rebuild that lost the group's signature -- or re-authored a row as whoever sent it -- would pass a count and fail the only thing an archive is for. It now compares `(id, author, signature)` per row across every archived kind, and then asserts each one is authored by the room and carries a signature. The artifact version is the one exception, and it has to be: nobody signs it, it is derived from the signed artifact on arrival. Which is exactly why it is not archived, and why a chapter's foreign key survives without it. **An old build does not ignore an archive page, it renders it.** Phase 9's first draft said an old build "files it as unsupported, exactly as it does today for anything it does not know" -- true, and it reads better than it lives. An unsupported row's content is `event.toJson()` and it renders as an ordinary chat bubble, so every member on an old build sees each archive page as a raw-JSON bubble of up to `MAX_PAGE_BYTES`, once per page. Nothing breaks and nothing is lost, but a group mid-upgrade gets a genuinely unpleasant transcript, and that is worth knowing before the first archive goes out. So the rollout rule is stated rather than implied: the receiving half ships safely on its own -- phases 1-4 send nothing -- and no member starts sending until every member understands kind 30327. The mitigation if that ever proves unacceptable is the one the appendix rejects for other reasons, and it is named there so the trade can be weighed rather than rediscovered. **The plan is marked built**, with a table of the five places the implementation chose differently from the plan and why: nine archivable kinds became six, a count cap that could never fire, queueing moved a phase later, a re-read that was never needed, and the rollout note above. Phase 8 also records the three tests that were not in the first draft, each written because something passed for the wrong reason -- a cap that could not fire, an out-of-order test on an archive that was never out of order, and a sweep whose "still missing" count included failures a later pass had already fixed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a315b86918 |
feat: say in the transcript that a member is being caught up
Phase 7 of docs/member-archive.md, in part. Three chat types -- `TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- so a room that fills itself in explains itself once. Without this the archive is entirely silent by design: it files no line per applied payload, because `ChatMessage` has an `autoGenerate` primary key and every payload would mint a fresh row on every pass of the sweep. The result was a member joining a working group and watching a room populate with no account of where any of it came from, which is worse than the noise it avoided. **One line per archive, not per page.** The received line is written when the request stamp is cleared, which is as close as this can get: an archive's pages are not distinguishable from each other at apply time, and clearing the stamp is exactly the moment a catch-up stops being pending. There is a test that delivers a payload per page, backwards, so the sweep runs repeatedly over many pages, and asserts the transcript holds two lines. **A push behind a Welcome writes nothing**, because the room was never asked. It lands before the member has opened the room, and "caught up on work you have not seen yet" is a line about nothing. Also tested. **The received line names no sender.** An archive can be assembled from pages sent by more than one member, so attributing the catch-up to one would be a guess dressed as a fact. The sent line does name its recipient, written into the content the way the invite line writes one -- which does not follow a rename, and is the accepted cost for a line about something that happened once. **Content is whole sentences**, so these stay out of the AUTHORED sets and nothing prefixes a name to them. And they are added to `ARCHIVE_TYPES` with a matching arm in the transcript, because the failure mode for a missed set is silent: the line renders as a chat bubble, looking exactly like a member having said "Caught up on 12 items". Icons per type rather than the `PanTool` fallback. **Two items from this phase are deliberately not done**, rather than written without the app in front of me: the banner saying a room is catching up, and a "Send history" action on the member row. The first is UI state plumbed through a view model into a layout and the transcript line covers the same ground; the second is a convenience, since both real paths are already automatic. Both are written up in the plan as outstanding, along with the thing this phase was also meant to say and does not: that an archive does not make its recipient able to sign, and does not carry the translated text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6d8866c2b0 |
feat: offer a new member the group's work alongside their welcome
Phase 6 of docs/member-archive.md, and deliberately the phase after the one that makes it unnecessary. `deliveryWelcome` now queues an archive for the member being invited, so in the ordinary case they have the group's signed work before they think to ask for it. **This is a latency optimisation, not the mechanism.** A page queued behind the Welcome is not delivered after it: they are different transports -- a relay-borne gift wrap and a kind:445 -- with no ordering between them, and a page that overtakes the Welcome is from an epoch ahead of the invitee's, so `MarmotInboundManager` drops it outright rather than deferring it. Nothing retries and the inviter sees a success. That is the failure in docs/marmot-membership.md wearing new clothes, and the only thing that closes it is the invitee asking once they are demonstrably in the group, which Phase 5 already does on their first open of the room. So nothing here reports failure to the inviter. A push that does not land is the ordinary case the pull exists for, and it sits inside `deliveryWelcome`'s own catch alongside the Welcome it rides behind. A room with nothing signed queues nothing and still invites. **One call, two occasions.** `ArchiveManager.answer` becomes `sendTo`: answering a request and pushing behind a Welcome are the same operation and differ only in who decided, so it is named for what it does rather than for either occasion. Also corrects the plan. Phase 6 claimed the room had to be re-read between the invite and the assembly, for the same reason sequential invites re-read it. It does not -- that rule is about the MLS snapshot a commit is built on, and this runs downstream of the commit over `Mantra*` rows, which no commit touches. Two tests against a real `deliveryWelcome`: inviting into a room with signed work queues exactly one archive page addressed to the invitee, and inviting into a room with none queues no page while still writing the Welcome's gift wrap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
873203e4b4 |
feat: let a member with none of the group's work ask for it
Phase 5 of docs/member-archive.md, and the half that makes the whole thing reliable. A device opening a room it holds no signed work for asks the group; any member holding the work answers, addressed to whoever asked. **A request cannot lose the race a push loses.** Pushing an archive at an invitee is an application message in the epoch the add created, and one that overtakes the Welcome is dropped rather than deferred -- silently, while the inviter sees a success. That is marmot-membership.md's failure mode arriving in a new costume. Sending a request cannot lose it, because being able to send one is the proof it was won: a device that can put an application message into the room has processed its Welcome and is at the group's epoch. It also covers three things no invite-time push reaches, and they are answered by one rule because they are indistinguishable from inside the database: a member added after the work, a reinstall whose invite is long past, and a second device that was never invited at all. Hence the crude condition -- no dialects and no artifacts -- rather than anything that tries to tell them apart. **Anyone may answer and nobody is elected to.** A duplicate answer costs bandwidth and nothing else: pages are idempotent and every member who is not the named recipient ignores them. So the stand-down that would avoid the waste is an optimisation to add later rather than a correctness gap to close now. A member with nothing signed answers nothing at all, which is the honest reply from one still catching up themselves, and beats an empty archive that looks like an answer. **Queued with no chat line**, the way a signing message travels. Broadcast does not depend on one -- `encryptAndSendMarmotInnerEvent` inserts its `BroadcastNostrEventRequest` unconditionally, which is what let the FROST rounds travel with no transcript -- and an archive that filed a line per page would put a row of envelopes in the room's history. One line per archive is the right number and it is not writable from here, since the pages are indistinguishable from each other at this point. **Schema 11 -> 12**: `ChatRoom.archiveRequestedAt`, nullable, so Room generates the migration. It stops a device asking again on every launch while an answer is in flight. Rooms written before it read back null, meaning "never asked", which is true of all of them and harmless. Cleared as soon as an archive applies anything -- not when a sender's page count claims the archive was complete. A page count is the sender's word about the transfer rather than about the group's record, so a member who left work out must not get the last word on whether to ask again. A partial answer is followed by another request rather than by silence. The trigger is opening the room, through `ChatRepository` rather than from the view model into the database. Cheap to call every time: it stops at a room that already holds work and at one still waiting. A failure is not reported, because nothing acknowledges a request and the next open asks again. Eight tests: a device with nothing asks and does not ask twice, a device with work does not ask, answering queues pages addressed to the asker with every archivable kind in them and no chat line, a member with nothing signed answers nothing, a member does not answer themselves, an applied archive clears the stamp so a partial answer can be followed up, and the whole round trip -- ask, answer, apply -- leaves the joiner holding the sender's rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ed4410b972 |
feat: apply an archive a member is sent, and sweep what arrived too early
Phase 4 of docs/member-archive.md, and the half where the security lives. A member who holds no share, took part in no signing session and cannot decrypt a word of the room's history now ends up with the same rows as everybody else -- and gets there without trusting whoever sent them. **Intercepted in `fromGroupEventResult`, not in `applyInnerEvent`.** An archive is neither a document nor a submission, and deciding whether to act on one needs the active key, which `applyInnerEvent` has no business knowing. That is the same reason the gift wrap above it is handled there, so it sits next to it. **Verification per payload, framing per page.** A forged payload costs itself and nothing else -- the rule `MarmotInboundManager` already uses for a forged direct message, and for the same reason: this runs inside the inbound transaction and one bad event must not take the room down with it. Refusing the whole page would also let a single forgery deny an entire archive. The page's own framing stays all-or-nothing, because a page that will not parse has lost the thing that says what it contains. **The allowlist runs before the signature check, and it is not a formality.** Verification admits an event to the apply path on the strength of the group's signature, which makes every kind the group has ever signed replayable by any member at any time. There is a test that puts a genuine, still-verifying `GroupKeyStateEvent` in a hand-rolled page -- `ArchiveEvent.build` refuses to make one, which is the outbound half of the same rule -- and asserts the receiver's key state does not move. **The chat line is dropped, deliberately.** `ChatMessage` has an `autoGenerate` primary key, so there is no id to dedupe on and every applied payload would mint a new row: a synthetic transcript dated now, and another one on every pass of the sweep. The archive restores the work. The conversation is forward secret and stays gone. **A device that is not the named recipient does nothing.** It can read the page -- it is an ordinary group message, and it is the group's own history -- but it already holds the work, and re-applying would rewrite every one of its rows to point at an archive page rather than at the event that introduced it. That is also what bounds the sweep: only the member being caught up ever builds the list. **The sweep needs no table.** Pages arrive over relays in no order, so page 3 can land before page 2 and its chunks have no chapter to hang off. Those throw a foreign key violation and would be lost -- except the inbound path already stores every inner event it decrypts, so re-reading them is the same shape `FrostSigningManager.replayStoredMessages` has, for the same reason: nothing was lost, it just had nowhere to go at the time. Two things about the loop, the second found by a test: Progress is measured by *failures falling*, not by rows written. "Repeat while a pass applied something" does not terminate, because every write is an upsert and succeeds forever. What strictly decreases is the count that threw. And the result is the last pass rather than the sum of them. Accumulating counts a payload once per pass it survived and reports failures a later pass went on to fix, so `failed > 0` stops meaning "still missing" -- which is the only question a caller asks it. Caught by strengthening the out-of-order test to assert that the page completing an archive leaves nothing behind, rather than only that the rows matched: without that, the test passed while reporting fourteen failures on a fully converged database. Seven tests over two real databases with the pages carried by hand. The one that matters puts four forgeries in a page beside one honest dialect -- the room's id as author with a made-up signature, a real quorum of another group, an event edited after signing, and a member's own rumor, which is what everything on the wire looks like today -- and asserts the receiver ends with exactly the honest one. The rest: a full catch-up matches the sender row for row with the group's signature intact, pages delivered backwards converge and are asserted to have really failed first so the test cannot pass for the wrong reason, an archive files no chat lines, a bystander applies none of it, and applying the same archive twice changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
acff66a22e |
feat: rebuild the group's signed record out of the rows it left behind
Phase 3 of docs/member-archive.md. `ArchiveManager.assemble` walks a room's rows, rebuilds each into the event the group signed, drops anything it cannot prove, and cuts the rest into pages. Nothing sends one yet. **The gate found a real bug, which is why it was the gate.** Signed events are not stored as events -- `FrostSigningManager.complete` applies one and what survives is a `Mantra*` row -- so an archive has to rebuild them with `toXEvent()` and stands or falls on that being byte-identical to what was signed. Every `toXEvent()` in the codebase turned out to be unused in production, written for exactly this and never called, so the "tag order matches build so the event id round-trips" comments on them were claims nothing had ever checked. One was wrong. `MantraArtifact.toArtifactEvent` put the alt tag last where `ArtifactEvent.build` puts it first, and left out the version metadata tag altogether -- because that tag is not on the artifact row at all. `fromArtifactEvent` reads the artifact's own fields and drops the version label, which `applyInnerEvent` has by then turned into the artifact's first `MantraArtifactVersion`. So the label is now a parameter, read off the initial version: the one whose `createdAt` is the artifact's, since `initialVersionOf` derives it from the same event. Neither fault would have surfaced as an error. Both produce a well-formed artifact whose id no longer matches its fields, which every receiver drops as a forgery, silently, one kind at a time. `ArchiveRoundTripTest` now signs each archivable kind with a real quorum, files it as a row, rebuilds it and asserts the signature still covers what comes out -- plus the negative case, that rebuilding with the wrong version label fails as a forgery rather than as a mistake, which is why the assembler reads the label rather than defaulting it. **The allowlist narrows from nine kinds to six, and this is the finding to read.** Only six of the thirteen nip30303 kinds ever reach a signing session; the rest travel as member rumors, vouched for by the MLS frame they arrived in and by nothing that survives leaving it. An artifact version is derived rather than signed -- which is fine, because applying the archived artifact derives it again and the chapters hanging off it keep their foreign key. Nothing builds a `TranslationEvent` at all. The contributor lists have no arm in `applyInnerEvent` that writes a row. And `TranslationChunkEvent` -- **the translated text itself** -- is submitted by `MantraDao.saveTranslation` as its author's rumor, because a translation is one member's work rather than a group decision. So an archive restores everything a translation hangs on and not the translation: a new member gets the dialects, the artifacts, the chapters, the source chunks, which translations exist and their chapter scaffolding, and none of the prose. That is a real limit rather than a detail, so it is written into the allowlist's own doc comment, into the plan's "what this does not do", and into a test named after it -- with the three ways out sketched and none of them taken here, because the cheapest gives up the property the rest of this rests on and the best is a product decision about whether translating is an act of the group or of a member. **Nothing unverifiable leaves.** Every rebuilt event is checked with `isSignedByRoom` against the same room id the recipient will use. Not politeness -- the receiver checks anyway -- but so the page count says what will actually arrive: a row from a member's rumor is dropped here rather than by the recipient. **Walked down the tree, not queried per kind.** Only dialects and artifacts have a by-room query and the rest hang off a parent, and the walk is also what puts an artifact's version label within reach. Order is settled afterwards by `inApplyOrder` rather than by the walk, since the walk groups by artifact and the foreign keys are by kind. **Paging is greedy against both caps**, because they bind different archives: a room of one-line dialects hits the count first and a room of chapters hits the bytes. An event too large for a page of its own is dropped with a log rather than failing the archive -- a chapter nobody can archive is a hole, a member who gets nothing is a bigger one. Assembling only; queueing moved to Phase 5, where the thing that decides when to send lives. That keeps this testable against a real database with no outbound path in the way. Seven tests over a real in-memory database seeded through `applyInnerEvent` itself, so what is archived is what a member's device really holds rather than rows built to suit the test: every payload verifies, all six kinds appear exactly as often as they were signed, the whole archive is in dependency order end to end, a member's unsigned dialect sitting in the same room is left out, an empty room archives nothing without failing, and two archives of identical rows do not share an id -- which is what stops two members answering one request from having their pages counted towards each other's total. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6e04f6c7af |
feat: give the group's signed record an envelope it can travel in
Phase 2 of docs/member-archive.md. Two kinds, three tags, a codec and two caps.
Nothing sends or applies one yet -- that is phases 3 and 4 -- so this changes no
behaviour at all.
`ArchiveEvent` (30327) carries a page of the group's signed events, each whole,
keeping its own id, author and signature so the receiver checks it rather than
believing it. `ArchiveRequestEvent` (30328) is how a device with none of it asks.
**Why not one `SubmissionEvent` per event.** The envelope fits and the meaning
does not. A submission is an *act* -- this member is putting this event in front
of this group -- and an archive asserts nothing; it re-delivers what the group
already agreed. On one kind a four-hundred-event backfill is indistinguishable
from four hundred new submissions and every device has to guess which it is
reading. It would also be one inner event and one kind:445 per payload where a
page is one, and the submission arm of `applyInnerEvent` files a chat line per
payload, which an archive must not.
**Why 3032x and not 30313.** 30313 is free beside the nip30303 document kinds
and is not used, on `FrostSigningEvents`' own advice: the DKG's 30310-30316
already overlap that range and are told apart only by living in NIP-17 gift wraps
instead, which it calls "an accident of routing rather than a decision, and the
next family added should not rely on it." This is that next family. 30327 is also
the right neighbourhood on the merits, next to `GroupKeyStateEvent` at 30326 --
an archive is a statement about the record rather than a document kind.
**One list is the apply order and the allowlist both**, because a separate
allowlist is one more thing that can disagree with the order it is applied in.
The order is Room's rather than nostr's: every archivable kind has a foreign key
on the one before it, and kind order is not dependency order -- a dialect (30304)
has to land before an artifact (30300), and a translation chapter (30308) hangs
off a translation artifact version (30306) which hangs off an artifact version
(30301). So it is a list, not a `sortedBy { kind }`, and there is a test that
fails if anybody makes it one.
It is an allowlist first. Verification admits an event to the apply path on the
strength of the group's signature, which makes every kind the group has ever
signed replayable by any member at any time. A `GroupKeyStateEvent` is
group-signed and passes verification perfectly, so an archive carrying an old one
is a validly signed statement about what the room signs with, replayed by whoever
kept a copy. Nothing but this list stops it. The contributor-list kinds (30305,
30307, 30310) are left out on the same principle from the other side:
`applyInnerEvent` has no arm that writes a row for any of them, so archiving them
would cost bytes and restore nothing.
**All-or-nothing parsing, per-payload verification.** These are not in tension;
they answer different questions. A page that will not parse has lost its framing,
and one silently shortened by an element would report a complete archive on its
page count while holding less than it says. A payload whose signature does not
verify is a well-framed page with one bad event in it, and costing its honest
neighbours would let a single forgery deny an entire archive.
**The count cap was 256 and 256 can never fire.** An event carries 64 characters
of id, 64 of pubkey and 128 of signature before it says anything, so the floor is
about 370 bytes and a 64 KB page cannot hold much past 170 of them -- the byte
cap always binds first and the count cap is a check that never runs. Found by
writing the test that a page at exactly the cap still decodes, which failed. Now
128, where both bind something: the count stops a page of many small payloads,
the bytes stop a page of few large ones. That test is what fails if somebody
later raises one number without the other, and the doc comment says they have to
move together.
**The `p` tag is a hint, not access control**, and `ArchiveRecipientTag` says so
where it is defined. The page is an ordinary group message and every member can
read it, which is right, because it is their own history going back to them. What
it decides is who *acts*: a device that is not named applies nothing, since it
already holds the work and re-applying would rewrite every one of its rows to
point at an archive page rather than at the event that introduced it.
`ArchivePageTag` refuses an index outside its own count rather than clamping it.
The pair is how a receiver decides it has everything, so a repaired one would let
a truncated archive read as complete.
Twenty-one tests over the codec, both caps, the allowlist, the order and the
tags. Also corrects the phase-2 section of the plan, which still said 256.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
bd5e0413f0 |
refactor: check a group's signature against the room id, not against a key
Phase 1 of docs/member-archive.md. No wire change, no behaviour change, and one function where there was one. `GroupKeyStateEvent.isSignedByGroup` did three things: walk a threshold key to the room it derives, compare that to the event's author, and check the id and the signature. Only the first of those needs a key. The other two need the id the walk produces -- and a room's id *is* that value, held from both ends by `GroupKeyState.verifies` and `FrostSigningManager.signingPath`. So the walk splits off and `isSignedByRoom(event, chatRoomId)` is what remains: the same three checks, with the one input a caller might not have already resolved. `isSignedByGroup` becomes the one-line caller that walks first, and every existing call site and test is untouched. **Why this is worth a commit of its own.** A member added after the ceremony holds no `DkgSession`, no share, and -- until a state is re-announced, which nothing does -- no `GroupKeyState` row either. Under the old signature they could not check a group signature at all, and an archive of the group's work would have had to be believed because a member said so. Under the new one they check it against the id in their own Welcome, and the sender of an archive stops needing to be trusted. That is the property phases 2-9 are built on, so it lands first and lands alone. **The catch moved and had to be kept.** `marmotGroupId` is now called outside `isSignedByRoom`, so `isSignedByGroup` keeps a `runCatching` of its own. Without it a threshold key that is not a point stops being a refused state and becomes an exception in the middle of the inbound path -- every input here is off the wire, and the whole contract of these functions is that malformed means no. There is a test that fails if the catch is dropped. **Tests**, added to `GroupKeyStateTest` where the FROST key material, the second group and the real-quorum `groupSignature` helper already live: - a room's id is the only key its signature verifies against -- the same group's sibling room fails, and so does another group entirely; - the verifier does not care what kind it is looking at, over four kinds including `GroupKeyStateEvent` itself. That last one is not incidental: a key state signed by the room passes exactly as a dialect does, which is why the archive needs an allowlist of kinds on top of this and cannot read "the group signed it" as permission to apply it; - a rumor nobody signed is not a group signature -- empty sig, member author, which is what every nip30303 event on the wire looks like today; - claiming the room as author proves nothing without the signature. The room id is in the h tag of every kind:445 the group has sent, so writing it into `pubKey` is free; the author check and the id check both pass and the signature is the whole feature; - an event edited after signing fails on the id, not on the signature -- and the original still passes, which is why the id check is not redundant; - malformed input is a no rather than a throw, on both forms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3ee1676a04 |
docs: plan handing a new member the group's signed history
A member added after the work was done sees none of it, and nothing in the app will ever show it to them. Two independent reasons, and the second is the one that surprises people. MLS gives no history: a Welcome carries the ratchet tree at the current epoch, not the transcript, and `MarmotInboundManager` drops anything from an epoch it holds no keys for. That is forward secrecy working rather than a gap to close. But group-signed events never travel at all. `FrostSigningManager.complete` says so in as many words -- a signed event authored by the threshold key cannot go out as an inner event, because the outbound pipeline would re-author it as its sender and strip the group's signature off -- so every device *derives* the finished event from its own `FrostSigningItem` rows. A member who was not in the session has no items, and no later message carries the event. So the second problem does not follow from the first and is not fixed by fixing it: even a member who could decrypt the whole back-transcript would still hold nothing an artifact, chapter or chunk could be built from. Which makes an archive not a convenience but the only path, and fixes the line the design has to hold: **it carries what the group signed, never the chat.** Restoring the chat would undo forward secrecy on purpose, and a signed event is the only thing a new member can check for themselves. **The property the whole plan rests on is already true.** A room's id *is* the group's threshold key derived at the room's path -- `GroupKeyState.verifies` and `FrostSigningManager.signingPath` hold that invariant from their own ends -- so `isSignedByGroup`'s three checks collapse to `event.pubKey == chatRoomId`, an id check and a signature verify. No key state row, no threshold key, no path, no lookup. A member who can name the room can verify its signatures, which is exactly the position a new member is in, and it means the sender of an archive does not have to be trusted at all. **Two guards the plan makes non-negotiable.** Nothing on the inbound nip30303 path verifies a signature today, and that is currently correct: rumors carry an empty sig and are authenticated by the MLS frame, so nothing on the wire has ever claimed group authorship. An archive is the first thing that does, so the verify is the feature's entire security rather than hardening on top of it. And verification turns "group-signed" into an admission ticket for the apply path, which is a wider door than it looks: a `GroupKeyStateEvent` is group-signed and would pass perfectly, so an archive could replay a genuine old one and re-point what the room signs with. The archive therefore carries an allowlist of document kinds, checked outbound and independently inbound -- the same shape, and the same reasoning, as the cap on `k` in frost-batch-signing.md. **Push and pull, in that order of appearance and the reverse order of importance.** Pushing an archive after the Welcome is what the question asked for, and on its own it fails the way marmot-membership.md describes: it is an application message in the epoch the add created, so one that beats the Welcome there is dropped rather than deferred, silently, while the inviter sees a success. So the joiner asks instead -- a request is proof it has processed its Welcome, and it covers the reinstall and the second device, which no invite-time push can. The push stays as a latency optimisation, deliberately phased after the thing that makes it safe. Nine phases: the verifier, the events, assembling an archive, applying one and the sweep that lets pages arrive out of order, the request, the push, UI, the cross-device tests, and rollout. The sweep needs no new table -- the inbound path already stores every inner event it decrypts, so it is the shape `FrostSigningManager.replayStoredMessages` already has. Also written down, because it is the first thing this will be reported as a bug for: an archive lets a new member *read* everything and does not let them sign anything. `proposeSigningBatch` wants a secret share and a place in the ceremony, and a group that re-runs its ceremony derives a different room rather than re-keying this one. Closing that needs share resharing, which is a great deal more work than this and is the thing to build after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
042313ea88 | Merge branch 'mantra' into claude/new-member-archive-events-e3e074 | ||
|
|
c8b62e606e |
feat: sign an artifact's first version with it, not derive it after
A chapter attaches to a version rather than to an artifact, so the first version is the parent of everything a group later translates. It was not signed. Every device rebuilt it from the artifact on arrival, which put a row on disk naming the group as its author and carrying no signature to show for it -- a parent vouched for by its own signed children rather than the other way round. The reason was written into both ends: a first version proposed on its own would cost a second quorum for one form. That is an argument against a second session, and it stopped being an argument at all once a batch existed. `proposeSigningBatch` is one ceremony, one approval and one transcript whatever k is. The same reasoning was already overturned once, for the same shape. A chapter's chunks were briefly derived from the signed chapter's text for exactly this reason, and they carry their own signatures now. The artifact version is the case that was left behind, and it needs the same form: `ArtifactVersionEvent` names the artifact it is of, and that id is a hash over the group's key at the room's path, so it cannot be known until the proposal is authored. `initialVersionOf` takes the lead the session built, mirroring `ChunkEvent.splitOf`, and the artifact is item 0 because a version row whose artifact does not exist yet is a foreign key violation. Two things had to move with it, and both would have been silent. `ChatMessage.applyInnerEvent` no longer derives a version under an artifact. The derived row and the signed one hash differently -- different author, different timestamp -- so keeping both would have stood two versions against one artifact and let a chapter hang off whichever it found. The `ArtifactVersionEvent` arm no longer writes a chat line. It never used to reach one: a derived version wrote nothing. Signed, it would have put "Added 1.0 to artifact versions" under every "Added In Detention to artifacts", which is the noise the chunk arm already declines to make beside a chapter. An artifact signed before this keeps a version label nothing turns into a row, so its version does not appear. That is what the chapter's chunks cost too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4855d31c9c | Merge branch 'mantra' into claude/translation-chunk-frost-signing-b01596 | ||
|
|
90c6db7827 |
refactor: drop the local path a chunk's translation no longer takes
The translation editor was the only caller of `saveTranslationChunk`, and it stopped calling it when it started proposing. What is left behind is dead: the method on `MantraRepository`, its no-op for previews, its implementation in `DatabaseMantraRepository`, and `MantraDao.saveTranslation` underneath them. Deleting it rather than leaving it is the point. Two ways to create a translation chunk, one of which bypasses the quorum, is one too many -- the next screen wanting one would find it and take it, and the group would end up with a translation in its name that nobody signed. The rule it enforced does not go with it. Keeping one translation per source chunk moved to `ChatMessage.applyInnerEvent`, where the row is now made, in the commit before this one -- and covers more there than it ever did here, since the group's other members were always able to leave a duplicate behind. `MarmotInnerEventDao.deleteByPayloadEventId` loses its only production caller here and stays. It is a DAO query rather than a private helper, the invariant behind it is still true and still tested -- a submission's id is the envelope's, so a superseded payload cannot be un-queued by its own id -- and `MantraDao`'s remaining `addDialect` and `addArtifactVersion` are in the same position: reachable now only from `MantraDaoJvmTest`, and a decision about the whole submit-to-group path rather than about this screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
abbb84bb29 |
feat: ask the group to sign a chunk's translation, not just save it
Everything else a group's library is made of -- the artifact, its dialects, its
chapters, the translations of it -- is signed into existence by a quorum. The
translation of a chunk was the last thing still being saved: `saveTranslation`
wrote the row on this device and queued a submission, and the group's only
recourse afterwards was social.
That is the wrong way round for this one in particular. An artifact is a link
and a name; a translated passage is a claim about what somebody else's words
mean, made in the group's name, and it is what every reader of that translation
reads instead of the original. If anything in the library deserves a quorum it
is this one.
So the screen proposes rather than saves, the way `AddArtifactScreen` does.
Nothing is written when the button is pressed. What goes out is a proposal to
sign a `TranslationChunkEvent`, and the translation appears on every member's
device at once -- authored by the room's own key rather than by whoever typed
it, since signing runs at the path the room was derived at -- when enough
members have signed.
**The form knows whether it can sign before it offers to.**
`TranslateChunkUIState.Loaded` now carries the room and
`frostSigningRepository.canSign`, so the view model has something to propose
with and the button has something to check. Greyed out with
`semantics { disabled() }` when the group holds no shared key, and the screen
says why: a group without one cannot translate here at all, and that is a dead
end to say up front rather than a proposal to be told about afterwards. The
disabled colours are borrowed from `ButtonDefaults` because M3 gives a FAB no
`enabled`, which is what the artifact and dialect forms already do.
**The index is read off the source chunk**, as the DAO did before it. A chapter
is translated a passage at a time and in no particular order, so counting what
is translated so far would number the translations by who got there first.
**Onto the session, not back to the chapter.** The editor is popped and replaced
by the signing screen: nothing has been translated yet, so a table still showing
the passage untranslated would read as a failure. Back from the session lands on
the chapter table, which is deliberately left alone -- the old flow popped and
reloaded it to reflect a save, and there is no longer a save to reflect.
**`ProposedEvent` learns kind 30309.** Without it, members would be asked to put
the group's name to "Event of kind 30309". A translated passage is summarised as
its position and then the translation itself: the words are the whole of what is
being decided -- signing this is agreeing they say what the original said -- and
the position is what tells the reader which passage to weigh them against.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
76b4a78581 |
fix: keep one translation per source chunk, however it arrives
A translation chunk is never edited. Retranslating a passage means a new event
carrying new words, and an event's id is a hash over its content -- so the
second translation is a row of its own rather than an overwrite of the first.
`MantraDao.saveTranslation` knew that and dropped the row it superseded, but it
only ever saw this device's own retranslations. Everything arriving from the
group went through `ChatMessage.applyInnerEvent`, which upserted and nothing
else. A member retranslating a passage somebody else had already translated left
two rows behind, and `TranslationChapterViewModel` pairs chunks with their
translations by `associateBy { it.chunkId }` -- one of the two wins, and which
one is whatever order SQLite happened to return them in for a query ordered on a
column they share.
So the rule moves to where the row is actually made, and now covers the group's
signatures and the relay's deliveries alike.
**Newest wins by the timestamp the group signed at, not by arrival.** Two
devices catching up read the same events in whatever order their relays hand
them over, and they have to end up holding the same translation either way. An
older translation arriving after the one that superseded it is dropped rather
than allowed to overwrite it. Ties break on the event id -- arbitrary, but the
same arbitrary on every device, which is the whole requirement.
**Matched on the source chunk, not on the chapter.** A chapter holds one
translation per passage, not one translation; matching on the chapter alone
would leave a chapter that could only ever show its most recently translated
paragraph. `getTranslationChunksByChunkId` is the query that says so.
**Tests.** `TranslationChunkApplyJvmTest` covers the three cases against a real
database: a retranslation replaces what it supersedes, a translation arriving
after the one that superseded it is dropped, and two chunks of one chapter each
keep their own. The first two fail against the plain upsert this replaces; the
third is what stops the fix from over-deleting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
8a23a28e54 |
fix: let the untranslated side read as text, not as a button
The translation cell was a `TextButton` with its content padding zeroed, which took care of the padding and left everything else a button brings: Material's pill shape, a 40dp minimum height, and a ripple rounded to match. So a chapter's two columns -- the same text, one side not yet translated -- did not read as two columns of one table. One was prose and the other was a control, and the thing being offered is not a control, it is the text with an invitation to write it. It is a plain `Row` now, laid out like the original cell beside it. The click moves up onto the cell's `Box`, before the 12dp padding rather than inside it, so the tap target is the whole cell rather than a button indented within it and the ripple is the rectangle the cell already was. `TableRow` grows a `rightModifier` to carry that, which is where a modifier for that cell belongs. Same greyed-out placeholder, same chevron, same destination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dbdff55ee0 |
feat: open the proposal a transcript line is about, not the room's latest
Tapping a signing line in the transcript opened whichever session the room was running, resolved by `liveSessionForChatRoom` -- the newest one not yet finished, or failing that the newest one at all. That is a guess, and it was a good one exactly as long as a room had one proposal to guess at. With a chapter and its translation open together, half the lines in the transcript led to the other proposal. The line now says which session it belongs to, so there is nothing left to guess: it carries `frostSigningSessionId` through to the route. A line written before that column opens the room's proposal list instead, which is the honest answer to a line that cannot say what it meant -- every proposal with its own state, and the reader picks -- rather than a guess dressed as an answer. That empties `FrostSigningRoute.sessionId` of its reason to be optional, so it is required, and `liveSessionForChatRoom` goes with it from the interface, the implementation and the no-op. `FrostSigningViewModel` loses its resolution step and the "This group is not signing anything right now" error underneath it -- which was never the right thing to say to somebody who had just tapped a line about a specific session. The transcript keeps doing the one job it is good at: showing a proposal as it happens, and saying whether it is still asking something of you. What it stops doing is standing in for a list of them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
116cc96de4 |
feat: give a group's proposals a screen of their own
The transcript is where a proposal is met, and it is a bad place to keep one. It answers one question -- is this still asking something of you -- in the middle of everything else the room said that day, and then it scrolls. Until now the only other way in was a signing screen that resolved "the room's live session", so a room with two proposals open had one of them reachable and the group's history had none of it. So: one row per session, newest first, live. The ones still waiting on the reader are gathered under "Waiting for you" and the rest follow, because they are two different kinds of thing to read -- the rest is what the group has done, those are what it is waiting on this member for -- and because burying the second open proposal under the first one's history is the whole failure this screen exists to answer. **Each row carries its own state, from the same rule the signing screen uses.** Whether a proposal still has a decision in it is asked of `FrostSigningManager.isAwaitingApproval` rather than worked out again here, so the list and the screen behind it cannot come to different answers about the same session. The rest of the status is the session's own stage said briefly: you agreed and it is waiting on n of m, you are one of the signers, enough members took part without you, signed, or -- for an abandoned one -- its failure reason, because "declined on this device" and "the group could not agree" are different things to have happened and the reason is the whole content of the ending. **Named after the lead item.** A batch's first item is the one the rest hang off -- a chapter's chunks carry the chapter's id -- so the chapter names the row and the count says the rest of it. An item that could not be read is said on the row rather than left for the screen behind it: a batch is all-or-nothing, so an unreadable item is a reason to refuse the whole proposal. **One query, not one per row.** `LocalFrostSigningSession` embeds the session and relates its items and its proposer, and `observeSessionsForChatRoom` becomes a `@Transaction` query over it -- the repository already declared that method and nothing called it, so this is the shape it should have had rather than a second query beside it. The model sorts the items rather than the query: Room does not order a relation, and item order is protocol rather than presentation, since two devices reading a batch in different orders aggregate against different messages. The proposer is joined for the reason the transcript joins a sender -- so a rename follows, and a member seen only as a pubkey is not stuck on the placeholder their profile was created with. **Derived once per emission.** Every row's events come out of stored JSON. That is not work to repeat on each recomposition of a scrolling list, so the view model does it when the flow emits and the screen renders what it is handed. Reachable from the group's detail screen, beside Shared Key and gated the same way: a shared threshold key only means anything in a room where every member is an equal admin, and a room with no key to sign with has nothing to propose. **Tests.** FrostSigningSessionDaoJvmTest covers what the list reads -- two sessions in a room come back newest first, each with its items in `itemIndex` order despite the relation's own order, and with the proposer resolved to a name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4047a2bae9 |
refactor: say what an event is in one place, not on one screen
`FrostSigningScreen` turned an event into words -- "New chapter", "Genesis 1 · 797 words · 31 chunks" -- inside a private composable, which was the right place for it while one screen was the only place a proposal was ever seen. The room's list of proposals is about to need the same words, and two copies of this mapping is two names for one thing. They would not drift immediately; they would drift the first time a kind is added and only one of them learns about it, and the reader would meet an artifact on one screen and "Event of kind 30300" on the other. `ProposedEvent.summarize` is the same `when`, moved whole, returning a label and a detail instead of a `Pair` so the two halves are named where they are read. Not a composable and deliberately: nothing about naming a thing needs a composition, and a plain function can be called from a view model, which is where the list does it -- once per emission rather than once per recomposition of a scrolling row. The reasoning moved with it, because it is reasoning about the words rather than about the screen: a member deciding whether to sign is deciding about a dialect or an artifact, "kind 30304" answers a question nobody asked, and the raw kind stays for anything unrecognised since refusing to describe an event is better than describing it wrongly. What stays on the screen is what is true only there: a batch is all-or-nothing, so an event that cannot be read is a reason to refuse the whole proposal rather than a gap to render around. No behaviour change. Same strings, same order, same fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4de87edf12 |
fix: tell one proposal's transcript lines from another's
A room with two proposals open showed "Review" on both, and then dropped it from both the moment either one was decided. The second proposal was still waiting on the reader, still had a decision in it, and had nowhere left to be reached from. Two proposals at once is not a corner case any more: a chapter and the translation scaffolding beside it are proposed as separate sessions, on purpose, and they run at the same time. Both write the same line types into the same stretch of transcript. `answeredRequests` matched a request against any later line of the fulfilling type, and `settledRequests` against any later ending. That reads a room signing one thing at a time exactly right -- the nonce after the request is the answer to it, because there is nothing else it could be an answer to -- and a room signing two things at once exactly wrong. Nothing else on the row could separate them: same type, same room, same minute, and `ChatMessage` carried no session. So the session goes on the row. `ChatMessage.frostSigningSessionId` is nullable, added as schema v11 through `AutoMigration(10, 11)`, and stamped by `FrostSigningManager.announce` -- the one place every FROST line is written, so there is no line that can be forgotten. Both rules read it when both rows have one and fall back to the clock when either does not. The fallback is not a compromise, it is the right reading of the rows it applies to. A line written before this column has no session and never will, and the rooms that wrote those lines could not run two sessions at once, so the clock is the whole truth there. A ceremony line falls back too and always will: a room runs one ritual at a time, and a DKG step is either taken or still waited on. **This reverses a call `FrostSigningRoute` argued for.** Its note said a chat row carrying a session id was "a poor trade for a lookup the screen can do". That was right when the lookup could only be wrong about which of one session it meant. The batch work made two sessions ordinary, and the lookup and the rules both became guesses at the same moment. A column on the table every message uses is the cost; two proposals, one of them unreachable, was the alternative. **Tests.** Three in TranscriptRequestStateTest for what the column buys: a nonce answers its own session's request and not the other's, one session completing settles nothing in the other, and a line naming no session is still read by the clock. TranslationBatchProposalJvmTest proves the other half against a real two-session proposal -- every FROST line the manager writes names its own session, and neither session's lines are attributed to the other. The rule is tested on rows and the stamping is tested on a database, because a rule that is right about rows nothing writes correctly is worth nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3fade6c849 |
feat: propose a long artifact's translation in more sessions, not none at all
An artifact of more than MAX_CHAPTERS_PER_TRANSLATION chapters could not be translated. The form refused, said so in red, and left nothing to be done about it -- the chapters are already signed, and unlike a chapter's paragraphs there is nothing the person reading that message can split. It was a cap on how long a book may be, wearing a cap on a batch as a disguise. The same answer the chapter side already gives: propose more than once. The translation and as many chapters as fit go in the first session, and the rest follow in sessions of their own, naming the translation the first is about to sign. The admins answer once per session, and the form counts them before the tap rather than colouring a refusal. MAX_CHAPTERS_PER_TRANSLATION stays, meaning what it now measures -- how many chapters ride in the translation's own session, the batch minus the place the translation itself takes. It is a cap on a session rather than on the work. The cost is the one every second session in this design carries, and is documented where it is paid: if the translation fails to reach a quorum while these succeed, they are valid signatures over rows naming a translation nobody has, which fail a foreign key on the way in and are logged rather than applied. The catch-up on the translation is what fills that in afterwards. **Tests.** TranslationBatchProposalJvmTest now covers the split: an artifact three chapters past the cap proposes a full first session and a second of three, every chapter of the work covered exactly once across both, in order, each naming the translation as the group will author it. The refusal test stays and keeps its point -- one session is still refused a chapter too many, because a batch that quietly dropped its last chapters would sign a translation the group believes covers the whole work while the end of it can never be translated. What changed is who prevents it: the screen splits rather than checks, and the manager still refuses independently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
df058dc61c |
feat: let a translation ask for the chapters it is missing
A translation is scaffolded from both ends now -- the chapters that existed when it was proposed, and each chapter signed afterwards putting itself in. Neither end closes the gap on its own, and no snapshot taken at proposal time can. Two ways they miss each other. A chapter and a translation proposed at the same moment each read what exists when they are proposed, so neither sees the other and nothing retries. And a scaffolding session that fails to reach a quorum leaves nothing behind to try again with -- the chapter's id is spent, since a re-proposed chapter is a different one. So the translation's own screen says what it is missing and offers to ask for it. Above the chapter list rather than below, because a chapter that is not in a translation is invisible from a list of the ones that are: the whole failure is that nothing looks wrong. **Matched on the chapter named, not counted.** `chaptersMissingFrom` compares which source chapter each translation chapter stands for. A count would read a translation that is missing its second chapter but picked up its third as one missing its last, and would then scaffold the wrong chapter -- leaving the real gap open and a duplicate beside it. It also means running a catch-up on a translation that is already complete proposes nothing at all, rather than a second copy of every chapter under fresh ids. **More sessions rather than a cap.** The missing chapters are chunked at MAX_BATCH_SIZE, one session each. A translation far enough behind to need more than a batch holds is not one to refuse; it is one the group answers for more than once. They share a timestamp, so a catch-up reads as the one act it is. The screen lands on the first session -- the rest are beside it in the room's list -- and the card stays until a quorum arrives, which is honest: the chapters are still missing until then. **Tests.** Two more in TranslationScaffoldTest, both about the matching rather than the counting: a gap in the middle, and a complete translation being missing nothing. Checked against a broken implementation -- taking the missing chapters as the tail after a count passes on a translation that fell behind at the end, which is the easy case, and is caught by the gap. Not covered: `catchUpMissingChapters` itself, which is plumbing over the templates those tests pin and the batch API the jvm tests pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
66119f34df |
feat: put a new chapter into the artifact's translations, in a session of its own
A translation covered the chapters that existed when it was proposed, and nothing put a later one into it. The chapter was signed, every device applied it, and in every translation of that artifact it simply was not there -- no translation chapter to hang translated chunks off, so the text could not be worked on at all. Invisible from the translation, which lists what it has. Adding a chapter now proposes twice: the chapter and its chunks as before, then a second session carrying one translation chapter per translation of that version. **Why a second session rather than more items on the first.** Because the two would compete for one batch. A session signs at most MAX_BATCH_SIZE events, a chapter is capped at MAX_CHUNKS_PER_CHAPTER paragraphs against it, and every dialect the group works in would have taken one of those places away. How long a chapter may be and how many languages it is read in have nothing to do with each other, and sharing the cap would have moved the first under whoever was typing whenever somebody else did the second. Nothing had to be built for a room to run two at once. Every FROST message carries the session it belongs to and everything is looked up by that id, so there is no "current" session on a room -- `liveSessionForChatRoom` is a fallback for a screen opened without one, not state the protocol keeps. And `itemsOver` mints an independent nonce seed per item per session, so two sessions running together can no more share an `R` than two items of one batch can. The cost is one more approval for the admins. **Naming a chapter nobody has signed yet.** The scaffolding needs the chapter's id, and the chapter is not signed for as long as a quorum takes. It does not have to be: the id is settled when the session opens -- it is the hash every signer puts their share behind -- so the second proposal reads the first session's item 0. `FrostSigningRepository.unsignedEvents` is that read, the counterpart of `signedEvents`, which by design gives back nothing until the group has answered. **What two sessions give up.** A batch is all-or-nothing; two batches are not. If the chapter fails to reach a quorum while its scaffolding succeeds, those are valid signatures over rows naming a chapter nobody has: they fail a foreign key on the way in, `applySignedEvent` logs them, and they never become rows. Nor is it recoverable -- a re-proposed chapter is a different id -- so those signatures are simply spent. Harmless, and the reason the next commit adds a catch-up. It also does not close the race. A chapter and a translation proposed at the same moment see neither the other, because both read what exists when they are proposed. No snapshot can fix that, which is again the catch-up's job. **Failure is one-way.** Scaffolding runs before navigating, not after: the route this screen sits on is popped on success, which clears the view model and takes `viewModelScope` with it, so anything launched afterwards would be cancelled somewhere in the middle. And a failure to open it is logged and swallowed -- the chapter is what was asked for and has already been proposed, and losing it because its scaffolding could not be opened would be the wrong way round. **The chapter's index, once.** It was read inline into the event; it is now a val, because the scaffolding has to place the translation chapter at the same one the chapter is signed at. `MantraTranslationArtifactVersionDao.getTranslationsByArtifactVersionId` is the new read, narrower than the by-artifact one: a chapter belongs to a version, and a translation of an older version is not one it is in. **Tests.** Three in TranslationBatchProposalJvmTest, against a real database and a real ceremony. Two sessions coexist in one room with the chapter's own batch untouched -- the chapter and a chunk per paragraph, whatever the translations -- and every scaffolded chapter naming the chapter of the other session. The caps do not compete: a chapter of the longest allowed length still proposes with eight translations waiting for it. And no two items across both sessions share nonce material, which is the one thing concurrency could actually get wrong; seeding an item's nonce from its index instead is caught here and by `SignedGroupKeyStateTest`, which already held the within-batch half of it. Not covered: that `AddChapterViewModel` opens the second session, which is plumbing across two dispatchers over templates these tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
359f54812f |
refactor: give the translation/chapter join one home
TranslationScaffold owns the rows that join a translation to the chapters it is a translation of. Pure refactor: the same events go out in the same order, `TranslationBatchProposalJvmTest` passes unedited apart from the call it makes, and no screen behaves differently. The move is worth making before anything is built on it. A translation chapter carries no words -- it is `(translation, chapter, position)` and nothing else, and it exists so a translated chunk has somewhere to hang. Both of the things it joins arrive on their own schedule: a chapter is signed into an artifact that already has translations, a translation is started on an artifact that already has chapters. So the same cross product has to be built from either side, and a second copy of it is a second chance to disagree about what a translation covers -- a disagreement that shows up as a chapter nobody can translate rather than as anything that looks like a bug. **Over ids, not rows.** `chaptersOf` takes translation ids and a `SourceChapter`, which is a chapter reduced to which one and where it sits, rather than a `MantraChapter`. Neither end is always a row: a translation being proposed exists only as the unsigned event a session is about to sign, and so does a chapter. `SourceChapter.of` is there for the callers that do hold a row. **Two things it decides rather than leaves to a caller.** Item order is apply order, so the nesting is fixed here -- translations outer, chapters inner, which keeps one translation's chapters contiguous and in reading order. And `createdAt` is taken once rather than read per template, so a scaffolding proposed as one act reads as one rather than as events that happen to share a minute. The index is the source chapter's own, never the position in the list handed in. They agree when the list is a whole version in order and stop agreeing the moment a caller holds a subset, and only one of them is what the group signed. **Tests.** TranslationScaffoldTest covers it as the pure function it is: the nesting, both directions it is built from, one timestamp for the lot, the empty cases, and the index surviving a non-contiguous subset. Checked against a broken implementation -- taking the index from the list position passes every test that uses a whole version in order, and is caught by the subset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
39e5df8253 |
feat: sign a translation into the artifact instead of submitting one
Starting a translation no longer creates one. It opens a signing session over a TranslationArtifactVersionEvent and a TranslationChapterEvent per chapter, and the translation appears -- on every member's device at once, authored by the room's shared key rather than by whoever picked the dialect -- when enough members have signed. The same trade the dialects, artifacts and chapters made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. A translation is what the group's readers will read the work as, so the second is the honest one. **The chapters, in the same batch.** A translation with no chapters is one nobody can start: a translated chunk hangs off a translation chapter, which hangs off the translation. They travel as their own signed events for the same reason the chapter's chunks do since |
||
|
|
2e133dd337 |
feat: sign a chapter and every chunk of it in one session
A chapter proposal now carries the chapter and a chunk per paragraph, and the group signs the lot at once. Every row a member ends up with is signed: a translation is of a chunk, and a chunk that carries the group's signature over its own words can be checked by anybody holding it, rather than only by re-deriving it from the chapter it came out of. This replaces the derivation two commits ago, which split the chunks out of the signed chapter's text on each device and left them as rumors. That was the right shape when a chunk could only have its own signature by having its own quorum. Batch signing removed that, and this is the other side of the trade `MantraChunk.chunksOf` was weighed against. **A batch whose items name each other.** A chunk carries its chapter's id, and that id is a hash over the group's key at the room's derivation path -- neither resolved until the proposal runs. A caller computing it would be recomputing `signingPath`, the one input in this protocol that must never come from a proposer, since the path decides which key the group signs as. So `proposeSigningBatch` gains a second form: a `lead` template, and a `dependents` builder handed the lead *after* it is authored, returning the events that reference it. Every id still comes out of `unsignedEventOf`, which makes an item naming a chapter nobody signed something that cannot be built rather than something to be tested for. `AddChapterViewModel` passes `ChunkEvent::splitOf` and nothing else. The lead is item 0. Items apply in `itemIndex` order and a chunk row whose chapter does not exist yet is a foreign key violation, so what is referenced is signed first as well as named first. **The cost, in front of whoever is typing.** `MAX_BATCH_SIZE` is 64 and the chapter takes one place, so a chapter is capped at 63 paragraphs and a longer one has to be split in two. That is a real limit on real prose. The form counts chunks against the cap as the text is typed, colours the count when it is past, says what to do about it, and will not propose -- because the alternative is an IllegalArgumentException after the fact. The manager still refuses independently; the screen is not what enforces it. **What went away.** `MantraChunk.chunksOf` and the derivation it did inside `ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent` still carries the whole text beside chunks that hold the same words: chunk boundaries are a decision about how to divide the work, and a chapter that kept only the pieces could never be divided differently again. **Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what each chunk names, counts and carries. `SignedChapterTest` signs a real batch, one FROST instance per item, and checks every chunk row is authored by the room and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the real proposal against a real database, which is where the sharp edge is: item order, the chunks naming the chapter as the group will author it, and both ends of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session behind. Checked against broken implementations: putting the lead last, naming the wrong chapter, and stamping the chunks off the clock are each caught, in both suites. `jvmTest` runs on linux again as of the merge, which is what made the database-backed test possible. Dropped a nonce-reuse test that was in the first draft of this: it asserted over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest` already hold the manager to giving every item its own nonce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e081d14f37 |
refactor: weigh the chapter's chunks against batch signing, and keep deriving
Batch signing landed on mantra while this branch was open, and it makes the argument this change was built on obsolete as written. MantraChunk.chunksOf said the chunks "cannot be events proposed on their own -- that would cost a quorum per paragraph". They can now: proposeSigningBatch would carry the chapter and a chunk per paragraph through one quorum, and every row would hold a signature of its own. Weighed and declined, and the KDoc now says so rather than leaning on a reason that stopped being true. MAX_BATCH_SIZE is 64, which caps a batched chapter at 63 paragraphs and fails an ordinary one outright; the text would go on the wire twice, whole on the chapter and again split across the chunks, and the proposal is the term that cap is sized against; and all-or-nothing over k items would make a long chapter less likely to be signed than a short one, for no reason a member could see. The signature it would buy is redundant besides -- the appendix rejects the manifest shape because an item then needs a lookup to be checked, and here that lookup is a foreign key: a chunk is a pure function of its chapter and cannot be stored without it. docs/frost-batch-signing.md records this under the slot Phase 7 leaves open -- "deciding *what* to batch" -- because the next caller will reach for the same shape. The rule it leaves behind: batch siblings, not derivations. Events that could each have been authored separately are worth a batch; events that are a function of another event in the same batch are worth deriving instead. **The merge.** Only SignedChapterTest broke: the five per-item columns moved off FrostSigningSession onto FrostSigningItem, so it builds an item and calls signedEvent(item, sig), which is how SignedArtifactTest was ported in the same commit. Nothing in the flow itself moved -- proposeSigning kept its signature as the one-event form, and complete() applies each signed event through ChatMessage.applyInnerEvent, so the chapter's chunk derivation works the same whether the chapter arrives alone or as one item of somebody else's batch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
60abf243ed | Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64 | ||
|
|
643334afff | Merge branch 'mantra' into claude/frost-batch-signing-435bbb | ||
|
|
1448ed5ad8 |
docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it. Nothing needs a feature flag. k=1 is the entire behaviour of the app as shipped -- no caller batches anything yet -- and at k=1 every message is byte-identical to the app before Phase 1: encodeProposal returns the bare event object, joinPayload of one value is that value, and every plural branch in the transcript is only taken above one. The doc now tabulates that rather than asserting it in prose, since it is the claim the whole rollout rests on. The one rollout constraint stands: before a caller batches, the group has to be on a build that understands array proposals. There is no negotiation for it and adding one is not worth it -- an old device refuses an array proposal outright, so the failure mode is a batch that never reaches threshold and is abandoned, visible in the transcript and costing a retry. Also records what is left, which is nothing in the protocol: deciding what to batch is a product question, bounded only by "a batch is only as available as its worst item" and "GroupKeyStateManager.propose must never batch". The phases are kept as written rather than rewritten into a description of the result -- the code reads better against the argument it came from -- with the two places the implementation chose differently (itemIndex over index, DROP COLUMN over a table rebuild) marked in their own sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2309879153 |
test(frost): cover the batch's failure modes and its crypto without a database
Phase 6 of docs/frost-batch-signing.md. 361 jvmTest and 227 testDebugUnitTest pass. ## Inbound path (SignedGroupKeyStateTest) Both drive the manager with a hand-built inner event rather than one the other device queued, which is the only way to be a faulty or dishonest member in this harness. - A one-value nonce offered for a three-item batch does not count towards the threshold: the coordinator never reaches a signer set. The length check is all that stands between a batch and a signer whose contribution lines up against the wrong messages, so truncating or padding would produce partial signatures aggregated against events nobody agreed to. The test then pumps the real nonce and the batch completes -- it is a stall, not damage, which is FrostSignerMessage's composite key doing its job. - A second proposal under the session's own id changes neither its event ids nor its seeds. Every seed is already committed to its item's message; a different batch under the same id would have those seeds produce a second partial signature over a second message, which is how a share is extracted. ## Real FROST, no database (FrostSigningRoundTest) - A k=3 batch from one signer set, all three verifying against the room's key -- the manager's shape with the database taken out of the way. - Item 0's signature does not verify against item 1. Signing three events in lockstep must not make any of them interchangeable. - Both halves of the no-shared-nonce property, because either alone is enough to be relied on by accident: SecretNonce.generate mixes the message in, so one seed under two messages already gives two nonces -- and the manager mints distinct seeds regardless. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9ac4bcbee3 |
feat(frost): show a whole batch on the signing screen, and say so in the chat
Phase 5 of docs/frost-batch-signing.md. The screen renders every event of a batch, and the transcript says how many there are. ## The approval gate The argument for one approval rather than one per event -- in FrostSigningManager's own header -- only holds if the member can see everything they are agreeing to. Two gates enforce that, and neither touches "Don't sign". - Every event has to be readable. `readable` compares the events that rendered against the items the session holds, so a batch with one unreadable element offers no Sign button at all rather than a Sign button for the ones that worked. A batch is all-or-nothing: agreeing to the two that rendered would be agreeing to the third as well. - A batch's Sign button waits until the list has been read to the end. A batch can hide an event below the fold in a way one event cannot -- what is off-screen is not further detail about the thing on screen, it is a different thing the member would also be signing. Only for k>1: a single event's screen behaves exactly as it did. Declining stays enabled through both. A member who cannot check what they are being asked to sign should still be able to say no, and saying nothing is indistinguishable from a phone in a pocket, which leaves the group waiting. ## Rendering WhatIsBeingSigned takes the list and the count it expects. Each event is still described as the thing it is -- a dialect, an artifact, a chapter -- by the extracted OneThingBeingSigned; the header counts them and the closing sentence about the group's key is said once for the batch rather than once per event. ## Transcript No new ChatMessage types, and no edits to FROST_TYPES, FROST_SETTLEMENTS or FROST_REQUEST_FULFILMENTS -- one line per member per step still describes what happened, whatever k is. Only the wording gains the number, because each of those lines describes work that covered the whole batch: "signed their part of all 3 events", "combined the parts into the group's 3 signatures", "asked the group to sign 3 events". At k=1 every line is byte-identical to before. 356 jvmTest and 224 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
426e53be9d |
feat(frost): offer batch signing through the repository
Phase 4 of docs/frost-batch-signing.md: the app-facing surface for what Phase 3 built, plus the two rules a caller has to know before reaching for it. FrostSigningRepository.proposeSigningBatch takes a List<EventTemplate<*>> and returns the one session that signs all of them. proposeSigning stays exactly as it was -- AddDialectViewModel, AddArtifactViewModel and GroupKeyStateManager need no edit, and none is made here. Both forms now share one `proposing` helper for the throw-to-null conversion. The reason it exists is unchanged and now covers two more cases: proposing throws when the group has no key, when this device was not in the ceremony, and now when a batch is empty or over MAX_BATCH_SIZE. All four are states the UI is supposed to have checked for, so they become a null the caller reports. ## The two rules, written where a caller will read them A batch is only as available as its worst item. It is all-or-nothing, so if any event cannot be aggregated the session fails and none of them are applied -- which means events that do not belong together should not travel together. A retry is a new batch, never the same one again. A failed batch looks like it has perfectly good nonces going spare; it does not. Every item's seed has already been published against an aggregate, and reusing one would produce two partial signatures over a single secret nonce. proposeSigningBatch mints fresh seeds, so proposing afresh is safe by construction and re-proposing is the only way to get it wrong. GroupKeyStateManager.propose records that it must never be batched: it is the statement every other session in the room is opened against, so bundling it with a dialect would make the room's ability to sign at all depend on that dialect's aggregation succeeding. Per-item partial success stays out of scope -- it would need mixed-state UI, a transcript that can say "3 of 5", and a complete() that applies a subset, for an outcome that indicates a bug or a dishonest coordinator rather than a normal ending. 356 jvmTest and 224 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
59c34263b3 |
feat(frost): let one signing session carry a batch of events
Phase 3 of docs/frost-batch-signing.md. A session can now be proposed over several events, and the whole batch is signed in one round of four group events with one approval. 356 jvmTest and 224 testDebugUnitTest pass. ## The wire, and the compatibility rule that shapes it FrostSigningEvents.encodeProposal serialises a batch of one as the bare event object it always was, and only a genuine batch as a JSON array. That is not tidiness. A build predating this reads an array with Event.fromJsonOrNull, gets null, and drops the proposal -- so an old device refuses a batch outright rather than signing part of one, while single signing keeps working right through a mixed-version rollout. Emitting an array unconditionally would break every one-event session for those devices and buy nothing. decodeProposal accepts both forms permanently: proposals in the old shape do not stop arriving because this build stopped writing them. It is all-or-nothing -- an array with one unreadable element is refused rather than silently shortened, because the batch's length is what every later payload is checked against, and a proposal that quietly lost an event would have every signer's contribution rejected for being the wrong size: a stall with nothing to blame. ## MAX_BATCH_SIZE, checked twice 64, enforced in proposeSigningBatch and again, independently, in acceptProposal. The second check is the one that matters. A proposal is the only place in this protocol where a remote party decides how much work everyone else does -- k native key generations, k signatures, and a group event carrying k payloads, from a single message -- and until batching that was bounded only by never being more than one. ## acceptProposal over a list Each element is rebuilt from its own fields under this device's own reading of the room's path and checked against the id it claims, exactly as before but per item, and the whole proposal is dropped if any one fails. The write-once rule widens from "the event this session signs" to "the ordered list of events this session signs": a second proposal under the same id whose list differs anywhere is logged and ignored. ## The API FrostSigningManager.proposeSigningBatch(events: List<EventTemplate<*>>) is public here rather than in Phase 4, because without it there is no way to produce a k>1 session and everything above would ship untested. proposeSigning keeps its signature as the one-event form, so no caller moves. Each template carries its own createdAt. ## Tests - FrostProposalCodecTest (new, commonTest): a batch of one is byte-for-byte the old JSON object -- the assertion that stands in for the old build nobody can run here -- plus order preservation, old-form decoding, and refusal of empty, malformed and partly-unreadable arrays. - SignedGroupKeyStateTest: a k=3 batch between two devices over two databases. Three signatures verifying against the room, three dialects applied on both devices in order, five messages from the coordinator and two from the other signer, and one approval line rather than three. - The negative test that matters: no two items of a batch share an aggregated nonce or a seed, and the two devices' seeds do not intersect. Every positive test still passes if two items share a nonce -- the signatures verify fine; what sharing costs is the secret share. - The cap is refused when proposed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
935a8fe37a |
refactor(frost): run a signing session as k FROST instances in lockstep
Phase 2 of docs/frost-batch-signing.md. Pure refactor: proposals still carry one event, the wire is byte-identical, and every test passes unchanged -- 344 jvmTest and 217 testDebugUnitTest, none of them edited in this commit. advance() now loops over FrostSigningItem rows rather than reading the first one. One nonce per item, one aggregate per item, one Session.create per item, one partial signature per item, one signature per item. The signer set, the public shares, the tweak cache and the approval stay shared, because they are the terms that do not enter e = H(R‖P‖m). The coordinator's aggregation is the place where that distinction bites: it builds one AggregatedNonce per item, each from that item's nonce from each chosen signer. Reusing one across two items would be reusing R across two messages. ## The payload codec, early joinPayload/splitPayload land here rather than with the wire change, because at a batch of one a comma join is the identity -- the payload is the bare value it has always been. That leaves Phase 3 to the proposal encoding alone. splitPayload is strict: a payload that is not exactly the batch's length is dropped rather than truncated or padded. It runs in orderedNonces, orderedPartialSignatures and splitForSession -- never in record(), which stores payloads without parsing them so that a nonce can arrive before the proposal that would give it a length to check against. ## Two short-circuits, and one trap in the first advance() runs on every arriving message, so at a batch of k it was k native key generations, k Session.creates and k signs each time, usually to discover there was nothing left to do. - Nonces are generated by `lazy`. The obvious version -- a guard computing `ownNonce == null || (isSigner() && ownPartial == null)` -- is wrong, and wrong in a way that reads fine and fails every signing test: the coordinator settles the signer set further down the same pass, so isSigner() at the top is false on exactly the pass where the coordinator goes on to sign, and the nonces are never generated. Reproduced as IndexOutOfBounds before switching to lazy, which has no prediction to make. - A device that is neither signing nor aggregating leaves before building any FROST session, rather than building k of them to do nothing with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dff41d417d |
feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the next phases follow. Schema only: a session still signs exactly one event, the wire is byte-identical, and every existing test passes on the moved columns. ## What moved, and why it had to A batch of k events is k independent FROST instances sharing a signer set, not one signature over k messages. That is forced rather than chosen: a Schnorr partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under one nonce R give two equations in one unknown and the secret share falls out. So the five columns that enter that equation -- unsignedEventJson, eventId, nonceRandom, aggregatedNonce, signature -- move to a child table keyed (sessionId, itemIndex). What stays on FrostSigningSession is everything outside it: the ceremony, the threshold, the derivation path, the signer set, and the one approval. itemIndex is protocol rather than presentation -- nonces and partial signatures are joined positionally against it -- so getItems() orders by it and nothing re-sorts. Spelled itemIndex rather than index to keep hand-written queries free of backticks. No itemCount column. The count is a COUNT(*), for the same reason signerIds is derived from the ceremony's participant order rather than stored: a denormalised count is one more thing that can disagree with the rows. ## Migration 9 -> 10 Manual, not auto: Room can create the table and drop the columns but cannot copy between them, and the copy is the whole point. A session in flight at upgrade holds its nonce seed and the aggregate it is already signing against, and neither can be regenerated -- losing either makes the next pass derive a different nonce for the same message and publish a second partial signature over it, which is the extraction case. Both are copied verbatim into item 0, so an in-flight session resumes as though nothing happened. Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires cascades -- with foreign keys enforced the rebuild would delete every signer message and every item just written. Whether it does depends on Room disabling foreign keys around migrations, which is not worth depending on when DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed, unconstrained columns; these five qualify, and getRoomDatabase pins BundledSQLiteDriver on every platform. ## Invariants established here for the phases that follow - signerIds and every item's aggregatedNonce are one write-once unit, applied by applyAggregate() -- items first in one transaction, then the session, so "some items aggregated" is unreachable and signerIds != null stays the gate. - Signatures likewise, via applySignatures(); isSigned() counts rows instead of reading a flag. - complete() verifies every signature before applying any event, so a batch is all-or-nothing rather than half-filed. - itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean an off-by-one in index handling produces a session that fails to aggregate rather than one that signs two messages under a single nonce. signedEvent() and isAwaitingApproval() now take the item(s) rather than the session, which propagates to the repository, the view model and the screen. advance() reads items.first() and Phase 2 turns that into a loop. ## Tests - FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert replacing rather than accumulating, signed-item counting, cascade delete. - FrostSigningItemMigrationJvmTest (new): the backfill against a real v9 database, asserting the seed and aggregate values survive -- not merely that a row appeared -- plus the exact column lists Room will check at open time. - 338 jvmTest and 217 testDebugUnitTest pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eb34c8edb3 |
feat: sign a chapter into the artifact instead of submitting one
Adding a chapter no longer creates one. It opens a signing session over a ChapterEvent, and the chapter appears -- on every member's device at once, authored by the room's shared key rather than by whoever pasted the text -- when enough members have signed. The same trade the dialects and artifacts made: a submission says "I am putting this in front of the group" and the group's only recourse afterwards is social, while a signature is the group saying it and it takes a quorum to say. The text everybody translates from is the group's, so the second is the honest one. **The chunks.** This is the part chapters had that dialects and artifacts did not. A chapter was submitted along with a ChunkEvent per paragraph, and that cannot survive the change: a translation is of a chunk rather than of a chapter, so a chapter without them cannot be worked on, but the chunks cannot have their own quorum without costing one signing session per paragraph, and they cannot be invented locally -- an invented id differs on every device, so members would silently disagree about which chunk a translation is of while every screen showed the same chapter. So the chunks are split back out of the signed chapter's own text when it is applied, in MantraChunk.chunksOf, on the pattern MantraArtifactVersion.initialVersionOf already set. Same bytes in, same rows out, everywhere. They are rumors, because nobody signed them; what the group signed is the chapter they were split from. It splits only what the group signed. A chapter that arrived as a submission was sent with its own chunk events, written under the submitter's key, and deriving a second set beside them would leave every paragraph in the chapter twice under ids nothing reconciles -- including on a marmot reindex, which replays a room's group events without anybody adding anything. **The index.** Where a chapter sits in its version is read at proposal time and signed into the event, rather than derived on arrival like the chunks are. A device applying the chapter cannot recount it: it would be counting a version other members may have added to in a different order, and the count has to be the one the group put its signature to. The window between proposal and quorum is longer than the old write-and-submit window was, so two chapters proposed at once can still land on one index -- the same race as before, wider. **What went away.** MantraDao.addChapter and its way up through the repository. Nothing called it once the screen proposed instead, and leaving a path that authors a chapter under a member's key while the UI insists on a quorum would have double-created the chunks besides. MantraRepository.getChaptersForArtifactVersion replaces the one thing it did that is still needed: counting the index. **The screens.** AddChapterScreen loads the room, the artifact's latest version and canSign up front, disables the FAB when either is missing the way the dialect and artifact screens do, and on success lands on the session rather than on an artifact the chapter is not in yet. FrostSigningScreen described a chapter proposal by name alone, so a member was asked to sign text whose size they could not see; it now reads name, word count and chunk count, the way an artifact shows its url. **Tests.** Two files, and each was checked against a broken implementation rather than only against a working one: deriving the chunks from the clock, inheriting the chapter's counts across every chunk, authoring the derived rows as their reader, and losing the paragraph position are all caught, as is splitting a chapter that arrived as a submission. SignedChapterTest runs a real 2-of-3 quorum over an actual proposal, because the claim worth holding -- the chapter is the group's, carries proof of it, and every device splits it into the same chunks -- is invisible when it breaks. Not covered: applyInnerEvent's upserts, which need a database no test here stands up, and AddChapterViewModel, which is plumbing across two dispatchers over a template the tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2089fdf8f0 |
test: cover the ceremony a robust room is created with
|
||
|
|
0c240a31c8 |
feat: open a robust group's key ceremony as it is created
Picking "robust" made a NIP-17 room and left it at that. The quorum the user had just set was read, explained, coerced into range -- and then dropped on the floor, with a TODO where it should have gone saying so: NIP-17 has no group state to change and so nothing to approve, and a different member set is simply a different room. That TODO had an answer the app has been able to give since ChillDkgRitual- Manager landed. The one thing a t-of-n rule can attach to here is a key the members generate together and cannot sign with unless t of n of them are present, and everything a ceremony needs is settled the moment the room exists: who is in it, and how many of them have to agree. So the room now opens one, and the proposal is its first message. ## Why at creation rather than behind the button The button is still there on the shared-key screen, and this changes nothing about it. What it cannot do is be found. A group that picked robust and got a plain NIP-17 room has the thing that makes it robust sitting one unmarked navigation away, and until somebody takes it the group's governance is a number nobody enforces. It is also how the rest of the group hears of the room at all. Standing up a NIP-17 room sends nothing to anybody -- there is no invite, no welcome, no key package -- so before this the first anyone learned of a robust group was whenever somebody happened to type into it. The proposal is now the first event out, and NostrDao already builds the room on the receiving side from a DKG payload's p-tags for exactly this reason. ## The order this runs in The room is created first, then the ceremony is proposed, then the screen navigates. createdChatRoomId is set the moment the room exists, before the proposal, so a second tap reuses that room rather than minting another -- and it is what freezes the type and quorum pickers, both of which are answered by then. Proposing before navigating means the chat opens with the ceremony already in it rather than filling in underneath the user. It costs no round trip: proposeRitual writes rows and queues a gift-wrap payload, and NotaryViewModel seals and broadcasts on its own schedule. The quorum is passed through as the threshold with no coercion. The screen derives its range from the picked members plus the creator, and createNip17ChatRoom stores exactly that set as the room's participants, so the range proposeRitual validates against is the same one the picker was bounded by. ## When the ceremony does not open Nothing is rolled back. The room is real, the group can talk in it, and the ceremony can be opened later from the group's details -- so failing the whole creation would be throwing away the part that worked. But it is not navigated past either. The screen stays put and says what happened, the way it already does when a Marmot group is created without some of its members; the button flips to "Open chat", which is what the user is left with. A robust group quietly without a key is the one outcome here worth interrupting for. ## Elsewhere The robust card's footnote now says that creating the group starts a key ceremony every member takes part in. Members are about to be asked to approve joining it, contributing to the key, and confirming the result, and none of that should be the first they hear of it. MantraNavHost hands the screen the DkgRepository it already builds for the ritual and approval routes; the preview takes the no-op. Left alone: DkgRitualViewModel still cannot read back the quorum a room was created with, because ChatRoom does not persist it. Its threshold picker re-derives a majority default, which now only matters for rooms made before this change or after a failed ceremony. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
de3b355600 |
feat: hand back a room's running ceremony rather than opening a second
proposeRitual minted a fresh session on every call. Nothing called it twice for the same room, so nothing went wrong: the only way in was the shared-key screen, and canStartRitual() returns false while a session exists that has not failed. That is about to stop being true. A robust group opens a ceremony as it is created, and a NIP-17 room id is derived from its member set -- so making the same group again returns the same room and asks it again. The guard also sat in the wrong place regardless: on an observed UI snapshot, a screen away from the write it was protecting. ## What a second proposal costs It is not a duplicate row. ChillDKG hashes the participant set and the threshold into the session identity, so a second ceremony over the same room is a second `n` and `t` for every member to reconcile, and members join whichever proposal reaches them first -- relays hand gift wraps back in no particular order, so which one that is differs per device. The group ends up split across two ceremonies, neither of which can assemble the participant count it needs. Worse if the first one had finished. FrostSigningManager.completedKey falls back to getLatestSessionForChatRoom when the room has no signed key state and its id is not derived from the threshold key; a newer, unfinished session shadows the completed one there, and the group stops being able to reach the key it actually holds. ## The rule, and where it now lives The room's live ritual is returned as-is, so a caller gets a session either way and cannot tell whether it opened one. That is what makes the creation path safe to re-enter. The rule itself is unchanged -- it is the one canStartRitual() has always applied, right down to which stages block. It now also lives next to the write, where a stale snapshot cannot race it. FAILED is excluded deliberately: it is the one stage that does not hold the room's slot. A collapsed ceremony leaves the group with no key and a room they can still talk in, which is exactly the group that should be able to try again. Every other stage, COMPLETE included, is a ceremony the room depends on the outcome of. Checked before the require()s rather than after. A running ceremony settled the threshold question when it opened, so validating the argument would be validating an input with no effect -- and it would turn re-entering with a different quorum into an exception instead of the ceremony that exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a805455df8 | Merge branch 'mantra' into claude/groupkeystate-frost-proposal-206090 | ||
|
|
44127cf514 |
test: exercise MarmotOutboundDao past the MLS guard
|
||
|
|
ed7a866421 |
test: run a real signing session between two devices, over two databases
|
||
|
|
5fa0d08dfd |
test: cover nip17 room derivation and its preconditions
A NIP-17 room has no MLS group, no key packages and no invites -- membership *is* the p-tag set on each message. Two things follow, and both are load-bearing. The room id is deriveChatRoomId over the member set, the same aggregate the inbound path derives from an arriving gift wrap. That is what makes creation idempotent, and idempotence here is not a nicety: two people starting the same conversation have to land on one room, or the thread exists twice with each side writing into its own copy and neither seeing the other. Covered from three angles -- the order members are named in does not change the id, creating the same conversation twice reuses the room as it stands rather than rewriting it, and a different member set derives a different room. The order-independence test is guarding `deriveChatRoomId`'s own `.sorted()`, not the DAO's `.toSet()`, and its comment now says so. That was established by mutation rather than assumed: rebuilding the member set as an order-preserving LinkedHashSet in the DAO changes nothing, because the derivation sorts anyway, while removing the sort fails the test. The distinction matters for anyone reading the DAO and concluding the set is what does the work. And `mlsGroupState = null` is what marks the room NIP-17. sendChatMessage reads exactly that field to choose between a kind:445 group event and per-recipient gift wraps, so a room that acquired MLS state would have its messages routed down a path no recipient is running. Covered alongside: the creator is a participant of their own conversation even when not listed among the participants -- sealGiftWrapPayload walks that list to decide who to wrap for, so omitting the creator would send messages every other member could read and the sender could not -- and naming the creator among the participants does not produce a second row for them. One test records a precondition and an asymmetry. Participant.participantPublicKey is a foreign key onto Profile, so createNip17ChatRoom raises a SQLite constraint failure for a member this device has no profile for, while getOrCreateChatRoom, one method down, answers the same "never seen this user" situation by returning null. A caller treating the two alike gets an unhandled exception out of the first. That was found by writing the tests -- seven of them failed with SQLite 787 before every member was seeded -- and is pinned rather than seeded around silently. The remaining getOrCreate coverage: it returns the room already stored rather than overwriting it with the defaults passed in, stands one up for a user it has a profile for, and writes nothing at all when it does not. Real secp256k1 keys throughout, because deriveChatRoomId does point arithmetic and treats an off-curve value differently from a valid one -- hex filler would exercise a path users never reach. 11 tests. composeApp jvmTest is 309 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8a6cb81bf9 |
test: cover the FROST signing session state
The signing counterpart to the DKG coverage, and it differs in the way the DAO's own comment gives: "unlike a DKG a group signs repeatedly, so there is no single current one to observe". Sessions accumulate rather than replacing each other, which makes room scoping and ordering load-bearing rather than incidental. The duplicate-suppression property is the same and matters for the same reason. The composite key (sessionId, signerPublicKey, kind) is what makes a redelivered nonce or partial signature replace its predecessor rather than add a row, and countMessagesByKind is what decides that enough signers have answered. A second row for one signer lets a session cross its threshold while short a real participant, and the aggregation then runs over a signer set that was never assembled. Covered by resending a nonce with a different payload, and separately by giving one signer both a nonce and a partial signature and asserting the second does not overwrite the first -- the kind in the key is the only thing keeping those apart. Also covered: a session reads back by id with its stage intact and a missing id gives null; a room's sessions accumulate newest first, with the latest reachable on its own; sessions are scoped to their room, which matters because signing happens in the #admins room and a device can be in more than one -- a session leaking across would have a signer answering a request its group never made; counts are per session and per round; and a completed session keeps its signature, which is what a resume reads to avoid signing the same event twice. One test records a difference rather than a guarantee. FROST orders a round's messages by createdAt where the DKG orders the same query by participant public key. Arrival order is per-device, so this ordering is not canonical across the group the way the DKG's is. It is pinned as it stands rather than asserted to be right: whether it is deliberate is not something this change can settle, and a caller that needs a canonical signer order has to impose one itself. Worth looking at separately. 8 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |