docs: write down the shared-key subsystem and how Marmot membership fails
First docs in the repo -- README.md is still the stock KMP template. Three
documents plus an index, covering the parts whose behaviour is not recoverable by
reading the code: where the reasoning lives in a protocol, where a failure mode is
silent, or where a decision looked arbitrary and was not.
marmot-membership.md is the one that earns its place. Everything about adding a
member compiles, the invite reports success, and a member simply never appears --
and the reason is never in the invite code. It records that
inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that
ChatRepository does not expose it, so every group invite takes the deferred-welcome
path including the first, when the group is still just its creator and the commit
has no audience at all. Then why that is silent rather than noisy:
MarmotInboundManager refuses future-epoch messages outright, on both wire formats,
with no queue and no replay, so a commit arriving before its recipient's welcome
is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past
epochs and does nothing for messages from ahead. Three options are set out with the
per-invite correctness table, including the honest limit that the recommended one
narrows the race without closing it.
shared-key-derivation.md argues why the paths are not BIP32 -- no chain code
exists, hardened derivation is impossible rather than unimplemented, and a FROST
tweak takes the scalar as input so the chain code leaves the problem entirely. It
records the x-only serialisation trap avoided by choosing the scalar directly, and
states the rule that must not be broken: never reconstruct a derived key in the
clear, because k = k' - t hands over the group key rather than one derived key.
shared-key-ceremony.md covers the seven kinds, the three approval gates and why
the coordinator's aggregations are deliberately not among them, faults as values
rather than exceptions, and the transcript's idempotency-by-construction. It also
writes down the invariant that produces no error when broken: pendingApproval must
mirror the gates in advance, or the screen offers an approval that does nothing --
or none while the ritual sits still.
Every factual claim was checked against the source rather than recalled, which
turned up one correction worth having: there are two future-epoch refusals, for
PrivateMessage and for Commit, so the drop covers both wire formats and not just
one.
Each document leads with the failure mode rather than the architecture, on the
grounds that a failure is what sends somebody to docs in the first place, and each
lists its known gaps -- including that none of this has run on a physical device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
# mantra docs
Notes on the parts of this app whose behaviour is not recoverable by reading the
code alone — where the reasoning lives in a protocol, a failure mode that is
silent, or a decision that looked arbitrary and was not.
| document | covers |
|---|---|
| [shared-key-ceremony.md ](./shared-key-ceremony.md ) | ChillDKG over NIP-17: the rounds, the approval gates, the chat transcript, participant ordering |
| [shared-key-derivation.md ](./shared-key-derivation.md ) | deriving further keys from the group's threshold key with FROST tweaks — why not BIP32, why no chain code, and the one rule that must not be broken |
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
| [frost-batch-signing.md ](./frost-batch-signing.md ) | signing several events in one ceremony — why one nonce can never cover two messages, and the phased schema, wire and UI work that follows from it |
docs: write down the shared-key subsystem and how Marmot membership fails
First docs in the repo -- README.md is still the stock KMP template. Three
documents plus an index, covering the parts whose behaviour is not recoverable by
reading the code: where the reasoning lives in a protocol, where a failure mode is
silent, or where a decision looked arbitrary and was not.
marmot-membership.md is the one that earns its place. Everything about adding a
member compiles, the invite reports success, and a member simply never appears --
and the reason is never in the invite code. It records that
inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that
ChatRepository does not expose it, so every group invite takes the deferred-welcome
path including the first, when the group is still just its creator and the commit
has no audience at all. Then why that is silent rather than noisy:
MarmotInboundManager refuses future-epoch messages outright, on both wire formats,
with no queue and no replay, so a commit arriving before its recipient's welcome
is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past
epochs and does nothing for messages from ahead. Three options are set out with the
per-invite correctness table, including the honest limit that the recommended one
narrows the race without closing it.
shared-key-derivation.md argues why the paths are not BIP32 -- no chain code
exists, hardened derivation is impossible rather than unimplemented, and a FROST
tweak takes the scalar as input so the chain code leaves the problem entirely. It
records the x-only serialisation trap avoided by choosing the scalar directly, and
states the rule that must not be broken: never reconstruct a derived key in the
clear, because k = k' - t hands over the group key rather than one derived key.
shared-key-ceremony.md covers the seven kinds, the three approval gates and why
the coordinator's aggregations are deliberately not among them, faults as values
rather than exceptions, and the transcript's idempotency-by-construction. It also
writes down the invariant that produces no error when broken: pendingApproval must
mirror the gates in advance, or the screen offers an approval that does nothing --
or none while the ritual sits still.
Every factual claim was checked against the source rather than recalled, which
turned up one correction worth having: there are two future-epoch refusals, for
PrivateMessage and for Commit, so the drop covers both wire formats and not just
one.
Each document leads with the failure mode rather than the architecture, on the
grounds that a failure is what sends somebody to docs in the first place, and each
lists its known gaps -- including that none of this has run on a physical device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
| [marmot-membership.md ](./marmot-membership.md ) | how members join an MLS group, and the epoch race that makes a missing member look like a successful invite |
docs: write down how a direct message travels, and what it costs
The reasoning behind this is not recoverable from the code, which is the
bar docs/README.md sets for having a document at all. Three things in
particular would otherwise have to be rediscovered by whoever changes this
next, and two of them are traps.
Why the wrap uses a throwaway key rather than the sender's own -- and what
that does not buy. It does not hide the sender from the group: MLS
authenticates every application message to a leaf, so the identity is
there regardless. What it costs is a carve-out in MIP-03's pubkey check
and the sender's ability to ever read their own messages back.
Why the check that carve-out removes is not a hole. The authorship claim
moves from the wrap's plaintext pubkey to the seal's verified signature,
bound to the MLS leaf that sent it -- strictly harder to forge than what
it replaced.
The one query that would broadcast one of these. What this builds is a
genuine, correctly signed NIP-59 gift wrap, indistinguishable from what
the NIP-17 path would be right to publish, and the only thing keeping it
off a relay is that it never becomes a GiftWrapPayload.
Written against what shipped rather than what was planned, so it records
two deviations. senderIdentity is resolved in NostrDao rather than added
to GroupEventResult.ApplicationMessage, because quartz is a binary
dependency here and the local checkout is a reference copy, not a build
input. And a failed validation drops the message and logs rather than
throwing, because the caller is inside storeNostrEvent's transaction.
The unbuilt parts are listed as absences rather than left implied: there
is no member picker, so a private message can only be a reply to one
somebody already sent, and nothing in the UI yet tells a user in words
that the group can see who they messaged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 19:10:22 +02:00
| [marmot-direct-messages.md ](./marmot-direct-messages.md ) | a one-to-one message inside a group as a stock NIP-59 gift wrap — what its MIP-03 carve-out costs, why the sender cannot read their own, and the one query that would broadcast it |
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
| [mls-skipped-keys.md ](./mls-skipped-keys.md ) | why a group event that arrives a moment late is dropped for good, which flows trigger it, the quartz fix, and the partial mitigation in this app |
docs: rewrite the sync note as what exists rather than what to build
The design landed across the six commits before this one, so the note is now
describing code. Reorganised around that: the reasoning that made it worth
writing is unchanged, but "the shape to build" is now "how it holds together"
and points at the classes, and the numbered traps have become properties of the
thing rather than warnings about a thing that did not exist yet.
Three sections earn their place after the fact:
- the two timestamp decisions, which are the ones most likely to be "cleaned
up" by someone who has not read this: no `since` on kind 1059 because our
own wraps are stamped up to two days in the past, and no watermark on 445
even though it would be safe, because `limit` already bounds the burst.
- the four ways a group id can appear, which is why the group filter is
derived from the room list rather than wired at the join sites.
- "Not done", which was previously implicit in a staging plan: connectivity
changes, NIP-42 AUTH, the collector-per-socket router the design originally
called for, and the fact that the DM relay set is one relay.
The "suggested order" section is gone; git log is a better record of it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:20:08 +02:00
| [long-running-sync.md ](./long-running-sync.md ) | the chat subscriptions that stay open instead of pulling once per screen — why the request queue could not simply hold one, and how the group filter follows the room list |
docs: inventory the unreferenced code in the sync and relay stack
Found while building the long-running sync. One item was orphaned by that
change; the rest was already dead and only became visible because the subsystem
was being read closely. Written down rather than deleted because several pieces
are one decision away from being wanted, and those decisions are not the sync
change's to make.
Every claim is "this identifier appears exactly once in composeApp/src, at its
own declaration", with the two things that method cannot see called out: Room
DAO methods are reached through generated code, and Compose entry points can be
invoked without a textual reference. The DAO cluster is flagged as the least
certain for exactly that reason.
Three findings are more than leftovers:
- RelaysSocketManager.userRelays is a field nothing ever writes. The
`userRelays` inside observeRelays is a different, shadowing local, so the
single-argument publishEvent always takes its FALLBACK_RELAYS branch and the
user's own relay list is never used for publishing. That is a bug wearing
dead code's clothes, and the fix is to populate the field, not to delete it.
- NostrPublisherRepository is entirely unreferenced, and it is the only
consumer of CachingImportRepository.importEvents. RelayPool and
RelaysSocketManager each take a cachingImportRepository parameter they store
and never dereference, satisfied by NO_OP_CACHING_IMPORT_REPOSITORY — so the
whole seam is a parameter passed from nowhere to nothing. Removing the
publisher lets the interface and both parameters go with it.
- sendAUTH is unused because NIP-42 is unimplemented, not because it is
surplus. AuthMessage is parsed and dropped, so a relay answering CLOSED with
auth-required is retried forever and can never succeed. Deleting sendAUTH
means deciding against authenticated relays; that is worth doing on purpose
or not at all. sendCOUNT and CountMessage are a similar matched pair — both
go or neither, since a CountMessage cannot arrive if nothing sends a COUNT.
isRecommendedRelay on the two request entities is separated out as its own risk
class: never written, never read, but a Room column, so it wants a migration
rather than a delete.
Ends with an order to do it in, cheapest and least risky first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 16:28:21 +02:00
| [dead-code.md ](./dead-code.md ) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover |
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
| [jvm-target.md ](./jvm-target.md ) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it |
docs: write down the shared-key subsystem and how Marmot membership fails
First docs in the repo -- README.md is still the stock KMP template. Three
documents plus an index, covering the parts whose behaviour is not recoverable by
reading the code: where the reasoning lives in a protocol, where a failure mode is
silent, or where a decision looked arbitrary and was not.
marmot-membership.md is the one that earns its place. Everything about adding a
member compiles, the invite reports success, and a member simply never appears --
and the reason is never in the invite code. It records that
inviteMemberToChatRoom hardcodes isOneMemberInitialGroupCreation = false and that
ChatRepository does not expose it, so every group invite takes the deferred-welcome
path including the first, when the group is still just its creator and the commit
has no audience at all. Then why that is silent rather than noisy:
MarmotInboundManager refuses future-epoch messages outright, on both wire formats,
with no queue and no replay, so a commit arriving before its recipient's welcome
is dropped and that member never advances. EPOCH_RETENTION_WINDOW retains past
epochs and does nothing for messages from ahead. Three options are set out with the
per-invite correctness table, including the honest limit that the recommended one
narrows the race without closing it.
shared-key-derivation.md argues why the paths are not BIP32 -- no chain code
exists, hardened derivation is impossible rather than unimplemented, and a FROST
tweak takes the scalar as input so the chain code leaves the problem entirely. It
records the x-only serialisation trap avoided by choosing the scalar directly, and
states the rule that must not be broken: never reconstruct a derived key in the
clear, because k = k' - t hands over the group key rather than one derived key.
shared-key-ceremony.md covers the seven kinds, the three approval gates and why
the coordinator's aggregations are deliberately not among them, faults as values
rather than exceptions, and the transcript's idempotency-by-construction. It also
writes down the invariant that produces no error when broken: pendingApproval must
mirror the gates in advance, or the screen offers an approval that does nothing --
or none while the ritual sits still.
Every factual claim was checked against the source rather than recalled, which
turned up one correction worth having: there are two future-epoch refusals, for
PrivateMessage and for Commit, so the drop covers both wire formats and not just
one.
Each document leads with the failure mode rather than the architecture, on the
grounds that a failure is what sends somebody to docs in the first place, and each
lists its known gaps -- including that none of this has run on a physical device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 14:39:19 +02:00
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
Start with the ceremony if you are new to this area; the Marmot notes all assume it.
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
Read the skipped-keys note before debugging any "the other device never got it"
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
report — it is silent, and it looks like every other kind of delivery failure. The
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
sync note stands alone, and the dead-code inventory reads as a follow-up to it. The
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
batch-signing note is a plan rather than a description of what is there: read it
after the derivation note, whose one rule is the same one it is built around. The
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
jvm-target note is unrelated to all of them: it is a build and packaging story.