Commit Graph

71 Commits

Author SHA1 Message Date
Kgothatso Ngako
1930d6aaef fix(subgroups): a subgroup may be the whole group
"A subgroup cannot be the whole group. Leave at least one member out." That rule
shipped in Phase 8 and it was wrong twice.

**It refused something legitimate.** A subgroup is a logical division -- a group
deciding that some of its work belongs to a differently-keyed room -- not a group
carving out a smaller membership. Every member being in it is an ordinary case,
and no guard here had any business deciding otherwise.

**And it was a proxy, not a check.** The thing it stood in for is real: a ceremony
room is derived from its admins, so a subgroup over everybody lands in the room
the group's *own* ceremony was held in, and `proposeRitual` handing back that
ceremony would quietly make the child the parent. But set size does not detect
that. A parent whose membership has changed since its own ceremony derives a
different room -- so the sizes can match with no collision, and differ with one.

**Sharing the room was never the problem; sharing a ceremony was.**
`DkgSession.parentChatRoomId` already told two ceremonies apart, so the fix is to
scope the lookup by it rather than to forbid the selection.
`DkgSessionDao.getLatestSessionFor(room, parent)` replaces `...ForChatRoom` at the
three places that decide whether a ceremony already exists: `proposeRitual`'s
one-at-a-time guard, `refuseCeremonyRoom`, and the two repository observers the
ritual screen follows. A room may now hold the group's own ceremony and a
subgroup's at once.

Nothing below that lookup had to learn about the second one. A ceremony's
messages, approvals and transcript are already keyed by session id; only the
question "what is this room's current ceremony" was ever room-scoped, and that
question was always really "for what purpose".

One collision survives and it is degenerate: the same parent, over the same
admins, twice. Those two have nothing left to distinguish them -- which is another
way of saying they are one subgroup asked for twice, and that is what the message
now says.

`a subgroup that is the whole group is refused` becomes `a subgroup may be the
whole group`, and two new cases pin the scoping: the group's own ceremony sitting
in the derived room does not block a subgroup there, and the same parent asking
twice over the same admins still does while a different admin set is untouched.

`docs/subgroups.md` keeps the withdrawn rule struck through in the refusals table
with a section saying why, rather than quietly deleting it -- the reasoning that
led to it is the reasoning somebody would repeat.

397 common tests, 713 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:38:49 +02:00
Kgothatso Ngako
0180425904 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
Kgothatso Ngako
6fb0af1147 docs: close five gaps in the subgroups plan, one of which wasted three ceremonies
A review pass over the plan committed in c1ce262f, against the code rather than
against the plan's own reasoning. Five things it left open, and three smaller
ones.

**Key packages are one-time-use, and the plan discovered that at step 4.**
`NostrDao` marks a bundle `consumed = true` as the device processes its own
Welcome, and `MarmotKeyPackageBundleDao` only ever returns one that is neither
consumed nor rotated -- so every group a member joins burns one, and a member who
is in the parent and has not published since has none left. Phase 6 refuses to
create a room when any member's package is missing, correctly, because the id is
derived and a half-created room occupies that address permanently. But that
refusal landed after a ChillDKG, a parent quorum and a child quorum had all
completed, each of which needed every selected admin present. Availability is now
a property of the picker -- prefetched as the screen opens, the way group creation
already does it, with the member marked, unselectable, and told to publish a new
one -- and Phase 6's check is restated as the backstop it should always have been,
since a package can be consumed elsewhere between the two.

**Nothing said where the subgroup's name came from.** `createAdminGroup`
synthesises "X (#admins)" and gets away with it because a group has one admin
room; a group has many subgroups and "X (#subgroup)" names none of them. The
picker takes a required name, it travels to `MarmotGroupData`, and it is copied
into the certificate so the parent's admins approve something legible rather than
a hash. That freezes it, which is the right trade and is now written down: the
name and the `p` tags are the *founding* roster, `certifies` deliberately does not
check either, and the Phase 7 list titles a row from the room where it has one and
only otherwise from the certificate. A test that must pass -- a certificate whose
name and members no longer match the room's -- guards the roster check somebody
will otherwise add.

**The ceremony room arrived nameless on everybody else's device.**
`getOrCreateNip17ChatRoom` already reads `parseSubject()` off the payload and
`ChillDkgRitualManager.broadcast` writes no subject tag, so a selected admin
watches an unnamed room appear with a ceremony running in it. One tag on the
proposal, and worth fixing for robust-group creation in the same breath, where the
subject reaches only the creator's own device.

**`ChatRoom.parentChatRoomId` must not be a foreign key**, which the three
neighbouring tables make it natural to get wrong: they all declare
`ForeignKey(onDelete = CASCADE)` onto ChatRoom, and a self-referential one would
mean deleting a parent room deletes every subgroup beneath it and, by their own
cascades, those rooms' messages, participants, key states and signed events.
RESTRICT is not the answer either. The pointer routinely names a room this device
does not have -- a subgroup member who was never in the parent has the id and
nothing else -- so a dangling value is the normal state and resolution is a lookup
allowed to return null.

**The chronicle gap was named on the parent's side and missed on the child's.** A
member welcomed into a subgroup after founding holds no key state either, since
`adopt` files one only from the signed event, so their verified parent link is
null. `SharedKeyDerivation.describe` gains a parent line beside the path, which
reaches them in the Welcome through the epoch-0 group context -- explicitly a hint
written by the room's creator, never promoted into the verified column, and
subordinate to a real key state wherever one exists. Chronicling the certificate
is the upgrade for both faces of the gap, and the section says what it would
actually cost: an apply-order slot and a decision about whether a room's chronicle
may carry an event its own key did not sign, which no chroniclable kind does.

Three smaller ones. The coordinator is load-bearing only for step 1: after the
ceremony fixes the participant set, any parent admin can propose the certificate
and any child admin the key state and the room, so a coordinator who drops out
strands nothing -- and the UI should offer the rung to whoever can act rather than
to whoever started. Two coordinators racing produce two certificates sharing a `d`
tag, which is a replacement rather than an accumulation, and `certificateFor`
takes the newest that verifies. And Phase 8 gains refusal rows for a blank name
and for a member with no key package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:04:32 +02:00
Kgothatso Ngako
c1ce262f3e 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
Kgothatso Ngako
113eda9f4d feat: sign a group's key state before its room exists, and put FROST on NIP-17
A room's `GroupKeyState` was the new #admins room's first application message:
the coordinator created the room, added the members, and only then asked the
group to agree what it signs with. The order is now reversed. The group agrees
it while it is still just a ceremony and a NIP-17 chat, and the room is created
already knowing.

**Two things were wrong with the old order, and neither was cosmetic.** The
room's founding fact was settled after the founding, so a session that never
reached a quorum left a live room whose every member fell back to rederiving --
which works, but only at the one path the constant names, and says nothing about
which ceremony a device should take its share from. And the members who had to
sign it were exactly the ones the room had just been created to hold: a member
whose key package could not be found was excluded from the room *and* from a
decision they held a share of, while `createAdminGroup` refuses to create the
room at all in that case. Agreeing first makes the state a precondition of the
room rather than an afterthought.

**Signing therefore has to work in a NIP-17 room, and `broadcast` is the only
place that knows.** In a Marmot room a signing message stays an ordinary inner
event, encrypted to the group and addressed to nobody, because who is in the
group is the MLS tree's business. In a NIP-17 room it goes out as one sealed
gift wrap per member and has to name them all, or the members it left out never
hear. Neither shape lets a recipient list decide anything -- the signer set
comes from the ceremony's host keys either way -- so tagging somebody does not
put them in it and failing to tag somebody only stops them hearing. Everything
above `broadcast` is the same protocol; `NostrDao` dispatches the 3032x kinds
off the gift-wrap path beside the DKG's, and the outbound path needed no change
because `sealGiftWrapPayload` already seals to the room's participants and
already refuses MLS rooms.

**`signingPath` gains the one case that cannot be self-checked.** Every other
candidate is right exactly when walking it reaches the room, which makes the
resolution self-checking rather than trusting. A NIP-17 room's id is an
aggregation of its members' keys, so no path reaches it and nothing can be
checked that way. What the group signs as there is the room it is about to make:
the ceremony's key at the app's admin path. That is admitted only when the
ceremony is *this room's own* -- `key.chatRoomId == chatRoomId`, read from this
device's database -- and the path is the constant rather than anything off the
wire, so a proposer still chooses nothing. Naming some other ceremony this
device holds a share for gets no path at all, and `completedKey` will not even
find a key for a NIP-17 room that did not host one, so such a room cannot open a
session; both are tested.

**A state's subject is now its own `d` tag, not the room it arrived in.** Those
used to be required to agree, and a mismatch was dropped -- the right rule while
a state was made in the room it described, and the wrong one now that the two
differ by design. Nothing is given up. The check that drop was standing in for
is still made and made against the *named* room: `GroupKeyState.verifies` has to
rederive it, and `isSignedByGroup` has to find a signature by the key that
rederivation reaches. A state can therefore only ever be about a room it
derives, whatever room it turned up in, so nobody can point one room at another
room's key by putting it through the wrong door. The arrival room survives only
as the fallback for a state carrying no `d` tag at all.

**`record` holds what it cannot file; `adopt` files it when there is a room.**
`GroupKeyState.chatRoomId` is a foreign key, so a state signed before its room
exists has nothing to hang on -- which is now the normal case rather than an
error. `record` says so and keeps the signed event; `adopt` reads it back off
`GroupSignedEvent` and files it the moment a room appears. Both ways into a room
end there: the member who creates it, in `createAdminGroup` and before the
members are added, since filing is local and doing it while the room is certain
to exist beats doing it after a step that can partly fail; and the member who
arrives on a Welcome, in `NostrDao`, off the same event they were already
holding because it was signed in the room they were already in. Nothing goes on
the wire in either case. A member who was not in the ceremony holds no such
event and gets nothing, which is right -- they hold no share either, so there is
nothing for them to pick the wrong one of.

**The screen watches the signed event, not a state row, and that is not
interchangeable.** There is no row until there is a room, so the only thing that
can say the agreement was reached is the event. `observeSignedGroupKeyState`
is a flow over `GroupSignedEvent` by kind for the same reason the button it
gates exists. Gating on the session's own items instead was rejected twice over:
`complete` writes `stage = COMPLETE` *before* `recordSignedEvents`, so a
collector woken by the session row can read before the event lands; and an item
can hold a signature that has not been verified yet -- `complete` is where each
one is checked against its id and author, and throws if it is not.

**The button is one control and two steps, in the order they have to happen.**
"Agree the group's signing key" until a quorum has signed, "Create the #admins
group" after. Offering both at once would be the old order still available, and
`createAdminGroup` refuses it in the view model as well, since the screen not
drawing something is not a guard. A failed session re-offers the propose button
and nothing else does, because a retry has to be a *new* session: the failed
one's nonce seeds have already been published against an aggregate, and reusing
one produces two partial signatures under a single secret nonce, which is how a
share is extracted. `propose` mints a fresh session id every time, so tapping it
is the safe retry by construction.

**One bug found in review, which the tests now pin.** `replayStoredMessages`
read only `marmotInnerEventDao`, so in a NIP-17 room a message arriving before
the proposal it belongs to -- routine on a fresh sync, where a relay hands over a
backlog in whatever order it likes -- was stored in the gift-wrap payloads and
never read back. It now reads whichever store the room's transport writes to,
which has to be the same reading `broadcast` makes. `a nonce arriving before the
proposal is replayed out of the gift wraps` fails against the old code.

**One wart, taken deliberately.** `GroupSignedEvent.chatRoomId` means the room a
signature was made in, which for every event but this one is also the room whose
key signed it. The key state is filed under the ceremony's room and authored by
the #admins room, so `GroupSignedEvent.verifies` cannot pass on that row --
check it with `GroupKeyStateEvent.isSignedByGroup`, which asks the question the
row cannot. Both columns are documented to say so. Re-filing the row under the
#admins room once it exists was the alternative and buys nothing: a key state is
not chroniclable, so no reader wants it there, and moving a row to keep one
helper honest is worse than saying where the helper stops.

`ChronicleManager` and `docs/member-chronicle.md` both argued for the
`isChroniclable` filter from "every room signs a `GroupKeyStateEvent` as its
first act", which is no longer true of any Marmot room. The filter stays and the
argument is restated: what it stops is a member replaying any group-signed
statement *about* the record as though it were work, and `applyPage` refuses the
same kinds coming the other way. The two are a pair and neither is safe to drop
on the strength of the other. `ChronicleAssemblyJvmTest` now puts its key state
on file by hand, which makes that test sharper rather than hypothetical.

`SignedGroupKeyStateTest`'s harness flattens the two transports into one
`Queued` shape and each device declares whether its room has MLS state, so every
existing test keeps testing the Marmot path and the seven new ones read the
same. `GroupKeyStateTest`'s "a state naming another group's key is dropped"
splits in two: one holding the room fixed and varying the key, which is still a
drop, and one varying both, which is another group's true statement and is now
attributed to that group's room rather than refused.

649 jvm tests and 373 common tests pass; `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 21:15:58 +02:00
Kgothatso Ngako
a56295b0e2 docs: record what phase 8 built, and what is left for a person across all nine
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
The plan's last phase becomes a record, and the document gains a closing status:
every count the audit was written to move, from the state in "Where this app
stands" to what `m3-audit.sh` reports today, and a gathered list of what a person
still has to look at — the eight screens with competing filled buttons, the two
list-detail families the pane work did not reach, the container transform, desktop
keyboard traversal, and the avatar picker's selected state.

**The audit caught the previous commit.** `ThemeGallery` added eight string
literals in composables, taking the count 39 -> 47, which is exactly the drift the
budget exists to notice. They are sample text — the words are chosen to be words,
so that colour pairings can be looked at — and putting them in the catalogue would
add eight entries no screen shows and a translator would have to be told to
ignore.

So the audit grows a third exemption marker beside `m3-color-exempt` and
`m3-spacing-exempt`: `m3-string-exempt`, per file rather than per line, because
the exemption is a property of what the file is for and eight markers down one
gallery would say less than one at the top of it. Back to 39, and the report now
says how many files are exempt so the mechanism cannot be used quietly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:26:56 +02:00
Kgothatso Ngako
b6aa2111ac docs: record what phase 7 built, including the API the plan named that does not exist
Phase 7 becomes a record. Three things in it are corrections to the plan rather
than notes on it, and all three are the kind that only surface once somebody
tries:

- `MotionSchemeKeyTokens`, which the plan says every spec should come from, is
  `internal` to material3 and not addressable from an app. `MaterialTheme.motionScheme`
  is the public surface and gives the same six specs.
- "every state change in the app is a hard cut" was true of screen states and not
  of navigation, whose default is a 700ms fade in navigation-compose's internals.
  Still worth replacing -- three times M3's duration, and a literal in a
  dependency -- but for a different reason than the one written down.
- Android has no reduce-motion setting. It has "Remove animations", which zeroes
  the animation duration scales, and Compose ignores those scales entirely.

Also what was deliberately left: the container transform between a list item and
its detail screen. It is `SharedTransitionLayout` work, and above the expanded
breakpoint the detail is already beside the list, so there is no container to
transform -- doing it before the pane split settles means writing it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:16:46 +02:00
Kgothatso Ngako
16775bf6c7 docs: record what phase 6 built, and give the audit a floor to defend it
The plan's phase 6 becomes a record rather than a proposal, in the shape the
earlier phases took: what was built, what was decided and why, what a person
still has to look at. Two decisions in it were the product owner's rather than
the code's -- promoting search and profile to navigation destinations, and doing
chat alone rather than all three list-detail families -- and both are named as
such with the date.

**The audit learns two things.**

It counted `NavigationBar(`, `NavigationRail(` and friends, and reported **zero**
for an app that had just grown a navigation bar: `NavigationSuiteScaffold` is
what chooses between them per breakpoint, and the concrete component never
appears in the source. It now counts the scaffold and its items.

And it grew a `floor()` beside `report()`. Every other budget in the file is a
ceiling that ratchets down as a phase lands, which is the right shape for
literals, hardcoded colours and untriaged nulls -- things a careless edit *adds*.
The adaptive work is the opposite: a screen that stops reading the breakpoint
still compiles and still renders, and the count goes down. So `--check` now also
fails when the adaptive API count drops below 12 or the navigation component
count below 2.

**Two `contentDescription = null` that the audit caught in this phase's own
work** -- the navigation item's icon and the new-chat button's -- now say
`Decorative`. Same null, and the same convention phase 3 established: recording
that somebody looked is the whole point, and a budget of zero only holds if new
code obeys it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:04:00 +02:00
Kgothatso Ngako
043d725599 feat: draw the expressive loading indicator, and stop shouting the sign-out button
Phase 5, second step, of docs/material-design-conformance.md. Two smaller pieces, and a
correction to the plan.

**41 loading states stopped being a gold spinner.** `LoadingDataIndicator` wraps every wait
in the app, and it drew a `CircularProgressIndicator` hardcoded to 80dp in
`colorScheme.secondary` -- the brand gold, which reads as a warning rather than as a wait,
on a component that has a size of its own. It now draws `LoadingIndicator`, which is M3's
component for an indeterminate wait with no progress to report and the one
`MaterialExpressiveTheme` expects to be paired with. One wrapper changed; 41 call sites
follow.

**The profile screen had two maximum-emphasis buttons, and one of them was Sign out.**
Seven actions in one list: five `TextButton`s (edit profile, key packages, change account,
profile keys, network relays) and two filled `Button`s. A filled button is M3's highest
emphasis and is meant for one action per screen, so this was two competing primaries -- and
the more prominent of the pair was the list's most destructive item.

Sharing is now `FilledTonalButton`: it is the useful action, at medium emphasis rather than
maximum. Signing out is a `TextButton` in the error colour, which is not a new pattern --
it is how leaving and deleting a group are already treated in `ChatRoomDetailScreen`.
Screenshot verified on emulator-5554: one tonal button, one red text button, five plain
ones, and a hierarchy a reader can follow.

**The plan was wrong about disabled FABs, and the code was right.** It said five screens
should stop hand-computing a container colour from a `can…` flag and pass `enabled`
instead. **No `FloatingActionButton` overload in material3 1.10 takes `enabled`** -- checked
in the source, zero matches for `enabled: Boolean` in FloatingActionButton.kt -- because
the spec's own position is that an unavailable FAB should not appear at all. Hand-computing
is the only way to show a disabled one.

More to the point, the existing code is already better than the plan assumed: it pairs the
colour with `Modifier.semantics { disabled() }` and a comment saying "looking unavailable
is not being unavailable: without this a screen reader still announces a button it is happy
to press." Left alone, and the plan corrected.

**Eight screens are left for a person.** LandingScreen puts "Sign in" beside "Create
profile", SocialPreconditionScreen puts "Invite a friend" beside "View invites", and six
others do the same. Both members of each pair are filled buttons. Which one is primary is a
product decision about what the screen is *for*, and picking wrong quietly weights a choice
the user is supposed to make freely -- so this is listed in the plan rather than guessed at
here.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. Both
changes are composition-time rendering, which this repo has no UI test infrastructure to
assert; the device screenshot stands in for it. `:composeApp:compileDebugKotlinAndroid`
builds and the apk runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 02:01:39 +02:00
Kgothatso Ngako
44bf2a01f0 feat: give the app somewhere to report an outcome, and every dead-end error a way out
Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.

**Sixteen copies of the same dead end.** The tree held sixteen instances of

    Column(horizontalAlignment = CenterHorizontally) {
        Spacer(Modifier.height(48.dp))
        Text("Something went wrong")
    }

and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.

`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.

`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.

**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.

`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.

It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.

**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.

**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)

**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:57:18 +02:00
Kgothatso Ngako
2117e22d48 refactor: make the 40 interpolated UI strings format strings, and assert the argument order
Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to
${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call
passing the expression. 49 call sites. Literals in composables go 76 -> 39;
`stringResource` goes 374 -> 424.

**A silent bug in the previous commit's extractor, found by this one.** Imports were
tested with `statement in source`, and the generated accessors are named after their
strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of
`...resources.translate_into_which_dialect`. The substring test decided the import was
already there, and the compiler reported "Unresolved reference 'translate'" in a file
whose imports looked complete. Both extractors now match whole lines, and the helper
carries the explanation.

**Four filters, each earned by something the dry run got wrong.**

*A template that is only interpolation has nothing to translate.* `Text("$name")` would
have become a resource holding `%1$s` -- longer, slower, and no more localisable than the
code it replaced.

*A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a
separator. The test has to be on the format string rather than on the literal halves: a
template opening with an interpolation leaves the first part empty and the second starting
with the separating space, which makes "%1$s Key packages" look like a fragment when it is
a whole label.

*`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the
six visible characters of the escape. They are decoded into the resource, which is UTF-8
and can hold `·` directly. `\\n` is **not** decoded, because
StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an
XML value would be reflowed by the parser.

*A term of a `+` concatenation is still not a string.* Same rule as the plain extractor.

**Three copy problems surfaced only here, because interpolated strings had never been
checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a
literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had
`"replying To ${…}"`. And a third instance of the old product name, in
`"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather
than a one-off: title case inside a template is still unchecked, and there are 83
concatenation fragments left where it could hide.

**Two new assertions, on the two things a compiler cannot see.** Argument *order* is
decided by where each `${…}` sat, and a transposition compiles and reads plausibly --
"Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument
string are asserted end to end. The three-argument one doubles as the check that `·`
was decoded rather than passed through.

**What is deliberately left.** 83 literals that are terms of a `+` concatenation.
Reassembling `"a " + x + " b"` into one format string means deciding what the whole
sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` --
which want a real plural resource rather than a format argument, and that is an API choice
rather than a rewrite. `m3-extract-formatted.py --remaining` lists them.

**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349.
`:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on
emulator-5554 through onboarding, the message list and a chat room with its text intact.
`m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:45:35 +02:00
Kgothatso Ngako
419504c982 refactor: move 315 UI strings into the resource catalogue, and prove the escapes survive
Phase 4, second step, of docs/material-design-conformance.md. 251 distinct strings, 315
call sites, from literals inside composables to `stringResource(Res.string.…)`. Literals
in composables go 332 -> 76; `stringResource` goes 0 -> 374.

**The extractor took four attempts, and each failure is why it is checked in.**

*A bare `text = "…"` is not a Compose string.* `text` is an ordinary parameter name and
this tree uses it on data classes: `NavigationUIState.Loading(text = "…")` is not a
composable, and rewriting it failed with "@Composable invocations can only happen from the
context of a @Composable function". So `Text(`/`BasicText(` calls are brace-matched and
only literals genuinely inside one are touched.

*A regex over quote pairs is not a Kotlin lexer.* Matching `"[^"]*"` over a whole file
pairs one string's closing quote with the next string's opening quote, so "literals" came
out as several lines of Kotlin. Restricting the body to one line fixed that and left a
subtler version: `"a ${if (n == 1) "chunk" else "chunks"} b"` has two inner literals
belonging to an outer template, and left-to-right matching lifts them out as strings of
their own. The script decided `"chunk"` and `"note"` were UI text worth translating. It now
scans properly -- on an opening quote, walk forward tracking `${` depth, recursing over
nested literals, and stop at the closing quote at depth zero.

*A fragment is not a string.* `"a " + x + " b"` is one sentence in three pieces, and " b"
is not something a translator can work with -- word order differs between languages. Three
filters, because the fragments hide in three shapes: adjacent to a `+`, leading or trailing
whitespace or no letters at all (", " and ":"), and -- the one that needed a fourth pass --
a pluralisation where the *parenthesis* is adjacent to the `+` and neither literal is:

    (if (proposal.eventCount == 2) "event" else "events") +

Testing the line rather than the literal catches those four sites while leaving a genuine
either/or alone: `if (session == null) "Start key ceremony" else "Try again"` has no `+`
and both branches are whole strings.

**Compose Resources is not aapt, and that was a bug this commit nearly shipped.** The
first version escaped apostrophes as `\'` and doubled `%`, which is what android's resource
compiler requires. Compose Resources does neither. `getString(Res.string.don_t_sign)`
returned

    Don\'t sign

backslash included, and there are 30-odd apostrophes in this catalogue. Every one of them
would have rendered with a visible backslash, on screens nobody opens often.

What makes this worth a permanent test rather than a fixed script: escape handling is
**partial**, not absent. The same run showed `\n` *is* processed --
"Currently no messages have been shared.\nBreak the ice." comes back with a real newline.
So there is no family rule to rely on, and the next escape somebody adds needs checking on
its own.

`StringCatalogueJvmTest` asserts all three cases through `getString`, which is the
non-composable reader for the same resources and needs no composition. It found the bug
before a device did.

**Names are derived from content**, snake_cased and truncated at a word boundary:
`something_went_wrong`, `add_artifact_to_the_group_library`. The conventional shape for an
automated extraction, with a known cost -- rewording the copy leaves the name slightly
stale. The alternative, naming by *purpose*, needs somebody to read 315 call sites, and a
name asserting the wrong purpose is worse than one that is a little dated.

**1101 dead strings out, 251 live ones in.** The catalogue previously held the phoenix
wallet fork's entire string table with nothing referencing it; it now holds this app's own,
plus `app_name`.

**What is left, and why.** 76 literals: 46 interpolated, which need format placeholders and
an argument order decided per site, and 30 concatenation fragments, which need their
sentences reassembled first. Both are the next commit, and both are jobs where a script
should not guess.

**Tests.** 947 pass, 598 jvm over 73 classes and 349 android over 44, up from 944/595/349 --
three new assertions in one new class. `:composeApp:compileDebugKotlinAndroid` builds, the
debug apk installs and runs on emulator-5554 with its text reading correctly through
onboarding and the message list. `m3-audit.sh --check` exits 0.

`ChronicleApplyJvmTest` failed once during this commit's verification and passed on rerun;
it is the pre-existing 1-in-8 flake filed during phase 3, and nothing here touches
chronicle code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:38:31 +02:00
Kgothatso Ngako
0304aca62a fix: sentence-case every UI string, settle the product name, and empty the dead catalogue
Phase 4, first step, of docs/material-design-conformance.md. M3's style guide is
unambiguous: "All text, including titles, headings, labels, menu items, navigation
components, app bars, and buttons should use sentence-style capitalization. ... Don't use
title case capitalization." The tree was title case throughout.

**100 occurrences across 60 distinct strings**, in two passes, and the second pass is the
interesting one.

The first pass matched `[A-Z][a-z]+( [A-Z][a-z]+)+` in a `text =`, `Text(` or
`contentDescription =` position and found 41 strings, 73 occurrences: "Add Chapter",
"Sign In", "Key Package Management", "Publish New Key Package". Then the audit reported
zero and the app still had "Invite a Friend" on its first screen.

Two holes. The pattern required every word after the first to be capitalised, so anything
with an article in it survived -- "Invite a Friend", "Add to Group", "Name of Artifact",
"Sign in to Npub". And it read one line at a time, so a `Text(` whose literal sat on the
next line was invisible. A whole-file scan allowing lowercase articles found 19 more
strings, 27 occurrences.

**Sample data is deliberately left in title case.** "Steve Biko", "John Doe", "Frank
Talk", "To Kill a Mockingbird", "Man With A Plan", "Woman Of Few Words" are people and
titles of works, and title case is how those are written. The first audit swept them up
and reported 67 offenders where the real number was 41, which is the kind of number that
teaches a reader to ignore the tool.

Also untouched: the KDoc reference to iOS's own "Increase Contrast" setting, which is
Apple's capitalisation of Apple's setting, and `logger.d("Queried Sync")`, which is
written for whoever is reading logcat.

**Two strings changed meaning rather than just case.** "Sign in to Npub" became "Sign in
with an npub" -- npub is a protocol term, lowercase everywhere else in this app, and you
sign in *with* one rather than *to* it. "Lightning Bolt", a content description, became
"Lightning payment": M3's rule for a description is to name the purpose rather than the
picture, and "bolt" is the picture.

**The product has one name now, and it is Mantra.** The launcher label, the desktop window
title, the landing screen and the package all said Mantra; the home screen's app bar said
"Torch" and `composeResources`' `app_name` said "Machankura". The app bar is fixed.
`UserAgent.APP_NAME` still says "Torch" and is left alone on purpose -- it goes on the wire
to relay operators, so it is a network identity question rather than a content one, and a
comment at the call site says so.

**The two destructive actions now say what they do.** "Leave group" and "Delete group" are
`TextButton`s that fire immediately, with no confirmation step and nothing stating the
consequence. M3: "Tell users what will happen if they take an action and how they can undo
it."

Read out of the repository rather than guessed, because saying the wrong thing about a
destructive action is worse than saying nothing. `leaveChatRoom` sets `leftGroupAt` and
posts a line to the room; `softDeleteChatRoom` sets `deletedAt` on the local row and
nothing else. So: "Posts a line to the room saying you left, and lets you delete it from
this device afterwards", and "Removes the room from this device. The messages stay on the
relays and with the other members." The second matters most -- a button labelled "Delete
group" with no qualifier invites the belief that the messages are gone, which is the
opposite of true.

**1101 dead strings deleted.** `composeResources/values/strings.xml` held the phoenix
wallet fork's whole catalogue -- notification channels, electrum settings, swap timeouts
-- and **nothing referenced any of it**. The tree's only two `stringResource` calls are
both commented out, and one of them names an `R.string`, which does not exist in a Compose
Multiplatform resource set at all. Keeping them made the file look like the app's
catalogue while the app's actual 332 strings sat in composables. It now holds `app_name`
and a note about what happens next.

A trap for the next person, recorded in the file: the compose resources plugin reports an
XML comment containing a double hyphen only as "XML file ... is not valid. Check the file
content." XML forbids `--` inside comments, and this commit hit it while writing that
note.

**The audit's check is now a script, for the reason the second pass exists.**
`docs/scripts/m3-title-case.py` scans whole files, allows articles, excludes sample data by
name and skips logger calls. Budget ratcheted to 0. The grep it replaces was wrong in three
ways and reported success anyway, which is worse than not checking.

**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. The debug
apk installs and runs on emulator-5554. `m3-audit.sh --check` exits 0. The 332 literals
themselves are the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:27:26 +02:00
Kgothatso Ngako
1f24aaf4bb fix: give the two single-field screens their initial focus, and check 200% text on a device
Phase 3, final step, of docs/material-design-conformance.md. The tree had **zero** uses of
`FocusRequester`, `LocalFocusManager` or `focusProperties`, so no screen defined where
keyboard focus starts.

**Two places get it, and only two.** M3's flow guidance asks for an initial focus per
screen and, for a dialog, that "focus is set to the dialog component, likely to a specific
interactive element within the dialog such as a text input field":

  - `StartDirectMessageToNpubOrNip05Dialog` -- one field and two buttons. Without this the
    dialog opens with nothing focused, so a keyboard or switch user tabs in from wherever
    focus happened to be.
  - The desktop `PassphraseGate` -- the first screen of the desktop app, whose entire
    content is one field, and where there is no tap to give it focus. Somebody who opens
    the app and starts typing should not have to reach for the mouse first.

The other seven text-field screens deliberately do **not** auto-focus. Requesting focus
raises the software keyboard, and on a screen that leads with content somebody wants to
read -- AddArtifact's chapter list, WriteNewNote's reply preview -- that covers the thing
they came for. M3 asks for the initial focus to be *defined*, not for a field to be
grabbed; on those screens the definition is "the top of the content".

**Large text verified on a device rather than reasoned about.** Two passes:

A static one first, since the failure mode is a fixed height around text. All 23 fixed
vertical dimensions outside `Spacer`s are icons, images and progress indicators -- 12 to
40dp `.size()` calls, a 200dp image, a 180dp `heightIn` cap. Nothing wraps text in a fixed
box.

Then at `font_scale 2.0` on an API 36 emulator, three screens: onboarding, the message
list, and a chat room. All reflow without clipping. The chat room is the useful one --
system messages wrap to two lines and their timestamps and chevrons stay aligned, the
composer keeps its full width, and the transcript stays readable. `font_scale` was put
back to 1.0 afterwards.

The physical device attached to this machine was left alone. `font_scale` is a
system-wide setting and changing it on somebody's actual phone to test an app is not a
reasonable thing to do; a fresh emulator was booted for it instead.

**An unrelated flaky test, measured and left alone.** `ChronicleApplyJvmTest > an answered
catch-up leaves one line, whatever it took to deliver` failed once during this commit's
verification with

    expected:<[chronicleRequested, chronicleReceived]> but was:<[chronicleReceived, chronicleRequested]>

and reproduces at **1 failure in 8** consecutive `--rerun` invocations on this tree. Both
transcript lines are written within the same second and the DAO's ordering has no
documented tie-break, so either order can come back. That is chronicle and database code;
nothing in this branch touches it. Whether it is a test bug or a real one -- two lines
swapping places in a user's transcript on reload would be a defect -- wants deciding by
somebody in that code, so it is filed rather than patched here.

**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged. Focus and
window insets are properties of a running composition; there is no Compose UI test
infrastructure here, and a test asserting the modifier is present would restate the diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 01:18:23 +02:00
Kgothatso Ngako
831d1c0ad4 fix: give every tappable element a real target, and every icon a decided description
Phase 3, second step, of docs/material-design-conformance.md. Two accessibility rules the
tree had no way to hold: M3's 48x48dp touch target and 44x44dp pointer target, and its
requirement that a decorative visual be *annotated* as decorative rather than merely left
undescribed.

**Nineteen `.clickable` chains had no minimum size, and three were text-sized.**
`ArticleCard` and `LiveStreamCardContent` each make an author's name tappable -- a
`labelMedium`, around 16dp tall -- and `LinkPreview` does the same to a `bodyLarge` url
with 2dp of vertical padding. The other sixteen are cards, rows and full-screen boxes that
are already far larger.

`minimumInteractiveComponentSize()` is applied to all nineteen rather than to the three,
because it is a no-op on anything already 48dp and that makes the rule checkable by a
script instead of by measuring. Worth being precise about what it does, since the modifier
is easy to describe wrongly: it reserves 48x48dp of **layout**, not of touch handling --
touch expansion happens at the input layer regardless. Layout is what keeps adjacent
targets from overlapping, what satisfies M3's 8dp separation, and what a mouse pointer on
the desktop build actually has to land on.

**`Clickable.kt` had it built in and moved house.** The vendored ACINQ helper defaults to
`RectangleShape` and `PaddingValues(0.dp)`, so a `Clickable` is exactly as big as its
content -- and its call sites wrap a 20dp emoji and a row of wallet text. It now applies
the modifier unconditionally, before `.padding(internalPadding)`, since a size modifier
after it would re-impose the smaller constraint.

It also stopped declaring `package com.machankura.compose.ui.composable.widgets.buttons`
while living under `press/mantra/`. That is the second of the three package namespaces the
UI was spread across; `Type.kt` was the first.

**Eighteen `contentDescription = null` were indistinguishable from eighteen oversights.**
`null` is the *correct* API -- M3 asks that decorative visuals be "annotated as decorative
in order to hide them in code", and null is how that annotation is spelled in Compose. The
problem is that it reads identically whether somebody decided or never looked.

So `Decorative` is introduced -- a `String?` that is null -- and fifteen sites now say
`contentDescription = Decorative`. Same bytes, same behaviour, and the difference between
a decision and a gap is now visible in the source and countable by the audit. Each of the
fifteen has adjacent text saying what the icon says: a lock beside "Private to Ada", a
check beside "The group has a shared key.", an icon inside a button whose label is right
there.

**Three were not decorative and now carry their state.**

  - `DkgRitualScreen`'s participant list -- a filled or empty circle beside each member.
    The name says who; only the icon says whether they have contributed. Now "Contributed"
    / "Not yet contributed".
  - `DkgRitualScreen`'s round header -- the title says which round and the count says how
    far along; only the icon says whether it finished. Now "Complete" / "In progress".
  - `ProposalListScreen`'s leading icon, which is the one this commit could not have left
    alone: the previous commit took the red away from the failure state on the highlighted
    card, because `error` is 2.67:1 there. The shape is now the only cue a sighted user
    gets and the description is the only cue anyone else gets. Now "Awaiting your
    signature" / "Signed" / "Failed" / "Waiting on others".

Descriptions follow M3's rule -- name the purpose, not the picture, and never the role.
"Contributed", not "green check", and never "Contributed icon", since the role is added
automatically and a screen reader would say it twice.

**Two new checks, replacing one that was asking the wrong question.**
`docs/scripts/m3-touch-targets.py` finds `.clickable` chains with no minimum size,
including chains broken across two lines. The audit used to count `.clickable` outright,
which is not a defect count: a clickable `Card` is fine and a clickable `Text` is not, and
only the modifier tells them apart. The audit also now separates `contentDescription =
null` (untriaged, budget 0) from `Decorative` (decided, reported at 15).

Both budgets ratcheted to 0, dated in the file.

**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, unchanged -- these
are layout and semantics properties, and this repo has no Compose UI test infrastructure to
assert them against a running composition. What stands in for it is the two scripts, which
check the property that *can* be checked statically: that the modifier and the decision are
present at every site. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh
--check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:52:15 +02:00
Kgothatso Ngako
4fe47c7d46 fix: derive every call-site colour from its container, ending nine contrast failures
Phase 3, first step, of docs/material-design-conformance.md. The generated palette was
already sound -- every `onX`-on-`X` pair in all six schemes clears 4.5:1 -- and every
failure in the app came from a colour reached for at the call site instead of derived from
what it sits on.

**The worst one made the app's most important rows invisible.** `ProposalListScreen` put a
`ListItem` inside a `Card` and overrode only the card's container:

    Card(colors = CardDefaults.cardColors(containerColor = primaryContainer)) {
        ListItem(colors = ListItemDefaults.colors(containerColor = Color.Transparent),

`cardColors(containerColor = …)` does derive `contentColor = contentColorFor(…)`, so
`LocalContentColor` inside the card was correct. `ListItem` does not read
`LocalContentColor`. Its headline comes from `ListTokens.ItemLabelTextColor`, which is
`onSurface`, and in the light scheme `onSurface` and `primaryContainer` are both `#1B1B1B`.
Measured on that card:

    headline (onSurface)          1.00:1     invisible
    leading icon (primary)        1.22:1
    supporting (onSurfaceVariant) 1.84:1
    "could not be read" (error)   2.67:1
    onPrimaryContainer            4.61:1     the only one that worked

Four of five below the floor, and the card is applied to exactly `proposal.awaitsYou` --
the proposals waiting on your signature. Dark was fine throughout, because there
`primaryContainer` is black, so this only ever showed in the light scheme.

The card's colours are now computed once and everything inside derives from
`cardColors.contentColor`: the six `ListItemColors` slots, the leading icon tint, the
"Review" label, and the unreadable-count line. `primaryContainer` is kept as the highlight
so this stays a fix rather than a restyle -- `secondaryContainer`, the brand gold, would
read more like "this needs you", and that is a design call recorded in a comment rather
than taken here.

On the highlighted card the failure state loses its red, because `error` is 2.67:1 there.
The signal survives in the icon and in the sentence "could not be read", which is the more
robust cue anyway and the only one available to somebody who cannot distinguish the red.

**`HomeScreen`'s top bar lost its override entirely.** `containerColor = primaryContainer`
with `titleContentColor = primary` is `#000000` on `#1B1B1B`: **1.22:1**, a black title on
a near-black bar. `TopAppBarDefaults` gives `surface`/`onSurface` and needed no help.

**Three of the four `alpha = 0.5f` sites were not text, which changes what they failed.**
The audit called them caption text; they are `CircularProgressIndicator` colours, so the
threshold is 3:1 rather than 4.5:1. At 2.49:1 they fail either way, but the plan said the
wrong thing and is corrected. The one that really is text -- `ArticleCard`'s published-at
timestamp at `alpha = 0.7f`, 3.96:1 -- is the fourth. All five now use `onSurfaceVariant`
at full opacity, 7.25:1, which is the role for secondary text and needed no alpha to
become one.

**The LIVE badge was a hand-mixed red.** `Color(0xFFE53935)` with a white label is 4.23:1,
under the floor for `labelSmall`. `error`/`onError` is the role for a red that has to be
read and is 6.46:1.

**The avatar picker used a content colour as a background.** `onSurface` at 50% composited
to a mid grey 2.49:1 from the unselected cells beside it -- so which emoji was selected was
close to unreadable. Now `secondaryContainer`, M3's role for a selected item. Worth being
straight about the limit: that role is 1.65:1 against the surface in this palette, which M3
accepts because its own selected states carry a second cue, an outline or a checkmark. This
grid has neither. Adding one is component work, and the comment and the plan both say so
rather than leaving it looking finished.

**Three colours stay hardcoded, and each says why at the site.** A new
`// m3-color-exempt: <reason>` marker, matching the spacing convention from phase 2, and
the audit honours it:

  - `QRCodeView` -- a QR code is read by a camera. Scanners need maximum luminance
    contrast between the modules and their background, and under dynamic colour
    `onSurface`/`surface` could be two mid tones and unscannable.
  - `FullScreenImageViewer`'s close button -- it floats over an arbitrary photograph, so
    no role is safe behind it. A translucent scrim with white on it is M3's own
    full-screen media treatment and the only pairing that holds over both a white sky and
    a black one.
  - `LoadingAsyncImage`'s spinner, but only when a blurhash placeholder is behind it. With
    no placeholder the surface is known and the role is used.

Exemptions belong at the call site: the reason travels with the code and a reviewer sees it
in the diff that adds it, rather than in a list of file names in the audit script.

**Two colours were tokenised without moving a pixel.** `Color.Black` on the blank route's
`Surface` and on the image viewer's backdrop are both `scrim`, which is `#000000` in every
one of this app's six schemes. Same bytes, and the value now travels with the theme.

**A new assertion for the case the others structurally cannot catch.** A translucent
container has no contrast ratio of its own -- it has one only once composited -- so
`ColorSchemeContrastTest` grows an eleventh test that composites the two remaining tinted
containers over `surface` and measures the result, in all six schemes, naming the call site
in the failure. The pairings this commit *fixed* are not restated: once the proposal card
derives its colours, the pair it produces is `onPrimaryContainer` on `primaryContainer`,
which the first assertion already walks.

**Audit budget for hardcoded colours ratcheted 9 -> 0**, dated in the file.

**Tests.** 944 pass, 595 jvm over 72 classes and 349 android over 44, up from 942/594/348.
`:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:45:01 +02:00
Kgothatso Ngako
1bd5ad960f docs: record what phase 2 built, and why the audit changed shape
The plan's phase 2 was written before the migration ran and assumed the work was
mostly a sweep for off-grid numbers. It was not: 10dp and 20dp dominate the tree and
both are already on the M3 scale, so only 89 of 527 were off-grid at all. The section
now says what was actually built -- the scale, the two migrations, and the reason the
audit's value-based classification had to become a shape-based one -- along with the
0.03% pixel diff that shows the sweep moved nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:37:30 +02:00
Kgothatso Ngako
8cd0f3f44d refactor: take the last 353 spacing literals onto the scale, and reach zero
Phase 2, final step, of docs/material-design-conformance.md. Every `.dp` literal in a
spacing position in the UI tree is now a token. 431 reads of `MaterialTheme.spacing.*`, one
reasoned exemption, and `m3-spacing-positions.py` exits 0.

**Shape decides the token, not just the value.** The migration script grew a per-shape
mapping because the same number means different things in different positions: 8dp of
padding is `compactPadding`, 8dp of gap is `itemGap`, and 8dp under a `Spacer` is neither
of those and stays `space100`. Where the pair determines the meaning the semantic name is
used, and nowhere else:

    padding + 8dp   -> compactPadding      10 sites
    padding + 16dp  -> containerPadding    10
    gap     + 4dp   -> relatedGap           8
    gap     + 8dp   -> itemGap             10

That is 38 of 353. The rest take the raw stop, and deliberately: assigning a semantic name
needs somebody to have read what the container *is*, and a name that asserts a meaning the
code does not have is worse than a stop that asserts none. `screenMargin` in particular is
unassignable mechanically -- it is 16dp of padding, exactly like `containerPadding` -- so
it has no call sites yet and gets them when someone reads the screens.

**Two spacers were standing in for zero.** `WriteNewNoteScreen` renders
`Spacer(Modifier.height(1.dp))` twice, in the `LazyColumn` item that shows a reply preview
when there is one. There is nothing to show and the item still has to render something;
1dp was the placeholder. Now `space0`, with a comment, because a 1dp gap that nobody
intended is the kind of thing that gets copied.

**One value is exempt, and says so at the site.** `SovereignWalletStartupScreen`'s
`Spacer(Modifier.height(128.dp))` is room to scroll the last wallet clear of the bottom of
the window -- reserved space, not a step in the rhythm. The scale tops out at `space900`
(72dp) and rounding to it would put the row back under the edge.

Rather than exempt it in the script by value, the classifier now honours an inline
`// m3-spacing-exempt: <reason>` comment on the lines directly above. Exemptions belong at
the call site: the reason travels with the code, a reviewer sees it in the diff that adds
it, and the tool stops accumulating a list of numbers that mean nothing on their own -- the
mistake the first version of this audit made with `DIMENSION_EXEMPT`.

**Where the tokens landed.** `space125` (10dp) 128 times and `space250` (20dp) 107 -- the
two values that already dominated the tree, now named. `space600` (48dp) 52 times, which is
the empty-state spacer from the previous commit. The long tail is 2, 4, 6, 12, 14, 16, 24,
32, 40 and 64dp.

**Verified that nothing moved.** The landing screen was captured on emulator-5554 before
and after and compared pixel by pixel on a 4px grid: **47 differing samples out of
162,000, 0.03%**, and they are the status bar clock. The sweep is a rename.

**Budget ratcheted 353 -> 0**, dated in the file. Phase 8 wires `--check` into CI, at which
point a new `.dp` in a `padding()` fails the build.

**Tests.** 942 pass, 594 jvm over 72 classes and 348 android over 44, unchanged --
`SpacingScaleTest` already asserts the scale, and there is nothing to assert about a
call site having been renamed that the compiler does not.
`:composeApp:compileDebugKotlinAndroid` builds, the debug apk installs and runs,
`m3-audit.sh --check` exits 0. 75 files, 432 insertions, 348 deletions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:36:59 +02:00
Kgothatso Ngako
f7a732d68a refactor: move the 89 off-grid spacing values onto the M3 scale
Phase 2, second step, of docs/material-design-conformance.md. 77 of the 89 literals that
were off M3's spacing scale sat in spacing positions and now read
`MaterialTheme.spacing.spaceNNN`; the remaining 12 are dimensions and are out of scope.
One drifted corner moved onto the shape scale.

**The mapping, and why each is the nearest stop rather than the nicest number.**

     5.dp  x10  -> space50   (4dp)   padding and gaps in dense rows
    15.dp  x14  -> space200  (16dp)  card and dialog padding, two gaps
    30.dp   x1  -> space400  (32dp)  the spacer under LoadingDataIndicator's spinner
    50.dp  x52  -> space600  (48dp)  the spacer above an empty or error message

Nearest-stop throughout, so the largest move is 2dp and most are 1. `5.dp` is equidistant
between `space50` and `space75`; it goes to 4dp because `spacedBy(4.dp)` is already the
idiom elsewhere in the tree and a scale with two answers for the same input is not one.

The 52 at 48dp are the same three lines copied into 16 files -- a `Spacer` pushing
"Something went wrong" down the screen. Phase 5 retires them into a shared empty-state
composable; migrating them first means that composable inherits a token rather than
another literal.

**One shape, and it is the argument for having a scale at all.**
`RoundedCornerShape(30.dp)` in `TextNoteEventDetail` was the only hand-written corner off
the M3 scale, at 30dp against `extraLarge`'s 28. Two units: invisible beside any single
other card, and exactly the drift that happens when the value is a literal. It is now
`MaterialTheme.shapes.extraLarge`, the first call site for the scale `Shape.kt` documented.

**Rewritten by a script that reads call shapes, not values, and it is checked in.**
`docs/scripts/m3-migrate-spacing.py` brace-matches three call shapes -- `padding(...)`/
`PaddingValues(...)`, `Arrangement.spacedBy(...)`, and a `.height()`/`.width()` whose
enclosing call is `Spacer(` -- and rewrites only literals that fall inside one. A
`.size(18.dp)` icon, a non-Spacer `.height()`, a `RoundedCornerShape` or a `BorderStroke`
can never be caught, which a regex over `\\d+\\.dp` would have done to all of them. It
inserts the two imports where they are missing and skips comment lines. Dry run by default.

**The audit was measuring the wrong thing, and this is where that showed.** It split
literals by value against a hardcoded `DIMENSION_EXEMPT` list -- and the split is not a
property of the value. `16.dp` is a spacing stop *and* a plausible icon size. `50.dp` was a
`Spacer` height in 52 places and a divider width in one, and no list of numbers separates
those. `docs/scripts/m3-spacing-positions.py` replaces it with the same brace-matching
parse the migration uses, so the audit and the migration agree by construction; the audit
now reports **353 spacing literals** left and 76 dimensions out of scope, and the exemption
table is gone.

That reframes phase 2's acceptance criterion into something checkable: spacing positions to
zero, dimensions untouched. The script exits 1 while any spacing literal remains.

**What is left off-scale, and why none of it is a defect.** Twelve dimensions: avatar sizes
at 35, 55, 70 and 75dp, icon sizes at 18 and 22dp, and a 50dp divider width. Avatar and
icon sizing is a component-spec question rather than a spacing one -- M3 gives icons 18/20/
24/40/48 and says nothing about avatars -- and the plan puts per-component specs after the
adaptive phase. They are reported rather than exempted so the number stays visible.

**Tests.** 942 pass, 594 jvm over 72 classes and 348 android over 44, unchanged --
this commit adds no assertions, and the ones it could add (`SpacingScaleTest`) landed with
the scale. `:composeApp:compileDebugKotlinAndroid` builds, `m3-audit.sh --check` exits 0.
Pixels move by at most 2dp, in 30 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:32:27 +02:00
Kgothatso Ngako
47bdb6f976 fix: promote the two pill colours to extended roles, fixing both contrast failures
Phase 1, step 5 of docs/material-design-conformance.md. `BluePill` and `RedPill` were raw
`Color` values in `Color.kt`, paired at the call site with `Color.White` and
`Color.DarkGray` by eye. Both pairings were below the 4.5:1 floor, and one of them was not
the colour it looked like.

**This step could not leave the pixels alone, and it is the only one so far that changes
them.** `Color.DarkGray` on `BluePill` measures **2.90:1**. `RedPill` was
`Color(230, 32, 32, 191)` -- the four-Int constructor, whose last argument is alpha, so it
is `#E62020` at 0.749. Opaque, white on it is 4.57:1 and passes; composited over the
surface as it actually renders, it is **3.50:1** and does not. Any correct version of these
two buttons is a visible change, so "adds, does not restyle" does not apply here and the
plan already said the call sites would move in this step.

**What M3 asks for here is an extended colour**, not a literal: a brand colour promoted to
a full role family -- `color` / `onColor` / `colorContainer` / `onColorContainer` -- so
that contrast is a property of the family rather than a decision repeated at each use.
`ColorFamily` was already declared in `Theme.kt`, unused, alongside an
`unspecified_scheme`; Material Theme Builder emits both, and this is what they are for.

**Derived by the same rule as the gold palette**, which the fixed-roles commit established
and verified: maximum in-gamut chroma at the source colour's Lab hue, sampled at M3's role
tones. BluePill's hue is 277.0 and RedPill's is 36.3.

    role              light      dark        blue light   red light
    color             tone 40    tone 80     #0060AB      #C00012
    onColor           tone 100   tone 20     #FFFFFF      #FFFFFF
    colorContainer    tone 90    tone 30     #D7E2FF      #FFDAD3
    onColorContainer  tone 10    tone 90     #001C39      #390C00

The buttons take `color`/`onColor`: 6.46:1 for the red pill and 6.44:1 for the blue, from
2.90 and 3.50.

**A side effect worth having.** At tone 40 the two pills are the same lightness, so they
now read as a matched pair. Before, `#E62020` sat beside `#5D8DD6` -- a saturated red next
to a soft periwinkle -- and the blue looked like the lesser option. On a screen whose whole
content is "commit, or wipe and leave", weighting one choice by accident is a defect of its
own.

**They travel on a composition local, not on `isSystemInDarkTheme()`.** `ColorScheme` has
no slot for extended colours, so `LocalExtendedColors` is provided by `TorchTheme` from the
same `darkTheme` it chooses the scheme with. Reading `isSystemInDarkTheme()` at the call
site would have been one line shorter and subtly wrong: it ignores a caller who passed
`darkTheme` explicitly, so a preview forcing dark would show light pills. The local
defaults to the light families rather than to `unspecified_scheme` -- nothing composes
outside `TorchTheme` today, and an invisible button is a worse way to discover that than a
light-themed one.

**No medium- or high-contrast variants, deliberately.** The entire surface is two buttons
on one screen, and the light family's weakest pair is 6.44:1 -- clear of the floor by more
than the contrast schemes would add. 32 more values for that would be out of proportion,
and the comment in `Color.kt` says so rather than leaving the omission to be read as an
oversight.

**`QRCodeView` lost its constructor default.** `QRCodeBackgroundPainter` defaulted
`backgroundColor` to `BluePill` -- a colour picked outside the theme for a surface that is
almost never seen, since at the default `padding = 0.dp` the logo painter covers the rect
it fills. The default is gone and the one call site passes it, so the choice is visible
rather than buried.

**Two new assertions, one of which is about the constructor.** `ColorSchemeContrastTest`
grows to 9. The first checks both pairs of every extended family at 4.5:1. The second
checks that every extended role is **opaque**, because `RedPill`'s alpha is what made the
first assertion insufficient: a translucent container has no ratio of its own -- it has one
only once composited -- so a contrast test would have measured a colour the user never
sees. That is the bug this commit fixes, and it would have passed a naive contrast test.

**The audit stopped counting its own commentary.** Fixing these call sites left a comment
*explaining* what `Color.White`/`Color.DarkGray` had been, and `m3-audit.sh` counted it as
a hardcoded colour -- so the file stayed in the report after being fixed. The script now
drops comment lines before counting. Budget ratcheted 11 -> 9: the two real sites, plus the
false positive the filter removes.

**Tests.** 930 pass, 588 jvm over 71 classes and 342 android over 43, up from 926/586/340.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build,
`m3-audit.sh --check` exits 0. The nine remaining hardcoded colours are phase 3's, and are
listed by the audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:23:31 +02:00
Kgothatso Ngako
52b57769e1 feat: adopt MaterialExpressiveTheme, and give shape, type and motion a named home
Phase 1, steps 3, 4 and 6 of docs/material-design-conformance.md. `TorchTheme` passed
`MaterialTheme` a colour scheme and a typography and nothing else, so shape and motion were
whatever the library defaulted to and there was nowhere to write down what any of it was
for.

**Expressive, by decision rather than by drift.** The plan deliberately left
`MaterialExpressiveTheme` vs `MaterialTheme` open, because it changes component defaults
app-wide and is a product call. Put to the product owner on 2026-09-08 and answered
expressive. The pinned material3 1.10.0-alpha05 ships the whole set -- `ButtonGroupKt`,
`SplitButtonKt`, `FloatingToolbarKt`, `LoadingIndicatorKt`, `ShortNavigationBarKt`,
`WideNavigationRail`, `MaterialShapesKt` -- and the tree already opts into
`ExperimentalMaterial3ExpressiveApi` in 66 places, so this makes explicit what the imports
had already assumed.

**All four slots are passed explicitly, and that is the point.**
`MaterialExpressiveTheme` defaults its colour scheme to `expressiveLightColorScheme()` and
its shapes and typography likewise -- Material's values, not this app's. Leaving any slot
to that default is the same class of accident as the twelve unassigned fixed roles fixed
two commits ago: it compiles, it renders, and it renders somebody else's design.

**No visual change on the screens checked, and that is worth stating rather than
assuming.** Measured on the API 36 emulator: the "Invite a Friend" button is byte-identical
before and after -- same fill `#4E5E8B`, same 357px box at the same y -- because the
expressive default for a `Button` at default size matches the baseline in this version.
What expressive actually buys is elsewhere: `LocalUsingExpressiveTheme` gating component
behaviour, the three increased shape steps, the fifteen `...Emphasized` type roles, and the
components phases 5 to 7 are built on.

**`MantraShapes` is baseline `Shapes()`, on evidence.** The corners hand-written across the
tree already land on the M3 scale --

    RoundedCornerShape(4.dp)   x3   = extraSmall
    RoundedCornerShape(12.dp)  x11  = medium
    RoundedCornerShape(16.dp)  x2   = large
    RoundedCornerShape(30.dp)  x1   ~ extraLarge (28dp)

-- so overriding the scale would restyle the app for no reason. What is wrong is that they
are literals, which is how the last one drifted two units off the scale and why none of
them can move per breakpoint later. `Shape.kt` documents the eight steps and what each is
for; migrating those seventeen call sites is a later phase, and this is what they migrate
onto. Declaring it explicitly rather than relying on the default gives the note somewhere
to live.

**`MotionScheme.expressive()` is wired and unused.** Nothing in the app animates today --
one `animateScrollToPage`, no `AnimatedVisibility`, no navigation transitions -- so this
buys nothing yet. It is here so that when the motion phase starts, every spec comes from
the scheme rather than from a literal `tween`, and the app's feel is one decision instead
of forty.

**`Type.kt` left `com.example.ui.theme`.** It has been declaring that package while living
under `press/mantra/compose/ui/theme/`, one of three namespaces holding live UI code in
this tree. The move is mechanical; the doc comment on it is not. It records what each type
family is *for* -- `display*` for a screen's identity, `headline*` for section tops,
`title*` for headers and list headlines, `body*` for anything read as a sentence, `label*`
for **component text only** -- because the audit's finding is not that the scale is wrong
but that 92 of 240 reads are `label*` while `display*` and `headline*` carry 9 between them
across 43 screens. A UI at one pitch. The file stays baseline; the rule now has a home for
the sweep that fixes the call sites.

**The desktop unlock screen renders in the app's theme for the first time.**
`PassphraseGate` sat in the `else` branch beside `MantraApp`, which applies `TorchTheme`
itself -- so the gate composed under the default `MaterialTheme` and its
`colorScheme.error` and `typography.headlineSmall` were baseline M3. It is the first screen
a desktop user sees. `TorchTheme` now wraps both branches.

That wraps the unlocked branch twice, deliberately. `MantraApp` keeps its own `TorchTheme`
because android and ios enter through it and would lose the theme entirely if it moved out;
a second application of identical values costs one `CompositionLocalProvider` composition.
The comment says so, since the redundancy looks like an oversight.

**Dynamic colour stays on, by decision.** Also put to the product owner: on Android 12+
`dynamicColor = true` wins unconditionally, so the six schemes are used only below Android
12, on ios and on desktop, and a modern phone paints the wallpaper palette. Answered keep
as-is. A comment on the selection in `TorchTheme` now says this outright, because otherwise
the next person to change `Color.kt` and see nothing happen on their phone will assume the
change did not work.

**Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, unchanged -- this
commit adds no assertions, because what it changes is either a library default (nothing to
assert that the compiler does not) or a doc comment. `:composeApp:compileDebugKotlinAndroid`
and `:composeApp:compileKotlinJvm` build, the debug apk installs and runs on emulator-5554
under the expressive theme, `m3-audit.sh --check` exits 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:17:41 +02:00
Kgothatso Ngako
21d57eba54 feat: honour the platform's contrast setting, reaching four schemes that were dead code
Phase 1, step 2 of docs/material-design-conformance.md. `Color.kt` has carried medium-
and high-contrast variants of both themes since it was generated -- 156 colour values,
wired into `lightColorScheme`/`darkColorScheme` in `Theme.kt`, and never selected.
`TorchTheme` chose between `darkScheme` and `lightScheme` and nothing else, so a user who
turned contrast up in Accessibility settings got no change at all.

M3's accessibility foundation leads with *honour individuals*: "supporting varying
preferences and choices that allow individuals to address how their changing conditions,
individual knowledge, and varying needs are met." The work to do that was already done and
disconnected.

**The expect/actual boundary moved, because it was in the wrong place.** `themeColorScheme`
took four arguments and did two unrelated jobs -- decide the contrast-free light/dark
scheme, and decide whether to prefer a wallpaper palette. Adding contrast to it would have
meant passing six schemes across the boundary and repeating the selection table in three
actuals. It splits instead into `platformThemeContrast()` and `dynamicColorScheme()`, each
answering one narrow platform question, with the six-way table as a plain function
`appColorScheme(darkTheme, contrast)` in common code. `dynamicColorScheme` returns null
rather than falling back internally so the fallback stays in one place.

**Android reads the setting and listens for changes.** `UiModeManager.getContrast()` is
API 34; the app's minSdk is 26, so below that the answer is Standard. The float is snapped
to the nearest of the platform's three documented positions rather than matched exactly, so
a future finer-grained slider degrades to the closest scheme this app has instead of
falling back to Standard.

The `ContrastChangeListener` is the part that is easy to leave out and matters most. A
contrast change does not restart the activity and does not arrive as a `Configuration`
update, so without it the new setting would take effect on the next cold start -- which is
precisely the case the setting exists for. `context.mainExecutor` rather than
`ContextCompat.getMainExecutor`: it needs API 28, this branch is already gated on 34, and
composeApp does not declare androidx.core -- it only arrives transitively through
activity-compose, which is not a dependency to lean on.

**iOS observes the notification for the same reason** --
`UIAccessibilityDarkerSystemColorsEnabled` plus
`UIAccessibilityDarkerSystemColorsStatusDidChangeNotification`. It is a boolean, not a
slider, so iOS reports High or Standard and never Medium.

**Desktop is honest rather than complete.** Windows publishes high contrast as the
`win.highContrast.on` AWT desktop property and fires a property change when it is toggled,
so that path is real and live. macos "Increase contrast" and the linux desktop equivalents
do not reach AWT, and reading them means a native call per platform, so on those two the
answer is Standard and the file says so. This is the right place for a user-overridable
preference later; a desktop app cannot always see what the desktop was told.

**Verified on an emulator, at the pixel.** API 36, dynamic colour temporarily switched off
(see below for why that is necessary), sampling the `onPrimaryContainer` pixel of the "Skip
for now" label as `settings put secure contrast_level` moved:

    standard (0.0)   #848484     onPrimaryContainerLight
    medium   (0.5)   #A7A7A7     onPrimaryContainerLightMediumContrast
    high     (1.0)   #D0D0D0     onPrimaryContainerLightHighContrast

The three declared values exactly, and **the app was not restarted between them** -- only
the setting changed, four seconds apart. That is the listener working end to end. The probe
that switched dynamic colour off is reverted in this commit; the emulator's contrast_level
is back at 0.0.

**A finding that came out of the verification, and is not fixed here.** `TorchTheme`
defaults `dynamicColor = true`, and on Android 12+ dynamic colour wins unconditionally --
so on essentially every current Android device **none of the six schemes is used at all**
and the app renders in whatever the user's wallpaper produced. The first screenshot of this
session shows the onboarding screen in Material lavender; switching dynamic colour off
reveals the black-and-gold brand for the first time. Nobody on a modern Android has been
seeing this app's palette.

That is a product decision, not a conformance one, so it is recorded in the plan's "What
this plan does not cover" rather than changed. It does bound what this commit buys: on
Android 14+ with dynamic colour on, contrast is honoured by the platform anyway (the
`system_*` resources shift with it, confirmed on the same emulator -- buttons went
slate-blue to near-black navy). What this commit reaches is Android below 12, Android 12-13,
iOS, and desktop.

**Three new assertions.** `AppColorSchemeSelectionTest` covers the table itself, because its
failure mode is silent and specific: a scheme wired to the wrong cell still renders a
complete, plausible UI, and somebody who turns contrast up and gets the medium scheme back
cannot tell it apart from a high-contrast scheme that is not very high. It asserts each of
the six cells by identity, that all six are distinct objects (a copy-paste leaving two cells
on the same scheme would pass the first test only if it also mislabelled one), and that
`onSurface` on `surface` never *falls* as contrast rises -- the one direction that must
hold, and deliberately not the full monotonicity assertion that ColorSchemeContrastTest
explains is false.

**Not compiled: the iOS actual.** The ios targets are declared only on macos (see
docs/jvm-target.md), so `Theme.ios.kt` is written against the UIKit and Foundation bindings
rather than checked by a compiler. Its file comment says so. The android and jvm actuals of
the same two functions are compiled, and the android one is verified on a device.

**Tests.** 926 pass, 586 jvm over 71 classes and 340 android over 43, up from 920/583/337.
`:composeApp:compileDebugKotlinAndroid` and `:composeApp:compileKotlinJvm` build,
`m3-audit.sh --check` exits 0. The 54 existing `TorchTheme { }` call sites are untouched --
the new parameter is defaulted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 00:10:06 +02:00
Kgothatso Ngako
86c9628eee fix: assign every ColorScheme role, so no component can fall back to Material lavender
Phase 1, step 1 of docs/material-design-conformance.md. `Theme.kt` assigned 36 of the
49 roles `androidx.compose.material3.ColorScheme` declares. The other thirteen took
`lightColorScheme()`/`darkColorScheme()` defaults, and for twelve of them that default
is the Material baseline palette: `primaryFixed` -> `ColorLightTokens.PrimaryFixed` ->
`PaletteTokens.Primary90` -> **#EADDFF**. Lavender, in an app whose primary is
`#000000`, in both themes, in all six schemes.

Nothing in the tree reads a fixed role today, which is why nobody has seen it. That
also means it could not have been found by looking at the app -- it springs the first
time an expressive component reaches for one, and it will look like a rendering bug
rather than a missing assignment.

**The tones were computed, not chosen.** M3 defines the family by tone: `xFixed` =
tone 90, `xFixedDim` = 80, `onXFixed` = 10, `onXFixedVariant` = 30, and ColorLightTokens
and ColorDarkTokens carry identical values for all twelve -- theme-independence is what
"fixed" means. Tone is CIE L*, so for a chroma-0 palette a tone is exactly the sRGB grey
at that L*, and inverting L* -> Y -> sRGB reproduces this palette's own greys **to the
byte**:

    tone   0  #000000   primaryLight
    tone  10  #1B1B1B   primaryContainerLight, onSurfaceLight
    tone  20  #303030   onPrimaryDark, inverseSurfaceLight
    tone  40  #5E5E5E   inversePrimaryDark
    tone  80  #C6C6C6   primaryDark, inversePrimaryLight
    tone  90  #E2E2E2   onSurfaceDark, surfaceContainerHighestLight
    tone  95  #F1F1F1   inverseOnSurfaceLight
    tone 100  #FFFFFF   onPrimaryLight

Eight independent hits. The primary and tertiary palettes are the standard M3 neutral
tonal palette at chroma 0, so their fixed families are derived rather than invented.

**The secondary palette is gold at Lab hue 87.5 degrees, and its dark half is maximum
in-gamut chroma at that hue.** Generating tones off that ramp regenerates
`onSecondaryDark` (#3D2F00, tone 20) and `secondaryLight` (#745B00, tone 40) byte for
byte, which is what licenses using it for tones 10 (#241A00) and 30 (#584400).

Its tones 90 and 80 are **reused rather than regenerated**. The palette already ships
#FFDE82 at tone 90 (as `secondaryDark`) and the brand gold #EFBF04 at tone 80 (as
`secondaryContainer`, identical in light and dark -- someone hand-set it, no generator
emits that). Regenerating would have produced #FFDF99 and #F1C100: a second gold two
units from the one already on screen, indistinguishable in isolation and wrong beside
it. A near-duplicate brand colour is worse than none.

**Sanity check on the whole derivation.** The four ratios these families produce land
within 0.1 of M3's own baseline fixed family --

    onFixed on Fixed        13.30   (baseline 13.32)
    onFixedVariant on Fixed  7.17   (baseline  7.23)
    onFixed on FixedDim     10.08   (baseline 10.08)
    onFixedVariant on Dim    5.44   (baseline  5.47)

-- because tone, not hue, sets the ratio. Two palettes with nothing in common landing
on the same four numbers is the check that the tone mapping is right.

**Containers hold across the contrast setting; content darkens.** That is the move
`Color.kt` already makes everywhere else -- `onSurfaceLight` goes #1B1B1B -> #111111 ->
#000000 while `surfaceLight` stays #F9F9F9 through all three -- so the fixed family
follows it: content tones 10/30, then 5/20, then 0/10. The weakest pair ladders
5.44 -> 7.73 -> 10.08. Shifting the containers instead would have moved the brand-visible
half for a setting that is about legibility.

**`surfaceTint` is the thirteenth, and it was never a defect.** Its default is `primary`,
which is correct: `surfaceColorAtElevation` composites it over `surface` at 2-8% alpha,
so an elevated light surface darkens toward primary and an elevated dark one lightens --
M3's own behaviour, and this app sets no elevations anywhere, so nothing reads it. It is
assigned explicitly anyway, with that reasoning in a comment, so that "every role is
assigned" is a property a reader can check by looking rather than by knowing which
omissions were deliberate. m3-audit.sh reports the two kinds apart for the same reason.

**Three new assertions, and the two that matter cannot be satisfied by accident.**
`ColorSchemeContrastTest` grows from 4 to 7:

  - both content roles on both fixed containers at 4.5:1, across all six schemes;
  - the fixed roles are the same colour in light and dark, which is the definition and
    would otherwise only fail on a screen that puts one beside a themed surface;
  - no role is left at the Material baseline palette -- the twelve baseline hex values
    read out of `PaletteTokens.kt` and asserted absent.

Verified by deleting `primaryFixed = primaryFixed,` from `lightScheme` alone: two tests
fail, naming the role and printing back `Color(0.917, 0.866, 1.0)`. Reverted.

**Audit budget ratcheted 12 -> 0**, dated in the file. Per the header's contract that is
the only direction a budget moves, and the commit that lowers it is the one that earns it.

**Tests.** 920 pass, 583 jvm over 70 classes and 337 android over 42, up from 914/580/337
-- three new assertions counted once per target. `:composeApp:compileDebugKotlinAndroid`
builds, `m3-audit.sh --check` exits 0. No visual change: every role that had a value keeps
it, and the thirteen that gain one were rendering baseline defaults nothing reads yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:59:34 +02:00
Kgothatso Ngako
2b0ce8d73b test: measure M3 conformance instead of asserting it, with a budgeted audit and a contrast test
Phase 0 of docs/material-design-conformance.md. Every count in that document was
produced by hand, which makes the eight phases after it opinions rather than work
with acceptance criteria. This is the harness that turns them back into numbers.

**`docs/scripts/m3-audit.sh` regenerates the whole audit, and can fail a build.**
Plain invocation reports; `--check` exits 1 when a budget at the top of the file is
exceeded. The budgets are the tree as it stands -- 11 hardcoded colours, 33 bare
`.clickable`, 18 null content descriptions, 12 unassigned colour roles -- and the
contract written into the header is that they ratchet **down**, in the same commit
that earns the reduction, and are never raised. Counts a phase has not reached yet
are `-1`, which reports but never fails. Phase 8 wires `--check` into CI, at which
point a raised budget is the diff a reviewer is looking for.

Verified both directions: `--check` exits 0 on the clean tree, and appending a
single `Color(0xFF00FF00)` to LoadingScreen.kt makes it exit 1 naming the budget.

**Two counts are reported apart from each other on purpose.** Thirteen ColorScheme
roles are never assigned in Theme.kt, and reporting that as one number would
overstate it. Twelve are the `*Fixed*` family, which default to
`ColorLightTokens.PrimaryFixed` -> `PaletteTokens.Primary90` -> `#EADDFF`, so a
monochrome app renders Material baseline lavender the moment anything reads one.
The thirteenth is `surfaceTint`, whose default is `primary` -- correct, and not a
defect. The script labels the first group "lavender" and the second "not a defect".

The `.dp` histogram splits three ways for the same reason. 527 literals: 419 on the
M3 spacing scale, 19 dimensions rather than spacing (a 1dp hairline, an avatar, an
image height), and 89 genuinely off-scale. The naive split reported 101 off-scale by
counting 1dp borders as bad spacing, which would have sent phase 2 chasing hairlines.
`DIMENSION_EXEMPT` is deliberately short and the header asks for a justification in
the commit that lengthens it.

**`ColorSchemeContrastTest` walks the real schemes, which cost a visibility keyword.**
Four assertions over all six declared schemes: every content role on its container at
4.5:1, `onSurface` on each of the seven tonal surfaces at 4.5:1, `outline` against
every surface it is drawn on at 3:1, and `primary`/`error` against `surface` at 3:1.
WCAG relative luminance from first principles -- the 0.03928 knee and the 2.4
exponent, not a gamma-2.2 approximation, because the approximation moves borderline
pairs by enough to change a verdict and the tightest pair in this tree is 4.56:1.

`Theme.kt`'s six schemes went from `private val` to `internal val` so the test can
see them. The alternative -- rebuilding the schemes inside the test from `Color.kt`'s
public values -- keeps production visibility untouched and was rejected: it would
assert the palette and miss the wiring, and the wiring is the half that fails
silently. `surfaceContainerHigh = surfaceContainerHighestLight` is a one-character
slip, compiles, and reads fine in review. A comment above the first scheme says this,
so the keyword is not quietly widened back.

**Verified that it bites.** Nudging `onSurfaceVariantLight` from `#4C4546` to
`#9C9496` -- a plausible "soften the secondary text" edit that nothing else in the
build would object to -- fails with `light: onSurfaceVariant on surfaceVariant is
2.29:1`, naming scheme, pair and ratio. Reverted; the committed value is unchanged.

**Monotonicity across the contrast ladder is deliberately not asserted.** The obvious
invariant -- high-contrast beats medium beats default for every pair -- looks right
and is false. Ten pairs move the other way, and correctly: in the light high-contrast
scheme `surfaceContainerHighest` goes darker to separate it from `surface`, which
drops its ratio against `onSurface` from 13.30 to 12.29 while raising the separation
that the change exists for. `onErrorContainer on errorContainer` drops 7.24 -> 5.19
from default to medium for the same kind of reason. Asserting the ladder would have
meant either a red test or nine exemptions; the floor is the real invariant and every
one of those values is comfortably above it. The test's doc comment records this so
the next reader does not add the assertion.

**Also not asserted: `outlineVariant`, and the call sites.** `outlineVariant` reads
1.61:1 against surface, which looks alarming and is not a defect -- M3's own baseline
sits in the same range and the role is a decorative divider, so `outline` is what
gets the 3:1 assertion. The seven call-site pairings that are genuinely below
threshold, including the 1.00:1 one in ProposalListScreen, belong to phase 3; adding
them now would mean checking in a red test.

**Doc reconciled to the script rather than the other way round.** Three hand counts
were wrong and are corrected in docs/material-design-conformance.md: 520 `.dp`
literals -> 527 (the earlier figure omitted the exempt dimensions), 90 `label*`
typography uses -> 92 (it missed `labelSmallEmphasized` and `labelLargeEmphasized`,
which are label roles too), and 101 off-scale -> 89. The phase 0 section is rewritten
from a plan into what was built, including what was decided against.

**Tests.** 914 pass, 580 jvm over 70 classes and 334 android over 42 classes, up from
906/576/69 and 330/41 -- the four new assertions, in one new class, counted once per
target because commonTest flows into both. `:composeApp:compileDebugKotlinAndroid`
builds. No app behaviour changes: the only production edit in this commit is
`private` -> `internal` on six vals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 23:51:34 +02:00
Kgothatso Ngako
1c02b25a07 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
Kgothatso Ngako
98f766fcd3 fix: put the invite in the room, so a stuck one can be seen
Inviting a member to a group that already had members put nothing whatsoever in
the transcript. Not "put it in late" -- nothing, and nothing ever if the invite
did not complete. So the one failure the user is best placed to notice, an
invite that never reached the person it was made for, was the one the app kept
to itself.

The line existed. It was written by `MarmotOutboundDao.deliveryWelcome`, which
is the wrong place for it, and the reason is the two paths through
`inviteMember` that docs/marmot-membership.md already describes. A group that is
still only its creator has nobody to inform, so its Welcome goes out immediately
and `deliveryWelcome` runs inside the invite. A group that has members must
broadcast a commit first, and its Welcome waits for a relay to acknowledge it --
`DatabaseNostrRepository.broadcastProcessed` picks the stored `MarmotCommitResult`
back up and delivers then. Every invite after a group's first therefore wrote
its transcript line a relay round trip away from the invite, if at all.

**Four separate silences, not one.** Worth listing because only the first is
about the deferral, and fixing that alone would have left the other three:

1. The deferred path wrote nothing until the ack, and nothing ever without one.
2. The write hung off `getMarmotKeyPackageById(...)?.let { getProfileByPublicKey(...)?.let { ... } }`.
   Those two lookups were there to *name* the invitee, and a miss on either cost
   the whole line rather than just the name.
3. `deliveryWelcome` wraps its body in `catch (e: Throwable) { logger.e(...) }`
   and returned Unit, so a Welcome that could not be built reached the log and
   no further.
4. `inviteMemberToChatRoom` is `@Transaction`. An invite that threw -- no MLS
   state for the room, a credential identity that does not match the peer --
   rolled its line back with everything else, which is right, and left no
   account of the refusal anywhere durable.

And the line it did write was `messageType = "message"`, `isUserMessage = true`,
so it rendered as a chat bubble: "Invited Bob to chat", attributed to the
inviter as something they said.

**Three membership types, and the line moves to invite time.**
`ChatMessage.MEMBERSHIP_TYPES` -- `memberInvited`, `memberInviteSent`,
`memberInviteFailed` -- rendered by the transcript as system notices through
`RitualNotice`, the way the ceremony, signing and chronicle lines already are.

`memberInvited` is written by `inviteMember` and by `addMembersToChatRoom`'s
batch path, *when the invite is made*, and deliberately **inside** the caller's
transaction. Both halves of that matter and they pull opposite ways: written any
later and an invite waiting on an ack that never comes shows nothing, which is
the bug; written outside the transaction and an invite that does not survive
`addMember` leaves the room claiming one was made.

`memberInviteSent` is written by `DatabaseNostrRepository` alone. It is not
written on the immediate path, and that is not an oversight: there the Welcome
goes out in the same breath as the invite, so one line is the whole truth. It
would also be a line the transcript could not order -- `MantraConverters` stores
`Instant` as `epochSeconds`, the room query is `ORDER BY createdAt DESC`, and two
rows written in the same second tie. Only the deferred path separates the two
events in time, so only it owes a second line.

`memberInviteFailed` carries the reason, because it is the only copy the user
gets. `deliveryWelcome` now returns `Boolean` and files this line from its own
catch before returning false -- its callers had no other way to see a failure it
had already swallowed, and on the deferred path there is no invite screen left
to fail back to. `addMembersToChatRoom` reads that answer instead of a
`runCatching` that could never catch anything.

**The refusal is written from outside the transaction that rolled it back.**
`DatabaseChatRepository.inviteMember` catches, calls `announceInviteFailed`, and
rethrows. The throw is what puts a message on the invite screen now; the line is
what is still there tomorrow. Swallowing it instead would have popped the user
back to the chat as though the invite had gone out, which is the bug the
existing `runCatching` in `AddMemberToChatRoomConfirmationViewModel` was added
to stop.

**No schema change.** `messageType` is a free-form string column with a default,
so new values need no migration -- unlike the chronicle rename, which had to
rewrite the ones already stored. Nothing reindexes these either: they carry no
`marmotGroupEventId`, so `getResolvedMarmotGroupEventIds` cannot see them and
`UNRESOLVED_MARMOT_TYPES` does not name them.

Six new tests. Four on the DAO: the immediate path leaves a line naming the
invitee where the old code left none, the deferred path leaves one *and* claims
no Welcome sent before any ack, everything an invite writes is a membership type
rather than something the transcript would render as a bubble, and a refused
invite leaves no claim that one was made. Two new ones on
`DatabaseChatRepository`, which had no test file: a refused invite is written
into the room, and the caller still gets the throw.

Still open, and now said plainly in the doc rather than implied: a Participant
row carries no state saying where its invite got to. The transcript narrates it;
the `TODO: Update status of participant Invitation.PENDING -> Invitation.SENT`
is untouched. Nor does an invitee with no published key package reach the room
at all -- that fails in the view model, before there is an invite to write a
line about.

806 tests pass -- 509 jvm, 297 android.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 18:16:26 +02:00
Kgothatso Ngako
19d57ef3a5 Merge branch 'mantra' into claude/happy-gauss-dbe258
Brings in the Chronicle rename and the deprecation of the row rebuild, and
carries the supersession fix across into the new vocabulary.

Git followed every rename on its own -- `ArchiveManager` -> `ChronicleManager`,
the tests, the docs -- and auto-merged all three files my fix had touched. What
it could not do is rename identifiers inside the hunks it merged, so the fix
arrived speaking the old language: `ChronicleAssemblyJvmTest` still called
`ArchiveManager.assemble` and `ArchiveEvent.decodePage`, which does not compile,
and six doc comments in `ChronicleManager` and `GroupSignedEvent` still said
"archive" -- the exact ambiguity with archiving a chat that the rename exists to
remove.

One real conflict, in the design note, and it is the same sentence twice: my
correction of "a retranslated passage archives once" against the rename of the
uncorrected claim. Resolved to the correction, in the new vocabulary -- the
property still holds, it just stopped being free the moment the chronicle was
read from `GroupSignedEvent` rather than rebuilt from rows, and
`ChronicleManager.currentTranslationsOnly` is what holds it up.

`compileKotlinJvm` passes over a test file that does not compile, so it was no
evidence here; `compileTestKotlinJvm` is. And the filter was re-checked the way
it was written: removing it fails the same three tests, so the merge did not
quietly neuter them.

503 jvm tests and 297 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:37:12 +02:00
Kgothatso Ngako
4890906b24 Merge branch 'mantra' into claude/rename-archive-chronicle-a4a8e0
The rebuild deprecation landed on mantra while the rename was in flight, and it
touched the same files by their old names. Git matched the renames itself, so
the only conflict was `ChronicleRoundTripTest`'s header, where both sides had
rewritten the same paragraph: mantra's says this file is now the gate on a
deprecated fallback rather than on the only path, which is the newer and truer
claim, so it wins and the rename is applied on top of it.

Everything the merge brought in went through the same substitution as the rest:
the nine `@Deprecated` messages and the "Retiring the rebuild" checklist all name
`ChronicleManager`, `ChronicleRoundTripTest` and docs/member-chronicle.md, which
are the files that now exist.

797 tests pass -- 500 jvm, 297 android. The five new ones are the migration's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:29:23 +02:00
Kgothatso Ngako
ea11e8b233 refactor: call it a chronicle, and keep "archive" for what a user does to a chat
Archiving a chat is an ordinary thing a user will want to do to a conversation,
and it is not this. This is the group's signed record, handed to a member who
joined after the work was done so their room stops being empty. Two unrelated
meanings of one word in one app is a bug waiting to be written, and
`ChatRoom.archiveRequestedAt` is exactly where they would have met: a column on
the chat row, named for the thing that is not the chat.

So the whole feature is Chronicle now -- `press.mantra.compose.nostr.chronicle`,
`ChronicleEvent` (30327), `ChronicleRequestEvent` (30328), the three tags,
`ChronicleManager`, `docs/member-chronicle.md`. The kind numbers do not move;
only the words do.

**The wire tags move too**, `archiveId` -> `chronicleId` and `archivePage` ->
`chroniclePage`, which is free exactly once. Both kinds are new and there is no
old build to stay compatible with -- the design note says so in as many words --
so the alternative was carrying the old spelling on the wire forever to save a
rename that costs nothing today. The recipient tag stays `p`; it was never ours.

**Schema v14, because two things had the old word written into stored data.**

`ChatRoom.archiveRequestedAt` becomes `chronicleRequestedAt`, renamed rather than
dropped and re-added: while it is set it is the only record that a device with an
empty room has already asked the group for its history, and a device that lost it
mid-flight would ask again on its next launch, and the one after that.

The three `ChatMessage.messageType` strings become their `chronicle*` spellings,
rewritten rather than left to a legacy constant the way `dkgApprovalNeeded` was.
These lines cannot be regenerated -- a chronicle is announced once, when it is
requested, sent and applied -- and an unrecognised type is not skipped by the
transcript. It renders as an ordinary chat bubble, so "Caught up on 12 items"
would come back attributed to a member as something they said.

`MIGRATION_13_14` does both, because Room can rename a column and cannot rewrite
rows in the same breath. `ALTER TABLE ... RENAME COLUMN` needs SQLite 3.25, which
`getRoomDatabase` guarantees by pinning `BundledSQLiteDriver`, and the column is
in no index, no foreign key, and there is not a view or trigger in the database
-- so nothing has to move with it. Five tests hold the two halves apart: the
value survives, the column keeps its position, a room that never asked still
reads as never having asked, the three types are rewritten, and every other type
is left alone.

**`isArchivable` is `isChroniclable`**, on the "recyclable" pattern, and it keeps
its job unchanged: the allowlist that stands between a replayed
`GroupKeyStateEvent` and the apply path.

No behaviour change beyond the rename. 797 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:27:36 +02:00
Kgothatso Ngako
87ef129de5 fix: archive the translation that stands, not every draft of it
Follow-up to reading the archive out of `GroupSignedEvent` rather than rebuilding
it from rows. The two sources do not hold the same thing, and one place where
they differ reaches the archive.

`ChatMessage.applyInnerEvent` supersedes a translation chunk: retranslating a
passage changes the text and so the event id, so the arm drops the row it
replaces -- newest by the timestamp the group signed at, id breaking a tie. The
record does not, and should not: a signature is the group's statement and
discarding one is not that table's business. So a passage translated three times
leaves one row and three events.

While the archive was rebuilt from rows that difference was invisible, because a
sender simply had nothing but the group's current answer to each passage. Read
from the record it is not: measured on a seeded room, one retranslation leaves
one row and puts **two** payloads in the archive, and it compounds -- every draft
a group ever signed would travel in every archive it ever sends, for as long as
the room exists.

**The rule is the applying arm's, restated rather than approximated.** An archive
that shipped one translation as current while the recipient settled on another
would have both validly signed and nothing downstream to notice they disagree, so
`currentTranslationsOnly` groups by `(translationChapterId, chunkId)` and keeps
the maximum by `(createdAt, id)` -- the same comparison, spelled the same way.

Dropping the drafts is safe precisely *because* the recipient applies that rule
too. This is not what keeps them correct; it is what stops them being sent work
they would discard on arrival.

Grouped per passage rather than per chapter, or retranslating one passage would
take every other passage's translation with it. A translation naming no passage
is left alone rather than lumped in with the rest: it is unappliable either way,
and letting one stand in for a whole passage would let a malformed event suppress
a good one.

Three tests, and all three fail if the filter is removed: a retranslated passage
leaves one row, two recorded events and one payload; three translations signed in
the same second settle on the same id the row keeps, which is what pins the
tiebreak to the applying arm's; and two passages each keep their own, which is
what a group-by-chapter mistake would fail.

Two documentation corrections alongside it. docs/member-archive.md said "a
retranslated passage archives once" as a property of the rows, which stopped
being true the moment the record became the source -- it now says what makes it
true again. And `GroupSignedEvent.verifies()` said a false means the row's
columns have drifted from the event they came from. That is the reading worth
acting on and it is not the only one: a room never derived from its group's key
signs as the bare threshold key rather than as its own id, so a perfectly good
event there fails and cannot be made to pass, the key it would need being absent
from the row and unreachable from one.

498 jvm tests and 297 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:19:12 +02:00
Kgothatso Ngako
c66f085681 refactor: deprecate the row rebuild, and write down what goes with it
`assemble` reads `GroupSignedEvent` now and rebuilds from `Mantra*` rows only
what that table does not hold, which is work signed before it existed. The
rebuild is therefore on its way out rather than merely second in line, and this
says so where a reader will actually meet it -- at the call site, from the
compiler -- instead of only in a paragraph they have to find first.

**Nine `@Deprecated` markers, and they are load-bearing as documentation.** The
eight `toXEvent()` methods and `ArchiveManager.rebuiltEventsOf`, each carrying
the same sentence: this is the fallback for pre-v13 work, read the event off the
table instead, and it goes when the last such install does. That raises nine
warnings in `commonMain` today, all of them inside the walk itself, so the
deprecation is visible in every build without anything failing over it. The
level is `WARNING` deliberately -- the code is still called, still correct, and
still the only thing standing between an older room and an empty archive.

**The checklist is a new section in docs/member-archive.md**, because the
interesting part of this removal is not the eight methods, it is everything
around them that is easy to take out by association or leave behind by accident.

*What goes*: the walk and the version-label recovery inside it, the union in
`signedEventsOf`, the eight rebuilds, and `ArchiveRoundTripTest` entire -- all
ten cases, which exist to hold the rebuild up and cover nothing else. Its own
header still opened with "signed events are not stored as events", which stopped
being true two commits ago, so it now says what it is: the gate on a deprecated
fallback, deleted with what it guards.

*Two already-dead cousins to sweep at the same time*, named because they will
look like part of the rebuild to whoever does the removal and are not:
`MantraTranslation.toTranslationEvent`, which nothing has ever called, and
`MantraTranslationChunkProposal.toTranslationChunkEvent`, on a model that is not
even a `@Database` entity.

*The tests that seed without recording*: in `ArchiveAssemblyJvmTest` the
`apply`-only seeding **is** the rebuild path, and two of its cases are about the
union specifically and mean nothing without it. `ArchiveApplyJvmTest` seeds its
sender the same way but is testing delivery rather than assembly, so it needs
the recording call *added* -- otherwise it quietly starts asserting against an
empty archive, which is the same silent-success failure this whole feature is
about.

**What only looks like it goes, which is the half worth writing down.**

The `isArchivable` filter in `signedEventsOf` is not part of the rebuild and
becomes the only thing standing. It is there *because* of the record: the walk
could only ever produce document kinds, so nothing needed filtering while it was
the source, and the table holds every kind the group has signed -- starting with
the `GroupKeyStateEvent` every room signs as its first act. Dropping it with the
walk turns every room's archive into an `IllegalArgumentException` from
`ArchiveEvent.build`. Two cases fail with exactly that if it goes, which is the
guard against removing it by association rather than by decision.

The verify filter in `assemble` stays too. With the rebuild gone it checks
events that were verified before they were recorded, so it cannot fail in
practice -- which is the argument for keeping it, not against. "Cannot happen"
is the state it exists to preserve.

`Mantra*.signature` and `Mantra*.publicKey` are explicitly *not* on the list.
They were what made a row rebuildable, and since v13 `groupSignedEventId` says
whether the group signed a row and points at the proof -- so they are arguably
redundant. But four test files assert on them and `MantraTranslationContributor`
builds a contributor list out of one, and it is a twelve-table migration with
its own tests to rewrite. It should be decided on its own merits, not ride along.

**The precondition cannot be checked, and the section says so plainly.** No
query answers "does any install still hold pre-v13 work" -- a device that
upgraded is indistinguishable from one that never had any, and the rows that
need rebuilding are on other people's devices. What is observable is the
`signedEventsOf` log line, which fires only when the rebuild actually
contributed something; fleet-wide silence is evidence and not proof. The cost of
getting it wrong is named as well, because it is not loud: the member keeps
their own rows and reads the room normally, and only loses the ability to
*answer* a request with the older half of the group's work -- so a newer member
asks, is answered, and receives an archive that is quietly short.

No behaviour change. 495 jvm tests and 297 android unit tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:06:17 +02:00
Kgothatso Ngako
a69d80d38f feat: archive the events the group signed, not rebuilds of its rows
`assemble` read the archive out of `Mantra*` rows, rebuilding each payload with
`toXEvent()` and standing or falling on that rebuild being byte-identical to
what was signed. It had to: nothing kept the events. `GroupSignedEvent` keeps
them now, so `signedEventsOf` reads the record first and rebuilds only what the
record does not hold.

**The rebuild stays, as the fallback, keyed by id.** A room whose work predates
v13 has no events on file, and dropping the walk would silently empty its
archive -- the failure mode being that a member asks for the history, a member
answers, and nobody notices the answer was blank. So both sources are read and
unioned by event id, which is also what a half-upgraded room needs: older work
only the rows remember, newer work on file, and neither half complete on its
own. The fallback can go once no install still carries pre-v13 work, and
`ArchiveRoundTripTest` is what holds it up until then.

**The allowlist does real work on the way out now, and this is the part that
would have bitten.** The rebuild could only ever produce document kinds, because
those are the only rows it walks. The record holds every kind the group has ever
signed -- and every room signs a `GroupKeyStateEvent` as its first act, so one
is on file in every room that has signed anything at all. `ArchiveEvent.build`
refuses a non-archivable kind with `require`, so an unfiltered read does not
quietly ship a key state: it throws, and the room's entire archive fails on the
one event every room has. `signedEventsOf` therefore filters on
`isArchivable` before anything else, which is the same rule `applyPage` applies
on the way in. Removing that one line fails two tests with exactly that
exception, which is how I know they are load-bearing rather than passing for the
reason I expected.

**An artifact whose initial version row is missing now archives.** The rebuild
has to recover the version label from that row -- `fromArtifactEvent` drops it,
so it is not on the artifact -- and logs and gives up without it, which is a
hole in the archive for any device that applied half a batch. Read from the
record there is nothing to recover: the label never left the event. That is the
case that makes the record the better source rather than merely the faster one,
and it has a test of its own.

**One verify filter over both sources**, because the rule is per event and not
per source: nothing leaves that the recipient could not check for themselves. A
drop still means different things on each side -- a member's own rumor sitting
in the same table as the group's work, versus a row that has drifted from the
event it recorded -- and the comment now says so, since the log line cannot.

**Ordering is unchanged where it matters and looser where it does not.**
`inApplyOrder` is a stable sort by dependency rank, so the union only affects
order *within* a rank: a room holding some work both ways can order two chapters
differently from a member holding one way only. Pages are idempotent and applied
payload by payload, and two members already differed by the order their rows
were written in, so this costs nothing.

`rebuiltEventsOf` still runs on every archive even where it contributes nothing,
because there is no way to tell a complete record from a partial one without
doing the walk, and it is a handful of indexed queries against a room's own rows.

495 jvm tests and 297 android unit tests pass. Five new cases in
`ArchiveAssemblyJvmTest`, which seeds through the real inbound path and now
records the same batch the way `FrostSigningManager.complete` does: payloads
compared byte-for-byte against what was signed, work held both ways travelling
exactly once, a genuinely room-signed key state left behind, a signed kind the
archive has no arm for left behind, and the artifact the rebuild has to leave
out archiving from the record. The existing assembly and end-to-end tests seed
without recording, so they go on covering the rebuild fallback unchanged --
which is why they all still pass, and why that is evidence rather than luck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:51:16 +02:00
Kgothatso Ngako
8f9e4de82e feat: keep what the group signed, and the path it signed as
A quorum signing something is the most expensive thing this app does and, until
now, the least recorded. `FrostSigningManager.complete` verified the signature,
handed the event to `ChatMessage.applyInnerEvent`, and let it go. What survived
was whatever the row it became happened to keep -- an artifact keeps its
`signature` and `publicKey`, a translation contributor list keeps nothing at all
because that arm is still a TODO, and a kind this build has no arm for keeps
nothing anywhere. The signature is the group's statement; the rows are one
reading of it. `GroupSignedEvent` is where the statement itself now lives, at
schema v13 behind an `AutoMigration(12, 13)`.

**The columns are `NostrEvent`'s, not a summary of one.** `id`, `publicKey`,
`kind`, `tags`, `content`, `signature` and the event's own `created_at` as
`createdAt`, so what is stored is an event rather than a description of one.
That is what makes `verifies()` answerable from the row alone: it delegates to
`GroupKeyStateEvent.isSignedByRoom`, which asks whether the author is the room,
whether the id is the hash of the fields sitting next to it, and whether the
signature checks out. No ceremony, no key state and no path have to be on hand
first -- which is exactly the position a member added after the ceremony is in.

**The derivation path is the point of the exercise.** `publicKey` is the group's
threshold key walked to `derivationPath`, and for a room that walk is also the
room id -- see docs/shared-key-derivation.md, where those are one value. Without
the path there is no way back from a signature to the ceremony behind it: a
threshold key alone does not say which of a group's rooms signed, and a room id
alone cannot be walked backwards. `GroupKeyState` records the path for the room;
this records it for the event, so an event stays checkable after the room's
state is gone or was never known. Null means the untweaked threshold key, the
same meaning it carries on `FrostSigningSession.derivationPath`, which is where
the signing path is copied from -- resolved from the room by `signingPath`,
never from a proposer.

**Two writers, and both file only what they have already checked.**
`FrostSigningManager.recordSignedEvents` files a whole batch in one write, after
every item's signature has verified and before any of them is applied -- a
session's events are one decision by one quorum, so half a batch on file is a
state no reader should have to reason about. `ArchiveManager.applyPage` files
each payload it accepts, after the allowlist and
`GroupKeyStateEvent.isSignedByRoom`, reading the room's path once per page from
`GroupKeyState` rather than once per payload. Neither failure is the caller's:
recording throws are logged and swallowed, because a ceremony that succeeded
must not be reported as failed over a row this device could not write down.

**The archive half is what makes a recipient more than a dead end.** A member
handed their history used to end up holding the rows and none of the events --
able to read the group's work, unable to prove any of it, and unable to build a
page for the next member to arrive. Now the events land too.

**`record` merges rather than overwrites, and that direction is deliberate.**
The same event reaches a device twice by design: once when the session that made
it completes, once from any archive page carrying it. The second arrival is the
poorer one -- an archive knows no session, and on a member who joined after the
ceremony no derivation path either -- so the incoming row fills gaps and never
empties them. The event's own fields are not merged because they cannot
disagree: the id is the hash of them, so two rows under one id either hold the
same event or one of them is not the event it claims to be.

**Every `Mantra*` row points back at it.** `groupSignedEventId` on all twelve
entities that carry `marmotGroupEventId`, stamped by `ChatMessage.applyInnerEvent`
through a new defaulted parameter. On a group-signed row it is the only
provenance there is: both Marmot ids are null, because there is no group event
and no inner event behind one -- a signed event authored by the threshold key
cannot travel as an inner event at all, since the outbound pipeline re-authors
rumors as their sender and would strip the signature off. The column is only set
when the record actually landed, so a row never points at an event that is not
there.

**`ArchiveManager`'s own doc said something that is no longer true.** It opened
with "signed events are not stored as events", stated as present-tense fact and
load-bearing for the paragraph under it. Corrected there and noted at the head
of the same section in docs/member-archive.md, which is a phase history and so
gets a note rather than a rewrite. Assembly still rebuilds payloads from rows via
`toXEvent()` and the round-trip gate still holds it up: a room whose work
predates v13 has no events on file, and rebuilding is the only way to reach it.
Reading assembled events from the table is worth doing once that fallback can be
dropped.

**Two things this deliberately does not touch.** `ChatMessage` gets no such
column -- it is not a `Mantra*` row and already carries `frostSigningSessionId`
for the lines that need to name a session. `MantraTranslationChunkProposal` has
a `marmotGroupEventId` but is not a `@Database` entity and nothing in
`composeApp/src` references it, so it was left as the dead code it is rather
than grown a column.

Rows are not backfilled by the migration. The events they came from are gone,
and minting an id for one would point a row at a signature nobody can produce;
null reads as "this device does not hold the event behind this row", which is
true of every row written before today.

490 jvm tests and 297 android unit tests pass. `GroupSignedEventDaoJvmTest` is
eight cases against a real 2-of-3 quorum rather than a stub signature, because a
fake one would satisfy every column assertion and prove nothing -- it covers the
round trip, the path walking back to the row's own author, the merge in both
directions, batch ordering, and a row edited after the fact no longer verifying.
`SignedGroupKeyStateTest` adds the end-to-end claim over two devices: a batch of
three signed in one session lands as three events on both, each at `m/9420/0/0`
that neither device was told and both derived from the room they stand in.
`ArchiveApplyJvmTest` asserts the receiver ends up holding the events and not
only the rows, and that the four forgeries in its adversarial page become no
signed-event rows either -- a forgery filed there is one the recipient goes on
to hand to everybody else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:43:18 +02:00
Kgothatso Ngako
47aa79ebc7 feat: archive the translated text too, now that the group signs it
The merge brought in two commits that close the gap this feature was written
around, so the allowlist grows from six kinds to eight.

`feat: sign an artifact's first version with it, not derive it after` makes the
version the second item of the artifact's own signing batch. `feat: ask the group
to sign a chunk's translation, not just save it` puts a quorum behind the prose.
Both were done for their own reasons and neither was about the archive, but they
are exactly what the archive was missing: an archive can only carry what its
recipient can check, so a derived version and a member-authored translation could
not travel. A new member got the whole structure and none of the words.

**30301 and 30309 do not go on the end of the list.** The order is the foreign
keys: a version sits between its artifact and the chapters hanging off it, and a
translated chunk hangs off both a source chunk and a translation chapter, so that
one really is last.

**`toArtifactVersionEvent` had the bug this predicted it would.** It emitted
[artifactId, alt] where `build` emits [alt, artifactId], so the id did not
round-trip -- the same fault fixed on `MantraArtifact.toArtifactEvent` in Phase 3,
in the second of the three unused rebuilds, and for the same reason: nothing had
ever called it, so the "tag order matches build" claim in its comment was never
checked. `toTranslationChunkEvent` was already correct. Both now have a
round-trip case, which is what makes the difference between a rebuild that is
right and one that has not been contradicted yet.

**`signedEventsOf` walks two steps further**, emitting each version and the
translation chunks under each translation chapter. A retranslated passage
archives once: the arm that applies a translation chunk drops the one it
supersedes -- newest by the timestamp the group signed at, id breaking a tie --
so what a sender holds, and therefore what travels, is the group's current answer
to each passage rather than its drafts.

**The seeds had to change with it.** Both database tests derived the artifact's
first version by applying the artifact, which is exactly what stopped happening;
they now sign it through `ArtifactVersionEvent.initialVersionOf`, the way the
batch does. That also removes the one exception in the end-to-end assertion:
every archived row is now authored by the room and carries a signature, where the
artifact version used to have to be excused for having neither.

480 tests pass. The plan's Phase 3 table, its built-vs-plan table and its "what
this does not do" section are updated -- what an archive cannot do is down from
two things to one, and the remaining one is that it still cannot make its
recipient able to sign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:13:43 +02:00
Kgothatso Ngako
f3984e838c test: prove the catch-up row by row, and say what an old build makes of a page
Phases 8 and 9 of docs/member-archive.md. The tests ran in the phases where the
code they cover first existed -- the way the batch-signing note's did -- so this
is what was missing from them, plus the rollout note, plus the plan marked built.

**Compared row by row, not by count.** The end-to-end test asserted the two
databases held the same *number* of artifacts, chapters and chunks. That is not
the claim: two databases can hold the same counts and disagree about every row,
and a rebuild that lost the group's signature -- or re-authored a row as whoever
sent it -- would pass a count and fail the only thing an archive is for. It now
compares `(id, author, signature)` per row across every archived kind, and then
asserts each one is authored by the room and carries a signature.

The artifact version is the one exception, and it has to be: nobody signs it, it
is derived from the signed artifact on arrival. Which is exactly why it is not
archived, and why a chapter's foreign key survives without it.

**An old build does not ignore an archive page, it renders it.** Phase 9's first
draft said an old build "files it as unsupported, exactly as it does today for
anything it does not know" -- true, and it reads better than it lives. An
unsupported row's content is `event.toJson()` and it renders as an ordinary chat
bubble, so every member on an old build sees each archive page as a raw-JSON
bubble of up to `MAX_PAGE_BYTES`, once per page.

Nothing breaks and nothing is lost, but a group mid-upgrade gets a genuinely
unpleasant transcript, and that is worth knowing before the first archive goes
out. So the rollout rule is stated rather than implied: the receiving half ships
safely on its own -- phases 1-4 send nothing -- and no member starts sending
until every member understands kind 30327. The mitigation if that ever proves
unacceptable is the one the appendix rejects for other reasons, and it is named
there so the trade can be weighed rather than rediscovered.

**The plan is marked built**, with a table of the five places the implementation
chose differently from the plan and why: nine archivable kinds became six, a
count cap that could never fire, queueing moved a phase later, a re-read that was
never needed, and the rollout note above. Phase 8 also records the three tests
that were not in the first draft, each written because something passed for the
wrong reason -- a cap that could not fire, an out-of-order test on an archive
that was never out of order, and a sweep whose "still missing" count included
failures a later pass had already fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:37:23 +02:00
Kgothatso Ngako
a315b86918 feat: say in the transcript that a member is being caught up
Phase 7 of docs/member-archive.md, in part. Three chat types --
`TYPE_ARCHIVE_REQUESTED`, `TYPE_ARCHIVE_SENT`, `TYPE_ARCHIVE_RECEIVED` -- so a
room that fills itself in explains itself once.

Without this the archive is entirely silent by design: it files no line per
applied payload, because `ChatMessage` has an `autoGenerate` primary key and
every payload would mint a fresh row on every pass of the sweep. The result was a
member joining a working group and watching a room populate with no account of
where any of it came from, which is worse than the noise it avoided.

**One line per archive, not per page.** The received line is written when the
request stamp is cleared, which is as close as this can get: an archive's pages
are not distinguishable from each other at apply time, and clearing the stamp is
exactly the moment a catch-up stops being pending. There is a test that delivers
a payload per page, backwards, so the sweep runs repeatedly over many pages, and
asserts the transcript holds two lines.

**A push behind a Welcome writes nothing**, because the room was never asked. It
lands before the member has opened the room, and "caught up on work you have not
seen yet" is a line about nothing. Also tested.

**The received line names no sender.** An archive can be assembled from pages
sent by more than one member, so attributing the catch-up to one would be a guess
dressed as a fact. The sent line does name its recipient, written into the
content the way the invite line writes one -- which does not follow a rename, and
is the accepted cost for a line about something that happened once.

**Content is whole sentences**, so these stay out of the AUTHORED sets and
nothing prefixes a name to them. And they are added to `ARCHIVE_TYPES` with a
matching arm in the transcript, because the failure mode for a missed set is
silent: the line renders as a chat bubble, looking exactly like a member having
said "Caught up on 12 items". Icons per type rather than the `PanTool` fallback.

**Two items from this phase are deliberately not done**, rather than written
without the app in front of me: the banner saying a room is catching up, and a
"Send history" action on the member row. The first is UI state plumbed through a
view model into a layout and the transcript line covers the same ground; the
second is a convenience, since both real paths are already automatic. Both are
written up in the plan as outstanding, along with the thing this phase was also
meant to say and does not: that an archive does not make its recipient able to
sign, and does not carry the translated text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:34:19 +02:00
Kgothatso Ngako
6d8866c2b0 feat: offer a new member the group's work alongside their welcome
Phase 6 of docs/member-archive.md, and deliberately the phase after the one that
makes it unnecessary. `deliveryWelcome` now queues an archive for the member
being invited, so in the ordinary case they have the group's signed work before
they think to ask for it.

**This is a latency optimisation, not the mechanism.** A page queued behind the
Welcome is not delivered after it: they are different transports -- a relay-borne
gift wrap and a kind:445 -- with no ordering between them, and a page that
overtakes the Welcome is from an epoch ahead of the invitee's, so
`MarmotInboundManager` drops it outright rather than deferring it. Nothing
retries and the inviter sees a success. That is the failure in
docs/marmot-membership.md wearing new clothes, and the only thing that closes it
is the invitee asking once they are demonstrably in the group, which Phase 5
already does on their first open of the room.

So nothing here reports failure to the inviter. A push that does not land is the
ordinary case the pull exists for, and it sits inside `deliveryWelcome`'s own
catch alongside the Welcome it rides behind. A room with nothing signed queues
nothing and still invites.

**One call, two occasions.** `ArchiveManager.answer` becomes `sendTo`: answering
a request and pushing behind a Welcome are the same operation and differ only in
who decided, so it is named for what it does rather than for either occasion.

Also corrects the plan. Phase 6 claimed the room had to be re-read between the
invite and the assembly, for the same reason sequential invites re-read it. It
does not -- that rule is about the MLS snapshot a commit is built on, and this
runs downstream of the commit over `Mantra*` rows, which no commit touches.

Two tests against a real `deliveryWelcome`: inviting into a room with signed work
queues exactly one archive page addressed to the invitee, and inviting into a
room with none queues no page while still writing the Welcome's gift wrap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:29:11 +02:00
Kgothatso Ngako
ed4410b972 feat: apply an archive a member is sent, and sweep what arrived too early
Phase 4 of docs/member-archive.md, and the half where the security lives. A
member who holds no share, took part in no signing session and cannot decrypt a
word of the room's history now ends up with the same rows as everybody else --
and gets there without trusting whoever sent them.

**Intercepted in `fromGroupEventResult`, not in `applyInnerEvent`.** An archive
is neither a document nor a submission, and deciding whether to act on one needs
the active key, which `applyInnerEvent` has no business knowing. That is the same
reason the gift wrap above it is handled there, so it sits next to it.

**Verification per payload, framing per page.** A forged payload costs itself and
nothing else -- the rule `MarmotInboundManager` already uses for a forged direct
message, and for the same reason: this runs inside the inbound transaction and
one bad event must not take the room down with it. Refusing the whole page would
also let a single forgery deny an entire archive. The page's own framing stays
all-or-nothing, because a page that will not parse has lost the thing that says
what it contains.

**The allowlist runs before the signature check, and it is not a formality.**
Verification admits an event to the apply path on the strength of the group's
signature, which makes every kind the group has ever signed replayable by any
member at any time. There is a test that puts a genuine, still-verifying
`GroupKeyStateEvent` in a hand-rolled page -- `ArchiveEvent.build` refuses to make
one, which is the outbound half of the same rule -- and asserts the receiver's key
state does not move.

**The chat line is dropped, deliberately.** `ChatMessage` has an `autoGenerate`
primary key, so there is no id to dedupe on and every applied payload would mint
a new row: a synthetic transcript dated now, and another one on every pass of the
sweep. The archive restores the work. The conversation is forward secret and
stays gone.

**A device that is not the named recipient does nothing.** It can read the page --
it is an ordinary group message, and it is the group's own history -- but it
already holds the work, and re-applying would rewrite every one of its rows to
point at an archive page rather than at the event that introduced it. That is
also what bounds the sweep: only the member being caught up ever builds the list.

**The sweep needs no table.** Pages arrive over relays in no order, so page 3 can
land before page 2 and its chunks have no chapter to hang off. Those throw a
foreign key violation and would be lost -- except the inbound path already stores
every inner event it decrypts, so re-reading them is the same shape
`FrostSigningManager.replayStoredMessages` has, for the same reason: nothing was
lost, it just had nowhere to go at the time.

Two things about the loop, the second found by a test:

Progress is measured by *failures falling*, not by rows written. "Repeat while a
pass applied something" does not terminate, because every write is an upsert and
succeeds forever. What strictly decreases is the count that threw.

And the result is the last pass rather than the sum of them. Accumulating counts
a payload once per pass it survived and reports failures a later pass went on to
fix, so `failed > 0` stops meaning "still missing" -- which is the only question
a caller asks it. Caught by strengthening the out-of-order test to assert that
the page completing an archive leaves nothing behind, rather than only that the
rows matched: without that, the test passed while reporting fourteen failures on
a fully converged database.

Seven tests over two real databases with the pages carried by hand. The one that
matters puts four forgeries in a page beside one honest dialect -- the room's id
as author with a made-up signature, a real quorum of another group, an event
edited after signing, and a member's own rumor, which is what everything on the
wire looks like today -- and asserts the receiver ends with exactly the honest
one. The rest: a full catch-up matches the sender row for row with the group's
signature intact, pages delivered backwards converge and are asserted to have
really failed first so the test cannot pass for the wrong reason, an archive
files no chat lines, a bystander applies none of it, and applying the same
archive twice changes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:19:15 +02:00
Kgothatso Ngako
acff66a22e feat: rebuild the group's signed record out of the rows it left behind
Phase 3 of docs/member-archive.md. `ArchiveManager.assemble` walks a room's rows,
rebuilds each into the event the group signed, drops anything it cannot prove,
and cuts the rest into pages. Nothing sends one yet.

**The gate found a real bug, which is why it was the gate.** Signed events are
not stored as events -- `FrostSigningManager.complete` applies one and what
survives is a `Mantra*` row -- so an archive has to rebuild them with `toXEvent()`
and stands or falls on that being byte-identical to what was signed. Every
`toXEvent()` in the codebase turned out to be unused in production, written for
exactly this and never called, so the "tag order matches build so the event id
round-trips" comments on them were claims nothing had ever checked.

One was wrong. `MantraArtifact.toArtifactEvent` put the alt tag last where
`ArtifactEvent.build` puts it first, and left out the version metadata tag
altogether -- because that tag is not on the artifact row at all.
`fromArtifactEvent` reads the artifact's own fields and drops the version label,
which `applyInnerEvent` has by then turned into the artifact's first
`MantraArtifactVersion`. So the label is now a parameter, read off the initial
version: the one whose `createdAt` is the artifact's, since `initialVersionOf`
derives it from the same event.

Neither fault would have surfaced as an error. Both produce a well-formed
artifact whose id no longer matches its fields, which every receiver drops as a
forgery, silently, one kind at a time. `ArchiveRoundTripTest` now signs each
archivable kind with a real quorum, files it as a row, rebuilds it and asserts
the signature still covers what comes out -- plus the negative case, that
rebuilding with the wrong version label fails as a forgery rather than as a
mistake, which is why the assembler reads the label rather than defaulting it.

**The allowlist narrows from nine kinds to six, and this is the finding to read.**
Only six of the thirteen nip30303 kinds ever reach a signing session; the rest
travel as member rumors, vouched for by the MLS frame they arrived in and by
nothing that survives leaving it. An artifact version is derived rather than
signed -- which is fine, because applying the archived artifact derives it again
and the chapters hanging off it keep their foreign key. Nothing builds a
`TranslationEvent` at all. The contributor lists have no arm in `applyInnerEvent`
that writes a row.

And `TranslationChunkEvent` -- **the translated text itself** -- is submitted by
`MantraDao.saveTranslation` as its author's rumor, because a translation is one
member's work rather than a group decision. So an archive restores everything a
translation hangs on and not the translation: a new member gets the dialects, the
artifacts, the chapters, the source chunks, which translations exist and their
chapter scaffolding, and none of the prose. That is a real limit rather than a
detail, so it is written into the allowlist's own doc comment, into the plan's
"what this does not do", and into a test named after it -- with the three ways
out sketched and none of them taken here, because the cheapest gives up the
property the rest of this rests on and the best is a product decision about
whether translating is an act of the group or of a member.

**Nothing unverifiable leaves.** Every rebuilt event is checked with
`isSignedByRoom` against the same room id the recipient will use. Not politeness
-- the receiver checks anyway -- but so the page count says what will actually
arrive: a row from a member's rumor is dropped here rather than by the recipient.

**Walked down the tree, not queried per kind.** Only dialects and artifacts have
a by-room query and the rest hang off a parent, and the walk is also what puts an
artifact's version label within reach. Order is settled afterwards by
`inApplyOrder` rather than by the walk, since the walk groups by artifact and the
foreign keys are by kind.

**Paging is greedy against both caps**, because they bind different archives: a
room of one-line dialects hits the count first and a room of chapters hits the
bytes. An event too large for a page of its own is dropped with a log rather than
failing the archive -- a chapter nobody can archive is a hole, a member who gets
nothing is a bigger one.

Assembling only; queueing moved to Phase 5, where the thing that decides when to
send lives. That keeps this testable against a real database with no outbound
path in the way.

Seven tests over a real in-memory database seeded through `applyInnerEvent`
itself, so what is archived is what a member's device really holds rather than
rows built to suit the test: every payload verifies, all six kinds appear exactly
as often as they were signed, the whole archive is in dependency order end to
end, a member's unsigned dialect sitting in the same room is left out, an empty
room archives nothing without failing, and two archives of identical rows do not
share an id -- which is what stops two members answering one request from having
their pages counted towards each other's total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:12:29 +02:00
Kgothatso Ngako
6e04f6c7af feat: give the group's signed record an envelope it can travel in
Phase 2 of docs/member-archive.md. Two kinds, three tags, a codec and two caps.
Nothing sends or applies one yet -- that is phases 3 and 4 -- so this changes no
behaviour at all.

`ArchiveEvent` (30327) carries a page of the group's signed events, each whole,
keeping its own id, author and signature so the receiver checks it rather than
believing it. `ArchiveRequestEvent` (30328) is how a device with none of it asks.

**Why not one `SubmissionEvent` per event.** The envelope fits and the meaning
does not. A submission is an *act* -- this member is putting this event in front
of this group -- and an archive asserts nothing; it re-delivers what the group
already agreed. On one kind a four-hundred-event backfill is indistinguishable
from four hundred new submissions and every device has to guess which it is
reading. It would also be one inner event and one kind:445 per payload where a
page is one, and the submission arm of `applyInnerEvent` files a chat line per
payload, which an archive must not.

**Why 3032x and not 30313.** 30313 is free beside the nip30303 document kinds
and is not used, on `FrostSigningEvents`' own advice: the DKG's 30310-30316
already overlap that range and are told apart only by living in NIP-17 gift wraps
instead, which it calls "an accident of routing rather than a decision, and the
next family added should not rely on it." This is that next family. 30327 is also
the right neighbourhood on the merits, next to `GroupKeyStateEvent` at 30326 --
an archive is a statement about the record rather than a document kind.

**One list is the apply order and the allowlist both**, because a separate
allowlist is one more thing that can disagree with the order it is applied in.

The order is Room's rather than nostr's: every archivable kind has a foreign key
on the one before it, and kind order is not dependency order -- a dialect (30304)
has to land before an artifact (30300), and a translation chapter (30308) hangs
off a translation artifact version (30306) which hangs off an artifact version
(30301). So it is a list, not a `sortedBy { kind }`, and there is a test that
fails if anybody makes it one.

It is an allowlist first. Verification admits an event to the apply path on the
strength of the group's signature, which makes every kind the group has ever
signed replayable by any member at any time. A `GroupKeyStateEvent` is
group-signed and passes verification perfectly, so an archive carrying an old one
is a validly signed statement about what the room signs with, replayed by whoever
kept a copy. Nothing but this list stops it. The contributor-list kinds (30305,
30307, 30310) are left out on the same principle from the other side:
`applyInnerEvent` has no arm that writes a row for any of them, so archiving them
would cost bytes and restore nothing.

**All-or-nothing parsing, per-payload verification.** These are not in tension;
they answer different questions. A page that will not parse has lost its framing,
and one silently shortened by an element would report a complete archive on its
page count while holding less than it says. A payload whose signature does not
verify is a well-framed page with one bad event in it, and costing its honest
neighbours would let a single forgery deny an entire archive.

**The count cap was 256 and 256 can never fire.** An event carries 64 characters
of id, 64 of pubkey and 128 of signature before it says anything, so the floor is
about 370 bytes and a 64 KB page cannot hold much past 170 of them -- the byte
cap always binds first and the count cap is a check that never runs. Found by
writing the test that a page at exactly the cap still decodes, which failed. Now
128, where both bind something: the count stops a page of many small payloads,
the bytes stop a page of few large ones. That test is what fails if somebody
later raises one number without the other, and the doc comment says they have to
move together.

**The `p` tag is a hint, not access control**, and `ArchiveRecipientTag` says so
where it is defined. The page is an ordinary group message and every member can
read it, which is right, because it is their own history going back to them. What
it decides is who *acts*: a device that is not named applies nothing, since it
already holds the work and re-applying would rewrite every one of its rows to
point at an archive page rather than at the event that introduced it.

`ArchivePageTag` refuses an index outside its own count rather than clamping it.
The pair is how a receiver decides it has everything, so a repaired one would let
a truncated archive read as complete.

Twenty-one tests over the codec, both caps, the allowlist, the order and the
tags. Also corrects the phase-2 section of the plan, which still said 256.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 14:00:30 +02:00
Kgothatso Ngako
3ee1676a04 docs: plan handing a new member the group's signed history
A member added after the work was done sees none of it, and nothing in the app
will ever show it to them. Two independent reasons, and the second is the one
that surprises people.

MLS gives no history: a Welcome carries the ratchet tree at the current epoch,
not the transcript, and `MarmotInboundManager` drops anything from an epoch it
holds no keys for. That is forward secrecy working rather than a gap to close.

But group-signed events never travel at all. `FrostSigningManager.complete` says
so in as many words -- a signed event authored by the threshold key cannot go out
as an inner event, because the outbound pipeline would re-author it as its sender
and strip the group's signature off -- so every device *derives* the finished
event from its own `FrostSigningItem` rows. A member who was not in the session
has no items, and no later message carries the event. So the second problem does
not follow from the first and is not fixed by fixing it: even a member who could
decrypt the whole back-transcript would still hold nothing an artifact, chapter
or chunk could be built from.

Which makes an archive not a convenience but the only path, and fixes the line
the design has to hold: **it carries what the group signed, never the chat.**
Restoring the chat would undo forward secrecy on purpose, and a signed event is
the only thing a new member can check for themselves.

**The property the whole plan rests on is already true.** A room's id *is* the
group's threshold key derived at the room's path -- `GroupKeyState.verifies` and
`FrostSigningManager.signingPath` hold that invariant from their own ends -- so
`isSignedByGroup`'s three checks collapse to `event.pubKey == chatRoomId`, an id
check and a signature verify. No key state row, no threshold key, no path, no
lookup. A member who can name the room can verify its signatures, which is
exactly the position a new member is in, and it means the sender of an archive
does not have to be trusted at all.

**Two guards the plan makes non-negotiable.** Nothing on the inbound nip30303
path verifies a signature today, and that is currently correct: rumors carry an
empty sig and are authenticated by the MLS frame, so nothing on the wire has ever
claimed group authorship. An archive is the first thing that does, so the verify
is the feature's entire security rather than hardening on top of it.

And verification turns "group-signed" into an admission ticket for the apply
path, which is a wider door than it looks: a `GroupKeyStateEvent` is group-signed
and would pass perfectly, so an archive could replay a genuine old one and
re-point what the room signs with. The archive therefore carries an allowlist of
document kinds, checked outbound and independently inbound -- the same shape, and
the same reasoning, as the cap on `k` in frost-batch-signing.md.

**Push and pull, in that order of appearance and the reverse order of
importance.** Pushing an archive after the Welcome is what the question asked
for, and on its own it fails the way marmot-membership.md describes: it is an
application message in the epoch the add created, so one that beats the Welcome
there is dropped rather than deferred, silently, while the inviter sees a
success. So the joiner asks instead -- a request is proof it has processed its
Welcome, and it covers the reinstall and the second device, which no invite-time
push can. The push stays as a latency optimisation, deliberately phased after the
thing that makes it safe.

Nine phases: the verifier, the events, assembling an archive, applying one and
the sweep that lets pages arrive out of order, the request, the push, UI, the
cross-device tests, and rollout. The sweep needs no new table -- the inbound path
already stores every inner event it decrypts, so it is the shape
`FrostSigningManager.replayStoredMessages` already has.

Also written down, because it is the first thing this will be reported as a bug
for: an archive lets a new member *read* everything and does not let them sign
anything. `proposeSigningBatch` wants a secret share and a place in the ceremony,
and a group that re-runs its ceremony derives a different room rather than
re-keying this one. Closing that needs share resharing, which is a great deal
more work than this and is the thing to build after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:52:33 +02:00
Kgothatso Ngako
dbdff55ee0 feat: open the proposal a transcript line is about, not the room's latest
Tapping a signing line in the transcript opened whichever session the room was
running, resolved by `liveSessionForChatRoom` -- the newest one not yet finished,
or failing that the newest one at all. That is a guess, and it was a good one
exactly as long as a room had one proposal to guess at. With a chapter and its
translation open together, half the lines in the transcript led to the other
proposal.

The line now says which session it belongs to, so there is nothing left to guess:
it carries `frostSigningSessionId` through to the route. A line written before
that column opens the room's proposal list instead, which is the honest answer to
a line that cannot say what it meant -- every proposal with its own state, and
the reader picks -- rather than a guess dressed as an answer.

That empties `FrostSigningRoute.sessionId` of its reason to be optional, so it is
required, and `liveSessionForChatRoom` goes with it from the interface, the
implementation and the no-op. `FrostSigningViewModel` loses its resolution step
and the "This group is not signing anything right now" error underneath it --
which was never the right thing to say to somebody who had just tapped a line
about a specific session.

The transcript keeps doing the one job it is good at: showing a proposal as it
happens, and saying whether it is still asking something of you. What it stops
doing is standing in for a list of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:43:00 +02:00
Kgothatso Ngako
4de87edf12 fix: tell one proposal's transcript lines from another's
A room with two proposals open showed "Review" on both, and then dropped it from
both the moment either one was decided. The second proposal was still waiting on
the reader, still had a decision in it, and had nowhere left to be reached from.

Two proposals at once is not a corner case any more: a chapter and the
translation scaffolding beside it are proposed as separate sessions, on purpose,
and they run at the same time. Both write the same line types into the same
stretch of transcript.

`answeredRequests` matched a request against any later line of the fulfilling
type, and `settledRequests` against any later ending. That reads a room signing
one thing at a time exactly right -- the nonce after the request is the answer to
it, because there is nothing else it could be an answer to -- and a room signing
two things at once exactly wrong. Nothing else on the row could separate them:
same type, same room, same minute, and `ChatMessage` carried no session.

So the session goes on the row. `ChatMessage.frostSigningSessionId` is nullable,
added as schema v11 through `AutoMigration(10, 11)`, and stamped by
`FrostSigningManager.announce` -- the one place every FROST line is written, so
there is no line that can be forgotten. Both rules read it when both rows have
one and fall back to the clock when either does not.

The fallback is not a compromise, it is the right reading of the rows it applies
to. A line written before this column has no session and never will, and the
rooms that wrote those lines could not run two sessions at once, so the clock is
the whole truth there. A ceremony line falls back too and always will: a room
runs one ritual at a time, and a DKG step is either taken or still waited on.

**This reverses a call `FrostSigningRoute` argued for.** Its note said a chat row
carrying a session id was "a poor trade for a lookup the screen can do". That was
right when the lookup could only be wrong about which of one session it meant.
The batch work made two sessions ordinary, and the lookup and the rules both
became guesses at the same moment. A column on the table every message uses is
the cost; two proposals, one of them unreachable, was the alternative.

**Tests.** Three in TranscriptRequestStateTest for what the column buys: a nonce
answers its own session's request and not the other's, one session completing
settles nothing in the other, and a line naming no session is still read by the
clock. TranslationBatchProposalJvmTest proves the other half against a real
two-session proposal -- every FROST line the manager writes names its own
session, and neither session's lines are attributed to the other. The rule is
tested on rows and the stamping is tested on a database, because a rule that is
right about rows nothing writes correctly is worth nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:41:01 +02:00
Kgothatso Ngako
2e133dd337 feat: sign a chapter and every chunk of it in one session
A chapter proposal now carries the chapter and a chunk per paragraph, and the
group signs the lot at once. Every row a member ends up with is signed: a
translation is of a chunk, and a chunk that carries the group's signature over
its own words can be checked by anybody holding it, rather than only by
re-deriving it from the chapter it came out of.

This replaces the derivation two commits ago, which split the chunks out of the
signed chapter's text on each device and left them as rumors. That was the
right shape when a chunk could only have its own signature by having its own
quorum. Batch signing removed that, and this is the other side of the trade
`MantraChunk.chunksOf` was weighed against.

**A batch whose items name each other.** A chunk carries its chapter's id, and
that id is a hash over the group's key at the room's derivation path -- neither
resolved until the proposal runs. A caller computing it would be recomputing
`signingPath`, the one input in this protocol that must never come from a
proposer, since the path decides which key the group signs as. So
`proposeSigningBatch` gains a second form: a `lead` template, and a
`dependents` builder handed the lead *after* it is authored, returning the
events that reference it. Every id still comes out of `unsignedEventOf`, which
makes an item naming a chapter nobody signed something that cannot be built
rather than something to be tested for. `AddChapterViewModel` passes
`ChunkEvent::splitOf` and nothing else.

The lead is item 0. Items apply in `itemIndex` order and a chunk row whose
chapter does not exist yet is a foreign key violation, so what is referenced is
signed first as well as named first.

**The cost, in front of whoever is typing.** `MAX_BATCH_SIZE` is 64 and the
chapter takes one place, so a chapter is capped at 63 paragraphs and a longer
one has to be split in two. That is a real limit on real prose. The form counts
chunks against the cap as the text is typed, colours the count when it is past,
says what to do about it, and will not propose -- because the alternative is an
IllegalArgumentException after the fact. The manager still refuses
independently; the screen is not what enforces it.

**What went away.** `MantraChunk.chunksOf` and the derivation it did inside
`ChatMessage.applyInnerEvent`. Chunks arrive as their own signed events now and
go through the `ChunkEvent.KIND` branch that was always there. `ChapterEvent`
still carries the whole text beside chunks that hold the same words: chunk
boundaries are a decision about how to divide the work, and a chapter that kept
only the pieces could never be divided differently again.

**Tests.** `ChapterChunkSplitTest` covers the split as a pure function -- what
each chunk names, counts and carries. `SignedChapterTest` signs a real batch,
one FROST instance per item, and checks every chunk row is authored by the room
and carries a signature over its own id. `ChapterBatchProposalJvmTest` runs the
real proposal against a real database, which is where the sharp edge is: item
order, the chunks naming the chapter as the group will author it, and both ends
of the cap -- 63 paragraphs proposes, 64 is refused and leaves no session
behind. Checked against broken implementations: putting the lead last, naming
the wrong chapter, and stamping the chunks off the clock are each caught, in
both suites.

`jvmTest` runs on linux again as of the merge, which is what made the
database-backed test possible.

Dropped a nonce-reuse test that was in the first draft of this: it asserted
over its own fixture, and `FrostSigningRoundTest` and `SignedGroupKeyStateTest`
already hold the manager to giving every item its own nonce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:34:12 +02:00
Kgothatso Ngako
e081d14f37 refactor: weigh the chapter's chunks against batch signing, and keep deriving
Batch signing landed on mantra while this branch was open, and it makes the
argument this change was built on obsolete as written. MantraChunk.chunksOf
said the chunks "cannot be events proposed on their own -- that would cost a
quorum per paragraph". They can now: proposeSigningBatch would carry the
chapter and a chunk per paragraph through one quorum, and every row would hold
a signature of its own.

Weighed and declined, and the KDoc now says so rather than leaning on a reason
that stopped being true. MAX_BATCH_SIZE is 64, which caps a batched chapter at
63 paragraphs and fails an ordinary one outright; the text would go on the wire
twice, whole on the chapter and again split across the chunks, and the proposal
is the term that cap is sized against; and all-or-nothing over k items would
make a long chapter less likely to be signed than a short one, for no reason a
member could see. The signature it would buy is redundant besides -- the
appendix rejects the manifest shape because an item then needs a lookup to be
checked, and here that lookup is a foreign key: a chunk is a pure function of
its chapter and cannot be stored without it.

docs/frost-batch-signing.md records this under the slot Phase 7 leaves open --
"deciding *what* to batch" -- because the next caller will reach for the same
shape. The rule it leaves behind: batch siblings, not derivations. Events that
could each have been authored separately are worth a batch; events that are a
function of another event in the same batch are worth deriving instead.

**The merge.** Only SignedChapterTest broke: the five per-item columns moved
off FrostSigningSession onto FrostSigningItem, so it builds an item and calls
signedEvent(item, sig), which is how SignedArtifactTest was ported in the same
commit. Nothing in the flow itself moved -- proposeSigning kept its signature
as the one-event form, and complete() applies each signed event through
ChatMessage.applyInnerEvent, so the chapter's chunk derivation works the same
whether the chapter arrives alone or as one item of somebody else's batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 10:13:09 +02:00
Kgothatso Ngako
1448ed5ad8 docs(frost): record batch signing as built, and what rollout needs
Phase 7 of docs/frost-batch-signing.md, which is the phase with no code in it.

Nothing needs a feature flag. k=1 is the entire behaviour of the app as shipped
-- no caller batches anything yet -- and at k=1 every message is byte-identical
to the app before Phase 1: encodeProposal returns the bare event object,
joinPayload of one value is that value, and every plural branch in the
transcript is only taken above one. The doc now tabulates that rather than
asserting it in prose, since it is the claim the whole rollout rests on.

The one rollout constraint stands: before a caller batches, the group has to be
on a build that understands array proposals. There is no negotiation for it and
adding one is not worth it -- an old device refuses an array proposal outright,
so the failure mode is a batch that never reaches threshold and is abandoned,
visible in the transcript and costing a retry.

Also records what is left, which is nothing in the protocol: deciding what to
batch is a product question, bounded only by "a batch is only as available as
its worst item" and "GroupKeyStateManager.propose must never batch".

The phases are kept as written rather than rewritten into a description of the
result -- the code reads better against the argument it came from -- with the
two places the implementation chose differently (itemIndex over index,
DROP COLUMN over a table rebuild) marked in their own sections.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 05:00:21 +02:00
Kgothatso Ngako
2309879153 test(frost): cover the batch's failure modes and its crypto without a database
Phase 6 of docs/frost-batch-signing.md. 361 jvmTest and 227 testDebugUnitTest
pass.

## Inbound path (SignedGroupKeyStateTest)

Both drive the manager with a hand-built inner event rather than one the other
device queued, which is the only way to be a faulty or dishonest member in this
harness.

- A one-value nonce offered for a three-item batch does not count towards the
  threshold: the coordinator never reaches a signer set. The length check is all
  that stands between a batch and a signer whose contribution lines up against
  the wrong messages, so truncating or padding would produce partial signatures
  aggregated against events nobody agreed to. The test then pumps the real nonce
  and the batch completes -- it is a stall, not damage, which is
  FrostSignerMessage's composite key doing its job.
- A second proposal under the session's own id changes neither its event ids nor
  its seeds. Every seed is already committed to its item's message; a different
  batch under the same id would have those seeds produce a second partial
  signature over a second message, which is how a share is extracted.

## Real FROST, no database (FrostSigningRoundTest)

- A k=3 batch from one signer set, all three verifying against the room's key --
  the manager's shape with the database taken out of the way.
- Item 0's signature does not verify against item 1. Signing three events in
  lockstep must not make any of them interchangeable.
- Both halves of the no-shared-nonce property, because either alone is enough to
  be relied on by accident: SecretNonce.generate mixes the message in, so one
  seed under two messages already gives two nonces -- and the manager mints
  distinct seeds regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:58:48 +02:00
Kgothatso Ngako
59c34263b3 feat(frost): let one signing session carry a batch of events
Phase 3 of docs/frost-batch-signing.md. A session can now be proposed over
several events, and the whole batch is signed in one round of four group
events with one approval. 356 jvmTest and 224 testDebugUnitTest pass.

## The wire, and the compatibility rule that shapes it

FrostSigningEvents.encodeProposal serialises a batch of one as the bare event
object it always was, and only a genuine batch as a JSON array. That is not
tidiness. A build predating this reads an array with Event.fromJsonOrNull, gets
null, and drops the proposal -- so an old device refuses a batch outright
rather than signing part of one, while single signing keeps working right
through a mixed-version rollout. Emitting an array unconditionally would break
every one-event session for those devices and buy nothing.

decodeProposal accepts both forms permanently: proposals in the old shape do
not stop arriving because this build stopped writing them. It is
all-or-nothing -- an array with one unreadable element is refused rather than
silently shortened, because the batch's length is what every later payload is
checked against, and a proposal that quietly lost an event would have every
signer's contribution rejected for being the wrong size: a stall with nothing
to blame.

## MAX_BATCH_SIZE, checked twice

64, enforced in proposeSigningBatch and again, independently, in
acceptProposal. The second check is the one that matters. A proposal is the
only place in this protocol where a remote party decides how much work everyone
else does -- k native key generations, k signatures, and a group event carrying
k payloads, from a single message -- and until batching that was bounded only
by never being more than one.

## acceptProposal over a list

Each element is rebuilt from its own fields under this device's own reading of
the room's path and checked against the id it claims, exactly as before but per
item, and the whole proposal is dropped if any one fails. The write-once rule
widens from "the event this session signs" to "the ordered list of events this
session signs": a second proposal under the same id whose list differs anywhere
is logged and ignored.

## The API

FrostSigningManager.proposeSigningBatch(events: List<EventTemplate<*>>) is
public here rather than in Phase 4, because without it there is no way to
produce a k>1 session and everything above would ship untested. proposeSigning
keeps its signature as the one-event form, so no caller moves. Each template
carries its own createdAt.

## Tests

- FrostProposalCodecTest (new, commonTest): a batch of one is byte-for-byte the
  old JSON object -- the assertion that stands in for the old build nobody can
  run here -- plus order preservation, old-form decoding, and refusal of empty,
  malformed and partly-unreadable arrays.
- SignedGroupKeyStateTest: a k=3 batch between two devices over two databases.
  Three signatures verifying against the room, three dialects applied on both
  devices in order, five messages from the coordinator and two from the other
  signer, and one approval line rather than three.
- The negative test that matters: no two items of a batch share an aggregated
  nonce or a seed, and the two devices' seeds do not intersect. Every positive
  test still passes if two items share a nonce -- the signatures verify fine;
  what sharing costs is the secret share.
- The cap is refused when proposed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:48:24 +02:00
Kgothatso Ngako
935a8fe37a refactor(frost): run a signing session as k FROST instances in lockstep
Phase 2 of docs/frost-batch-signing.md. Pure refactor: proposals still carry
one event, the wire is byte-identical, and every test passes unchanged --
344 jvmTest and 217 testDebugUnitTest, none of them edited in this commit.

advance() now loops over FrostSigningItem rows rather than reading the first
one. One nonce per item, one aggregate per item, one Session.create per item,
one partial signature per item, one signature per item. The signer set, the
public shares, the tweak cache and the approval stay shared, because they are
the terms that do not enter e = H(R‖P‖m).

The coordinator's aggregation is the place where that distinction bites: it
builds one AggregatedNonce per item, each from that item's nonce from each
chosen signer. Reusing one across two items would be reusing R across two
messages.

## The payload codec, early

joinPayload/splitPayload land here rather than with the wire change, because at
a batch of one a comma join is the identity -- the payload is the bare value it
has always been. That leaves Phase 3 to the proposal encoding alone.

splitPayload is strict: a payload that is not exactly the batch's length is
dropped rather than truncated or padded. It runs in orderedNonces,
orderedPartialSignatures and splitForSession -- never in record(), which stores
payloads without parsing them so that a nonce can arrive before the proposal
that would give it a length to check against.

## Two short-circuits, and one trap in the first

advance() runs on every arriving message, so at a batch of k it was k native
key generations, k Session.creates and k signs each time, usually to discover
there was nothing left to do.

- Nonces are generated by `lazy`. The obvious version -- a guard computing
  `ownNonce == null || (isSigner() && ownPartial == null)` -- is wrong, and
  wrong in a way that reads fine and fails every signing test: the coordinator
  settles the signer set further down the same pass, so isSigner() at the top
  is false on exactly the pass where the coordinator goes on to sign, and the
  nonces are never generated. Reproduced as IndexOutOfBounds before switching
  to lazy, which has no prediction to make.
- A device that is neither signing nor aggregating leaves before building any
  FROST session, rather than building k of them to do nothing with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:40:22 +02:00
Kgothatso Ngako
dff41d417d feat(frost): move a signing session's per-event columns onto FrostSigningItem
Phase 1 of docs/frost-batch-signing.md, which is added here as the plan the
next phases follow. Schema only: a session still signs exactly one event, the
wire is byte-identical, and every existing test passes on the moved columns.

## What moved, and why it had to

A batch of k events is k independent FROST instances sharing a signer set, not
one signature over k messages. That is forced rather than chosen: a Schnorr
partial signature is `s = k + e·x` with `e = H(R‖P‖m)`, so two messages under
one nonce R give two equations in one unknown and the secret share falls out.

So the five columns that enter that equation -- unsignedEventJson, eventId,
nonceRandom, aggregatedNonce, signature -- move to a child table keyed
(sessionId, itemIndex). What stays on FrostSigningSession is everything outside
it: the ceremony, the threshold, the derivation path, the signer set, and the
one approval.

itemIndex is protocol rather than presentation -- nonces and partial signatures
are joined positionally against it -- so getItems() orders by it and nothing
re-sorts. Spelled itemIndex rather than index to keep hand-written queries free
of backticks.

No itemCount column. The count is a COUNT(*), for the same reason signerIds is
derived from the ceremony's participant order rather than stored: a
denormalised count is one more thing that can disagree with the rows.

## Migration 9 -> 10

Manual, not auto: Room can create the table and drop the columns but cannot
copy between them, and the copy is the whole point. A session in flight at
upgrade holds its nonce seed and the aggregate it is already signing against,
and neither can be regenerated -- losing either makes the next pass derive a
different nonce for the same message and publish a second partial signature
over it, which is the extraction case. Both are copied verbatim into item 0, so
an in-flight session resumes as though nothing happened.

Removing the columns uses ALTER TABLE DROP COLUMN rather than the usual
create-copy-drop-rename rebuild. FrostSignerMessage and FrostSigningItem both
reference FrostSigningSession(id) ON DELETE CASCADE, and DROP TABLE fires
cascades -- with foreign keys enforced the rebuild would delete every signer
message and every item just written. Whether it does depends on Room disabling
foreign keys around migrations, which is not worth depending on when
DROP COLUMN cannot go wrong. It needs SQLite 3.35 and unindexed,
unconstrained columns; these five qualify, and getRoomDatabase pins
BundledSQLiteDriver on every platform.

## Invariants established here for the phases that follow

- signerIds and every item's aggregatedNonce are one write-once unit, applied
  by applyAggregate() -- items first in one transaction, then the session, so
  "some items aggregated" is unreachable and signerIds != null stays the gate.
- Signatures likewise, via applySignatures(); isSigned() counts rows instead of
  reading a flag.
- complete() verifies every signature before applying any event, so a batch is
  all-or-nothing rather than half-filed.
- itemsOver() gives each item its own 32 bytes of seed. Independent seeds mean
  an off-by-one in index handling produces a session that fails to aggregate
  rather than one that signs two messages under a single nonce.

signedEvent() and isAwaitingApproval() now take the item(s) rather than the
session, which propagates to the repository, the view model and the screen.
advance() reads items.first() and Phase 2 turns that into a loop.

## Tests

- FrostSigningSessionDaoJvmTest: index ordering, single-item read, upsert
  replacing rather than accumulating, signed-item counting, cascade delete.
- FrostSigningItemMigrationJvmTest (new): the backfill against a real v9
  database, asserting the seed and aggregate values survive -- not merely that
  a row appeared -- plus the exact column lists Room will check at open time.
- 338 jvmTest and 217 testDebugUnitTest pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 04:33:46 +02:00