Commit Graph

533 Commits

Author SHA1 Message Date
Kgothatso Ngako
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>
2026-09-06 10:34:12 +02:00
Kgothatso Ngako
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>
2026-09-06 10:13:09 +02:00
Kgothatso Ngako
60abf243ed Merge branch 'mantra' into claude/add-chapters-frost-signing-c17e64 2026-09-06 10:07:40 +02:00
Kgothatso Ngako
643334afff Merge branch 'mantra' into claude/frost-batch-signing-435bbb 2026-09-06 10:03:13 +02:00
Kgothatso Ngako
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>
2026-09-06 04:54:12 +02:00
Kgothatso Ngako
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>
2026-09-06 04:50:35 +02:00
Kgothatso Ngako
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>
2026-09-06 04:48:24 +02:00
Kgothatso Ngako
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>
2026-09-06 04:40:22 +02:00
Kgothatso Ngako
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>
2026-09-06 04:33:46 +02:00
Kgothatso Ngako
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>
2026-09-06 04:15:36 +02:00
Kgothatso Ngako
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>
2026-09-06 04:08:44 +02:00
Kgothatso Ngako
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>
2026-09-06 04:08:17 +02:00
Kgothatso Ngako
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 a909108, and changes the author of
every event the group signs, including the artifacts of 786c060.

## The key state is proposed, not announced

a909108 had the room's creator write the GroupKeyState row, say so in the room
on kind 30326, and every receiver keep it if the room's id rederived from the
key it named. That check was sound and is still here -- a state that does not
rederive its own room is dropped, whoever sent it -- but it left the first thing
a group ever does as the one thing a single member decides alone.

So the key state goes through the door everything else the group says goes
through. GroupKeyStateManager.announce becomes propose, which opens a
FrostSigningEvents.PROPOSAL over an unsigned 30326 and writes no row. The state
comes into existence when a quorum has signed it, on every device at once,
applied by FrostSigningManager.complete like any other signed proposal:

    creator  --[ 30320 proposal over an unsigned 30326 ]-> everyone
    ...members approve, nonces, signer set, partials, aggregate...
    everyone --applies the signed 30326 locally-->  GroupKeyState row

Nothing waits on it. Between creating a room and that session completing there
is no state to read, and completedKey's rederivation scan -- kept from before
the table existed -- is what keeps the room signable in the meantime, including
for the key-state session's own members. That is the only reason a bootstrap
here does not deadlock, and the scan's doc now says so rather than describing
itself as legacy.

proposeSigning gains an optional `key`, for the one caller that cannot be asked
which key the room signs with because establishing that is its whole job. It is
honoured only if this device actually holds a share of it, so naming a ceremony
cannot talk a session into signing with material it has not got.

## Everything signs as the room, not as the group's root key

unsignedEventOf and advance now build from SharedKeyDerivation.derive at the
room's path instead of TweakCache.create on the bare threshold key. Both halves
had to move together: a signature aggregates against whatever the cache carries,
so the cache and the author on the event have to be the same derivation or
nothing verifies.

Since marmotGroupId(K, path) *is* derive(K, path).hex, the pubkey on every event
a room signs -- dialect, artifact, chapter, key state -- is now that room's id.
A reader checking one needs no lookup at all: the key they expect is the id of
the room they found it in. marmotGroupId's doc now carries that second meaning,
and there is deliberately no second name for the value; "the room's id" and "the
key it signs as" are one function because they are one key.

The path is resolved by FrostSigningManager.signingPath and never taken from a
proposal, because it decides which key the group signs as -- a proposer able to
choose it could have every signer put their share behind an author of the
proposer's choosing. Three candidates in descending order of knowledge (the
room's GroupKeyState, the path in its MIP-01 description, the app's default),
and one is accepted only if walking it reaches the room's id, which makes the
resolution self-checking rather than trusting. acceptProposal runs the same
resolution independently on every device.

Null is a real answer, not a failure: completedKey will still find a key for a
room that was never derived from it -- a ceremony held in that very room, the
fallback kept for rooms the app no longer makes -- and such a room has no key of
its own to sign as, so it signs as the threshold key, which is what it always
did.

## What a receiver now checks

GroupKeyStateManager.stateFrom asks two independent questions, and a state has
to answer both:

  - Is it true? The room's id is the key derived at the path, so a state that
    does not rederive its own room names a key the room was not made from.
    Unchanged, and still the half that safety rests on. It knows nothing about
    who is speaking, and that is deliberate: a member with no share can state a
    true state and it is still true.

  - Did the group say it? GroupKeyStateEvent.isSignedByGroup: the author must be
    the key the content walks to at the path in the tags, the id must hash the
    fields sitting next to it, and the signature must verify. Since that walk is
    the room's id, a passing state is signed by the room it is about.

The second does not make a state truer -- the derivation already settled truth.
It makes the record of what a room signs with a thing a quorum agreed to. The
practical effect is that a true state nobody signed is now refused, which is the
behaviour change worth knowing about: an unsigned 30326 from an older client is
stored as an inner event and dropped as a state.

## Restart safety, and a nullable column

FrostSigningSession gains derivationPath, and the database goes to v9 on an
auto-migration. It is an input and is stored for the same reason nonceRandom is:
the cache is rebuilt on every pass of advance, and a session that resolved a
different path after a restart would regenerate a different nonce from the same
seed -- publishing a partial signature against an aggregate nobody else
computed.

Nullable, meaning no derivation at all: the untweaked threshold key. That is
both the honest answer for a room not derived from the key and what sessions
predating the column read back as, so a session caught mid-flight by the
migration finishes under the key it began under rather than switching between
two of its own rounds.

SharedKeyDerivation.derive now takes its key back out of the cache rather than
from the point, so an empty path is a real answer equal to what a session
created from that cache signs against. No behaviour changes for a non-empty
path, where the walk overwrites it on the first step.

## One place that files a key state

ChatMessage.applyInnerEvent records it, which it must: the signed 30326 reaches
every device through applySignedEvent, and the branch there previously returned
null and dropped it. The now-duplicate dispatch in NostrDao is removed, so the
locally applied signature and any wire-borne 30326 take the identical path.
Still no chat line -- standing state, and the session already wrote the
transcript of it happening.

## Elsewhere

DkgRepository.announceGroupKeyState becomes proposeGroupKeyState, taking the
room and returning the signing session rather than the state, since the state is
not what the call produces any more. DkgRitualViewModel calls it after members
are added, unchanged and for the unchanged reason: adding them commits a new
epoch, and a proposal published before it reaches nobody who could sign it.

FrostSigningScreen describes a key-state proposal as the group's shared key with
its path and ceremony, rather than "Event of kind 30326" -- a member deciding
whether to sign should be shown the thing.

## Tests: 217, 0 failures

  - GroupKeyStateTest is rewritten around real quorum signatures from
    Frost.trustedDealerKeygen. New: a true state nobody signed is dropped, a
    member's own signature over one is dropped, one group signing about another
    group's key is dropped, a state edited after signing is dropped, a state
    signed at the wrong path is dropped, and the room signs as its own id.
  - SignedArtifactTest pins that an artifact's author is the room it was signed
    in, and explicitly not the group's root key.
  - FrostSigningRoundTest runs both rounds against the tweaked cache now, which
    is the part most likely to be silently miswired -- a badly built cache
    produces a signature that simply fails to verify, on every device, quietly.
  - SharedKeyDerivationTest pins the migration contract: a walk of no steps
    lands on the threshold key, and a null derivationPath reads back as that
    empty walk rather than as the default path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:18:58 +02:00
Kgothatso Ngako
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>
2026-09-06 02:01:35 +02:00
Kgothatso Ngako
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
2026-09-06 01:52:10 +02:00
Kgothatso Ngako
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>
2026-09-06 01:49:57 +02:00
Kgothatso Ngako
6aff34c5c7 Merge branch 'mantra' into claude/artifact-frost-signing-proposal-a414f6 2026-09-06 01:38:08 +02:00
Kgothatso Ngako
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>
2026-09-06 01:37:58 +02:00
Kgothatso Ngako
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>
2026-09-06 01:23:42 +02:00
Kgothatso Ngako
b87e6e4ed5 Merge branch 'mantra' into claude/frost-proposal-review-visibility-8f6fab 2026-09-06 01:13:47 +02:00
Kgothatso Ngako
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>
2026-09-06 01:13:42 +02:00
Kgothatso Ngako
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. a909108 added it so members would stop rederiving;
no member has ever received one.

**Why the neighbours worked, and hid it.** The DKG rides NIP-17 gift wraps
through a different path entirely, so a room could finish a ceremony, hold a
real shared key, and report canSign() == true with the signing transport
dead beneath it. nip30303 submissions work because MantraDao.sendMarmotInnerEvent
pairs every queued rumor with a ChatMessage carrying its id -- not as a
delivery mechanism, just because a submission is also something a member did.
FROST was the first traffic to use the group path without a chat line, which
is why this reads as a FROST bug and is not one.

**Why it went unnoticed.** Nothing failed. The coordinator's own device is
fully convinced: proposeSigning writes the session, announceStarted puts a
line in the chat, advance() runs, and publishOwn records the coordinator's
own nonce and announces that step too. From the proposer's side a session
nobody else can see is indistinguishable from one waiting on slow peers.

Unlike 65e4a3a, the queue did not block. marmotGroupEventId is set before
the transcript lookup, so the row left the queue cleanly and the next one
was picked up. Every message was lost individually, in silence, with no
backlog to notice.

**The fix.** The broadcast insert is hoisted out of the branch, and the
decision it was tangled with is lifted into MarmotDelivery.plan: given a
group event, a relay list, and a chat message or null, what has to be
written. A group event is sent because it was queued; a chat line is linked
because a member said something. The DAO now computes that plan and executes
it, with the insert as a plain unconditional statement ahead of the
bookkeeping that legitimately does depend on there being a line.

The extraction is not decoration. encryptAndSendMarmotInnerEvent is
Room-backed and cannot be stood up in a unit test, which is exactly how the
gate survived; separating the decision from the filing of it is the same
move MarmotDirectMessage.classify exists for, and for the same stated
reason.

**Tests.** MarmotDeliveryTest, six of them. The two that matter are "a
signing proposal goes out, though nothing in the chat points at it" and "the
send does not depend on the transcript", which asserts the broadcast list is
identical with and without a chat message. The rest pin the supporting
facts: one request per relay naming the event, every request written pending
because that is the only status the broadcaster looks at, the linkage that
does depend on a chat line, and an empty relay list as the sole legitimate
way to produce an empty broadcast list -- so that an empty list always reads
as "nowhere to send it" and never as "nothing to send".

**Not covered, deliberately.** These pin the decision, not the call site. Re-
nesting the insert inside chatMessageOrNull?.let would leave MarmotDelivery
correct and every test passing. Closing that needs the DAO itself under
test: BundledSQLiteDriver is on the classpath and getInMemoryDatabaseBuilder
exists, but its android actual wants a real Context, testDebugUnitTest is
plain JVM, and there is no androidUnitTest source set or Robolectric. That
is its own change, not one to smuggle in here.

Verified: :composeApp:compileDebugKotlinAndroid succeeds; 160 tests pass,
154 before these six. The inbound half was read rather than assumed --
NostrDao dispatches FrostSigningEvents kinds to processSigningPayload,
inbound rumors are stored with marmotGroupEventId set so they cannot re-enter
the outbound queue, and the out-of-order replay path is intact. Outbound was
the only break. That two participants now actually see a proposal is
inference from the code, not an observation: it wants two devices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 01:06:05 +02:00
Kgothatso Ngako
65e4a3acc0 fix: seal the Welcome, the one gift wrap an MLS room must publish
No invite to a Marmot room has been delivered since 1700e6d. The Welcome
was built, hashed, queued and logged exactly as before -- and then refused
one step short of the relay, by a guard that had no idea it was looking at
one.

**The guard.** 1700e6d ("send a direct message into the group, wrapped for
one member") added a backstop at the top of sealGiftWrapPayload:

    val chatRoom = database.chatRoomDao().findChatRoomById(giftWrapPayload.chatRoomId)
    if (chatRoom?.chatRoom?.mlsGroupState != null) { log; return }

Its reasoning is sound and still is: a Marmot direct message is a genuine,
correctly signed NIP-59 gift wrap, indistinguishable from one this path
would be right to publish, and the only thing keeping it off a relay is
that it never becomes a GiftWrapPayload row. Refusing at the seal as well
means a future caller cannot walk one onto a relay by accident.

**Why it caught the Welcome.** MarmotOutboundDao.deliveryWelcome writes a
GiftWrapPayload with chatRoomId = nostrGroupId -- the MLS room's own id,
which by construction has mlsGroupState set. Every Welcome therefore
matched a refusal keyed on mlsGroupState alone. There is no Welcome that
does not: the tag identifying the room is the whole point of the event.

The kind:444 arm further down -- the one that wraps only for the
participant who published the referenced key package -- became
unreachable, which is why nothing in the logs said "welcome" at all.

**Why it went unnoticed.** The room reaches the correct state on the
inviter's side whether or not the Welcome goes out: addMember advances the
epoch, the group state is persisted, the Participant row exists, and
"Invited X to chat" is written to the transcript. From the coordinator's
side an invitee who never heard anything is indistinguishable from one who
joined -- recorded as a known gap in docs/shared-key-ceremony.md, and this
is what was behind it.

**The second-order damage.** observeUnsealedGiftWrapPayloads is

    SELECT * FROM GiftWrapPayload WHERE publicKey = :p AND giftWrapSealId IS NULL

collected as a Flow<GiftWrapPayload?> -- one row at a time. giftWrapSealId
is only ever set inside persistAndBroadcastGiftWrap, which the guard
returns before reaching, so a refused payload stays unsealed forever and
sits at the head of that queue. The first Welcome a user queued blocked
every gift wrap behind it, in every room, for the life of the install.

**The fix.** Kind first, room second. MIP-02 addresses kind:444 to someone
who is not yet in the group and holds no key to read a kind:445 -- a
relay-borne gift wrap is the only way to reach them, and deliveryWelcome
queues one on purpose. Every other kind is refused exactly as before.
sendChatMessage already branches on mlsGroupState before writing a
payload, so an MLS room's messages never arrive here anyway; the guard
stays as the backstop it was meant to be.

docs/marmot-direct-messages.md claimed "An MLS room should never produce a
NIP-17 gift wrap for any reason". That premise is what made the guard look
complete, so it is corrected rather than merely amended, with the test to
apply when adding a kind to the exemption: can its recipient read a
kind:445? If so, it does not belong on this path.

**Not fixed, deliberately.** A refusal still leaves the payload unsealed
and head-blocking. That is now unreachable -- nothing else can queue a
payload against an MLS room -- but it remains a trap for whatever gets
refused next. Marking a payload refused needs a state the schema does not
have, so it is left for its own change rather than smuggled in here.

Verified: :composeApp:compileDebugKotlinAndroid succeeds. No test covers
this -- DatabaseChatRepository is Room-backed, and Room-backed code has no
unit test harness in this project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 00:40:31 +02:00
Kgothatso Ngako
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.** f38a5f1 fixed the three kind:1059 filters that named the wrong
pubkey, including the two `authors=[userPublicKey]` requests in NostrDao that
could never match a wrap signed by a throwaway key. This branch deleted those
same two blocks, inverting the same `if` to the `== null` case, for the same
reason. The code merged to the same shape; only the comments conflicted, and
they are combined.

**Nip17Filters wins, and the live subscription now defers to it.** ad3304a
extracted the inbox filter to one definition precisely because it had been wrong
in three call sites, with the no-`since` reasoning this branch arrived at
separately. Keeping a fourth copy inside LiveSubscriptionManager would recreate
the problem that commit exists to solve, so:

  - queueCatchUpSynchronization now calls Nip17Filters.inbox() instead of
    building an identical SynchronizationFilter with its own limit constant,
  - Nip17Filters gains liveInbox(), the same shape as a quartz Filter for a REQ
    rather than a SynchronizationFilter for the queue, and giftWrapFilter()
    defers to it.

Two types for one filter is not duplication worth removing — the queue stores
one and hashes it for computeId, a live subscription puts the other on the wire
— but they belong side by side, because drift here means one of them quietly
stops matching mail.

**ChatMessageListViewModel keeps this branch's resolution.** mantra had it
refresh our own inbox on open (Nip17Filters.inbox on our DM relays, purpose
"chat"); this branch removed that call entirely. Both were right when written,
and the merge is where the second becomes true: LiveSubscriptionManager holds
exactly that filter open on exactly those relays for the whole account and
reconciles it on every foreground, so opening a chat has nothing left to ask
for. The redundancy is now recorded in the comment where the branch used to be,
so it reads as superseded rather than dropped. Discovery — the kind-10050 lookup
for a participant we cannot yet address — is untouched, and the purpose is no
longer a conditional now that only one case reaches it.

The commonTest coroutines-test dependency arrived on both sides; the comment
gives both reasons.

Verified: 154 tests pass, both branches' suites included — Nip17FiltersTest and
the marmot direct-message suites alongside this branch's 46.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:58:40 +02:00
Kgothatso Ngako
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>
2026-09-05 23:45:08 +02:00
Kgothatso Ngako
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>
2026-09-05 23:36:57 +02:00
Kgothatso Ngako
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>
2026-09-05 23:31:11 +02:00
Kgothatso Ngako
0319f1613b Merge branch 'mantra' into claude/nostr-event-save-issue-6e9467 2026-09-05 23:30:28 +02:00
Kgothatso Ngako
5321e4af72 Merge branch 'mantra' into claude/distracted-franklin-e95ba4 2026-09-05 23:24:45 +02:00
Kgothatso Ngako
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>
2026-09-05 23:24:21 +02:00
Kgothatso Ngako
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>
2026-09-05 23:22:06 +02:00
Kgothatso Ngako
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>
2026-09-05 23:18:54 +02:00
Kgothatso Ngako
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>
2026-09-05 23:17:43 +02:00
Kgothatso Ngako
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>
2026-09-05 23:09:53 +02:00
Kgothatso Ngako
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>
2026-09-05 23:07:03 +02:00
Kgothatso Ngako
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>
2026-09-05 23:06:49 +02:00
Kgothatso Ngako
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>
2026-09-05 22:09:47 +02:00
Kgothatso Ngako
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>
2026-09-05 21:55:10 +02:00
Kgothatso Ngako
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>
2026-09-05 21:38:23 +02:00
Kgothatso Ngako
fcc28de931 Revert "fix: hold a payload whose parent has not arrived instead of losing the event"
This reverts commit d7aac49.

Reverting restores the defect it addressed: a payload referencing a row
the receiver does not have violates a foreign key, and SQLite aborts,
rolling back the whole inbound transaction -- the nostr event, the group
event, the submission and the transcript line, none of them retried.
That is what produced the observed `FOREIGN KEY constraint failed` on an
artifact whose dialect had not arrived.

Also drops the schema back to v5. Any device already migrated to v6 will
refuse to open its database, since the builder sets no destructive
fallback on downgrade; clear that app's data before installing a build
from this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:12:50 +02:00
Kgothatso Ngako
d7aac49cf1 fix: hold a payload whose parent has not arrived instead of losing the event
A receiver hit `FOREIGN KEY constraint failed` on an artifact submission
and lost the whole group event. The artifact referenced a dialect the
receiver did not have, MantraArtifact.dialectId is a foreign key, and
SQLite answers a violated constraint by aborting -- which rolled back
the entire transaction the inbound pipeline runs in. Gone with it: the
NostrEvent, the MarmotGroupEvent, the submission's MarmotInnerEvent
holding the payload verbatim, and the transcript line. Nothing retries,
so the artifact stayed lost even once the dialect turned up.

Every nip30303 entity is a child of another and the schema enforces all
of it -- artifact→dialect, version→artifact, chapter→version,
chunk→chapter, translations→both of theirs -- so this was every branch,
not one.

And submissions make arriving before your parent ordinary rather than
exotic. That is the point of them: an admin submits a backlog in
whatever order they hold it, and a member who joined last week can be
sent what the group was told last month. Both produce payloads whose
parents are not here yet, and both were losing data.

So check the parents before inserting. A payload that arrives early is
held on the submission row -- awaitingEventId names what it waits for --
and applied when that arrives. Releasing one can release another, a
version freeing its chapters and those freeing their chunks, so it walks
outward until nothing more comes unstuck. A payload with a second parent
still missing is re-pointed at that one rather than retried on every
arrival.

Nothing is written to the transcript while a payload is held. Nobody has
said anything yet; the line appears when it is applied, in the position
its own timestamp gives it.

Two things fall out of the shape:

parentRefsOf is pure and separate from the lookups, because the mapping
is the part that can silently drift from the schema and there is no
database harness in commonTest to catch it. ParentRefsTest pins one case
per kind. Which table an id lives in is carried as the kind of event
that would have created it, so there is no second enum to keep in step.

applyInnerEvent takes ids rather than a GroupEvent, since replay happens
long after that object is gone. A released payload is recorded as not
ours: we hold the parents of anything we wrote, having written those too.

Also reconstructs a held bare nip30303 event from its own columns rather
than parsing its content as an event -- only submissions carry an event
there, and reading both that way would have stranded every bare one
permanently.

Verified: the v5→v6 migration runs clean on the receiver's real
populated database. The hold path itself still needs a fresh submission
from a sender to exercise end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:05:35 +02:00
Kgothatso Ngako
2d0fe6f5fc fix: disable Add Artifact until a dialect is picked
Submitting without a dialect was rejected in the view model, which
called onFailure, which navigated to ImplementationPendingRoute("Failed
Artifact") -- a whole screen away from the form, saying nothing about
which field was wrong, and leaving the way back to the only sensible fix
as the back button.

That is a bad way to report any missing field, but the dialect is the
one where it is unrecoverable in place. A blank name or url is answered
by typing; a dialect has to already exist, and since dialects moved to
the group screen there is nothing on this form that can conjure one. So
an unpicked dialect is not a mistake to report after the fact, it is a
state the button should not be pressable in.

Material 3 gives ExtendedFloatingActionButton no `enabled` parameter, so
this paints the disabled colours from ButtonDefaults.buttonColors() --
the same ones every other disabled button in the app resolves from the
theme, rather than an alpha invented here -- and returns early from
onClick.

Also marks it disabled to accessibility services. Colours alone leave a
screen reader announcing a button it is happy to press, and pressing it
does nothing, which is worse than a button that says it is unavailable.

A group with no dialects at all is covered by the same condition, since
there is then nothing to select.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:38:03 +02:00
Kgothatso Ngako
6c63027912 feat: submit nip30303 events to the group instead of authoring them into it
With the receiving side able to open an envelope, start sending one.
Every nip30303 event now leaves as a SubmissionEvent payload and none
leaves on its own: addDialect, addArtifact, addArtifactVersion,
addChapter and each of its chunks, addTranslationArtifactVersion and
each of its translation chapters, and saveTranslation. Because all four
add screens reach the wire through MantraDao, none of them needed
touching.

Two helpers carry it:

  rumorOf(template, publicKey)  the unsigned event a template describes.
                                Its id is computed exactly as the
                                matching Mantra* entity computes its own,
                                so the row on disk and the payload on the
                                wire are one event rather than two copies
                                of one.
  submitToGroup(...)            wraps a payload, queues the submission as
                                an unprocessed rumor, and writes the chat
                                line.

Two things this drags in, neither optional:

The queued MarmotInnerEvent is now the envelope, so its id is the
envelope's and no longer the nip30303 event's. saveTranslation replaces
a chunk whenever its text changes -- the id is derived from the content,
so an edit is a new row -- and un-queued the superseded one by
deleteById(stale.id). That silently stops matching anything once the row
is a submission, leaving the stale translation to be sent anyway. It now
also deletes by what the submission carries, via deleteByPayloadEventId.

The add* methods return the entity rather than the queued rumor. This is
a correctness fix, not tidying: AddArtifactViewModel navigates to
ArtifactDetailRoute on that id, and AddTranslationArtifactVersionViewModel
feeds addDialect's id straight back in as a dialectId. Both used to be
handed a MarmotInnerEvent whose id happened to equal the entity's, and
both would now have been handed a submission id -- one navigating to an
artifact that does not exist, the other tagging a translation with a
dialect that does not. Returning MantraArtifact/MantraDialect/etc. makes
.id mean the entity everywhere and matches saveTranslationChunk, which
already returned its entity.

The sendMarmotInnerEvent overload taking a LocalChatRoom loses its last
caller; submitToGroup names the submitter explicitly, which is the thing
that matters now that it is not necessarily the author.

Outbound still only ever submits payloads authored by the submitter --
nothing in the app originates a foreign event yet. submitToGroup is
where that would attach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:21:52 +02:00
Kgothatso Ngako
ce77b77240 feat: apply the nip30303 event a submission carries, keeping its author
Teach the receiving side to open an envelope before anything starts
sending one. In that order a client that has this can already handle
submissions from a client that does not yet send them; the reverse would
turn every artifact, dialect and chapter into an "unsupported" row for
anyone who had not updated.

Despite the name, MarmotInboundManager does not dispatch on inner-event
kinds -- it decrypts MLS and hands back a GroupEventResult. The kind
dispatch has always lived in ChatMessage.fromGroupEventResult, so that
is where support for a new kind goes.

The `when (event.kind)` body becomes applyInnerEvent, which takes the
event to apply separately from how it arrived:

    event                 the nip30303 event, written by whoever wrote
                          it -- possibly nobody in this group
    marmotInnerEventId    the row the group actually delivered
    senderPublicKey       the member who delivered it
    createdAt             when they did

For a plain nip30303 event those all come from the one event, which is
exactly the old behaviour. For a submission they come from the envelope
while `event` is the payload. Entity rows take their author from the
payload via fromXEvent, so the chat line says who added something and
the row says who wrote it -- the point of the envelope, made real at the
only place it can be.

createdAt deliberately follows the envelope rather than the payload: a
submitted archive translation can be years old, and sorting the group's
transcript by when the source was written would file "X added a
translation" somewhere nobody will scroll to.

The stored MarmotInnerEvent stays the outer event -- that is what the
group sent -- and gains payloadEventId naming what it carries. The
payload is not given a row of its own: it is recoverable from the
submission's content, and a second row with a null marmotGroupEventId
would look to the outbound pipeline like something waiting to be sent.
Nullable column, so AutoMigration(4, 5) is all it needs; rumors queued
before this read back null, which is correct, since none of them were
submissions.

Two submissions are stored but not applied, because there is nothing in
them to make a row from: one whose payload will not parse, and one
carrying another submission. Both surface as "unsupported" rather than
disappearing.

The unsupported fallback also stops attributing to groupEvent.pubKey,
which is the ephemeral key every kind:445 is signed with and so names
nobody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:21:25 +02:00
Kgothatso Ngako
fa380e94e1 feat: add a SubmissionEvent that carries a nip30303 event as its payload
Every nip30303 kind so far describes a thing: an artifact, a dialect, a
chapter, a translated chunk. None of them describes the act of putting
one in front of a group, and until now nothing needed to -- a group
event's sender was the author of the event inside it, so the two
questions had one answer by construction.

That construction is also the limit. It means a group can only ever hold
work written by its own members under their own keys. A translation
lifted from a public archive, a chapter transcribed by an outside
contributor, an artifact somebody published years ago: none of it can go
in without a member re-authoring it and taking the byline.

Kind 30312 is the envelope that separates them. Its content is the
payload event's JSON, whole -- same id, same pubKey, same signature,
nothing rewritten to look like the submitter's work. The submitter signs
for the envelope; the author still signs for the event. Two tags name
what is inside so a client can decide whether it can apply a submission
without parsing the content first:

  payloadKind   the payload's kind
  payloadId     the payload's id, with the author slot carrying the
                payload's author -- who, unusually for an id tag in
                this package, is often not the event's sender

Kinds 30300-30311 are taken (30305 and 30307 by contributor lists), so
30312 is the next free one.

A submission is not an endorsement and grants nothing. Who may submit is
the group's business; this only makes the question expressible.

The test covers the property the whole thing rests on: an event written
by an outsider goes into an envelope, comes out of a JSON round trip
with its id, author and signature intact, and does not acquire the
submitter as its author. It also pins payload() returning null rather
than something empty when the content will not parse -- which needed
android.util.Log stubbing, since quartz logs on that path and unmocked
Log methods throw, failing the test on the log line rather than on what
it came to check.

Nothing sends or reads one yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:21:00 +02:00
Kgothatso Ngako
e14be2d187 refactor: pick an artifact's dialect, do not invent one while adding it
Adding an artifact offered a "New dialect" chip that swapped in three
more fields -- name, country, language -- and minted a dialect on the
way to creating the artifact. Now that a group defines its dialects on
its own screen, that path is a second, worse way to do the same thing:
it creates a dialect as a side effect of an unrelated action, in a form
where the fields belong to neither entity clearly, and with no sight of
what the group has already defined beyond a row of chips.

Drop it. The chip row is now exactly the dialects that exist, and
selectedDialectId changes meaning from "null = create a new one" to
"null = nothing picked yet" -- which the FAB rejects alongside the other
required fields, rather than falling through to creating something.

A group with no dialects yet gets a line saying so and pointing at the
group screen, instead of a lone chip that opens a form.

addArtifact loses the three TextFieldStates and existingDialectId for a
single dialectId, and with them the branch that called addDialect and
threaded its id back in. Validation is now one condition rather than one
per path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:20:35 +02:00
Kgothatso Ngako
6ff9fd2d38 feat: let a group define the dialects it translates into
Dialects existed but had nowhere to come from. The only way to create
one was the "New dialect" branch buried inside the add-artifact form,
which meant a dialect could only be born as a side effect of adding the
first artifact written in it. A group that wanted to line up the
languages it works in before any source material arrived had no way to
say so, and a dialect created that way was invisible afterwards -- there
was no screen anywhere that listed what the group had defined.

Give the group detail screen a Dialects section between Library and
Projects: the dialects defined in this room, each showing its name over
"<language> · <country>", and an Add Dialect button. The cards do not
navigate -- there is no dialect detail screen to open, and a card that
goes nowhere is worse than one that plainly does not.

The add screen is the add-artifact form with the artifact half removed:
the same chat-room title bar, the same bottom bar with an extended FAB,
the same three fields (name, country, language) styled the same way.
On success it returns to the group with popUpTo<ChatRoomDetailRoute>
{inclusive = true}, replacing the stale detail screen beneath it so the
new dialect is actually in the list when you land -- these lists load
once, in the view model's initiate().

One deliberate difference from AddArtifactViewModel: it wraps its whole
body in `localChatRoom.chatRoom.toMlsGroup()?.let { ... }` and so does
nothing at all, silently, in a NIP-17 room. Nothing under
MantraRepository.addDialect needs an MLS group, so the gate is left out
rather than copied into a new screen as a button that does nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 20:20:07 +02:00
Kgothatso Ngako
b0d0199113 feat: let a member send and read a private message in the room
The UI half. Tapping somebody else's message in a group offers "Reply
privately to <name>", which arms the composer; sending clears it.

The riskiest thing about this feature is not cryptographic. It is somebody
sending to the room what they meant for one person, or the reverse, and
neither can be taken back once it is on the wire. So an armed composer
carries three signals at once -- a chip naming the recipient, a placeholder
that says "Private message to <name>" instead of "Say what now?", and a
tinted field -- and the chip's close button is the single tap back to the
room. The recipient is read and cleared together with the text before the
send suspends, so a second message cannot inherit the first one's
audience.

Two renderings, because a direct message looks different depending on
whether this device can open it.

Readable: the ordinary bubble, plus a lock and "Private to <name>" (or
"Private to you"). A private message must never pass for a public one, and
the label names the other party because that is what a reader would
otherwise assume was the whole room.

Opaque: a system line -- "Alice sent a private message to Bob" -- in the
shape of RitualNotice rather than a bubble. An empty bubble attributed to
Alice would read as her having said nothing, and one with placeholder text
would read as her having said the placeholder. It is not tappable; there
is nothing behind it to open.

Reply privately is offered only on somebody else's message in an MLS room.
A NIP-17 room has no audience for a message to be private from, so there
the option would be meaningless.

Names resolve from the room's participants, which the view model already
holds -- the recipient is a member by definition -- so neither line needs
a join, and both fall back to a shortened key rather than to "unknown". A
line that cannot say which member it means is worse than an ugly one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:08:36 +02:00
Kgothatso Ngako
79e62d8239 fix: attribute a group message to the member who sent it
isUserMessage was computed by comparing the active key against
groupEvent.pubKey -- the kind:445's signer. That is a fresh random
ephemeral key on every send (`NostrSignerInternal(KeyPair())` in
encryptAndSendMarmotInnerEvent), so it could never equal anybody's
identity. The comparison was false for every Marmot message in every room,
which means every message a member sent themselves rendered as somebody
else's: wrong side of the transcript, wrong colour, and no delivery
status, which is drawn only for our own lines.

All fourteen arms now read the MLS sender identity, which is authenticated
to a leaf and is the only thing this can honestly be computed from. For
the four results that carry no leaf index -- the commit and proposal
statuses -- it is null and yields false, exactly what they got before.

Separated from the direct message work that exposed it because it changes
bubble alignment in every existing MLS room, and that is worth being able
to revert on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:05:49 +02:00
Kgothatso Ngako
5ae974517b feat: read a direct message, or say one was sent
Completes the inbound half. A member now files one of three things when a
kind:1059 arrives as an application payload, and which one depends only on
whether their key opens it.

The carve-out first. MarmotInboundManager rejects any inner event whose
pubkey is not the MLS sender's credential identity -- MIP-03, and the check
that stops a member minting events attributed to somebody else. A gift wrap
is keyed to a throwaway key by construction and names nobody, so it cannot
satisfy a check about its author; kind:1059 is now exempt.

The check is not weakened, it is relocated. What replaces it is
`seal.pubKey == senderIdentity` on a seal whose signature verifies -- a
signature bound to an MLS leaf, rather than a plaintext field compared to
one. It is strictly harder to forge: the attack it stops is a member
re-wrapping a seal they were legitimately sent and passing it off to a
third party as its author's, and that fails here because the MLS frame
says who actually sent this one.

senderIdentity also stops being optional. It was previously only compared;
now every sender-derived field reads from it, because the payload carries
no author at all. A leaf with no identity is an error rather than a
mismatch.

Attribution is resolved in NostrDao and handed to fromGroupEventResult,
rather than added to GroupEventResult.ApplicationMessage where it belongs.
quartz is a binary dependency here (com.vitorpamplona.quartz:quartz:1.14.0)
and the local checkout is a reference copy, not a build input, so the
result type cannot gain a field without publishing a fork. NostrDao holds
the group, the leaf index is already on the result, and an application
message advances no epoch, so the tree has not moved by the time it reads
it. Same value, no fork.

The three outcomes:

The recipient opens the wrap and gets the words. The rumor is stored as its
own MarmotInnerEvent keyed on the rumor's id -- the id the sender queued --
so both sides of the conversation hold one message under one identity. The
wrap keeps its own row as the wire artifact.

A bystander gets a line with no content. That is the feature working: the
group is meant to see that a private message was sent and to whom, and
nothing else.

The sender, on a re-sync, is indistinguishable from a bystander, because
the wrap's key was discarded and we cannot open our own message. Left
unguarded this files an empty placeholder over the row sendChatMessage
wrote -- which is the only copy of those words anywhere. Hence the early
return on senderIdentity == us, mirroring the guard
NostrDao.persistInboundChatMessage already carries on the NIP-17 path.

A failed validation drops the message and logs rather than throwing. The
caller is inside storeNostrEvent's transaction, and a forged direct message
should cost its own line, not the whole event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:04:39 +02:00