bd5e0413f09a72e1ba8f1ab5f1c9f9581b70ed1a
543 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
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 | ||
|
|
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> |
||
|
|
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> |
||
|
|
a8f6638325 |
feat: sign a room's key state into being, at the room's own key
Two changes that turned out to be one. A room's key state stops being something its creator announces and becomes something the group signs, and every FROST signature moves from the group's root threshold key to the key derived at the room's own path -- which is the room's id. The second is what makes the first worth having: a key state is now signed by the very key it names. Supersedes the announcement introduced in |
||
|
|
af81933ab4 |
Merge branch 'mantra' into claude/room-db-testing-setup-b053cd
Brings the branch up to date with the 40 commits mantra gained while the jvm target was being built, so that merging the other way is a fast-forward. One conflict, in docs/README.md, where both sides added rows to the index table. Kept both, and gave the jvm-target note a clause in the closing prose since it is the one document there that is not about the protocol. One thing the auto-merge could not have caught. `9250991` added NostrEventDao.getMarmotGroupNostrEventsByChatRoomId as a blocking query, which android accepts and which Room refuses to generate for any other target -- so the merged tree failed :composeApp:compileKotlinJvm with the same "Only suspend functions are allowed in DAOs declared in source sets targeting non-Android platforms" that phase 4 dealt with 58 times. Made suspend; its only caller, NostrDao.reindexMarmotGroupEvents, was already suspend, so again no cascade. That is now a standing cost of this branch rather than a one-off: any DAO method added on mantra while this is outstanding will break the jvm build on merge. It is a one-word fix each time, and the compiler names the line. Verified on the merged tree: :composeApp:compileKotlinJvm and :composeApp:compileDebugKotlinAndroid green, :composeApp:testDebugUnitTest 208 passing, :composeApp:jvmTest 214 passing -- both test tasks re-run from scratch rather than taken from the cache. The jvm figure is larger than the android one because jvmTest inherits commonTest, so declaring the target quietly gained the whole shared suite a second execution environment. That is worth knowing independently of whether desktop ever ships: the same tests now run on the host, without an emulator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
607ef72bc3 |
Merge branch 'mantra' into claude/marmot-group-reindex-events-96a0d0
# Conflicts: # composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt # composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatMessage.kt |
||
|
|
925099125b |
feat: read a room's group events again when they arrived out of order
Relays impose no ordering, so a kind:445 can turn up before the group can read it: an application message encrypted under an epoch whose commit has not landed, or a commit for an epoch ahead of the local one. Both are stored and then dropped -- MarmotInboundManager refuses an out-of-epoch commit precisely so it does not half-mutate the group -- and nothing goes back for them once the missing event fills the gap. The message is on disk, readable, and never read. A "Reindex Events" button at the bottom of the group's detail screen is that second look. Only events with nothing to show for them are replayed: no chat line at all, or one of the two placeholder types. A room where nothing went wrong is left exactly as it was, which is what makes the button safe to press on a hunch. Passes repeat while a pass recovers something, because created_at order is not epoch order and a commit recovered by one pass is what lets the next read the messages that were waiting on it. **Replaying was not safe as it stood.** Every row the path writes is keyed on an event id and upserts in place -- MarmotGroupEvent, MarmotInnerEvent, and the nip30303 entities -- with one exception. ChatMessage's primary key is autogenerated, so writing a freshly built line always inserts, and a re-read would have left the room showing each recovered message twice, once as "Undecryptable Message" and once as itself. ChatMessage.reconcileMarmotLine matches on the group event id instead, so a re-read is an update, and refuses to let a placeholder overwrite a line that says something. That last rule is what protects the line this device wrote on the way out for a message it sent: our own kind:445 cannot be read back, since the sender ratchet has consumed the generation, and without the rule a replay would have replaced our words with "Undecryptable Message". The MLS group itself was already safe to replay against, which is worth saying because it is the part that looks dangerous: a commit behind the current epoch is rejected as a duplicate before it touches the group, one ahead is refused, and a consumed ratchet generation throws before mutating anything. The exception was quartz's EpochCommitTracker, which does not dedupe and only empties when a commit applies -- so replaying a held commit just grew the list and left it pending forever. forgetPendingCommits drops the room's entries first, and the sweep feeds the events back in the order CommitOrdering picks a winner in, so a contested epoch resolves the same way it would have on every other device. **What is testable, and what is not.** The DAO is not: testDebugUnitTest is plain JVM and Room's in-memory builder wants an Android Context. So the two pieces carrying decisions are lifted out where they can be run without one -- MarmotReindexSweep for the stopping rule, and reconcileMarmotLine for which of two lines wins -- and the DAO is left as query, sweep, write. The filter tests pin why the query's `tags LIKE` is a prefilter and not a test: an event belonging to another room can mention this one in a q tag, and its own h tag is what rejects it. **Not recovered by any of this.** A message whose key is gone -- one the ratchet has already advanced past, or one from an epoch predating this device's join. And events that never reached disk at all: storeNostrEvent is a single transaction, so a kind:445 arriving before its room exists rolls back its own insert along with the failed indexing, and only a re-sync brings it back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6aff34c5c7 | Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6 | ||
|
|
786c0602da |
feat: sign an artifact into the library instead of submitting one
Adding an artifact no longer creates one. It opens a signing session over an ArtifactEvent, and the artifact appears -- on every member's device at once, authored by the group's shared key rather than by whoever typed it -- when enough members have signed. The same trade the dialects 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 library is the group's. **The first version.** This is the part the dialect had no answer for. An artifact was creating an initial ArtifactVersion as a second submitted event, and that cannot survive the change: a chapter attaches to a version rather than to an artifact, so an artifact without one is inert, but a version cannot be submitted before the artifact it points at exists, cannot have its own quorum without costing a second signing session per form, and cannot be invented locally -- an invented id differs on every device, so members would silently disagree about which version a chapter hangs off while every screen showed the same artifact. So the label rides on the artifact as an `artifactVersion` tag and the row is derived from the signed artifact's own fields when it is applied. Same bytes in, same row out, everywhere. It is a rumor, because nobody signed it; what the group signed is the artifact that declares it. **What went away.** MantraDao.addArtifact and its way up through the repository. Nothing called it once the screen proposed instead, and leaving a path that authors an artifact under a member's key while the UI insists on a quorum would have double-created the version besides. **Tests.** Three files, and each was checked against a broken implementation rather than only against a working one: deriving the version from the clock, dropping the label from the proposal, authoring the derived row as its reader, and losing the signature on the way out of the session are all caught. SignedArtifactTest runs a real 2-of-3 quorum over an actual proposal, because the claim worth holding -- the row is the group's, and carries proof of it -- is invisible when it breaks. Not covered: applyInnerEvent's two upserts, which need a database no test here stands up, and AddArtifactViewModel, which is plumbing across two dispatchers over a template the tests already pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf4041a5b2 |
feat: mantra compiles for the jvm
Phase 4. Declares jvm(), implements all 16 expects, and bumps the submodule to the fork branch carrying phases 1-3. :composeApp:compileKotlinJvm is green. **The actuals were the small half. Room was the blocker.** The first jvm compile failed with 58 copies of "Only suspend functions are allowed in DAOs declared in source sets targeting non-Android platforms". Room permits blocking query methods on android and nowhere else, so every @Dao function that was neither suspend nor Flow-returning had to change -- 58 of them across 24 files. KSP reports these in alphabetical batches, so the count shrinks in stages and looks bottomless; scanning the dao package directly for abstract funs with no suspend and no Flow return finds them all at once. It stops there, which is the only reason this is a 58-line change rather than a refactor. Every one of the 15 call sites outside the dao package was already inside a suspend function -- the repositories were written that way throughout -- so nothing needed rewriting. One private helper, DatabaseNostrRepository.matchNegentropicNostrEvents, had to become suspend, and its single caller was already suspend, so the cascade terminated immediately. Zero call-site edits. **The cost lands on android, not on the jvm.** A blocking DAO method runs on its caller's thread; a suspend one is dispatched to the query coroutine context, which getRoomDatabase sets to Dispatchers.IO. That is the better behaviour -- it is what stops a query running on the main thread -- but it is a real change to the shipping platform, made for a target that does not run yet. Hence the unit tests below rather than a compile alone. **BusinessManager was not an expect**, so nothing warned about it. It is now ported to the fork's jvmMain (05ce7eb); Phoenix.jvm.kt and NavigationViewModel.jvm.kt are otherwise the ios actuals with one changed import, since those files use no ios API. **schedulePlatformLogic schedules nothing, and logs that it does not.** Android starts two WorkManager jobs here, one of which is ChannelsWatcher -- it wakes periodically to notice a channel force-closed while the app was shut. A desktop application has no process once its window closes, so there is nothing to wake, and running the watcher in-process would be strictly worse than not running it: it would only fire while the app was already open and watching. The exposure is real and belongs in release notes rather than a comment -- a desktop wallet left closed past a force-close deadline does not notice. Smaller calls. PlatformContext carries an application directory, since there is no Context to read one from, and PlatformDatabaseBuilder puts aux.db under it rather than in java.io.tmpdir, which is what the abandoned Aux implementation did behind a TODO and which most systems clear on reboot. themeColorScheme ignores dynamicColor, which means Material You and has no desktop counterpart. AppVersion reads the jar manifest that compose.desktop writes, falling back when running from a class directory. Verified: :composeApp:compileKotlinJvm green, :composeApp:compileDebugKotlinAndroid green, and :composeApp:testDebugUnitTest 52 passing -- the one that matters, since this commit changes shared code every android query path goes through. Not verified: nothing has run. No jvm entry point exists yet, so the database has never been opened on this platform and no business has been started. That is phase 5, which also has to unlock JvmKeyStore before the wallet starts -- a passphrase prompt, not just a window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
b87e6e4ed5 | Merge branch 'mantra' into claude/frost-proposal-review-visibility-8f6fab | ||
|
|
e22a8ae4cd |
fix: stop asking a member to review a signature the group has settled
The transcript's "Review" affordance is a promise: tapping it leads to a
decision still there to be made. For a FROST signing proposal it was only
ever withdrawn one way -- and a proposal can be processed three.
**How a request was closed.** RitualNotice drops the tint and the call to
action when the request is answered, and a request counts as answered when
the step it asked for has since been published by this device:
FROST_REQUEST_FULFILMENTS = mapOf(TYPE_FROST_APPROVAL_NEEDED to TYPE_FROST_NONCE)
Approving publishes a nonce, so approving closes it. Nothing else does.
**Declining.** decline() fails the session and broadcasts a FAILURE. It
publishes nothing of the member's own, by design -- a refusal is a refusal.
So no fulfilment line is ever written, and the request went on asking, in
primary tint, for a decision the member had already made. Tapping it
reached a screen with no buttons on it, which was the screen being right.
**A quorum that did not need them.** A t-of-n key finishes without
everybody. The coordinator takes the first t nonces, and a member whose
phone was in a pocket is simply not among them -- but advance() returned at
the approval gate on their device, so the arriving SIGNATURE was stored and
nothing was done with it. Their session sat at COLLECTING_NONCES forever.
The request stayed lit, the screen still offered Sign and Don't sign, and
both answers were wrong: a nonce nobody was waiting for, or a refusal that
would flip a COMPLETE session to FAILED on every device and announce
"Nothing was signed" to a group holding the signature. fail() writes the
stage with update() rather than moveTo(), so that last one was reachable.
**The transcript.** A request is now closed by being *answered* or by being
*settled* -- a frostComplete or frostFailed line after it. The two are kept
apart deliberately. Answered keeps the tick; settled does not, because the
member never answered and crediting them with a signature they refused, or
were never asked for, is worse than the summons was. Both rules moved out
of the composable onto ChatMessage, where they are stated once and tested.
Settlement is signing-only: a ceremony step can only be taken or waited
for, so a DKG request has no equivalent and reading one from a signing
session's end would drop a summons the ritual is still stalled on.
**The session.** The transcript alone could not close the third case: the
device that never approved wrote no terminal line to read. advance() now
completes on a signature that has already arrived, ahead of the approval
gate rather than below it. That gate is there to keep this device's own
material off the wire, and finishing puts none there -- it verifies the
aggregate, applies the event and announces, all from what is already
stored. Everything it now skips on that path is work the signature made
pointless anyway: a late nonce, a partial signature nobody will aggregate.
Three things follow. isAwaitingApproval reports false, so FrostSigningScreen
hides the buttons -- it now asks the manager rather than re-deriving the
rule, which had drifted into a second copy of it. A late "Don't sign"
cannot abandon a signature that exists. And the signed event finally lands
locally for a member who never approved: applySignedEvent sat below the
gate and was being skipped, so a dialect the group signed without them
never reached their store.
Verified: :composeApp:compileDebugKotlinAndroid succeeds, and
:composeApp:testDebugUnitTest passes -- 165 tests, 16 of them new. Eight
cover the transcript rules against a hand-built row list; eight cover
isAwaitingApproval, including the settled-signature case. What stays
uncovered is advance() itself, which is Room-backed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
02117643c4 |
fix: send a group event because it was queued, not because the chat mentions it
No FROST signing message has ever reached another participant. The proposal
was built, MLS-encrypted, wrapped under the exporter secret, signed as a
kind:445, written to NostrEvent and MarmotGroupEvent, and its queue row
marked processed -- and then never handed to a relay, by a branch that was
never about delivery at all.
**The gate.** The tail of MarmotOutboundDao.encryptAndSendMarmotInnerEvent
looked up the transcript row for the queued rumor and did everything else
inside it:
val chatMessageOrNull = database.chatMessageDao()
.getChatMessagesByMarmotInnerEventId(marmotInnerEvent.id)
chatMessageOrNull?.let { chatMessage ->
... relation, marmotGroupEventId ...
val ids = database.broadcastNostrEventRequestDao().insert(...)
}
The BroadcastNostrEventRequest rows are the only thing that puts a kind:445
on a relay -- observeBroadcastNostrEventRequestsByStatus("pending") is what
the broadcaster watches, and nothing else inserts them for this path. So the
question "does the chat have a line for this?" was silently answering the
question "should the group receive this?".
**Why FROST always lost.** A signing message has no ChatMessage by design.
FrostSigningManager.broadcast queues the rumor alone, and announce() writes
its milestone lines with marmotInnerEventId = null on purpose: each device
writes its own transcript from the messages it has already received, so the
lines cost no traffic and cannot disagree with the session they describe.
The inbound half states the same intent from the other side --
ChatMessage.applyInnerEvent returns null for every FrostSigningEvents kind,
because a row there would be a second, worse account of what the manager
already narrates.
That is every kind in the family, not just the proposal: nonces, the signer
set, partial signatures, the finished signature and the failure notice all
go through the same broadcast(). A session could not have completed even if
a proposal had somehow arrived.
**GroupKeyStateManager.announce had it too.** Same shape, same silence: a
room's kind:30326 announcement of which key it signs with was queued,
encrypted and dropped.
|
||
|
|
65e4a3acc0 |
fix: seal the Welcome, the one gift wrap an MLS room must publish
No invite to a Marmot room has been delivered since |
||
|
|
39eac61838 |
Merge branch 'mantra' into claude/long-running-chat-sync-8983dc
mantra had moved on ~30 commits, several of them in exactly this area — and it turns out both branches independently found the same bug and drew the same conclusion about the same filter. **The overlap.** |
||
|
|
bcdfd2ec94 |
Merge branch 'mantra' into claude/marmot-direct-message-type-7a0473
Twenty-two commits had landed on mantra since this branch left it, several of them in the same files. Merged this way round so mantra stayed untouched until the result compiled and its tests passed. The migration had to be renumbered, and this is the conflict that mattered. mantra is at database version 7 and already has its own 5.json -- for MarmotInnerEvent.payloadEventId, nothing to do with direct messages. This branch had also written a 5.json, for a different schema. Resolved by restoring mantra's 5.json untouched and moving the direct message columns to an AutoMigration(7, 8) with a regenerated 8.json. Taking either 5.json over the other would have left every device validating a migration chain against a schema it was never built from; keeping version = 5 would have made a v7 install refuse to open at all. The regenerated 8.json is two ADD COLUMNs and nothing else, same as before. fromGroupEventResult was restructured on mantra: the kind switch moved into applyInnerEvent, and a SubmissionEvent envelope now wraps nip30303 payloads. Took that structure and re-applied the direct message branch ahead of it rather than inside it -- a gift wrap is not a nip30303 payload to apply, and what happens to it depends only on whether this device's key opens it, so it does not belong in a function about applying submissions. The isUserMessage fix was re-applied to the eight call sites mantra's version has, up from the six it had here. ChatMessageListViewModel and ChatRoomMessagingScreen took mantra's versions with the composer state, the two renderings and the reply action layered back on. docs/README.md keeps both new rows and mantra's closing note about the skipped-keys document. 108 tests pass, up from 50 here and 83 on mantra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a909108300 |
feat: announce which key a room signs with, instead of rederiving it
A signer holds a different secret share under every ceremony it took part in, and signing with the wrong one produces a partial signature that cannot aggregate. Nothing said which was which: FrostSigningManager found a room's key by walking every ceremony this device holds a share for and rederiving each one's room id until one matched. That search can only find rooms derived at the one path the constant names. SharedKeyDerivation.parsePath was written to lift that limit and was never called, so a room derived anywhere else was invisible to signing. So the coordinator now says it. GroupKeyStateEvent (kind 30326) carries the threshold public key, the ceremony that made it and the path the room's id came from, posted into the room as its first application message and filed as a GroupKeyState row. completedKey reads that row first and follows it to the share. Nothing secret travels. Every member of the room can read the event, so a share on it would be each member holding everyone else's -- a 1-of-n key wearing a t-of-n's clothes. The event names the ceremony; the share stays in DkgSession.secretShare on the device that generated it. The coordinator is untrusted, as everywhere else in the ceremony, so a state is verified rather than believed: the room's id *is* the threshold key derived at the path, and one that does not rederive its own room is dropped. That is the same guarantee the rederivation gave, kept rather than traded for a lookup. The old scan stays behind it for rooms that predate the table. Announced after the members are added, which is the only order that works -- adding them commits a new epoch and MLS will not let a member read what was encrypted before the one they joined at. A member invited later still misses it and falls back to the scan, which is where every member was before this existed. Replacement is this app's job. These are rumors inside a Marmot group event, so no relay applies the 3xxxx rule, and the DAO keeps the newest announcement per room so a backfill cannot walk a room backwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f5eb744ca7 |
test: cover the long-running sync, and open the seams needed to do it
The six commits that built the live chat sync added no tests. Everything they
touch fails silently by nature — a filter that drops messages, a subscription
that stops being replayed, a group whose id never reaches the `#h` tag — so the
symptom is always "some messages didn't arrive", days later, on someone else's
phone. 46 tests, in four files.
**What is covered**
RelayPoolSubscriptionTest (13) — the pool's half of surviving a dropped socket.
A query is retained and replayed on reconnect; a closed one is forgotten and
stops the socket reconnecting for it; closing one of two leaves the other alone;
a negentropy exchange is never replayed (its rounds are stateful, so resuming
one reconciles against a conversation the relay is no longer having); an update
to a live subscription replaces what gets replayed, including when the send
itself fails; dropping a relay or closing the pool forgets what they carried;
replay is scoped to the relay that reconnected. Plus the semantic the whole
change rests on, asserted in both directions: a live subscription keeps
delivering after EOSE, a one-shot query still ends at it.
LiveSubscriptionReconcileTest (12) — the requirement this all exists for: the
group filter follows group membership with nobody calling a subscribe function.
Joining widens the filter *in place* rather than reopening (a reopen would drop
the live tail of every other group in that chunk); leaving drops one; leaving
everything closes the subscription; churn inside the debounce window collapses
to one update; a NIP-17 room never becomes a group subscription. Then the
collect loop: events stored against the relay they came from, an event after
EOSE still stored, a CLOSED reopened once the back-off elapses and not before,
and a rate-limited CLOSED waiting far longer — but still coming back.
Backgrounding closes and foregrounding rebuilds, reconnects, and queues the
catch-up.
LiveSubscriptionPlanTest (11) — the filter and planning rules, led by the one
most likely to be "tidied up" later: the gift wrap filter carries no `since`,
because NIP-59 randomizes created_at into the past and a `since` near the
present silently drops new messages.
RelayBackPressureTest (4) and ReconnectBackoffTest (6) — the two pure decisions.
Which CLOSED reasons mean "ease off", and the backoff arithmetic including the
exponent clamp: 2.0.pow(4000) is Infinity and Duration * Double throws on it, so
without it a socket failing long enough turned its reconnect loop into a crash
loop, at the point the network was least likely to recover unaided.
**Seams opened to get there**, each a readability win on its own terms:
- NostrSocketClientFactory becomes an interface with DefaultNostrSocketClientFactory
behind it, so the pool can be driven by a fake socket.
- RelayPool takes its CoroutineScope, so the replay a reconnect triggers can be
observed rather than raced.
- LiveSubscriptionManager depends on a new LiveSubscriptionTransport (4
methods) rather than RelaysSocketManager, which observes the active wallet in
its init and cannot be stood up in a test at all.
- Its pure planning helpers move to the companion as `internal`, and its
launches inherit the caller's dispatcher instead of pinning Dispatchers.IO.
SynchronizationViewModel already launches observe() on IO, so nothing moves —
but a coroutine that picks its own dispatcher cannot be driven by a test
scheduler.
- reconnectDelay is extracted to ReconnectBackoff.kt with jitter as a
parameter, so the arithmetic can be pinned without randomness.
- endsLiveSubscription names the live-subscription termination rule next to
isTerminalFor, which is the one-shot rule. Having both named makes the
difference between them reviewable rather than implicit.
kotlinx-coroutines-test is added to commonTest: the pool's bookkeeping is all
suspend functions and there is no runBlocking in a common source set.
The tests were checked by mutation, not just by passing — reintroducing a
`since`, making EOSE terminal, dropping the leftGroupAt filter and removing
retention from query() each produce failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
0319f1613b | Merge branch 'mantra' into claude/nostr-event-save-issue-6e9467 | ||
|
|
5321e4af72 | Merge branch 'mantra' into claude/distracted-franklin-e95ba4 | ||
|
|
fb21678813 |
test: pin where a commit's bytes land when the row recording it is written
The mis-routed `framedCommitBytes` fixed in the previous commit was invisible for
one reason: nothing anywhere covered the persisted row. The bytes that reach a
relay come off the in-memory `CommitResult`, so the wire path stayed correct and
the stored path was wrong, and no test looked at the stored path.
## Why the mapping moved before it could be tested
A test that built `MarmotCommitResult` itself would have been writing its own copy
of the mapping and asserting against that. It would have passed against the buggy
code, because the bug was at the call site the test was not using.
So the mapping is now `MarmotCommitResult.from`, called by
`MarmotOutboundDao.inviteMember` and exercised directly by the test. That also
removes the shape that produced the bug rather than just the instance of it: the
old call site listed its named arguments in an order different from the
declaration, which is what put `preCommitExporterSecret` and `framedCommitBytes`
two lines apart. `from` lists the payload in declaration order, in one place, so
there is no second site to get wrong.
## What is covered
Four tests, each payload given a distinct self-identifying value so that a field
arriving in the wrong column names both halves of the mistake instead of comparing
equal by accident:
- every payload field lands in its own column.
- the framed commit column never holds the exporter secret -- the regression,
stated as an invariant rather than an equality so it keeps holding for a
`CommitResult` this test did not anticipate.
- a `CommitResult` that never framed its commit still stores a commit. quartz
defaults `framedCommitBytes` to `commitBytes` and the entity repeats that
default; the fallback must not quietly become the secret either.
- the bookkeeping `DatabaseNostrRepository` reads back on acknowledgement is
carried through. `id`, `chatRoomId`, `userPublicKey` and
`peerKeyPackageEventId` are all 64-char hex, so two of them swapped in `from`
would typecheck exactly as silently as the original bug.
Checked by reintroducing `framedCommitBytes = commitResult.preCommitExporterSecret`
into `from`: three of the four fail. A green suite that would stay green against
the bug it names is not coverage.
## What is not covered, and why
That the bytes published equal the bytes stored -- the property one level above
this one -- still is not. It needs the DAO, and the DAO needs Room: `commonTest`
carries only `kotlin.test`, the room3 KSP processor is registered for the android
and ios targets alone with `kspJvm` commented out, and `getInMemoryDatabaseBuilder`
wants a `PlatformContext` no unit test has. That is a Robolectric or instrumented
target, which is a larger change than this fix earns and is better decided on its
own merits than smuggled in here.
The ack-triggered rebroadcast that would have turned the bug into a live fault does
not exist yet, so there is nothing to test there either. When it is written, the
invariant it needs is already asserted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
ad3304a665 |
refactor: build the DM inbox filter once, where it can be asserted
The filter fix a commit ago changed a value inline in a ViewModel, which is
not a place a test can reach: ChatMessageListViewModel needs a repository and
a coroutine scope to construct, and NostrDao needs Room. So the filter that
had just been wrong in three call sites went back to having no coverage at
all.
Nip17Filters.inbox is that filter with one definition. ChatMessageListViewModel
and ChatRoomListViewModel now both call it — they had been building it
separately and identically, which is also what made their negentropy requests
collapse into one under computeId, a coincidence better expressed as shared
code than left to hold by luck.
Nip17FiltersTest asserts every clause that was got wrong in production:
- the p tag names us, not a peer
- there is no authors clause, because a wrap is signed by the throwaway key
GiftWrapEvent.create mints and discards, so authors=[anything knowable]
matches nothing on any relay
- there is no since cursor, because NIP-59 back-dates a wrap by up to two
days and a high-water mark taken from the newest wrap we hold skips mail
stamped behind it — the trap waiting for whoever acts on the TODO in
NegentropySynchronizeRequest.toSynchronizeNostrEventRequest
- the wire JSON is pinned, so an added default cannot quietly split the two
callers back into separate requests
- the SQL NostrEventFilterQuery builds from it bounds no author either,
since negentropy is only as good as the agreement between the set we build
locally and the set the relay builds from the same filter
Neither of the two failure modes this covers was visible from reading the
filter. The authors clause failed silently for as long as it existed, and the
peer p-tag failed loudly but somewhere else entirely — in a Room transaction,
three files away, as a MAC error out of Nip44.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
42dd38cfc4 |
test: pin the two invariants this session left unguarded
Both are silent when broken, which is why they are worth asserting rather than reasoning about. **The cache's reuse decision.** MlsGroupCache exists because quartz drops a secret tree's skipped-generation keys on save, so rebuilding a group between two messages loses any that arrives late. Its safety argument is one comparison: reuse while the stored state is still what the cache last wrote, rebuild when it is not. Get that wrong in either direction and nothing complains -- reuse too eagerly and a group carries on from a ratchet another writer already moved, which corrupts decryption rather than failing it; reuse too rarely and the cache does nothing and the original bug is back with no symptom. That decision is now a generic LiveInstanceCache with MlsGroupCache as a typed facade over it, so it can be tested without standing up an MLS group. Splitting it also made two behaviours explicit that were previously incidental: a failed build no longer leaves the old instance behind, and an instance whose use threw is deliberately not cached -- it is half-advanced and never persisted, so the next caller has to start from disk. **Rumor and row ids agreeing.** MantraDao writes an entity whose id comes from fromXEventTemplate and separately builds the rumor it submits with rumorOf, which hashes the template itself. Both are meant to produce one id and nothing checked it. Diverging would mean submissions naming an event nobody has, deleteByPayloadEventId silently un-queuing nothing so superseded translations go out anyway, and every receiver creating a second row instead of converging on the sender's -- all of it invisible, since the ids are opaque hex either way. Asserted per kind, plus the whole chain out through the submission envelope. Both suites were mutation-checked rather than trusted: inverting the staleness comparison fails one cache test, recording the pre-block state fails another, and hashing the rumor under a different author fails all six id tests. Still uncovered, and not cheaply fixable: FrostSigningManager's and MantraDao's state machines both need a Room harness, and commonTest has none. The FROST crypto path is covered by FrostSigningRoundTest; the message-driven parts around it are not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a74a4b71cf |
test: cover the two decisions that decide who said what
The crypto was tested; the logic that acts on it was not. Both untested pieces were the security-critical ones, and neither fails loudly when it goes wrong -- one silently widens who may impersonate whom, the other silently destroys a message. Extracted MarmotDirectMessage.classify, which decides what an arriving wrap is to this device, from ChatMessage.directMessage, which turns that decision into rows. The decision is pure; only the filing needs a database, and Room-backed code cannot be unit-tested in this project. Same split, and for the same reason, as pulling the wrap/open crypto out of the DAO in the first place. Extracted MarmotInboundManager.mip03Rejection for the same reason. Its kind:1059 exemption is the most dangerous line in this feature: widened to another kind, or stripped of its kind guard, it hands every member of every group the ability to publish events as anybody, and nothing else in the pipeline would notice. There is now a test that walks seven kinds and asserts each is still held to MIP-03. Fifteen cases, the ones worth naming: `our own message is ours, even though we cannot open it` and `ours is decided before anything is opened`. A sender cannot decrypt their own wrap -- the key was discarded -- so by decryption alone this is indistinguishable from a bystander's view, and only the MLS identity separates them. Get it wrong and the inbound path files an empty placeholder over the row sendChatMessage wrote, which holds the only copy of those words. It is the one failure here that loses data rather than rendering something wrong. `words sealed by one member and sent by another are dropped`. The check that replaces MIP-03 for this kind, tested directly rather than described in a comment as it was before. One test asserts something I had wrong. I expected a seal relabelled with another member's pubkey to be caught by the signature check; it never reaches it. NIP-44 derives the conversation key from the pubkey being claimed, so relabelling a seal makes it undecryptable by the person it was encrypted for -- the label is bound to the key, not merely asserted alongside it. The outcome is Unreadable, which is the truth: the recipient genuinely cannot read it. `a seal tampered with after signing is dropped` covers what verify() does catch, using an alteration that survives decryption. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d110737f9a |
fix: keep a room's MlsGroup alive so a late message can still be read
Two events published in the same second reliably lose one of them. The receiver stores the kind:445 and produces nothing from it -- no inner event, no chat line, no error anybody sees, because MarmotGroupEvent is written before the message is decrypted and so survives while everything downstream silently does not. Observed as a FROST signing session that never started on the receiver: proposeSigning publishes the proposal and then the proposer's own nonce, the relay handed them back in the other order, and the proposal was dropped. The nonce is still sitting there filed against a session that will never exist. The same bug ate a dialect earlier, which then took out the artifact referencing it via a foreign key. MLS is specified to tolerate this. RFC 9420 says a receiver that gets generation N+1 before N keeps the intermediate keys so the older message can still be read, and quartz's SecretTree does exactly that, in a private skippedKeys map. What it does not do is persist it: exportSenderStates() returns the ratchet positions only, so saveState() drops the cache. NostrDao rebuilt the group from stored state for every inbound event, so the cache was empty every single time, and generation N arriving after N+1 failed `require(generation >= applicationGeneration)` and was swallowed. Terminal -- the key is derived from a ratchet that has moved past it, and nothing asks the sender to resend. This keeps the instance alive instead. MlsGroupCache holds one MlsGroup per room, and the inbound path goes through it, so skippedKeys survives from one message to the next. That covers the case that actually bites -- a burst arriving in one sync, decrypted one after another against the same tree -- which is what every bursty flow needs: proposeRitual sends two, addArtifact sends two, and addChapter sends one per paragraph plus one, of which only the ones arriving in ascending generation order survived. Reuse is conditional on the stored state still being exactly what the cache last wrote. Sending a message advances the sender ratchet and saves; so does adding a member. When that happens the cache rebuilds rather than carrying on from a group that has been overtaken -- which is what keeps this from being worse than no cache at all: the fallback is always the old behaviour, never a diverged ratchet. One lock per room, not one overall, because the group is mutable and decryption advances it: two events for the same room decrypted at once would corrupt the tree, and a busy room should not hold up a quiet one. **This is a mitigation, not the fix.** It does not survive a restart, and it does not survive another writer, so a long enough reorder still loses the message. The fix belongs in quartz -- carry skippedKeys through saveState/restore -- and quartz is a mavenCentral binary, not a fork, so it cannot be made here. docs/mls-skipped-keys.md has the analysis, the patch, the migration constraint on the persisted state format, and the three ways to actually land it. Not verified end to end: the proposal that exposed this cannot be recovered, since its generation is already past, so confirming the fix needs a fresh burst. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f38a5f12f3 |
fix: ask relays for gift wraps addressed to us, not to our peers
Three kind:1059 sync filters named the wrong pubkey. ChatMessageListViewModel asked for `#p:[peer]` with no author constraint, which subscribes to every wrap anyone has ever sent that peer. None of it is decryptable by us, and it is the direct source of the Invalid Mac saves fixed in the previous commit. It now asks for `#p:[us]` on our own DM relays — the only shape of gift wrap filter that can return something we hold a key for. The peer's relays were the wrong place to look regardless: under NIP-17 a sender publishes to the *recipient's* DM relays, so our mail lands on ours. The two in NostrDao asked for `authors:[userPublicKey]` + `#p:[participant]`, commented "messages from this relay that were sent by us". A gift wrap is signed by the throwaway key from GiftWrapEvent.create, never by the sender's identity key, so no author value we could know will ever match one. These requests were queued once per participant and always reconciled to empty — failing silently rather than loudly, which is why they outlived the bug that made the third filter visible. Both `if (chatMessageRelayListEvent != null)` branches held nothing else, so each is inverted to the `== null` case that does the real work: warn, and queue a profile sync for the participant whose DM relay list we are missing. Nothing is lost; neither filter ever returned an event. Two things worth recording about what a filter can and cannot express here. A wrap discloses only its recipient, so "the messages in this conversation" is not askable — `#p:[us]` pulls the whole inbox and that is the narrowest correct request. That is the privacy property being paid for, not a limitation to work around. Sent-message recovery is likewise not a filter problem. It needs a second wrap addressed to ourselves at send time, which giftWrapAndBroadcast does not yet emit; the `#p:[us]` filters already in place would pick those up with no new subscription. purpose on the chat message request changes from "sent-messages" to "chat", matching the now-identical filter in ChatRoomListViewModel. Since computeId buckets by minute and NegentropySynchronizeRequestDao upserts, the two collapse into a single request rather than racing as separate rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f57644aa1f |
fix: stop discarding gift wraps addressed to someone else
An inbound kind:1059 whose `p` tag is not our pubkey took down the entire
save transaction:
java.lang.IllegalStateException: Invalid Mac: Calculated f1db537e…, decoded: 45c8c86a…
at com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf.fastExpand
at com.vitorpamplona.quartz.nip44Encryption.Nip44v2.checkMessageKeys
…
at press.mantra.compose.database.model.GiftWrapMessage.decryptGiftWrapSeal
at press.mantra.compose.database.dao.NostrDao.indexNostrEvent
at press.mantra.compose.database.dao.NostrDao.storeNostrEvent
Two separate things were wrong.
The first is that decryptGiftWrapSeal attempted the decryption at all. When
the recipient did not match our key it logged "We are unwrapping a message we
may have sent" and called
Nip44.decrypt(content, privateKey = ourPrivKey, pubKey = giftWrapEvent.pubKey)
giftWrapEvent.pubKey is the wrap's ephemeral author. NIP-59 encrypts the wrap
under ECDH(ephemeralPriv, recipientPub), and GiftWrapEvent.create mints that
ephemeral key with NostrSignerSync(KeyPair()) and discards it on return.
ECDH(ourPriv, ephemeralPub) is a third, unrelated key, so the MAC check could
never pass. A sender genuinely cannot unwrap their own gift wrap; that is the
point of the construction, not a gap in it. The call threw its result away
anyway (keyPair.privKey?.let { …; null }) and fell through to the trailing
`return null`, so it was a probe whose only possible outcome was an exception.
The second is that a null seal was treated as a failure. indexNostrEvent
throws GiftWrapUnsealException on null, which unwinds out of the Room
transaction in storeNostrEvent and rolls back everything written for the
event: the NostrEvent row, its NostrEventRelay row, and the GiftWrapMessage
upserted moments earlier. The only catch sits in DatabaseNostrRepository,
which logs and continues — and that catch also swallows the
`status = "processed"` upsert on the SynchronizeNostrEventRequest, so the
event was re-fetched and re-failed on every later sync pass.
isAddressedTo now answers the question with no crypto at all, and the indexer
returns early for wraps that are not ours: the event and the wrap row survive,
the remainder of indexNostrEvent still runs, the transaction commits, and the
sync request is marked processed. GiftWrapUnsealException goes back to meaning
what it says — addressed to us, but unsealing failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
3e4166f13d |
feat: sign a dialect into existence instead of submitting one
Adding a dialect no longer creates one. It opens a signing session over a DialectEvent, and the dialect appears -- on every member's device at once, authored by the group's shared key rather than by whoever typed it -- when enough members have signed. That is the difference between the two envelopes. A submission says "I am putting this in front of the group"; the group's only recourse afterwards is social, and the row records the submitter as its author. A signature is the group saying it, it takes a quorum to say, and the author on the row is the group's key. For something as load-bearing as the set of dialects a group translates into, the second is the honest one. **Where the signed event becomes a row.** Every device has the event and the signature once the session completes, so each applies the result itself rather than waiting to be sent something it can already build -- the same reasoning the transcript lines are written on. Nothing goes on the wire for it, and nothing could: the outbound pipeline re-authors rumors as their sender, so a group-signed event pushed through it would come out stripped of the signature and attributed to whoever sent it. Applying reuses the inbound path's dispatch rather than repeating it. applyInnerEvent takes plain ids now instead of a GroupEvent, and both are null here, because there is no group event and no inner event behind a row a device derived for itself. A failure there is logged and the session still completes: the signature is made and valid, and failing the session would tell the group to abandon something that succeeded. **The screen.** One, not three. A ceremony asks three different questions so it gets three approval screens; signing asks one -- sign this or do not -- so a single screen has to carry the whole case: what is being signed, who else has agreed, and what the group is still waiting on. The event is shown as the thing it is, a dialect with its name and country and language, because a member deciding whether to sign is deciding about a dialect and "kind 30304" answers a question nobody asked. Anything unrecognised falls back to the raw kind, which is better than describing it wrongly. The member ladder names people rather than counting them, for the same reason the ceremony's does: "1 of 2" does not tell anyone whose door to knock on. It stays useful after the decision, since a member who has already signed is exactly who needs to see who has not. **Getting there.** Signing lines render in the transcript as system notices like ritual lines -- nobody said them either -- but they lead to the session rather than to the key. A chat row carries no session id and adding a column to the table every message uses would be a poor trade for a lookup, so FrostSigningRoute takes a nullable id and the screen resolves the room's live session. Approving is recorded as answered by the nonce line rather than the partial signature: agreeing is agreeing to take part, and the coordinator may then pick a quorum without you, which should not leave you looking like you never replied. **Proposing needs a key.** The FAB is disabled, and says why, when the room has none -- proposeSigning throws there, and it is not reachable outside the #admins room in the first place. AddDialectViewModel drops MantraRepository, which it no longer uses for anything. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
63c1879ace |
refactor: carry signing on marmot inner events, not gift wraps
A signing message is now an ordinary Marmot inner event: queued with a
null marmotGroupEventId, picked up by the outbound pipeline, MLS-encrypted
and broadcast as one kind:445 for the room. Inbound it arrives through
ChatMessage.fromGroupEventResult like every other inner event, and is
dispatched from NostrDao rather than from the gift-wrap branch.
The ceremony keeps NIP-17 because it has no choice: its participants are
not yet a Marmot group, and its purpose is to produce the key one would
be keyed on. Signing has that solved for it, so it was paying for
addressing it does not need -- a gift wrap is sealed once per recipient,
so every message cost one wrap per member, and every message had to name
the whole group in p-tags. A group event is encrypted to the group once.
That also removes a small dishonesty. The signer set is supposed to come
from the ceremony; carrying p-tags meant each message also asserted a
membership list, and two sources for one fact is one too many. Now who
can read a message is the MLS tree's business and who may sign is the
ceremony's.
Which room follows from the transport. A ceremony runs in a NIP-17 room
-- every member an equal admin, no MLS tree to be outside of -- and a
group event needs an MLS one, so signing cannot happen where the ceremony
did. It happens in the #admins room, which is the right venue anyway: it
already exists after a ceremony, its membership is exactly the share
holders, and its id *is* the key, derived by
SharedKeyDerivation.marmotGroupId.
So completedKey rederives rather than reading a column: a room cannot be
pointed at a key it was not derived from. Receivers were already
independent of this, naming their key in the proposal's frost_key tag and
looking it up locally.
Mechanical consequences:
- processSigningPayload, acceptProposal, record and isFromCoordinator
take the decrypted Event instead of a GiftWrapPayload.
- replayStoredMessages reads MarmotInnerEvent rows, via a new
getByChatRoomAndKinds, and rebuilds the rumor from the row's own
columns.
- applyInnerEvent returns null for the signing kinds. They are the
manager's, and it writes transcript lines naming who did what, so an
"unsupported" row would be a second and worse account of the same
thing.
- DkgSessionDao gains getKeyHoldingSessions for the derivation match.
The kind comment is rewritten rather than kept. 3032x was chosen to clear
the DKG, which now shares no transport with signing and cannot clash with
it; what it actually has to clear is the nip30303 document kinds, which
run 30300-30312 and are dispatched by the same inbound path. It still
does. The DKG's own overlap with those numbers is noted there as the
routing accident it is, so nothing added later leans on it.
No schema change: both tables and the columns landed in v6 with the
previous commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b4ac65f5c9 |
feat: sign a nostr event with the group's shared key
A ceremony leaves every member holding a share of a t-of-n key and no way
to use it. This is the other half: a session that turns an unsigned nostr
event into one signed by the group.
The shape is ChillDkgRitualManager's, deliberately. The member who
proposes coordinates, protocol messages travel as gift-wrapped rumors on
the same NIP-17 pipeline chat messages use, each inbound message is
persisted and then the session is asked whether it can move, and every
step is recomputed from stored inputs so a device killed mid-round
resumes on the next message. Anyone who has read that manager can read
this one.
proposer --[ 30320 proposal ]-> everyone the unsigned event
signer --[ 30321 nonce ]-> everyone this device's public nonce
proposer --[ 30322 signer set ]-> everyone who signs, and their aggregated nonce
signer --[ 30323 partial ]-> everyone this device's partial signature
proposer --[ 30324 signature ]-> everyone the finished 64-byte signature
anyone --[ 30325 failure ]-> everyone abandon + blame
Three things are genuinely different, and each is why this is a separate
manager rather than another branch of that one.
**It does not need everybody.** A DKG cannot finish until every member
takes part; that is what makes the key. Signing needs t, and waiting for
n would throw away the property the group ran a ceremony to get. So the
coordinator waits for the threshold to be reachable, picks a set and says
who is in it. Members left out do nothing and stall nothing.
**Restart-safety is forced rather than chosen.** SecretNonce cannot be
serialised and refuses to be used twice, so storing the randomness it
derives from and regenerating on demand is the only way a session
survives the app closing. That is safe for exactly one reason: a session
signs one message and cannot be made to sign another. Two rules hold it
in place and both are load-bearing rather than tidy:
- the event id is written at creation, and a proposal that disagrees
with it is refused rather than applied;
- the aggregated nonce and signer set are write-once. A coordinator
that sends a second, different set is ignored. Obeying it would mean
two partial signatures over one secret nonce against two challenges,
which is precisely how a secret share is extracted. The session
stalls; the share does not.
**One approval, not three.** A DKG asks three times because each step
publishes something different and commits the member to something
different. Here every step serves one decision -- sign this event or do
not -- and the event is fixed before the member is asked, so a second
prompt would be the same question twice. Declining is broadcast rather
than silent: a t-of-n group can sign without you, but only if it knows.
Two things are checked rather than trusted, both because the coordinator
is untrusted by construction: the event id is recomputed from the
proposal's own fields, so a proposer cannot have the group sign one thing
while showing them another; and the finished signature is verified before
the session is called complete, so a bad aggregate is a failure here
rather than a rejection at every relay it reaches.
Signer ids are derived, not stored: a member's FROST id is their index in
the bytewise sort of the ceremony's host keys, the same ordering ChillDKG
hashed into the session identity and the same one the public shares are
in. Deriving means signing cannot disagree with the ceremony that made
the key.
DkgSession gains publicShares, kept because FROST validates each signer's
secret share against its public one. A ceremony finished before this
column reads back null and signing runs without that check rather than
refusing.
The tests run the same calls in the same order against real FROST and
assert the aggregate verifies as a nostr signature. That path was written
from reading the library rather than from a working example, so it is the
part most likely to be subtly wrong -- and wired up wrong it fails
silently, on every device.
Kinds start at 30320 with a gap. The DKG runs 30310-30316 and the
nip30303 document kinds run 30300 up; those two already collide at 30310
and 30311, and SubmissionEvent sits on 30312, which is also the DKG's
round-1 kind. They are kept apart today only by riding different
transports, which is luck. Signing shares a transport and rooms with the
DKG, so it starts clear of both.
No UI yet: this is the session logic, reachable through proposeSigning,
approve and decline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
fcc28de931 |
Revert "fix: hold a payload whose parent has not arrived instead of losing the event"
This reverts commit
|