Files
mantra-kmp/docs/README.md

73 lines
7.7 KiB
Markdown
Raw Normal View History

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 |
docs: plan subgroups, and phase the four ceremonies a group needs to make one A group can make another group, and the child can prove where it came from. This is the plan for that, in nine phases, written against the code at b50b1762 and not yet built. **A subgroup is an ordinary robust group plus one artefact.** Fresh ChillDKG key, fresh room, fresh quorum, and a birth certificate -- the parent's signature over the child's room id -- carried on the child's `GroupKeyState`. Deriving the child at `m/9420/1/0` instead would cost no ceremony at all and was rejected: a derived child is the parent wearing a different hat, administered by the parent's members with the parent's quorum, when the whole point is that a different set of people can act on their own. The certificate is a claim about lineage, never a delegation of authority, and nothing here lets one group sign for the other. **Four steps, in the only order they can happen.** The ceremony produces `K`, so the child's id exists; the parent's quorum certifies that id; the child's quorum signs a key state carrying the certificate; the coordinator creates the room. No step is a policy choice -- each needs the one before it -- and the last is gated on the key state for the same reason `createAdminGroup` already is. **What the parent's admins actually sign is the argument that shaped the event.** Taken literally the certificate is 32 opaque bytes produced by a ceremony most of them were not in. So the content is exactly the new group id as specified, and the tags carry the child's threshold key, the path and the admin set -- covered by the same signature, since an id hashes over its tags -- which lets a signer's device check `marmotGroupId(key, path) == content` before agreeing, and lets a coordinator who lies about who is in the child do it in a field the parent's signature covers. **The whole certificate travels as JSON on the key state, not a bare signature.** A signature plus a rule for rebuilding the event it covers is a rule that breaks silently the first time the event's shape changes: a rebuild differing by one byte hashes to an id whose signature fails, and is indistinguishable from a forgery. A parent tag rides beside it as an index into the certificate rather than a second source of truth -- Phase 3 drops any state carrying one without the other, or the two disagreeing, so there is no state where the index is believed and the certificate is not. **The ceremony stays on gift wraps, and the reason is `mls-skipped-keys.md`.** Holding all three steps in the parent's Marmot room is the better design and the plan says so at length rather than dismissing it: the certificate already runs there, and the key state and the ceremony move together or not at all, since both `GroupKeyStateManager.propose` and `signingPath` tie a key state to the room its ceremony ran in. The mechanical cost is three enumerable changes. The reason to wait is that the skipped-keys note already lists `proposeRitual` as a reliable trigger, and a DKG cannot finish without every participant -- so one message dropped for good stalls it permanently, where FROST needs `t` of `n` and routes around a lost nonce. Revisit when the quartz fix lands; the collision Phase 4 refuses disappears with it. **Three admins in total, and the threshold is set before anything is published.** Three is `ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE` for the reason that constant gives, and the coordinator counts because they hold a share by construction, so the picker asks for two others. `t` has to be chosen on that same screen and nowhere later: ChillDKG hashes it and the host keys into the session identity, so it is fixed the moment the proposal goes out, and a group that disagrees about it gets no key rather than a weak one. The nine phases are ordered so the checkable parts come first and can ship dark: the certificate and its verifier are pure, the schema is three nullable columns, and nothing produces a certificate until the button in Phase 7 exists. Phase 6 extracts the 120 lines of Marmot room creation out of `DkgRitualViewModel` so both flows share the rules that are already right there. What it does not do is named rather than left to be found: no revocation, no delegation, certificates are not chroniclable, one subgroup per admin set, and every selected admin has to show up twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:53:31 +02:00
| [subgroups.md](./subgroups.md) | a group making another group — the four ceremonies, what the parent's signature actually covers, and why the child's key is fresh rather than derived |
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 |
refactor: call it a chronicle, and keep "archive" for what a user does to a chat Archiving a chat is an ordinary thing a user will want to do to a conversation, and it is not this. This is the group's signed record, handed to a member who joined after the work was done so their room stops being empty. Two unrelated meanings of one word in one app is a bug waiting to be written, and `ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on the chat row, named for the thing that is not the chat. So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`, `ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags, `ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move; only the words do. **The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` -> `chroniclePage`, which is free exactly once. Both kinds are new and there is no old build to stay compatible with -- the design note says so in as many words -- so the alternative was carrying the old spelling on the wire forever to save a rename that costs nothing today. The recipient tag stays `p`; it was never ours. **Schema v14, because two things had the old word written into stored data.** `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than dropped and re-added: while it is set it is the only record that a device with an empty room has already asked the group for its history, and a device that lost it mid-flight would ask again on its next launch, and the one after that. The three `ChatMessage.messageType` strings become their `chronicle*` spellings, rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was. These lines cannot be regenerated -- a chronicle is announced once, when it is requested, sent and applied -- and an unrecognised type is not skipped by the transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items" would come back attributed to a member as something they said. `MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is in no index, no foreign key, and there is not a view or trigger in the database -- so nothing has to move with it. Five tests hold the two halves apart: the value survives, the column keeps its position, a room that never asked still reads as never having asked, the three types are rewritten, and every other type is left alone. **`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps its job unchanged: the allowlist that stands between a replayed `GroupKeyStateEvent` and the apply path. No behaviour change beyond the rename. 797 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:27:36 +02:00
| [member-chronicle.md](./member-chronicle.md) | handing a member added after the work was done the group's signed record — why the events are not on the wire at all, and why the room's id is enough to verify them |
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 |
| [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 |
| [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable |
docs: plan npub sign-in, starting from what a read-only identity is for The nsec plan's out-of-scope note said a read-only mode is a product, not a branch. This plan takes that at its word: what a public key can see here is thin -- a profile card, its follows as search results, no feed, no rooms, since every room is MLS or a gift wrap to the key that was not pasted -- so the first section decides what such an identity is for before anything is designed. It is a preview: the app as your own profile, before you paste a secret into it. That one word settles the Messages tab (an empty state that offers the upgrade), pasting the nsec of a read-only key (an upgrade in place under the same id, not "already on this device"), sign out (real for this kind only), and not-found (try again or a different key, never set one up). Eight phases: a nullable key on Identity, with the note that the compiler will be silent about it; a plaintext list beside the two key files, app-side through the public getDatadir and AtomicFileWrite so nothing needs a JitPack tag; the third startup branch, a read-only KeyPair built in one place, and only the two pumps that read; the sign-in screen, where hex stays a secret because an x coordinate is almost always also a valid scalar; a LocalCanSign capability and an inventory of every write entrance one tap from the three tabs; two exits; two round trips; rollout. Two traps found on the way are recorded where they bite: quartz's KeyPair(privKey = null) generates a fresh key rather than meaning "no key", and decryptGiftWrapSeal forwards exactly that; and signInToProfile plants a second kind 0 on a second call, which nothing reached until the upgrade path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@78807fe956a212885ffffd0d340647b94c1fc23b
2026-09-12 15:21:13 +02:00
| [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles |
docs: plan several profiles on a device, starting from what a profile is The switch already exists as a state transition -- switchToWallet, resetToSelector, a null identity sent to startup with popUpTo(0), every collector a child of collectLatest -- and is reachable from nowhere: Landing shows only when the device holds no identity, so a second can never be added, and the profile tab's "change account" is a pending route. This plan builds the two entrances and fixes what the transition gets wrong. Before either, it changes one rule the two sign-in plans share. A seed's nostr key is not written to the credentials file; it is derived from the words at listing and from the running node at activation, and the writers keep one key out of two files. That puts the wallet where the profile should be: the list is a merge of two files with opposite ideas of what a row is, and the node has to run for a profile to know its own key. Phase 1 makes a profile a credential and a seed a wallet attached to one -- the credential written when the seed is, repaired into the file for every seed already on the device, merge inverted to list credentials and attach seeds by pubkey, the identity's key read from the credential with the node's as a cross-check, and a second refusal on forget for a key a seed derives. The node still starts for a profile with a wallet attached, for the channel watcher rather than for the key; making it lazy is now one branch and is named as its own decision. The other decision is that a switch is a restart of the signed-in graph, not a swap under it: every route carries the key it was pushed for. That settles the switcher as a pushed screen behind one tap, the previous node stopped, the last-used profile as the one that opens on launch, and a profile added from inside switched to. Nine phases: the credential; the switch, with the relay observer that never cancelled its predecessor and the node that kept running; the startup precedence, which put "show me the list" above "open this one" and never saved a default; the switcher, showing the nostr profile rather than "Default name" and labelling a row with a wallet attached; the add rows, where a profile created from inside is a bare key and not a second wallet, and "end this" -- which wipes every profile's database -- goes; an owner on the two fetch queues and an inbox sweep on activation, because a gift wrap fetched under the other profile's key is stored and never opened again; the exits; a round trip; rollout, with the one downgrade that lists a seed's profile twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@a5ff264164aafe89c1eb7f0487db61e9e1f6d436
2026-09-13 00:09:02 +02:00
| [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose |
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 |
| [npub-profile-preview.md](./npub-profile-preview.md) | showing the person before a direct message is started from a pasted npub — why the preview is a screen keyed by public key, what "found" means when a row can be a placeholder, and the phase that makes the button honest about a key package that never comes |
docs: measure the UI against the M3 foundations, and phase the work that follows A plan, not a change: what m3.material.io/foundations asks for as of its May 2026 revision, what these 43 screens actually do, and eight phases ordered so that each one makes the next mechanical rather than judgemental. **The spec was read, not remembered.** m3.material.io is a client-rendered SPA -- WebFetch returns an empty `<main>` and the tab URLs 404 on direct navigation -- so the numbers here came out of a real browser session clicking through the tab controls. That mattered: the May 2026 revision renamed window size classes to **breakpoints** and there are now five of them rather than three (compact / medium / expanded / large / extra-large, at 600 / 840 / 1200 / 1600dp), renamed responsive design to adaptive design, and published the spacing system as tokens on an 8dp scale where `space100 = 8dp`. Writing this from memory of older M3 would have produced a plan against a vocabulary the current spec no longer uses. **The palette is fine; the call sites are not.** Every `onX`-on-`X` pair in all six declared schemes clears 4.5:1, the tightest being `onPrimaryContainer` on `primaryContainer` at 4.61:1 light and 4.56:1 dark. So the generated scheme is not the problem and this plan does not propose a repalette. What fails is colour decided locally, seven pairings of it, and the worst is not visible to a reviewer: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = ...)` does derive `contentColor = contentColorFor(...)`, so `LocalContentColor` inside the card is correct. But `ListItem` does not read `LocalContentColor` -- its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface` -- and the call site overrides only `containerColor`. In the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. That is **1.00:1**, and it is applied exactly to `proposal.awaitsYou`, so the proposals waiting on your signature are the ones rendered invisible. `HomeScreen`'s `titleContentColor = primary` on `containerColor = primaryContainer` is the same mistake at 1.22:1. Ratios were computed rather than eyeballed; the script is in the Phase 0 deliverable. **Twelve colour roles fall through to Material baseline lavender.** `Color.kt` never assigns `primaryFixed`, `primaryFixedDim`, `onPrimaryFixed`, `onPrimaryFixedVariant` or the secondary/tertiary equivalents, so `lightColorScheme()` defaults them to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`. Nothing reads them today, which is why it has never been noticed; the trap springs the first time an expressive component does. Read out of the pinned `material3-desktop-1.10.0-alpha05-sources.jar` rather than assumed. **Four of the six declared schemes are unreachable.** The medium- and high-contrast variants are written out in full in `Color.kt` -- 78 colour values -- wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and then never selected: `TorchTheme` chooses between `darkScheme` and `lightScheme` only. The work to honour a platform contrast setting is already done and disconnected. **10dp and 20dp are not the problem they look like.** They are the two dominant spacing values (132 and 115 uses) and both are *on* the M3 scale, as `space125` and `space250`. The plan says so rather than proposing a sweep that would change nothing. What is wrong is that none of the 520 `.dp` literals records whether it is padding, a gap or a margin -- the three categories the spec gives different rules to -- so nothing can be adapted per breakpoint later. About 101 are off-scale (50dp x 53, 15dp x 14, 5dp x 10 and so on), and `Modifier.height(50.dp)` appears 49 times as the same copied spacer above the same copied error message. **Findings that were measured and then dropped.** `outlineVariant` reads 1.61:1 against surface and `secondaryContainer` 1.65:1, both of which look alarming and neither of which is a defect: M3's own baseline sits in the same range, and the 3:1 rule the spec gives is for clustered interactive containers, not dividers or tonal surfaces. `onSurface.copy(alpha = 0.38f)` is the specified disabled opacity and the spec exempts disabled states from contrast entirely. Reporting these would have padded the count and cost the reader trust in the rest. **The rest of the audit, in counts.** 334 string literals in composables against 2 `stringResource` calls, with title case throughout ("Edit Profile", "New Chat") where the style guide asks for sentence case. Zero `Snackbar` across 26 `Scaffold`s. 16 copies of `Text("Something went wrong")`, none of which offers a retry. 90 of 240 typography reads on `label*` roles, which are for component text, while `display*` and `headline*` carry 9 uses between them across 43 screens. 33 bare `Modifier.clickable` with no minimum target, two of them text-height. Two `BoxWithConstraints` and no window-size handling at all, on a project with a desktop target whose own entry point already says so in a comment. **Eight phases, ordered by what each unblocks.** 0 baseline harness, 1 theme, 2 spacing tokens, 3 accessibility floor, 4 content, 5 states and feedback, 6 adaptive layout, 7 motion, 8 guard rails. Tokens come before the call sites that consume them; the accessibility floor comes before the adaptive work that would otherwise double the surface to fix; guard rails come last so they lock in real state rather than aspiration. Phase 6 is the only one that cannot be done mechanically and the only one marked not reversible alone. **What it deliberately does not decide.** Whether the target is `MaterialExpressiveTheme` or `MaterialTheme` -- the pinned material3 ships the full expressive set and the code already opts into `ExperimentalMaterial3ExpressiveApi` in 66 places, but it changes default component shapes and sizes app-wide, so it is a product call and Phase 1 raises it rather than answering it. Also out of scope: whether the monochrome palette is right, the per-component specs, iOS (which only builds on a mac, and whose HIG asks 44dp where M3 asks 48dp), and the three package namespaces the UI currently lives across. No code changes. `docs/README.md` gains the row and the closing paragraph's note on how this one relates to the others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:44:32 +02:00
| [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme |
docs: plan the pull of Curated's work back into Mantra, and the tool that makes it mechanical Curated (remote `curated`, curated/curated-kmp) forked from this repository at ba26c0b1 on 2026-09-09 and has landed thirty-nine commits since: a group's own nostr identity, curated lists over kinds 31888-31890, sign-in with a recovery phrase, an nsec or an npub, a back button on every pushed screen -- and two rebrands, 744 and 765 files each, underneath all of it. docs/curated-to-mantra.md is which of that Mantra wants, in what order, and how it lands under Mantra's names. docs/scripts/curated-unbrand.py is the rewrite the plan depends on, and docs/README.md gets the row. **The plan is measured, not argued, and that is its whole claim to be read.** The obvious way to write it was to reason from the commit list about what would conflict. Instead the method was run to the end on a scratch worktree before a sentence was written: Curated's history rewritten into Mantra's names, replayed onto origin/mantra with `rebase --rebase-merges`, built for both targets, and put through jvmTest, testDebugUnitTest and the m3 audit. Every number in the document is from that run -- 37 of 39 commits replay, 3 conflicts with known resolutions, 1,036 jvm tests and 530 common tests at 0 failures, all audit budgets met, Room regenerating nothing -- and the appendix records it so the next reader can tell a drifted number from a wrong method. A plan whose mechanics had not been tried would have been a list of hopes about seven hundred renamed files. **Mantra has not moved since the fork, and that decides the shape of the problem.** The merge-base of the two branches is origin/mantra itself, so `git merge curated/curated` is a fast-forward: it merges nothing and makes Mantra become Curare, icon, package rename and the deletion of Mantra's own library, dialects and projects sections included. The document says so before anything else because it is the one thing git does by default. It also means the pull is not a merge at all but a translation -- a Curated commit written in Mantra's vocabulary applies to Mantra as if it had been written there -- which is why a history rewrite followed by an ordinary rebase works, and cherry-picking the original commits (every path and every import line in every context wrong) does not. **808a3459, which drops the translation sections, is dropped rather than reverted afterwards, because that was measured too.** The first plan was to replay everything and restore the sections with a forward commit written against the final row layout. Trying the drop instead showed git merging 930d37c8, 1e52fc8f and 55664cc7's rewrite of ChatRoomDetailScreen around it without a conflict: the screen comes out as signing key, the five identity rows, Propose event, then Library, Dialects, Projects, then Subgroups, with `artifacts` and `dialects` still on the UI state, and the same 1,036 tests pass -- GroupSignedWorkRowsJvmTest only asserts that Subgroups is below the block. The one conflict is a modify/delete on a test 55664cc7 deletes anyway. A restore commit would have been Mantra owning a layout upstream never had. **Curated lists are pulled although they are not Mantra's product, and the reasoning is written out in full because it is the only real product question.** Excluding the five commits is not deleting five commits: the paste flow routes kind 31889 beside 0, 1 and 10002, broadcast seeds an entry's relays from its schema, the row restructure builds five rows of which two are these, the back-button sweep touches the suggestion screens, and 165 of the 306 strings added since the fork are theirs, interleaved. Carving that out means editing five other commits by hand and then owning a group screen that conflicts with upstream's on every future pull. Including it costs no schema, no migration and no query on a screen nobody opens. The principle underneath is stated as such: the method only stays cheap while Mantra's tree is a superset of Curated's non-brand tree, so every feature left out is a permanent seam. If product wants the two rows gone, that is two lines behind a constant, not surgery. **The brand line lands as one re-messaged commit of comments, and Torch is retired natively first.** Run through the rewrite, the two rebrand commits collapse from 744 and 765 files to sixteen and twenty-one, and what survives is the reasoning the rebrands added about the names they refused to change -- the comments on TWEAK_TAG, HOST_KEY_DERIVATION_TAG and Relays.ephemeral and the paragraph in shared-key-derivation.md. Mantra should have those; the next person to grep "mantra/" here is as likely to finish the rename as anyone upstream was. The rewritten d26cf6c7 is a different case: Mantra is still Torch in TorchTheme (116 references), UserAgent, two error strings, and an iOS config that builds Torch.app under a bundle id from two brands ago. That is Mantra's own debt and Phase 0 pays it as MantraTheme and "Mantra", so that CurareTheme maps onto a name that means something rather than onto the brand before last. Then d26cf6c7' is dropped in the replay, since its work is done -- the plan was made to say this explicitly after Phase 0 and Phase 2 were found to disagree about it. **The normaliser is a script in docs/scripts rather than a description, because three of its rules were wrong before they were right and prose would have hidden that.** `\b` does not fire inside snake_case, so `\bcurare\b` misses the string keys that carry the brand and they need look-around rules that treat `_` as a boundary. File names carry identifiers, so a path rule that only moves directories leaves MantraNavHost.kt and CurareNavHost.kt side by side with equal content. And "Curated" is two words -- the brand in generation one, the protocol's name at the tip (CuratedSchemaEvent, nostr/curated/, the queue's "Curated" mark) -- so the bare word is deliberately not a rule and only the generation-one identifiers are mapped, which is exact because the two commits written in that generation never use the word as a brand in code. Every rule matches a brand token and none matches a Mantra one, so the twelve Mantra* entities, the two mantra/ prefixes and ephemeral.mantra.press cannot be touched by construction; the check is that the entity-token count does not fall (433 before, 435 after, none lost) and that the rewritten tip compiles, which no missed identifier would survive. It has a tree mode for `filter-branch --tree-filter` and a patch mode for `format-patch` files; the consolidated script was re-run on a fresh export of the tip and reproduces the tree that was built and tested, to the byte, logo aside. **The phases are cuts through the graph, not lines of work, so no commit is replayed twice and the merges are recreated where they were.** The natural grouping is by feature line, but the lines interleave -- the nsec branch and the queue fork at the same commit and rejoin at c8de3a1f, the back-button branch joins the accept flow at fedbe724 -- and two of those merges carry hand resolutions that a flattened replay would silently lose. Each phase therefore replays every commit reachable from its cut that the previous phase did not, `--onto <mantra tip> <previous cut> <this cut>`, and the two conflicts and two reconcile files that the dry run found sit in Phase 4 with their resolutions written down as a runbook. The exactness check closes each phase: the diff between the replay and the rewritten tip must be exactly what was chosen to drop, and anything else is a rule that is wrong or a resolution that is, found there rather than in production. **Phase 6 says what the fork should become, because otherwise this document is needed again next week.** Every future upstream commit is written in to.curare.*, and each pull costs the dry run, the review and the prose -- affordable once, corrosive weekly. The options are given in the order they should be taken: converge the source package (a Kotlin package is not a brand, and the rebrands' own messages say the domain is Mantra* everywhere that matters), then invert the relationship so Curated is Mantra plus a thin brand-and-product series, and only failing both keep the normaliser maintained beside the code it maps. Two things already flow the other way and are named: 39fb64b6, since Curated still carries the dead WalletManagerExtension.kt and one caller, and Phase 0's Torch retirement. **Several counts in the draft were checked against the data and corrected before they were committed.** The entity-token figure quoted from the rebrand's message (1,369) was measured with a different pattern and is replaced by this run's own (433 -> 435); "roughly 150 of 293" strings became 165 of 306; the back-button commit touches eight of Mantra's translation screens, not nineteen; and the claim that nothing in the fork added a DAO method was too strong -- three queries were added, and the statement now says no table and no migration, with 19.json byte-identical after a build as the evidence. The dry run, for the record: normaliser at the tip 812 files rewritten and 806 paths moved; filter-branch over 39 commits in 84 s; replay with the logo and 808a3459 dropped 37 commits, 3 conflicts, 2 reconcile files, residual diff exactly the logo plus the three files 808a3459 touched; :composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean; :composeApp:jvmTest 1,036 tests, 0 failures (Mantra alone: 733); :composeApp:testDebugUnitTest 530 tests, 0 failures; :composeApp:m3Audit all budgets met with 12 adaptive uses and 2 navigation components. The scratch worktree and the tmp/ branches were removed afterwards; the script recreates them in under two minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:04:35 +02:00
| [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand |
docs: plan the second pull from the fork, with its dry run already done The upstream moved the day the first pull landed: ten commits on curated/curated, 86cb876b..29027f2b, that let a device hold several profiles and move between them, and with them the fork's first schema migration, version 20. The first plan named them the next pull and said the schema change earned a decision of its own. This is that plan, and unlike the first it was written after the dry run rather than before it, so its numbers are measured: rewrite from the fork point in 103 s, ten commits replayed onto 776455ec with nine clean and one resolved by rule, both compilers clean, jvmTest 826 -> 868 and testDebugUnitTest 413 -> 420 with no failures, every audit budget met, and 20.json regenerated byte-identical after a full build. The verdict is all ten, because the line is one feature and seven fixes braided together and four of the fixes are live on Mantra today with one profile: the relay observer that is never all cancelled, the read-only inbox that stays closed after its nsec is pasted, the DataStore race on desktop, and the startup screen's wallet-worded literals. Four decisions: take the whole line rather than carve the fixes out of five commits; take upstream's version 20 verbatim and adopt the rule that whichever tree migrates first owns the number; the one conflict is Mantra's own import from 39fb64b6, resolved theirs, which closes one of the first plan's owed reverse-pulls; and two of the device's profiles in one group is a Mantra follow-up before release, since ChatRoom is keyed by the group id alone. What changed in the method: Mantra's tree is no longer a superset of the fork's, so the exactness check becomes "the residual between the trees is the same before and after the pull, file for file", and the plan gives the commands. The replay driver gains the one rule the dry run needed, with its reason. The README gets the row and a reading-order sentence, and the first plan points forward from the paragraph that predicted this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:41:24 +02:00
| [curated-to-mantra-profiles.md](./curated-to-mantra-profiles.md) | the second pull: the fork's several-profiles line, ten commits and its first schema migration — which of it is a fix Mantra has today, who allocates a schema version number when two trees share one history, and the one conflict, which was Mantra's own |
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
docs(frost): record batch signing as built, and what rollout needs Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it. Nothing needs a feature flag. k=1 is the entire behaviour of the app as shipped -- no caller batches anything yet -- and at k=1 every message is byte-identical to the app before Phase 1: encodeProposal returns the bare event object, joinPayload of one value is that value, and every plural branch in the transcript is only taken above one. The doc now tabulates that rather than asserting it in prose, since it is the claim the whole rollout rests on. The one rollout constraint stands: before a caller batches, the group has to be on a build that understands array proposals. There is no negotiation for it and adding one is not worth it -- an old device refuses an array proposal outright, so the failure mode is a batch that never reaches threshold and is abandoned, visible in the transcript and costing a retry. Also records what is left, which is nothing in the protocol: deciding what to batch is a product question, bounded only by "a batch is only as available as its worst item" and "GroupKeyStateManager.propose must never batch". The phases are kept as written rather than rewritten into a description of the result -- the code reads better against the argument it came from -- with the two places the implementation chose differently (itemIndex over index, DROP COLUMN over a table rebuild) marked in their own sections. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
batch-signing note is a phased plan that has been built: read it after the
docs: plan handing a new member the group's signed history A member added after the work was done sees none of it, and nothing in the app will ever show it to them. Two independent reasons, and the second is the one that surprises people. MLS gives no history: a Welcome carries the ratchet tree at the current epoch, not the transcript, and `MarmotInboundManager` drops anything from an epoch it holds no keys for. That is forward secrecy working rather than a gap to close. But group-signed events never travel at all. `FrostSigningManager.complete` says so in as many words -- a signed event authored by the threshold key cannot go out as an inner event, because the outbound pipeline would re-author it as its sender and strip the group's signature off -- so every device *derives* the finished event from its own `FrostSigningItem` rows. A member who was not in the session has no items, and no later message carries the event. So the second problem does not follow from the first and is not fixed by fixing it: even a member who could decrypt the whole back-transcript would still hold nothing an artifact, chapter or chunk could be built from. Which makes an archive not a convenience but the only path, and fixes the line the design has to hold: **it carries what the group signed, never the chat.** Restoring the chat would undo forward secrecy on purpose, and a signed event is the only thing a new member can check for themselves. **The property the whole plan rests on is already true.** A room's id *is* the group's threshold key derived at the room's path -- `GroupKeyState.verifies` and `FrostSigningManager.signingPath` hold that invariant from their own ends -- so `isSignedByGroup`'s three checks collapse to `event.pubKey == chatRoomId`, an id check and a signature verify. No key state row, no threshold key, no path, no lookup. A member who can name the room can verify its signatures, which is exactly the position a new member is in, and it means the sender of an archive does not have to be trusted at all. **Two guards the plan makes non-negotiable.** Nothing on the inbound nip30303 path verifies a signature today, and that is currently correct: rumors carry an empty sig and are authenticated by the MLS frame, so nothing on the wire has ever claimed group authorship. An archive is the first thing that does, so the verify is the feature's entire security rather than hardening on top of it. And verification turns "group-signed" into an admission ticket for the apply path, which is a wider door than it looks: a `GroupKeyStateEvent` is group-signed and would pass perfectly, so an archive could replay a genuine old one and re-point what the room signs with. The archive therefore carries an allowlist of document kinds, checked outbound and independently inbound -- the same shape, and the same reasoning, as the cap on `k` in frost-batch-signing.md. **Push and pull, in that order of appearance and the reverse order of importance.** Pushing an archive after the Welcome is what the question asked for, and on its own it fails the way marmot-membership.md describes: it is an application message in the epoch the add created, so one that beats the Welcome there is dropped rather than deferred, silently, while the inviter sees a success. So the joiner asks instead -- a request is proof it has processed its Welcome, and it covers the reinstall and the second device, which no invite-time push can. The push stays as a latency optimisation, deliberately phased after the thing that makes it safe. Nine phases: the verifier, the events, assembling an archive, applying one and the sweep that lets pages arrive out of order, the request, the push, UI, the cross-device tests, and rollout. The sweep needs no new table -- the inbound path already stores every inner event it decrypts, so it is the shape `FrostSigningManager.replayStoredMessages` already has. Also written down, because it is the first thing this will be reported as a bug for: an archive lets a new member *read* everything and does not let them sign anything. `proposeSigningBatch` wants a secret share and a place in the ceremony, and a group that re-runs its ceremony derives a different room rather than re-keying this one. Closing that needs share resharing, which is a great deal more work than this and is the thing to build after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:52:33 +02:00
derivation note, whose one rule is the same one it is built around. The member
refactor: call it a chronicle, and keep "archive" for what a user does to a chat Archiving a chat is an ordinary thing a user will want to do to a conversation, and it is not this. This is the group's signed record, handed to a member who joined after the work was done so their room stops being empty. Two unrelated meanings of one word in one app is a bug waiting to be written, and `ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on the chat row, named for the thing that is not the chat. So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`, `ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags, `ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move; only the words do. **The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` -> `chroniclePage`, which is free exactly once. Both kinds are new and there is no old build to stay compatible with -- the design note says so in as many words -- so the alternative was carrying the old spelling on the wire forever to save a rename that costs nothing today. The recipient tag stays `p`; it was never ours. **Schema v14, because two things had the old word written into stored data.** `ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than dropped and re-added: while it is set it is the only record that a device with an empty room has already asked the group for its history, and a device that lost it mid-flight would ask again on its next launch, and the one after that. The three `ChatMessage.messageType` strings become their `chronicle*` spellings, rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was. These lines cannot be regenerated -- a chronicle is announced once, when it is requested, sent and applied -- and an unrecognised type is not skipped by the transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items" would come back attributed to a member as something they said. `MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which `getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is in no index, no foreign key, and there is not a view or trigger in the database -- so nothing has to move with it. Five tests hold the two halves apart: the value survives, the column keeps its position, a room that never asked still reads as never having asked, the three types are rewritten, and every other type is left alone. **`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps its job unchanged: the allowlist that stands between a replayed `GroupKeyStateEvent` and the apply path. No behaviour change beyond the rename. 797 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:27:36 +02:00
chronicle note is a phased plan that has not been built, and reads as the
docs: plan handing a new member the group's signed history A member added after the work was done sees none of it, and nothing in the app will ever show it to them. Two independent reasons, and the second is the one that surprises people. MLS gives no history: a Welcome carries the ratchet tree at the current epoch, not the transcript, and `MarmotInboundManager` drops anything from an epoch it holds no keys for. That is forward secrecy working rather than a gap to close. But group-signed events never travel at all. `FrostSigningManager.complete` says so in as many words -- a signed event authored by the threshold key cannot go out as an inner event, because the outbound pipeline would re-author it as its sender and strip the group's signature off -- so every device *derives* the finished event from its own `FrostSigningItem` rows. A member who was not in the session has no items, and no later message carries the event. So the second problem does not follow from the first and is not fixed by fixing it: even a member who could decrypt the whole back-transcript would still hold nothing an artifact, chapter or chunk could be built from. Which makes an archive not a convenience but the only path, and fixes the line the design has to hold: **it carries what the group signed, never the chat.** Restoring the chat would undo forward secrecy on purpose, and a signed event is the only thing a new member can check for themselves. **The property the whole plan rests on is already true.** A room's id *is* the group's threshold key derived at the room's path -- `GroupKeyState.verifies` and `FrostSigningManager.signingPath` hold that invariant from their own ends -- so `isSignedByGroup`'s three checks collapse to `event.pubKey == chatRoomId`, an id check and a signature verify. No key state row, no threshold key, no path, no lookup. A member who can name the room can verify its signatures, which is exactly the position a new member is in, and it means the sender of an archive does not have to be trusted at all. **Two guards the plan makes non-negotiable.** Nothing on the inbound nip30303 path verifies a signature today, and that is currently correct: rumors carry an empty sig and are authenticated by the MLS frame, so nothing on the wire has ever claimed group authorship. An archive is the first thing that does, so the verify is the feature's entire security rather than hardening on top of it. And verification turns "group-signed" into an admission ticket for the apply path, which is a wider door than it looks: a `GroupKeyStateEvent` is group-signed and would pass perfectly, so an archive could replay a genuine old one and re-point what the room signs with. The archive therefore carries an allowlist of document kinds, checked outbound and independently inbound -- the same shape, and the same reasoning, as the cap on `k` in frost-batch-signing.md. **Push and pull, in that order of appearance and the reverse order of importance.** Pushing an archive after the Welcome is what the question asked for, and on its own it fails the way marmot-membership.md describes: it is an application message in the epoch the add created, so one that beats the Welcome there is dropped rather than deferred, silently, while the inviter sees a success. So the joiner asks instead -- a request is proof it has processed its Welcome, and it covers the reinstall and the second device, which no invite-time push can. The push stays as a latency optimisation, deliberately phased after the thing that makes it safe. Nine phases: the verifier, the events, assembling an archive, applying one and the sweep that lets pages arrive out of order, the request, the push, UI, the cross-device tests, and rollout. The sweep needs no new table -- the inbound path already stores every inner event it decrypts, so it is the shape `FrostSigningManager.replayStoredMessages` already has. Also written down, because it is the first thing this will be reported as a bug for: an archive lets a new member *read* everything and does not let them sign anything. `proposeSigningBatch` wants a secret share and a place in the ceremony, and a group that re-runs its ceremony derives a different room rather than re-keying this one. Closing that needs share resharing, which is a great deal more work than this and is the thing to build after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:52:33 +02:00
membership note's unanswered half: what a member who joins late can be given,
and the one thing they cannot. 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.
docs: record what the subgroups plan built, and the six places it chose differently All nine phases are built, one commit each. The phases are kept as written -- they are the reasoning, and the code reads better against the argument it came from than against a summary of itself -- with a table of where the building disagreed with the plan. Six worth reading. `openCeremony` was never built, because a wrapper over two repository calls the picker already makes would be a third name for one act. `MarmotGroupCreation` is reached through the repository rather than called from a view model, because view models here talk to repositories and managers take the database. The guards' tests are in jvmTest rather than pure, because every refusal reads the database and a pure version would test less. Phase 4 added two tags rather than one, the second fixing a bug older than subgroups -- every robust group has been arriving nameless on every device but its creator's. `stateFrom` needed a third reader and a wrapper, because "tag present and unreadable" looks identical to "absent" through a parser, and "refused" has to be distinguishable from "none claimed". And Phase 8's two capability refusals short-circuited the tests already written, which is how it came out that the fixtures had never had a parent that could sign. Two the plan got right and worth keeping if this is ever rewritten: the key-package check moved to the picker on review, before a line was built, and it is the difference between a subgroup failing in a second and failing after three ceremonies; and the founding-roster rule has a test whose job is to fail the day somebody adds the comparison that looks obviously missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:52:19 +02:00
The subgroups note is a phased plan that has been built; it assumes both
docs: plan subgroups, and phase the four ceremonies a group needs to make one A group can make another group, and the child can prove where it came from. This is the plan for that, in nine phases, written against the code at b50b1762 and not yet built. **A subgroup is an ordinary robust group plus one artefact.** Fresh ChillDKG key, fresh room, fresh quorum, and a birth certificate -- the parent's signature over the child's room id -- carried on the child's `GroupKeyState`. Deriving the child at `m/9420/1/0` instead would cost no ceremony at all and was rejected: a derived child is the parent wearing a different hat, administered by the parent's members with the parent's quorum, when the whole point is that a different set of people can act on their own. The certificate is a claim about lineage, never a delegation of authority, and nothing here lets one group sign for the other. **Four steps, in the only order they can happen.** The ceremony produces `K`, so the child's id exists; the parent's quorum certifies that id; the child's quorum signs a key state carrying the certificate; the coordinator creates the room. No step is a policy choice -- each needs the one before it -- and the last is gated on the key state for the same reason `createAdminGroup` already is. **What the parent's admins actually sign is the argument that shaped the event.** Taken literally the certificate is 32 opaque bytes produced by a ceremony most of them were not in. So the content is exactly the new group id as specified, and the tags carry the child's threshold key, the path and the admin set -- covered by the same signature, since an id hashes over its tags -- which lets a signer's device check `marmotGroupId(key, path) == content` before agreeing, and lets a coordinator who lies about who is in the child do it in a field the parent's signature covers. **The whole certificate travels as JSON on the key state, not a bare signature.** A signature plus a rule for rebuilding the event it covers is a rule that breaks silently the first time the event's shape changes: a rebuild differing by one byte hashes to an id whose signature fails, and is indistinguishable from a forgery. A parent tag rides beside it as an index into the certificate rather than a second source of truth -- Phase 3 drops any state carrying one without the other, or the two disagreeing, so there is no state where the index is believed and the certificate is not. **The ceremony stays on gift wraps, and the reason is `mls-skipped-keys.md`.** Holding all three steps in the parent's Marmot room is the better design and the plan says so at length rather than dismissing it: the certificate already runs there, and the key state and the ceremony move together or not at all, since both `GroupKeyStateManager.propose` and `signingPath` tie a key state to the room its ceremony ran in. The mechanical cost is three enumerable changes. The reason to wait is that the skipped-keys note already lists `proposeRitual` as a reliable trigger, and a DKG cannot finish without every participant -- so one message dropped for good stalls it permanently, where FROST needs `t` of `n` and routes around a lost nonce. Revisit when the quartz fix lands; the collision Phase 4 refuses disappears with it. **Three admins in total, and the threshold is set before anything is published.** Three is `ChatRoomType.MINIMUM_ROBUST_GROUP_SIZE` for the reason that constant gives, and the coordinator counts because they hold a share by construction, so the picker asks for two others. `t` has to be chosen on that same screen and nowhere later: ChillDKG hashes it and the host keys into the session identity, so it is fixed the moment the proposal goes out, and a group that disagrees about it gets no key rather than a weak one. The nine phases are ordered so the checkable parts come first and can ship dark: the certificate and its verifier are pure, the schema is three nullable columns, and nothing produces a certificate until the button in Phase 7 exists. Phase 6 extracts the 120 lines of Marmot room creation out of `DkgRitualViewModel` so both flows share the rules that are already right there. What it does not do is named rather than left to be found: no revocation, no delegation, certificates are not chroniclable, one subgroup per admin set, and every selected admin has to show up twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:53:31 +02:00
shared-key notes and reads as the ceremony's second half — what a group does
once it has a key, and what it can say about a group that does not yet.
docs: measure the UI against the M3 foundations, and phase the work that follows A plan, not a change: what m3.material.io/foundations asks for as of its May 2026 revision, what these 43 screens actually do, and eight phases ordered so that each one makes the next mechanical rather than judgemental. **The spec was read, not remembered.** m3.material.io is a client-rendered SPA -- WebFetch returns an empty `<main>` and the tab URLs 404 on direct navigation -- so the numbers here came out of a real browser session clicking through the tab controls. That mattered: the May 2026 revision renamed window size classes to **breakpoints** and there are now five of them rather than three (compact / medium / expanded / large / extra-large, at 600 / 840 / 1200 / 1600dp), renamed responsive design to adaptive design, and published the spacing system as tokens on an 8dp scale where `space100 = 8dp`. Writing this from memory of older M3 would have produced a plan against a vocabulary the current spec no longer uses. **The palette is fine; the call sites are not.** Every `onX`-on-`X` pair in all six declared schemes clears 4.5:1, the tightest being `onPrimaryContainer` on `primaryContainer` at 4.61:1 light and 4.56:1 dark. So the generated scheme is not the problem and this plan does not propose a repalette. What fails is colour decided locally, seven pairings of it, and the worst is not visible to a reviewer: Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) { ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent), `cardColors(containerColor = ...)` does derive `contentColor = contentColorFor(...)`, so `LocalContentColor` inside the card is correct. But `ListItem` does not read `LocalContentColor` -- its headline comes from `ListTokens.ItemLabelTextColor`, which is `onSurface` -- and the call site overrides only `containerColor`. In the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`. That is **1.00:1**, and it is applied exactly to `proposal.awaitsYou`, so the proposals waiting on your signature are the ones rendered invisible. `HomeScreen`'s `titleContentColor = primary` on `containerColor = primaryContainer` is the same mistake at 1.22:1. Ratios were computed rather than eyeballed; the script is in the Phase 0 deliverable. **Twelve colour roles fall through to Material baseline lavender.** `Color.kt` never assigns `primaryFixed`, `primaryFixedDim`, `onPrimaryFixed`, `onPrimaryFixedVariant` or the secondary/tertiary equivalents, so `lightColorScheme()` defaults them to `ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`. Nothing reads them today, which is why it has never been noticed; the trap springs the first time an expressive component does. Read out of the pinned `material3-desktop-1.10.0-alpha05-sources.jar` rather than assumed. **Four of the six declared schemes are unreachable.** The medium- and high-contrast variants are written out in full in `Color.kt` -- 78 colour values -- wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and then never selected: `TorchTheme` chooses between `darkScheme` and `lightScheme` only. The work to honour a platform contrast setting is already done and disconnected. **10dp and 20dp are not the problem they look like.** They are the two dominant spacing values (132 and 115 uses) and both are *on* the M3 scale, as `space125` and `space250`. The plan says so rather than proposing a sweep that would change nothing. What is wrong is that none of the 520 `.dp` literals records whether it is padding, a gap or a margin -- the three categories the spec gives different rules to -- so nothing can be adapted per breakpoint later. About 101 are off-scale (50dp x 53, 15dp x 14, 5dp x 10 and so on), and `Modifier.height(50.dp)` appears 49 times as the same copied spacer above the same copied error message. **Findings that were measured and then dropped.** `outlineVariant` reads 1.61:1 against surface and `secondaryContainer` 1.65:1, both of which look alarming and neither of which is a defect: M3's own baseline sits in the same range, and the 3:1 rule the spec gives is for clustered interactive containers, not dividers or tonal surfaces. `onSurface.copy(alpha = 0.38f)` is the specified disabled opacity and the spec exempts disabled states from contrast entirely. Reporting these would have padded the count and cost the reader trust in the rest. **The rest of the audit, in counts.** 334 string literals in composables against 2 `stringResource` calls, with title case throughout ("Edit Profile", "New Chat") where the style guide asks for sentence case. Zero `Snackbar` across 26 `Scaffold`s. 16 copies of `Text("Something went wrong")`, none of which offers a retry. 90 of 240 typography reads on `label*` roles, which are for component text, while `display*` and `headline*` carry 9 uses between them across 43 screens. 33 bare `Modifier.clickable` with no minimum target, two of them text-height. Two `BoxWithConstraints` and no window-size handling at all, on a project with a desktop target whose own entry point already says so in a comment. **Eight phases, ordered by what each unblocks.** 0 baseline harness, 1 theme, 2 spacing tokens, 3 accessibility floor, 4 content, 5 states and feedback, 6 adaptive layout, 7 motion, 8 guard rails. Tokens come before the call sites that consume them; the accessibility floor comes before the adaptive work that would otherwise double the surface to fix; guard rails come last so they lock in real state rather than aspiration. Phase 6 is the only one that cannot be done mechanically and the only one marked not reversible alone. **What it deliberately does not decide.** Whether the target is `MaterialExpressiveTheme` or `MaterialTheme` -- the pinned material3 ships the full expressive set and the code already opts into `ExperimentalMaterial3ExpressiveApi` in 66 places, but it changes default component shapes and sizes app-wide, so it is a product call and Phase 1 raises it rather than answering it. Also out of scope: whether the monochrome palette is right, the per-component specs, iOS (which only builds on a mac, and whose HIG asks 44dp where M3 asks 48dp), and the three package namespaces the UI currently lives across. No code changes. `docs/README.md` gains the row and the closing paragraph's note on how this one relates to the others. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:44:32 +02:00
The Material Design note is a phased plan that has not been built, and is the
only one about what the app looks like rather than what it does; read the
jvm-target note first if you want to know why its adaptive-layout phase exists.
docs: record what the pull built, and the seven places it chose differently Phases 0 to 5 of docs/curated-to-mantra.md are landed on this branch: thirty commits pulled from curated/curated, each carrying a `Pulled-From` trailer naming the commit it came from, on top of Phase 0's two native ones. The plan is kept as written and its status line now says what happened, with the table the house keeps for a plan that has been built -- what it said against what it turned out to be -- and the README's sentence about it follows. **Seven differences, and none of them are corrections to the plan's decisions.** The three decisions -- drop the brand line but keep its reasoning, keep Mantra's sections by dropping 808a3459, take the curated lists -- all held, and the measured predictions about them (the screen's order, the tests, the audit) came true to the number. What changed was mechanics: the phased replay is a cherry-pick per commit rather than a rebase of each cut, because a recreated merge cannot reach a parent that was replayed in an earlier phase; the library pin follows the commit rather than staying put, because the compiler said the nsec line does not build against 84cc44c; the join files are taken from upstream's own merges rather than unioned, for three reasons that each took a failed attempt to learn; and the exactness residual is seventeen files rather than sixteen plus a logo, because Mantra's own side had moved too. Each is written in the table with the reasoning beside it, so the next pull starts from what happened. **The numbers are per phase, so a regression later can be placed.** jvmTest 736, 864, 905, 1,007, 1,039 and testDebugUnitTest 403, 486, 495, 530, 530 across Phases 0 to 5; both compilers clean and every m3 audit budget met at each; the final jvmTest executed rather than restored from the build cache, 1,039 tests in 46 seconds of test time, 0 failures. The plan's own dry run predicted 1,036; the three extra are 39fb64b6's pin of the library's nostrPublicKey() against NIP-06. **What is not done is named rather than implied.** Phase 6's two reverse-pulls -- 39fb64b6, since the fork still carries the app-side WalletManagerExtension.kt it made redundant, and Phase 0's Torch retirement -- land in the other repository and are not this branch's to make. Its last item is a decision about what the fork becomes, and the plan's recommendation stands: converge the source package so the next pull is a plain cherry-pick. And the upstream has already moved on -- ten commits for several profiles on one device, one of them the fork's first schema migration -- which are the next pull, kept separate because a migration landing on Mantra's database deserves a decision of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:06:46 +02:00
The curated-to-mantra note is a phased plan that has been built, save for its last
phase, which is a decision: it is about the repository rather than the app, and reads
refactor(groups): take out the group's nostr identity and the curated lists, and keep broadcast unreachable Lines B, D and H of docs/curated-to-mantra.md, pulled from the Curated fork this morning, go again this afternoon: the group's nostr profile (kind 0), its four relay lists (NIP-65, NIP-17, NIP-50, NIP-51), its posts (kind 1), the curated schemas it publishes (31889), the suggestions it reads (31888) and the entries it accepts (31890). Eighty-two files, twelve screens, the `nostr/curated/` package, the readings (`GroupNostrProfile`, `GroupRelayList`, `GroupPost`, `GroupCuratedSchema`, `GroupCuratedEntry`, `CuratedSuggestion`), the `applyInnerEvent` arms for all eight kinds, the `ChatRepository` and `NostrRepository` reads that fed them, 226 strings, and every test that came with them. A translation collective has no list to curate and no reason to describe itself under a key no member controls, and five rows saying so on every group's screen were five rows about somebody else's product. **The pasted-event proposal goes with them, because it was theirs.** `GroupEventProposal.ACCEPTED_KINDS` was exactly kinds 0, 1, the four relay-list kinds and 31889 -- "the kinds the group's screen has a place for", in its own words -- so with the five rows gone it would have refused every paste. Keeping it as a generic "sign any event" was considered and rejected: the screen's whole argument was that a member goes to the row to see the event landed, and there is no row. The `ProposedEvent` summaries for the same kinds go too; the one test of its pre-existing fallback ("Event of kind N") is kept, as the only line of `ProposedEventTest` that was about code this repository had before the pull. **Broadcast stays, and nothing opens it.** `BroadcastGroupSignedEventScreen`, its route, view model, state and `BroadcastButton` are kept, on the decision that a way to send a group-signed event to relays is worth having against the day something wires a button in -- the button's only call sites were the five removed screens. Two things had to change for it to compile against a tree with no relay lists: `defaultRelaysFor(kind)` is now the app's own publish set for every kind, since the General list it preferred, the Blocked list it filtered by and the schema relays it widened to no longer exist; and `relayUrlOrNull` moves into the view model's companion from the deleted `GroupRelaySet`, unchanged. The hint on the screen says "the relays this app publishes to" rather than "where the group has said it lives". Its two tests are rewritten around the new seed: the screen test answers for the first two seeded relays and counts the rest as asked, and empties the list by hand to see the empty state, since the seed always has something in it. Deleting broadcast outright -- the tidier tree -- was the recommendation and was declined. **Every pre-existing file is back at its pre-pull content plus the kept lines' hunks, and nothing else.** Eleven files -- `ChatMessage.kt`, `NostrEventDao.kt`, `NostrEvent.kt`, `LocalChatRoom.kt`, `Member.kt`, `ProposedEvent.kt`, `ChatTranscript.kt`, `ProfileAvatar.kt`, the group screen's view model and state, and `m3-title-case.py` -- were touched by no kept commit and are restored from ba0830a3 byte for byte, so `inComparableGroups` is a private helper of the group screen again rather than a shared extension one deleted screen needed, and `ProfileAvatar` has one overload again. The rest were restored and had the kept hunks re-applied: the back button's three on `ChatRoomDetailScreen`, broadcast's `groupSignedEvent` read on the two chat repositories, and on the two nostr repositories the sign-in reads, by reverse-applying the queue commit's hunk. The check is `git diff ba0830a3 -- <file>`, which shows only those. Reverting the eight commits was rejected because the back button and the read-only identity work landed on top of them and would have conflicted in every one of the shared files; editing the current files by hand was rejected because it leaves residue that a diff against the base cannot distinguish from a decision. **The strings that went are exactly the ones nothing references any more and that the pull added.** Six strings were unreferenced before the pull and stay; the header sentence and one capitalised "Mantra" that the queue commit's join carried are prose, not feature, and stay too. The seven section comments that described removed blocks go; the broadcast block's stays. **The plan's third decision said "hide the two rows behind a constant".** That covered the curated rows and not the profile, relays and posts beside them, and the call was to take the whole identity block out. The record of what went and what stayed is the paragraph after the built table in docs/curated-to-mantra.md, with the seam it leaves for the next pull: an upstream commit that touches the identity block conflicts at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and is dropped. docs/README.md says the same in a sentence. The nsec and npub notes still name `EditGroupCuratedSchemaViewModel`; they are records of what was built upstream and are left as written. Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:compileKotlinJvm, :composeApp:jvmTest (826 tests, from 1,039), :composeApp:testDebugUnitTest (413, from 530) and :composeApp:m3Audit, every budget met, 12 adaptive uses at the floor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:45:15 +02:00
alone, except that its first decision leans on the derivation note's one rule. Two of
the lines it pulled -- the group's nostr identity and the curated lists -- were taken
out again the same day, and its record says what went and what stayed. The nsec and
docs: plan the second pull from the fork, with its dry run already done The upstream moved the day the first pull landed: ten commits on curated/curated, 86cb876b..29027f2b, that let a device hold several profiles and move between them, and with them the fork's first schema migration, version 20. The first plan named them the next pull and said the schema change earned a decision of its own. This is that plan, and unlike the first it was written after the dry run rather than before it, so its numbers are measured: rewrite from the fork point in 103 s, ten commits replayed onto 776455ec with nine clean and one resolved by rule, both compilers clean, jvmTest 826 -> 868 and testDebugUnitTest 413 -> 420 with no failures, every audit budget met, and 20.json regenerated byte-identical after a full build. The verdict is all ten, because the line is one feature and seven fixes braided together and four of the fixes are live on Mantra today with one profile: the relay observer that is never all cancelled, the read-only inbox that stays closed after its nsec is pasted, the DataStore race on desktop, and the startup screen's wallet-worded literals. Four decisions: take the whole line rather than carve the fixes out of five commits; take upstream's version 20 verbatim and adopt the rule that whichever tree migrates first owns the number; the one conflict is Mantra's own import from 39fb64b6, resolved theirs, which closes one of the first plan's owed reverse-pulls; and two of the device's profiles in one group is a Mantra follow-up before release, since ChatRoom is keyed by the group id alone. What changed in the method: Mantra's tree is no longer a superset of the fork's, so the exactness check becomes "the residual between the trees is the same before and after the pull, file for file", and the plan gives the commands. The replay driver gains the one rule the dry run needed, with its reason. The README gets the row and a reading-order sentence, and the first plan points forward from the paragraph that predicted this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:41:24 +02:00
npub sign-in notes arrived with it and record what they built. The profiles pull note
is the same exercise a second time, planned with its dry run already done and built
the same day; read it after the first, because it assumes the method and the three
decisions and only says what changed — chiefly that the tree is no longer a superset,
and what the check for an exact pull becomes when it is not.
docs: record what the nsec sign-in plan built, and the twelve places it chose differently Phase 8 of docs/nsec-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it actually turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each one a decision worth reading before touching the code it describes -- the not-found exit as a screen state rather than a route, input problems on the field rather than through ErrorState, the kind 0 written over the placeholder rather than beside it, forget taking the account rows too. Two findings that belong to no phase are recorded with it: the android source set's file-name collision that put the failure classifier in its own file, and DataStoreManager's process-global preferences cache, which only a test reusing a key across directories could have found. And the one rollout fact that matters: the library commit is on claude/nostr-key-store in the submodule, bumped in by the Phase 3 commit, and has to be pushed and tagged with this branch. The docs index now says the plan has been built and points at the table. Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@2326839e48c8dd71b6225aa6a0216b809f3f9b73
2026-09-12 12:30:45 +02:00
The nsec sign-in note is a phased plan that has been built; it inherits the
key-storage decision from the jvm-target note and drives the navigation state
machine `NavigationViewModel.processLocalAccount` implements, so read it with the
docs: record what the nsec sign-in plan built, and the twelve places it chose differently Phase 8 of docs/nsec-sign-in.md is the rollout, which is process rather than code; what is left to write down is what the seven phases before it actually turned out to be. The header moves from "Not built" to "Built", with the table the other phased plans keep: what the plan said against what the implementation did, twelve rows, each one a decision worth reading before touching the code it describes -- the not-found exit as a screen state rather than a route, input problems on the field rather than through ErrorState, the kind 0 written over the placeholder rather than beside it, forget taking the account rows too. Two findings that belong to no phase are recorded with it: the android source set's file-name collision that put the failure classifier in its own file, and DataStoreManager's process-global preferences cache, which only a test reusing a key across directories could have found. And the one rollout fact that matters: the library commit is on claude/nostr-key-store in the submodule, bumped in by the Phase 3 commit, and has to be pushed and tagged with this branch. The docs index now says the plan has been built and points at the table. Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@2326839e48c8dd71b6225aa6a0216b809f3f9b73
2026-09-12 12:30:45 +02:00
code open, and read its table of where the build chose differently first.
The npub sign-in note is a phased plan that has been built, and reads as that
docs: plan npub sign-in, starting from what a read-only identity is for The nsec plan's out-of-scope note said a read-only mode is a product, not a branch. This plan takes that at its word: what a public key can see here is thin -- a profile card, its follows as search results, no feed, no rooms, since every room is MLS or a gift wrap to the key that was not pasted -- so the first section decides what such an identity is for before anything is designed. It is a preview: the app as your own profile, before you paste a secret into it. That one word settles the Messages tab (an empty state that offers the upgrade), pasting the nsec of a read-only key (an upgrade in place under the same id, not "already on this device"), sign out (real for this kind only), and not-found (try again or a different key, never set one up). Eight phases: a nullable key on Identity, with the note that the compiler will be silent about it; a plaintext list beside the two key files, app-side through the public getDatadir and AtomicFileWrite so nothing needs a JitPack tag; the third startup branch, a read-only KeyPair built in one place, and only the two pumps that read; the sign-in screen, where hex stays a secret because an x coordinate is almost always also a valid scalar; a LocalCanSign capability and an inventory of every write entrance one tap from the three tabs; two exits; two round trips; rollout. Two traps found on the way are recorded where they bite: quartz's KeyPair(privKey = null) generates a fresh key rather than meaning "no key", and decryptGiftWrapSeal forwards exactly that; and signInToProfile plants a second kind 0 on a second call, which nothing reached until the upgrade path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@78807fe956a212885ffffd0d340647b94c1fc23b
2026-09-12 15:21:13 +02:00
plan's out-of-scope note answered: it takes the `Identity` type and the sign-in
machine as given and asks what an identity with no secret is for, before it asks
how to build one; read its table of where the build chose differently first.
docs: record what the multiple-profiles plan built, and the places it chose differently Phases 1–8 are implemented, in order, one commit each; Phase 9 is the rollout and stays as written. The plan's phases are kept as the reasoning, and the table at the top says where the build chose differently: the repair reads before it writes and hands the listing its result; one WalletAttached outcome with two ways in; the node stop and the relay scope injected for their tests; the default save that cannot crash; a ProfilesViewModel over flows; a NewProfileWriter and a route flag where the plan expected the create screen's existing writer to serve; the colliding id on the outcome rather than on the enum; the DAO's own requests left unowned because they fetch public kinds; the inbox reopened by re-indexing each wrap in its own transaction rather than by lifting the unseal branch out; and the round trip's A made through the create view model with a never-started PhoenixBusiness for the switch to stop. Three things found on the way and in no phase are recorded beside the table: the library's unsynchronised global-preferences cache, reached by two threads at once for the first time, now behind JvmGlobalPrefs on the jvm target; the same cache's consequence for tests that construct the sovereign view model; and the gift wrap seal's link to its wrap, a foreign key that existed and was never written until the inbox sweep asked the question it answers. The README's row and reading order say the note is built, and name switchToIdentity where they named switchToWallet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@29027f2b922fb12d705547c7e2161af5d9c358e8
2026-09-13 01:57:05 +02:00
The multiple-profiles note is a phased plan that has been built, and reads as the
third of the sign-in notes: it asks what happens when the device holds two
docs: plan several profiles on a device, starting from what a profile is The switch already exists as a state transition -- switchToWallet, resetToSelector, a null identity sent to startup with popUpTo(0), every collector a child of collectLatest -- and is reachable from nowhere: Landing shows only when the device holds no identity, so a second can never be added, and the profile tab's "change account" is a pending route. This plan builds the two entrances and fixes what the transition gets wrong. Before either, it changes one rule the two sign-in plans share. A seed's nostr key is not written to the credentials file; it is derived from the words at listing and from the running node at activation, and the writers keep one key out of two files. That puts the wallet where the profile should be: the list is a merge of two files with opposite ideas of what a row is, and the node has to run for a profile to know its own key. Phase 1 makes a profile a credential and a seed a wallet attached to one -- the credential written when the seed is, repaired into the file for every seed already on the device, merge inverted to list credentials and attach seeds by pubkey, the identity's key read from the credential with the node's as a cross-check, and a second refusal on forget for a key a seed derives. The node still starts for a profile with a wallet attached, for the channel watcher rather than for the key; making it lazy is now one branch and is named as its own decision. The other decision is that a switch is a restart of the signed-in graph, not a swap under it: every route carries the key it was pushed for. That settles the switcher as a pushed screen behind one tap, the previous node stopped, the last-used profile as the one that opens on launch, and a profile added from inside switched to. Nine phases: the credential; the switch, with the relay observer that never cancelled its predecessor and the node that kept running; the startup precedence, which put "show me the list" above "open this one" and never saved a default; the switcher, showing the nostr profile rather than "Default name" and labelling a row with a wallet attached; the add rows, where a profile created from inside is a bare key and not a second wallet, and "end this" -- which wipes every profile's database -- goes; an owner on the two fetch queues and an inbox sweep on activation, because a gift wrap fetched under the other profile's key is stored and never opened again; the exits; a round trip; rollout, with the one downgrade that lists a seed's profile twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@a5ff264164aafe89c1eb7f0487db61e9e1f6d436
2026-09-13 00:09:02 +02:00
identities, and its first phase changes one rule the other two share — a seed's
key becomes a credential like any other — so read it with `StoredIdentity.merge`,
docs: record what the multiple-profiles plan built, and the places it chose differently Phases 1–8 are implemented, in order, one commit each; Phase 9 is the rollout and stays as written. The plan's phases are kept as the reasoning, and the table at the top says where the build chose differently: the repair reads before it writes and hands the listing its result; one WalletAttached outcome with two ways in; the node stop and the relay scope injected for their tests; the default save that cannot crash; a ProfilesViewModel over flows; a NewProfileWriter and a route flag where the plan expected the create screen's existing writer to serve; the colliding id on the outcome rather than on the enum; the DAO's own requests left unowned because they fetch public kinds; the inbox reopened by re-indexing each wrap in its own transaction rather than by lifting the unseal branch out; and the round trip's A made through the create view model with a never-started PhoenixBusiness for the switch to stop. Three things found on the way and in no phase are recorded beside the table: the library's unsynchronised global-preferences cache, reached by two threads at once for the first time, now behind JvmGlobalPrefs on the jvm target; the same cache's consequence for tests that construct the sovereign view model; and the gift wrap seal's link to its wrap, a foreign key that existed and was never written until the inbox sweep asked the question it answers. The README's row and reading order say the note is built, and name switchToIdentity where they named switchToWallet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Pulled-From: curated/curated@29027f2b922fb12d705547c7e2161af5d9c358e8
2026-09-13 01:57:05 +02:00
`SovereignWalletViewModel.switchToIdentity` and the startup screen open, and read
its table of where the build chose differently first.
The npub profile preview note is a phased plan that has not been built, and is the
smallest of the plans: it changes the entrance to the direct-message flow and
nothing past it, so read it with `StartDirectMessageToNpubOrNip05Dialog` and
`ChatRoomMessagingViewModel.initiateNewChat` open, and its four decisions before
its four phases.