Commit Graph

865 Commits

Author SHA1 Message Date
Kgothatso Ngako
7b504ae5fb Merge branch 'mantra' into claude/serene-dubinsky-41325e
The ios build line, ten commits, against one nip30303 tag fix. Not a single
file is touched by both sides, so nothing had to be reconciled: this branch
changes TranslationArtifactVersionIdTag and adds TagRoundTripTest, and mantra
arrives with the Darwin ktor engine, the Room constructor, the gradle 9 stub
task, the pin bump and the comma sweep.

19543df2 wrote down the thing to check here: Phase 3 took the commas out of 84
commonTest names to get linkDebugTestIosArm64 past Kotlin/Native's "Name
contains illegal characters: ','", and nothing enforces it, so every merge
arriving with new commonTest files can quietly undo it. This one brings
TagRoundTripTest, whose six names carry no comma. Checked rather than assumed.
Apostrophes are fine -- the sweep kept "the artifact's kind" -- so "no tag
parses another tag's array" stands.

The pin bump is a chain of gitlinks and nothing else: e0d2b823 moves
lightning-kmp to e5b22f5, which moves bitcoin-kmp to 9e3c4f8, which moves
secp256k1-kmp to 8adf6911, onto the merged threshold tips. native/secp256k1 is
unchanged. All four were already in this worktree's object store, so the chain
followed offline.

testDebugUnitTest 605 and jvmTest 1227, both zero failures -- 599 and 1221
before this branch, plus the six new ones in each. The m3 audit is green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 01:24:22 +02:00
Kgothatso Ngako
276608679f fix(nip30303): a translation reference parses as itself, not as a dialect
TranslationArtifactVersionIdTag.parse was declared to return DialectIdTag? and
constructed one. The tag-name guard was right -- it compared against this
class's own TAG_NAME -- so the event id came off the right slot of the right
tag, and nothing behaved differently: all nine call sites reach straight through
to .ref, and the two classes have identical shape. A caller that branched on the
type, or collected parse results across the family, would have got a dialect
reference where a translation reference belongs.

The twenty-four tags in that package were written from one template, so the
whole family was checked: this was the only mismatch. Every other parse returns
and constructs its enclosing class.

The wire names are a separate matter and stay as they are. Six tags carry a
stray Tag suffix -- dialectIdTag, chapterIdTag, chunkIdTag, translationIdTag,
translationChapterIdTag, translationChunkIdTag -- which ProjectIdTag already
documents as a copy-paste slip that cannot be fixed, because those strings sit
inside event ids the group has signed. Renaming one invalidates every signature
over it.

TagRoundTripTest covers all twenty-four, grouped by the four shapes they take,
plus a matrix asserting no parse accepts any of the other twenty-three arrays --
the same template's other failure mode, a copied TAG_NAME.

The mechanism is the type argument, and it has to be written out. The first
draft of the test left T to inference and passed against the bug when it was
reintroduced to check: T is read off the function reference, so a parse
returning DialectIdTag simply makes T be DialectIdTag, every member the two
classes share resolves, and assertIs<T> is a tautology. With the class named
explicitly at each call site, a parse whose declared return type is a sibling
no longer satisfies (Array<String>) -> T? and the file stops compiling on that
tag's own line -- earlier and more precisely than any runtime check. Verified
by re-breaking the file: the build fails naming the offending parse. The KDoc
says so, because tidying the type argument away would silently defeat the test.

One more test, on arrayOfNotNull rather than on the family: it strips only
trailing nulls and writes "" for an interior one, so a reference assembled with
an author but no relay hint is [name, id, "", author] and the author stays in
slot 3. That is the shape most of these tags are assembled in, and if it ever
changed every author would be read as a relay hint and dropped.

605 unit tests pass on --rerun-tasks, and m3Audit is clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 01:07:56 +02:00
Kgothatso Ngako
19543df263 Merge branch 'mantra' into claude/curated-kmp-ios-build-plan-ec280d
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
The african-dialect line, nine commits, against the four landed ios
phases. No conflict: the only file both sides touched is docs/README.md,
where each added its own table row, and mantra left the library pin
alone.

The merge is worth a note anyway, because it could have undone Phase 3.
That phase took the commas out of 84 commonTest names to get
linkDebugTestIosArm64 past Kotlin/Native's "Name contains illegal
characters: ','", and mantra arrives with five new commonTest files. They
happen to carry none, so the invariant holds -- but it holds by luck
rather than by anything enforcing it, and the next branch to merge may
not be so tidy.

jvmTest 1221, testDebugUnitTest 599, the library's 159, and the m3
audit's ten counts all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 00:54:03 +02:00
Kgothatso Ngako
4a11cc6ed9 Merge branch 'mantra' into claude/african-dialect-convenience-de2f69
One file overlapped and git merged it cleanly: ChatRoomDetailScreen.kt, where
mantra turned `parentChatRoomIds` into `parents` carrying each parent's room, and
this branch added the "Add dialects from a list" button under the dialect list.
Checked rather than assumed -- the merged file is mantra's version plus only this
branch's additions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 00:38:21 +02:00
Kgothatso Ngako
46ccec3439 fix(scripts): stop m3-touch-targets.py reading comments as code
A KDoc line on the dialect picker's row composable, explaining that it used
`Card(onClick = …)` rather than a `Modifier.clickable`, was counted as a bare
clickable chain. The budget is zero, so a sentence about the rule broke
`./gradlew check`. The comment was reworded to get around it, which was the wrong
fix -- the sentence was right -- and it is restored here.

Not a new rule. m3-spacing-positions.py skips `\s*(//|\*|/\*)` and
m3-title-case.py skips the same three prefixes as a startswith tuple; only this
checker was missing the two block-comment ones, having been written with `//`
alone. m3-audit.sh explains the whole problem at NOT_A_COMMENT, where a note
about a hardcoded colour once counted as a colour.

The same function had the mirror bug, which is the dangerous direction. It looked
for minimumInteractiveComponentSize in a two-line window without stripping
comments, so a comment above a real bare `.clickable` that merely mentioned the
modifier would have excused it. A false positive is noise; a silently excused
offender is a miss. Both lines of the window are now read as code only.

Checked against six cases: a bare clickable is still caught, a guard on the same
line and a guard on the line above still excuse one, a starred KDoc no longer
counts, and a real offender under a comment naming the modifier is now caught.
Counts for the tree are unchanged -- 0 before and after -- which was worth
establishing, since a broken detector would also report zero.

A prefix test rather than block tracking, and the limitation is written on the
function: a `/* ... */ `body line not starting with `*` still reads as code.
There are 539 block comments under ui/ and not one such line, because every KDoc
here stars its body, and three identical rules spelled three ways would be worse
than one limitation on the record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 00:25:16 +02:00
Kgothatso Ngako
55c8f92cd3 docs: record what the first four ios phases actually did
Phases 0 to 4 are on this branch and verified on linux; 5 needs two
decisions and an asset, 6 and 7 need a mac. Each phase's text is left as
it was planned, with a Landed note where what happened is worth knowing.

Three findings the plan did not predict. The three library bumps touch no
Kotlin source anywhere in the chain -- eleven build scripts in
secp256k1-kmp and nothing else -- so gradle reports jvmTest UP-TO-DATE
afterwards, which reads like a stale cache and is not one. That bump also
carries four fixes beyond the stub archives, the only consequential one
being compileSdk 35 -> 36 in secp256k1-kmp's jni/android, below Mantra's
own 37. And two of the 84 test names needed rewording by hand, where the
comma had been carrying real work rather than pausing.

Also records that `-Xdisable-phases` is in but unproven -- no
KotlinNativeLink task exists here for it to match -- and the team id
Phase 5 is expected to use, which still wants confirming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 00:00:15 +02:00
Kgothatso Ngako
e6c3c8a244 fix(ios): build ktor clients on Darwin so wss/https work on a device
CIO has no TLS implementation on kotlin/native, so every secure
connection died at the handshake with "TLS sessions are not supported
on Native platform". Pick the engine per platform -- CIO on android
and the desktop jvm, Darwin on ios -- and pin both the relay socket
client and coil's image fetcher to it.

Coil is the half that is easy to miss. Nothing in the app configured an
ImageLoader, so its default ktor fetcher built a bare `HttpClient()` and
took whichever engine the classpath served first; every avatar and
preview over https would have failed the same way the sockets did, with
nothing in the app's own code to point at. `MantraApp` is the one place
both call sites are reachable from, and the factory is installed above
`MantraTheme` so it is in place before the first AsyncImage composes.

The darwin dependency goes inside the existing isMacOsX gate, so nothing
about resolution on a linux or windows host changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:57:59 +02:00
Kgothatso Ngako
d024838bae build(ios): unbreak the ios link tasks
Two unrelated failures with one symptom from outside: neither
`linkDebugTestIosArm64` nor `linkReleaseFrameworkIosArm64` completes.

linkDebugTestIosArm64: Kotlin/Native rejects backticked test names
containing a comma ("Name contains illegal characters: ','"), so the
commas come out of the commonTest names -- 84 of them across 35 files.
Every one was a `, `, so the sweep is a deletion and nothing else; two
names where the comma was doing real work are reworded rather than
jammed together ("EOSE does not end a live subscription, CLOSED does"
became "a live subscription ends on CLOSED and not on EOSE"). jvmTest's
own 138 are left alone: Kotlin/Native never compiles that source set,
and 138 cosmetic edits do not belong in a commit about a link failure.

linkReleaseFrameworkIosArm64: Kotlin 2.4.10's release backend crashes in
StaticInitializersOptimization on this module's call graph, so
RemoveRedundantCallsToStaticInitializersPhase is disabled for the release
native links. The pass is an optimization only. It matches no task on
this host, where no ios target is declared at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:54:27 +02:00
Kgothatso Ngako
fc19583225 feat(ios): declare publish cancellation to swift, and unshadow Platform
Seven `@Throws` on the publish path now name
`kotlin.coroutines.cancellation.CancellationException` alongside
`NostrPublishException`. Without it a publish cancelled by the screen
navigating away crosses the ObjC boundary as an unexpected throw rather
than as the cancellation it is, and swift has no way to tell the two
apart. Six of the seven already existed; `EventPublishTransport` had
none, so the interface the group-signed path publishes through said less
about itself than the implementation behind it.

`Platform.ios.kt` also imported `kotlin.native.Platform`, which shadows
the app's own `Platform` interface the file is there to implement. It was
unused and nothing on this host compiles iosMain, so the compiler never
said so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:51:13 +02:00
Kgothatso Ngako
73b2c8ae9a feat(ios): give Room a way to construct the database without reflection
`MantraDatabaseConstructor` has been in commonMain all along -- `expect`,
`@Suppress`ed for having no actual, and referenced by nothing. Room's KSP
only generates the actual when `@ConstructedBy` names the object, so on a
target with no reflection there was no way into the database at all.

This is the one annotation in the ios work that changes every other
target too: the generator now emits an `actual object` for android and
jvm as well, each returning `MantraDatabase_Impl()`, where before it
emitted none. jvmTest is unmoved at 1174, and MantraDatabaseJvmTest's
comment about the no-Context builder overload being available "because
MantraDatabaseConstructor gives Room a way to instantiate without
reflection" is now true rather than aspirational.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:48:43 +02:00
Kgothatso Ngako
dda2f95c1b build: register the stub archive task under a name Gradle 9 accepts
`db15e59` -> `e0d2b82`, carrying secp256k1-kmp `8adf691`. The stub-archive
task the previous two commits brought in was declared with
`val createLinuxStubStaticLibs = tasks.registering(...)`, whose delegate
provider Gradle 9 no longer takes outside a `by` delegation -- so the task
was never registered under a name and the cinterop tasks had nothing to
depend on. `tasks.register<Exec>("createLinuxStubStaticLibs")` instead.

With this the chain's five levels are where Curated's are: `e0d2b82` ->
`e5b22f5d` -> `9e3c4f8` -> `8adf691` -> `7a35fde1`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:44:08 +02:00
Kgothatso Ngako
1b8df6b446 build: follow the chain onto the merged threshold tips
`2dcd4fb` -> `db15e59`. The previous commit's fix reached this tree as a
side commit on each of the three nested repositories; this moves all of
them onto the `threshold` branch tips that merged it -- secp256k1-kmp
`9bcae25` -> `6c7b1e4`, and bitcoin-kmp and lightning-kmp with it.

Bookkeeping in appearance only. The pins the previous commit named were
rebased away upstream, so a later fetch would not have found them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:43:54 +02:00
Kgothatso Ngako
a8f0e26f2a build: carry secp256k1-kmp's fix for configuring off a linux host
`84cc44c` -> `2dcd4fb`, which carries secp256k1-kmp `9bcae25` down the
chain. Its upstream subject says "linux cinterop sync fix" and reads
backwards from here: the whole of it is behind `if (!currentOs.isLinux)`,
so on this host it is a no-op, and what it actually fixes is configuring
the chain on a mac.

secp256k1-kmp's .def declares `staticLibraries` for every target and the
cinterop tool refuses to emit a klib while the archive is absent from
libraryPaths. Off a linux host the linux archives are never built --
buildSecp256k1LinuxArm64 is onlyIf(isLinux), and the linuxX64 cinterop
points at native/build/linux, which only a linux host fills -- so the
cinterop commonization an IDE sync resolves for linuxMain fails with
"cinterop file ... does not exist". Cinterop only parses headers, so the
fix leaves it enabled and satisfies the archive check with placeholder
ones; compile, link and publication for the linux targets stay off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:43:32 +02:00
Kgothatso Ngako
a998cf20a9 docs: the plan for an ios build, phased against the route Curated walked
Curated reached a device in eight commits. Five defects stand between this
tree and the same place -- the native chain not configuring on a mac, Room
with no way to construct the database on native, a cancellation Swift cannot
see, two separate ios link failures, and CIO having no TLS on kotlin/native.
Each is diagnosed upstream, so the work is porting a known answer.

Three places this tree cannot simply cherry-pick: the test-name sweep has to
run over Mantra's own 84 names in 35 files (ten of Curated's 38 files are
from lines Mantra never pulled, and Mantra has eight Curated does not); the
fork's "linux cinterop" fix is `if (!currentOs.isLinux)` and is really the
fix that makes a mac work; and `@ConstructedBy` changes what Room's KSP
generates for every target, not only the one it unblocks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:37:27 +02:00
Kgothatso Ngako
dd405e90e5 test(dialects): guard the catalogue against a proposal's two limits
DialectCatalogueSizeTest, modelled on ChapterProposalSizeTest and measured the
way the manager measures it -- the real encoding rather than an estimate.
Neither limit is checked anywhere a reviewer would see it: proposeSigningBatch
enforces both with a require, so the first sign of the catalogue outgrowing a
batch would be a wave that split in two, or one that threw. Somebody adding a
language has no reason to be thinking about FROST.

The third assertion changed shape while being written. "A wave is small rather
than just legal", as a fraction of MAX_PROPOSAL_BYTES, failed: the catalogue
encodes to 16,832 bytes and a quarter of the budget is 16,384. The quarter was a
guess. What actually wants guarding is the cost of one dialect -- 301 bytes,
bounded here at twice that -- because that is the number that would drift if a
dialect event grew something large, and unlike a fraction it does not move every
time a language is added.

The size test's failure message says not to trim the catalogue. The view model
already chunks by MAX_BATCH_SIZE, so a larger catalogue is two sessions rather
than a bug; what would be lost is a group adopting the whole list answering once,
and that is a decision rather than a repair.

Also records the plan as built, with a table of the seven places the build chose
differently, and indexes it in docs/README.md.

One of those entries is a finding rather than a choice: m3-touch-targets.py reads
comments as code, so a KDoc explaining that a row used a clickable Card "rather
than a Modifier.clickable" broke a zero budget and with it ./gradlew check. The
comment was reworded, which is the wrong fix. m3-audit.sh already solves this for
the colour check and says why, at NOT_A_COMMENT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:03:08 +02:00
Kgothatso Ngako
226653d282 fix(frost): one transcript line for a wave, not one per dialect
A signed event files its chat line unless the batch holds more than one event of
its kind, in which case the session's completion line speaks for them.

Needed by the previous commit. A wave of fifty dialects would otherwise post
fifty "Added X to dialects" lines into the group's conversation, one after
another, followed by a line saying the group signed fifty events -- a single act
burying the conversation it was announced in.

Every kind that could already appear many-per-batch returns null from
applyInnerEvent for precisely this reason: a chunk is part of a chapter and the
chapter's line speaks for it, and likewise a translation chapter, a contributor
list and both project memberships. DialectEvent is the first kind where a batch
holds many peers each of which wants a line. So checked against every batch the
app builds today, this changes nothing: the leading kind of each is one of its
kind and keeps its line.

It is not a rule about batch size, which is the mistake available here. A chapter
and its sixty-four chunks is one ChapterEvent, and gating on the batch being
larger than one event would have taken the chapter's line away.

Decided at the call site because that is the only place that can see the batch.
applyInnerEvent is untouched -- it still writes the row, which is the part that
matters -- and only the chatMessageDao upsert is skipped. ChronicleManager needed
nothing: it already drops the line a payload returns, and says why.

Three tests on SignedGroupKeyStateTest, which runs real two-device sessions: one
dialect still files its line, three file none and still land three rows, and a
dialect with the artifact that names it files both. The first two are the same
mechanism asserted at 1 and at 0, so neither can pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:59:14 +02:00
Kgothatso Ngako
c03112fbbc feat(dialects): propose the ticked dialects as a wave
The FAB, the view model method behind it, and the navigation onto the session it
opens. The picker can now do the thing it is for.

One session per MAX_BATCH_SIZE entries, the way
TranslationArtifactVersionDetailViewModel catches up a translation's missing
chapters. The catalogue is smaller than one batch today so the chunking never
fires; it is one line, it is the established idiom, and it is what stops the
sixty-fifth catalogue entry from being a bug rather than a second session. The
jvmTest exercises it anyway, with a synthetic selection of 65, because nothing
else will.

One timestamp for the lot, so a wave reads as the one act it is.

The callback is invoked from Dispatchers.Main, and that is not decoration: it
navigates, and navigating off Dispatchers.IO crashes. The handoff belongs in the
view model rather than at the call site.

A batch is all-or-nothing and a retry has to be a fresh proposal, since the
items' nonce seeds have already been published against an aggregate. Both are
answered by the rule the screen is built on -- it only ever proposes what is
missing -- so asking again after a failed wave lands exactly the entries the
failed one did not, which is what makes the signing screen right to say it is
safe to ask.

The new jvmTest runs the real proposal against a real database and a real key,
because the half with the sharp edge is the one the caller cannot see: every
event of a wave is re-authored under the group's key at the room's derivation
path inside proposeSigningBatch, so the ids the group signs are not the ids
dialectsOf produced. It also asserts the round trip the picker depends on -- a
proposed wave parses back into the entries that were ticked, through the same
fromDialectEvent arm applyInnerEvent will take -- and that missingFrom then
stops offering them.

Nothing was needed to make a wave readable: ProposedEvent already has its
dialect arm, and WhatIsBeingSigned already leads a batch with "N events signed
together" and refuses to let any of it be signed unless all of it parsed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:54:25 +02:00
Kgothatso Ngako
2efe320564 feat(dialects): a screen for picking dialects out of the catalogue
Route, state, view model, screen and the way in from the group's detail screen,
beside "Add dialect" rather than instead of it. It shows and picks; proposing is
the next commit.

Three standings per row, not two. A language the room holds and a language it is
already signing both show and neither can be ticked, with different words on
each, because "not available" has two explanations and telling a member the
wrong one is worse than telling them nothing: one is theirs already, and the
other is a proposal somebody else opened that they can go and sign.

The standing is derived from DialectScaffold.missingFrom rather than computed
beside it. That function is what the proposal will be built from, so a row
saying MISSING where it disagreed would offer a dialect the room already has --
and the duplicate would not even collide, since dialect ids differ by createdAt.
One authority; held-versus-in-flight is only a label on what it already excluded.

The view model holds its state in a StateFlow rather than the mutableStateOf its
neighbours use, and not for consistency's sake: those write once, and this one is
written from Dispatchers.IO on the way in *and* again from a collector on the
room's signing sessions, which is where a mutableStateOf in a view model drops an
off-main-thread write made during the first composition.

Anything that stops being missing while the screen is open comes off the
selection, the way SelectSubgroupAdminsViewModel unpicks a member who turns out
to have no key package. Another admin proposing a language this member had ticked
would otherwise propose it twice.

"Select everything missing" means the catalogue and not the filtered rows: a
member who has typed "so" into the search and taps it means all of it, and a
selection that silently depended on a filter is the harder of the two to notice.

Twelve strings, sentence case, and one of them replaces the literal
AddDialectScreen had been carrying for the same sentence about a group with no
shared key.

A clickable Card rather than a bare clickable modifier, so a row is 48dp without
anybody having to remember minimumInteractiveComponentSize.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:49:49 +02:00
Kgothatso Ngako
f47db49454 feat(dialects): read the dialects an open session is already signing
DialectScaffold.pendingIn, the half of the identity rule that rows cannot
answer. Without it the feature has a race it would hit on first use: two admins
open the picker, both see the same forty missing, both propose, and the room
gets eighty dialects -- forty of them duplicates nothing will ever merge.

Readable because FrostSigningRepository.unsignedEvents gives a batch's events at
proposal time, before any signature, which is exactly when this has to be known.
The lambda is passed in rather than imported, as ProposedEvent.summarize takes a
resolver: it keeps the function pure, testable without a database and callable
from a composable.

COMPLETE and FAILED sessions are both excluded, for opposite reasons. A complete
session's dialects are rows by now, so counting them would hide them behind
"waiting for the group" rather than "already yours". A failed one left nothing
behind and has to be proposable again -- the signing screen says as much, and a
retried batch must be a fresh one anyway.

A session whose items will not parse contributes nothing rather than throwing,
and the trade is named where it is made: its dialects look selectable and may be
proposed twice, which is visible and recoverable, against a picker that will not
open at all, which leaves an admin no way to act. unsignedEventJson is written by
this device from a template it built, so that branch means a damaged database
rather than an odd proposal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:39:38 +02:00
Kgothatso Ngako
08dc412594 feat(dialects): work out what a room is missing from the catalogue
DialectScaffold.missingFrom and .dialectsOf, built as a deliberate parallel to
TranslationScaffold -- same package, same shape, same reason. "What is this room
missing" gets asked by the picker, again when a wave comes back, and a third time
by whatever reconciles the two; three copies would be three chances to disagree.

Matching is on (code, country) and never the name, which is the rule the eleven
tests exist to pin. A room that typed "Southern Sotho" where the catalogue says
"Sesotho" still holds st-in-Lesotho, and proposing it again would make a
duplicate that nothing can merge -- the two would not even collide, since their
ids differ by createdAt.

Normalisation is trim + lowercase(), the no-argument form on purpose: it is
locale-invariant in Kotlin common, so a device set to Turkish does not decide
that TI and ti are different languages.

dialectsOf takes one createdAt for the whole wave rather than reading the clock
per event, as TranslationScaffold.chaptersOf does, so a wave reads as the one act
it is. That also makes its ids a function of its inputs, which is what lets the
test assert the round trip back through fromDialectEventTemplate: what the picker
chose is what the row says once the group has signed it.

Pure and takes everything it reads, so it is testable without a database and a
composable can ask it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:36:38 +02:00
Kgothatso Ngako
9d34a2cc99 feat(dialects): a catalogue of the languages most spoken in Africa
Fifty-six entries -- fifty-three African languages and three European ones --
for populating a group's dialects without typing each into a three-field form.
Nothing reads it yet; the plan it belongs to is docs/african-dialect-catalogue.md.

The unit is a language *in a country* rather than a language, because that is
what a dialect is here: `MantraDialect` is (name, country, code), so Kiswahili
is three entries and that is the point rather than a duplication.

(code, country) is the identity and the name is deliberately not part of it.
Nothing anywhere makes a dialect unique -- the id is a content hash including
createdAt, there is no index, and the rows are quorum-signed so they cannot be
rewritten -- so duplication has to be prevented at proposal time, and of the
three fields the name is the only one that is a matter of spelling.

Codes are ISO 639-1 where the language has one and 639-3 otherwise, rather than
639-3 throughout: a uniform `sot` would disagree with the `st` that every room
which used the form has already signed.

Two decisions a reviewer should look at. `lua` names Tshiluba (Luba-Kasai);
639-1's `lu` is Luba-Katanga, a different language of the same country, and
taking the two-letter code because it was there would have named the wrong one.
And the three European languages are a list of their own rather than lines
buried in the African one, so dropping them is one edit.

It lives outside ui/ on purpose. m3-audit scans ui/ and strings.xml for string
literals and title case, and "Northern Sotho" is title case for the same reason
m3-title-case.py keeps a SAMPLE set of proper nouns -- reference data out of
reach is cheaper and more honest than an m3-string-exempt marker on a file that
is not UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:34:29 +02:00
Kgothatso Ngako
5875655a32 Merge branch 'mantra' into claude/recursing-hermann-e58bbb
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
2026-10-02 22:27:28 +02:00
Kgothatso Ngako
aff1e330a0 fix(repository): a no-op answers all sixty calls, and a test keeps it total
Sixty `TODO("Not yet implemented")` bodies sat across four no-op repositories -- 41 of
45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on `MarmotRepository`,
1 of 1 on `SearchRepository`. f46a7c62 fixed the one that was firing and flagged the
rest for their own change; this is that change.

**Why a stub here is not one test's problem.** These are reached from view models that
call their repository inside `viewModelScope.launch(Dispatchers.IO)`, a coroutine nobody
awaits. The `NotImplementedError` escapes to the platform handler, where
kotlinx-coroutines-test's `ExceptionCollector` stashes it in `unprocessedExceptions` --
a `runBlocking` test has no `TestScope` registered to take it -- and the *next* `runTest`
drains the stash and rethrows it as `UncaughtExceptionsBeforeTest`, under its own name,
for a call it never made. Reproduced before touching anything: `jvmTest --rerun-tasks`
failed `NavigationRoutingTest > an account already on the device does not stay on the
loading screen` with four suppressed `NotImplementedError`s, each naming
`NostrRepository.kt:376` from `ChatMessageListViewModel.kt:366`. Seventeen jvmTest files
build on the Nostr one alone.

**Inert, not loud -- for every member.** The question per member was whether a silent
body or a deliberate throw is right, and the callers settle it: the only two populations
are `@ConformancePreviews` composables in commonMain and test fixtures, and both want a
repository that has no data rather than one that refuses. A preview that throws is a
broken preview. `NO_OP_DKG_REPOSITORY` and `NO_OP_FROST_SIGNING_REPOSITORY` already work
this way, so this follows that idiom instead of inventing a second one.

**Throwing is never the mechanism for being loud.** Keeping a throw for members that
"should not be called" was the alternative, and it is wrong twice: it re-opens exactly
this bug the next time such a member is reached from an unawaited launch, and it puts the
assertion in the fixture instead of the test. A member a test must not call is that
test's assertion to make -- override it with a recorder and assert it stayed untouched.

**A callback is not invoked.** `onCompletion`, `onSuccess` and `onError` are left alone
by `createNewProfile`, `createNewTextNote`, `leaveChatRoom`, `softDeleteChatRoom` and
`deleteChatRoom`. Calling `onCompletion` would say work happened that did not, which is
how a test comes to pass on the fixture rather than on the code; a test that wants a
branch fired overrides the member and fires the one it is testing.

**A flow emits its empty value once, rather than never.** `flowOf(null)` and
`flowOf(emptyList())` throughout, and `ChatRepository`'s two existing `flow { }` stubs
converted with them. An empty flow leaves a screen in Loading for ever; one emission lets
it render its empty state, which is what a preview is for.

**`createMlsDirectMessage` is the one that cannot say "nothing".** It owes the id of a
room and `String` has no absent value, so it returns `""`, documented as such. The
signature is the real oddity -- both creators beside it can report failure
(`createNip17ChatRoom` returns null, `createMarmotGroup` an `Outcome`) -- but changing it
would touch `DatabaseChatRepository` and its caller, so it is flagged in a comment rather
than widened here. Nothing reaches it in practice: `initiateNewChat` calls it only once a
profile and a key package have both resolved, and against this object neither ever does.

**Nothing was passing vacuously, checked rather than assumed.** The risk in a blanket
sweep is a test that currently fails honestly starting to pass on an empty body. No test
anywhere asserts `NotImplementedError`, and the suite went from 982 tests with 1 failure
to 983 with 0, with nothing newly failing -- including across the `flow { }` change and
the callback decision.

**The guard discovers the singletons rather than listing them.** `NoOpRepositoryTotality`
reads the `repository/` directory, keeps every file declaring a `NO_OP_` val, and reports
each `TODO(` in code with its file, line and member -- comments excluded, since the
contract on `NO_OP_NOSTR_REPOSITORY` names `TODO()` in its own prose. A hand-written list
was the first attempt and was already wrong: it named six files when the tree holds eight,
missing `MantraRepository` and `CachingImportRepository`, both total by luck rather than
cover. A floor of 8 goes with it, in the spirit of `m3-audit.sh`'s floors, because the way
a source-reading test fails silently is by finding nothing and passing. Both halves were
mutation-tested: a reintroduced `TODO()` fails naming `MarmotRepository.kt:49` and its
member, and a floor of 99 reports all eight files found.

**Calling the members for real was the alternative, and tells you less.** Sixty live
calls need a fixture for every parameter type, and a reflective sweep cannot tell a body
that threw from an argument it could not build -- so a failure would not distinguish the
bug from the harness. The invariant is a source-level one, so it is checked there.

**f46a7c62's narrow override is removed.** With the no-op total, the
`object : NostrRepository by ...` in `CeremonyQueueRoutingJvmTest` is dead weight, and its
comment had become false -- it asserted the member is "one of the forty-one `TODO()`
bodies". Replaced with the bare no-op and a past-tense note pointing at the guard.

**Not done here: the reporting mechanism.** The open question was whether the suite should
fail fast on an uncaught coroutine exception, or name the right test. Tracing
kotlinx-coroutines-test 1.11 shows the culprit's stack trace is already preserved, as a
suppressed cause on the `UncaughtExceptionsBeforeTest` -- nothing is lost, it is only
filed under the wrong name, and Gradle's console shows just the top line. A handler that
re-printed it would add noise, not information, so none was added; the missing name is
what this commit supplies. `MarmotRepository.NO_OP_MARMOT_KEY_PACKAGE_BUNDLE` is also
still misnamed -- it is a repository, not a bundle -- and left for its own change.

1171 jvm tests, 0 failed, under `--rerun-tasks`, on the merged tree. m3Audit clean,
`:composeApp:compileDebugKotlinAndroid` green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:21:49 +02:00
Kgothatso Ngako
86ff4a1cf0 Merge branch 'mantra' into claude/parent-group-name-display-c8ae05 2026-10-02 22:18:59 +02:00
Kgothatso Ngako
395bfa6b8b feat(subgroups): say which group the parent is, not only which id
The parent card on a room's detail screen read "Parent group" over sixteen hex
characters. Every other card naming a room names it -- `SubgroupCard` has shown
`room?.chatRoom?.subject` since subgroups landed -- and the one card pointing the other
way up the lineage did not, although the room it points at is usually right there in the
database.

So: "Parent group" moves to the overline, the group's name becomes the headline, and the
truncated id moves underneath, where it can still be read against what the lineage says.
The label keeps its place rather than being replaced, because a card carrying a name and
nothing else says nothing about which way the lineage runs, and the signing key's card --
same icon column, same shape -- sits directly above this one.

**The name can only come from the room, and there is not always one.** A birth
certificate names the child and never the certifier, so a member of the subgroup who is
not also in the group above holds a verified lineage with nothing to call it by. That
case keeps the card it already had: the id as the headline, no supporting line. It is not
a corner -- it is the ordinary position of anyone welcomed into a subgroup rather than
founding one -- so the room is a nullable field rather than something to wait for.

`SubgroupManager.parentGroupsOf` is `parentsOf` with each id looked up, returning
`Parent(chatRoomId, room)` the way `subgroupsOf` returns `Subgroup`. The verified set and
its canonical order are untouched; a parent whose room is missing stays in the list. The
id-only `parentsOf` stays as it is, because `offersTo` compares ids and should not pay
for rooms it will not read.

Three tests: a parent this device is in is named by its room, one it is not in is still
listed with nothing to name it, and two parents keep the lineage's own order -- asserted
against `parentsOf` rather than a written-out order, since canonical means sorted by id
and nobody should have to know how two derived ids sort.

459 common tests, 985 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 22:18:53 +02:00
Kgothatso Ngako
5105d8bd3c Merge mantra into the chapter uploads branch
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Thirty-eight commits had landed on mantra since this branch left it. Six
conflicts, all of them two sessions adding to the same place: the string
catalogue, the docs index, and the four files the projects line and this one
both touched.

Only one needed a decision. Both sides added parameters to
ProposedEvent.summarize -- chatRoomId and resolve for naming a project's owner,
chunkCount for saying how many chunks travel with a chapter -- so the signature
is the union, with chunkCount last so nothing positional on either side moved.
FrostSigningScreen and ProposalListViewModel pass all three, and the signing
screen's summary now names the thing, resolves its ids, and counts the batch.

The submodule pin is identical on both sides, and mantra's schema v20 to v22
does not collide: this branch adds no entity and no migration.

1170 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 21:58:15 +02:00
Kgothatso Ngako
2ba7633dfb Merge branch 'mantra' into claude/tablet-chat-room-selection-c504d5
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
2026-10-02 21:42:46 +02:00
Kgothatso Ngako
bba304fec0 Merge branch 'mantra' into claude/project-feature-plan-b58ab2 2026-10-02 21:29:34 +02:00
Kgothatso Ngako
f46a7c627d test(subgroups): stop a ceremony-queue test throwing into another test's name
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
`./gradlew :composeApp:jvmTest` failed `NavigationRoutingTest > an account already on
the device does not stay on the loading screen` with `UncaughtExceptionsBeforeTest:
There were uncaught exceptions before the test started`. **"Before the test started" is
the tell.** `runTest` reports exceptions that reached the default handler before it
began, so the name on the failure is whichever `runTest` ran next -- not the one that
threw. `NavigationRoutingTest` passes alone and the subgroups work never touched it.

The thrower is `CeremonyQueueRoutingJvmTest`, and the chain is five links with no
branch in it:

1. `awaiting()` builds a `ChatMessageListViewModel` on the bare
   `NO_OP_NOSTR_REPOSITORY` and calls `initiate()`.
2. `initiate()`'s second line is `scheduleSynchronization()`.
3. That launches on `Dispatchers.IO` and asks `MemberProfileSync` who cannot be named.
4. The fixture's `room()` gives every `LocalParticipant` a `profile = null`, so the
   answer is never empty and the next line always runs.
5. `queueNegentropySynchronizeRequest` is one of the **forty-one**
   `TODO("Not yet implemented")` bodies on that no-op, out of forty-five members. It
   throws in a coroutine nobody awaits, four times a run -- once per test in the class.

Fixed the way this file already fixes it for `DkgRepository`: an `object : X by NO_OP_X`
with the one member overridden. The comment above it carries the chain, because the next
person to see `UncaughtExceptionsBeforeTest` will be reading the name of the wrong test.

**Verified under the condition that reproduced it.** It is order-dependent, so a
filtered run is not evidence -- the culprit passes alone, and the pair passed too. The
full suite under `--rerun-tasks` failed reproducibly before and is 982 of 982 after.

**Not fixed here: the landmine itself.** Sixty `TODO()` bodies sit across four no-op
repositories -- 41 of 45 on `NostrRepository`, 12 of 37 on `ChatRepository`, 6 of 7 on
`MarmotRepository`, 1 of 1 on `SearchRepository` -- and seventeen jvmTest files build
view models on the Nostr one alone, so this will recur; the reporter had already hit it
through `getProfileWithPublicKey`. It is not a sweep, though: "no-op" means something
different for each member, the return types vary from `Unit` to `Flow<Int>`, and a
blanket empty body could make a test pass vacuously where it currently fails honestly.
Sixty judgement calls about shared code do not belong in a commit about one flake, so it
is flagged for its own change.

982 jvm tests, 0 failed, under `--rerun-tasks`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 21:22:29 +02:00
Kgothatso Ngako
1a6034dadb Merge branch 'mantra' into claude/subgroups-multiple-parents-053be4 2026-10-02 21:06:30 +02:00
Kgothatso Ngako
fbf376fe20 feat(chat): the room list marks the room the pane is answering with
The commit before this one made a second tap change the transcript. It did not make the
list say which room the transcript belongs to, and on a tablet those are two halves of
one question: a reader choosing their next room is looking at the list, not at the app
bar on the other side of the window that repeats the title. With nothing marked, the
only evidence of a selection was in the half of the screen they had looked away from --
which is its own reason the pane read as unresponsive even once it worked.

**`secondaryContainer`, which is M3's container for a selected list item**, set through
`CardDefaults.cardColors` so the content colour comes with it: `cardColors`'
`contentColor` defaults to `contentColorFor(containerColor)`, so the row's text moves to
`onSecondaryContainer` without a second call site to keep in step. A row's title keeps
its `ProfileColor`, which is a fixed-lightness HSL colour and already scheme-blind, so
the swap does not change what that line is contrasted against in either scheme -- a
pre-existing question this commit neither fixes nor worsens.

**`isSelected` is a predicate, and nullable, because the two layouts disagree about
whether selection exists at all** -- not merely about its value. Where the list is the
whole screen, tapping a room navigates to it and the list is about to be gone: no room
is "the selected one", and both a highlight left behind on the way out and a reader told
"not selected" about every room would be describing a pane that is not on the screen.
`null` says that. A predicate answering false for every room does not, because that is
also exactly what a two-pane layout looks like before the first tap, and those two
states are different things for the list to be saying.

**The call site reads the layout rather than the selection**, `listPaneWidth == null`
rather than `selectedChatRoomId == null`. The selection is `rememberSaveable` and
outlives a window that narrows, so a tablet turned from landscape to portrait keeps it
-- and deriving the mark from the selection would leave a highlighted row on a
one-pane screen with no pane beside it.

**The semantics property is set only where it means something.** A row in a
selection-bearing list carries `selected`, true or false, so a screen reader can say
which of them the pane is showing; a row in a list that navigates carries nothing,
rather than announcing "not selected" about a pane that does not exist. `Modifier.then`
of an empty `Modifier` is how the row opts out, which keeps the branch at the one place
that knows.

**Tests: 2 cases, both checked against the feature removed.** The first taps two rooms
in a 1000dp window and asserts the mark lands on the right card, moves rather than
accumulating, and is absent before the first tap while the rows are already selectable
-- "asked nothing yet" and "not the one being shown" being the two states the nullable
predicate exists to tell apart. The second asserts a 700dp list has no selectable row
at all, in two halves on purpose: a list that never rendered also has no selectable
rows, so it checks the room is on screen before checking it is not selectable.

Both first drafts counted `isSelectable()` across the whole window and failed by exactly
one, because `PrimaryScrollableTabRow`'s `Tab` is selectable too. The assertions are
scoped to the rows through a matcher that requires both selectability and the room's
subject -- which works because `Card(onClick = ...)` merges its descendants, so one node
carries the subject and the selected state together. That merge is also why a selected
room is two nodes saying its subject and a merely listed one is a single node, which is
what the previous commit's case counts.

**Not done.** Still nobody has looked at this on a tablet. The tests prove the mark
follows the selection and that a one-pane list stays silent; they say nothing about
whether the colour reads as a selection at arm's length on a real screen.

976 jvm tests, 454 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:51:30 +02:00
Kgothatso Ngako
9be3e01d8b fix(subgroups): hand an adoption's outcome back on the main thread
"Add a parent group", then tapping any group, crashed outright:

    IllegalStateException: Method setCurrentState must be called on the main thread
      at androidx.lifecycle.LifecycleRegistry.enforceMainThreadIfNeeded
      ...
      at NavController.popBackStack
      at SelectAdoptingGroupScreen.kt:153
      at SelectAdoptingGroupViewModel$offerTo$1.invokeSuspend

`offerTo` does its work in `viewModelScope.launch(Dispatchers.IO)` and called
`onDone` from inside it. The screen's callback called `onNavigateBack()` straight
through, which is `navController.popBackStack()` -- and navigation moves a back stack
entry's lifecycle, which is main-thread-only. Nothing in the chain switched.

**The dispatcher is the view model's business, not every caller's**, which is the
shape the app already uses in a dozen places and in both of the files this one was
modelled on: `SelectSubgroupAdminsViewModel.confirm` wraps its navigation in
`withContext(Dispatchers.Main)` and `AddArtifactViewModel` launches its `onSuccess`
there. A `finish` helper does it here, with the crash written above it so the next
person to add a callback to this class sees why. `ChatRoomDetailViewModel.acceptParent`
gets the same treatment: its only caller shows a snackbar, which happens to be
main-safe already, but a callback contract that is "safe if you are careful" is the
same bug waiting for a second caller.

**And the screen no longer navigates away on success**, which fixes a second thing
found on the way to the first. The snackbar was launched in
`rememberCoroutineScope()` -- this screen's own -- and then the screen popped, so the
message was racing its own scope's cancellation; `rememberNotifier` exists for exactly
that hazard and was not being used. Staying put is the better answer anyway, because
the list is the confirmation: the row just asked leaves it, since a certificate now
stands for the pair and `refuseAdoption` says so. The way out is the back button, which
a pushed screen has to have regardless. `offerTo` reloads before handing back so that
is true by the time the message appears.

The outer `scope.launch` around `offerTo` goes too -- it never suspended, and wrapping
a main-thread call in a launch was part of what made the threading hard to see.

No test covers this and none of the 1,416 would have. It is a dispatcher boundary
between a view model and a navigation callback, which is to say it lives in the one
layer this work had never run: compile-and-test verified, never driven through the app.
That was named as the risk when the feature landed, and this is it.

454 common tests, 962 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:51:26 +02:00
Kgothatso Ngako
3cd9001c27 Merge branch 'mantra' into claude/project-feature-plan-b58ab2
Eighteen commits of subgroup adoption and key-ceremony work, and one collision
that was the point of writing Phase 0 down.

**Schema.** `mantra` took 21 for `GroupKeyStateParent` -- a room's parentage as a
row, so it can have two -- as a manual `MIGRATION_20_21` that copies before it
drops. Projects had taken 21 on this branch. Neither had shipped, so projects
renumbered to **22**, an `AutoMigration(from = 21, to = 22)` on top of theirs;
`schemas/21.json` is now theirs and `22.json` is the two together, 58 tables.
docs/project-feature.md records it, including that Phase 0 told us to check for
exactly this and checked the sibling fork rather than the base branch.

Three conflicts. `MantraDatabase.kt` wanted both entity sets and both
migrations, and their v21 contributes only a comment there since the entry is
manual. `ProposedEventTest.kt` and the schema file were additive either way.

Nine files auto-merged, and `ChatMessage.kt` was the one to watch: they reshaped
the `SubgroupBirthCertificateEvent` arm while this branch added three project
arms to the same `when`. Git saw no conflict and the compiler agrees.

**One test fails, and it arrives with the merge rather than from it.**
`NavigationRoutingTest` reports "uncaught exceptions before the test started",
which is leakage, not its own failure: `CeremonyQueueRoutingJvmTest` (new in
b6c3e3dd) builds a `ChatMessageListViewModel` over
`NO_OP_NOSTR_REPOSITORY`, whose `queueNegentropySynchronizeRequest` is a
`TODO()`, and it throws on a background dispatcher. Whichever `runTest` starts
next is blamed, so it is order-dependent -- the same test passes alone and in
smaller selections. This branch touches neither `scheduleSynchronization` nor
`NostrRepository`; its only edit to that view model is the `summarize` call.

1051 jvmTest with that one failure, 482 testDebugUnitTest clean, m3Audit's
budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:48:17 +02:00
Kgothatso Ngako
b95d723edd fix(chat): a tablet's room list can pick a second room, not just the first
The home screen's detail pane answered with the first room it was ever given. Tap
another and the list highlighted nothing, the transcript did not change, and the app
bar kept the first room's title -- so from the expanded breakpoint up, which is a
tablet in landscape and every desktop window, the split was a one-shot: the first
conversation you opened was the only one you could read without leaving the screen.

**`key(chatRoomId)` does not rebuild a view model, and the comment claiming it did was
the bug.** `viewModel()` resolves against `LocalViewModelStoreOwner.current` under a
key derived from the *class* -- `ViewModelProvider.get(modelClass)`, no key, straight
to the default `DEFAULT_KEY:canonicalName`. A composition key replaces a group:
`remember`s are forgotten and `LaunchedEffect`s restart, which is real but is not where
a view model lives. The store belongs to `HomeRoute`'s `NavBackStackEntry` and does not
change while the reader is on the home screen, so the second tap re-ran the
composition, asked for a `ChatRoomMessagingViewModel`, and got back the one holding
room one -- its `chatRoomId` a constructor val, its factory never invoked a second
time. `initiateChatRoomDetail()` then re-read room one and the pane redrew what it
already had.

**This is a list-detail-only failure, which is why nothing caught it.** Every other
screen in the app reaches the chat transcript by `navController.navigate(...)`, and a
pushed route is its own `NavBackStackEntry` and so its own store -- one subject per
owner, the class key never collides. The pane is the one place one screen is composed
for a succession of subjects without the entry changing. Below expanded the home screen
still pushes, so a phone never had this; the four existing cases in
`ChatPaneLayoutJvmTest` assert the widths and the empty state, which were right.

**`ViewModelStoreScopedTo` gives the pane a store of its own rather than keying the two
`viewModel()` calls by room id.** Both put the right room on screen. Keying would also
leave every room visited holding a live view model in the home entry's store until the
reader left the screen, each with its relay subscriptions and database collectors still
running -- a leak that grows with how much a reader browses, which on this layout is
the point of the layout. A scope that clears on the way out is the same correctness and
none of that.

**It is hand-rolled because the stock API is not here.** `rememberViewModelStoreOwner`
lands in `androidx.lifecycle:lifecycle-viewmodel-compose` 2.11.0 and does exactly this,
including surviving an Android configuration change by consulting the parent's
lifecycle; the build resolves `org.jetbrains.androidx.lifecycle`, and the class is in
neither the 2.9.6 nor the 2.11.0 artifact in the cache. A bare `ViewModelStoreOwner` is
the whole contract a scope needs here -- nothing in this app puts a `SavedStateHandle`
in a view model, so there is no saved state to delegate, and it deliberately does not
implement `HasDefaultViewModelProviderFactory` because every call inside passes its own
factory. The cost of hand-rolling is that a rotation rebuilds the pane's view models
instead of keeping them; the selection itself is `rememberSaveable` and survives, so
what that costs is a re-read of one room.

**Both wrappers stay, and they are not redundant.** The store scope alone would leave
the composer's draft text in place as the reader moved to the next room -- which on this
screen is the failure the composer is built around three separate signals to prevent,
one person's words going to another -- and would not restart the effect that loads a
room, so the pane would sit on `Loading`. `key` alone was the bug. One resets the
composition, the other the view models.

**Tests: a fifth case in `ChatPaneLayoutJvmTest`, checked against the bug reinstated.**
Two rooms told apart by their subject, which both panes draw as a title, so two nodes
saying it means "this is the room on the right" and one means "this room is merely
listed" -- an assertion that needs no new test tag and no reach into the view model.
Tap room one, wait for two; tap room two, wait for two of *it* and one of room one.
With `ViewModelStoreScopedTo` reduced to `content()` the second wait fails with
`ComposeTimeoutException` while the other four cases still pass, so the case pins the
view model scope rather than the layout. The waits are generous, because what they wait
for is a database read on another dispatcher and the cost of being wrong about a thread
hop is a test that fails on a loaded machine.

**Not done.** Nobody has looked at this running on a tablet; the test proves the pane
follows the selection, which is not the same thing. The list pane also still draws no
selected state -- M3's list-detail expects the selected row to carry one, and its
absence is its own reason the pane read as unresponsive. Left out deliberately: it is a
separate change to a different file, and this one is the part that was broken.

974 jvm tests, 454 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:39:26 +02:00
Kgothatso Ngako
93f8ab313c test(projects): cover the three view models and the query nothing asked about
The feature's logic was tested from the events down to the chronicle and not at
all above the repository. Four gaps, each one something no other layer would
catch.

**AddProjectViewModel.** A project and its contents go out as one batch whose
memberships carry the project's id -- which is a hash over the group's key at
the room's path and does not exist until the proposal is made, so the screen
hands over a function of the signed lead. Nothing downstream notices if that
function returns the wrong thing: a membership naming another project is a
valid event, signs cleanly, and lands as a row pointing elsewhere. Eight cases
assert the batch's shape against a recording repository that signs the lead the
way a finished session does, including that the creator is the member filling
the form in -- there is no field for it and nothing else to check it against.

**AddToProjectViewModel.** The candidate filtering is the only thing stopping a
duplicate membership, because a duplicate is a valid event with its own id: the
keys are satisfied, the signature is good, the row gets written. A wrong filter
would spend a quorum saying what the group has already said and look like the
feature working. Ten cases, including that the two halves filter independently.

**FrostSigningViewModel's resolver.** Unresolved, the signing screen asks a
member to agree to "a4f1be55d0c3". Seven cases through the same `summarize`
call the screen makes: a membership reads as the artifact and label, a stranger
falls back to a hash rather than an invented name, the room owning its own work
says nothing, and a project being signed in the same batch resolves to nothing
-- which is why a membership says the version first.

**getProjectsByTranslationArtifactVersionId** had no test at any layer while its
artifact-side twin had one from the start. That asymmetry is what a copied join
hides.

Two notes for whoever writes the next view-model test. These view models hold
state in `mutableStateOf` and initiate on `Dispatchers.IO`, so there is nothing
to collect and the call returns before the work is done -- hence `awaitValue`,
polling in real time for the same reason `NavigationIdentityRoutingJvmTest`
does. And `NostrRepository.NO_OP_NOSTR_REPOSITORY.getProfileWithPublicKey` is a
`TODO()`, so a resolver that asks it about anybody throws on a background
dispatcher and surfaces as a timeout rather than a failure anybody can read;
these read profiles from the seeded database instead, which also gives the
honest answer for a stranger.

983 jvmTest, 447 testDebugUnitTest, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:26:05 +02:00
Kgothatso Ngako
b6c3e3ddc0 test(subgroups): pin what addresses is worth, and when one ceremony is the whole question
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Two surfaces the last three commits added and left resting on indirect coverage. Both
are decisions rather than plumbing, and neither had a test that would notice it changing.

**`SubgroupBirthCertificateEvent.addresses` had none at all.** Its sibling `certifies`
has nineteen, several of them real FROST forgeries, because a certificate is the whole
of what a subgroup relationship is. `addresses` was then added *beside* it as a
deliberately weaker reading -- no derivation, no signature -- and exercised only through
a view model, which is the wrong place to learn what it is worth.

The invariant worth having is the one a future reader can get wrong: **an event can pass
`addresses` and fail `certifies`.** Two cases say so in the two ways it happens -- an
unsigned proposal, which is exactly what the ceremony screen holds and asks about, and a
forgery authored and signed by the child's own quorum, which is anybody at all. A third
asserts `certifies` implies `addresses`, so the weaker one cannot drift into being the
stricter one while both are called "which child is this about".

The other two are the case the predicate exists for: two subgroups of one parent. A
sibling's well-formed proposal is not addressed to this child, *and is* addressed to the
sibling -- told apart rather than both refused, which a predicate that merely failed
closed would also satisfy.

Checked by giving `addresses` a signature check: the two "and certifies nothing" cases
fail, the other three do not.

**`hidesOtherCeremonies` had none either**, and it decides where a tap in the transcript
goes. `CeremonyQueueRoutingJvmTest` pins both of its guards separately, which is the
point of four cases rather than two:

 - one ceremony owed -> open it. Drop the `size > 1` and this fails.
 - two owed -> the list, from either tap, because neither is the whole of what the room
   is waiting on this member for.
 - a *finished* ceremony's line -> open it, even while two others are owed. This is the
   restraint, and dropping the "is itself awaiting" clause is what it catches: a reader
   tapping history is asking to see history, not to be handed somebody else's queue.
 - an abandoned one is never owed, even when `pendingApproval` says it is.

Each regression was reinstated separately and each is caught by exactly one case.

One thing the empty expectation needed care over, and it is the same trap a test in
`26d305bc` fell into: a watcher that writes once cannot be waited for by its own empty
value, because empty is indistinguishable from not having run. The abandoned case waits
on a clock instead and says so.

**`hidesOtherDecisions`, the proposal counterpart this mirrors, still has no test.** It
predates all of this and is left alone, but the asymmetry now runs the other way and is
worth knowing about.

**Still untested, and deliberately so rather than overlooked:** the three entry points
into the ceremony list -- the "Key ceremonies" row on the group's details, which exists
to be reachable in a *Marmot* room where "Shared key" is not, the standing notice
handing back null for several, and the transcript line's routing. Those are desktop
compose-ui tests over `ChatRoomDetailScreen` and `ChatTranscript`, both of which stand
up considerably more than anything here does.

982 jvm tests, 459 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:22:17 +02:00
Kgothatso Ngako
306b8bb150 test(chapters): the five gaps a coverage pass found, and two vacuous tests
A pass counting tests per file rather than per feature found five pieces of
this work with none. One of them mattered.

MAX_PROPOSAL_BYTES was enforced and never tested, on either side. The inbound
check is the half that counts -- the proposal is the one place a remote party
decides how much work everybody else does -- and it had nothing at all. Four
tests now, propose and accept, bytes and count, in SignedGroupKeyStateTest
where the adversarial inbound tests belong. The count cap's accept side was
untested too, although the propose-side test's own comment already claimed
otherwise.

Two of those tests passed vacuously as first written, which is the part worth
keeping. acceptProposal rebuilds every event from its own fields under the
room's path and drops the whole proposal if an id does not match, so a
hand-built event with an invented id is refused whatever its size -- and an
assertNull then stays green with the guard deleted. authoredProposal fixes it
by taking its events from real proposals, so their ids are the ones the
manager computes, and a control asserts that a well-formed proposal of the
same shape does open a session. That control failed on its first run, which is
how the vacuity was found; commenting the guard out now fails exactly the test
named after it.

UploadChaptersScreen had no UI test, against a project with fifteen
runDesktopComposeUiTest files that tests other screens this way. Ten now: the
cost of an upload stated before the picker opens, the index and name per row,
every per-row reason rendering, and the FAB counting only what it would
actually propose rather than how many files were picked.

canPropose, kibibytes and the chapter summary had none either, each being read
only by a composable. Eleven, five and five.

Still open, and not closable here: the three FilePicker actuals need
instrumentation this project does not have, and on iOS a host that can compile
them.

1030 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:20:12 +02:00
Kgothatso Ngako
26d305bc14 fix(subgroups): a parent's second subgroup can be certified, and sign its own key state
Both rungs after a subgroup's ceremony run in the **parent's** room. The birth
certificate is the parent's signature, so it is signed where the parent's key is; the
key state is signed over the parent's room because the room it is *about* does not
exist yet -- that is the whole point of signing it first. So a parent making its second
subgroup has two certificate signings and two key-state signings in one room, and the
ceremony screen read them out by event kind alone.

`observeSessionsForChatRoom` is `ORDER BY createdAt DESC`, so `firstOrNull` on a kind
match is "the newest of that kind in this room" -- which is some other subgroup's. The
screen for one child followed another child's progress.

**The display was not the damage.** Both rungs disable their button while a signing of
that kind is running and un-failed:

```kotlin
enabled = … && (certificateSession == null || certificateFailed)
```

and a *completed* signing is un-failed forever. So once a parent had certified one
subgroup, it could never be asked to certify another: the rung sat there disabled with
no way out, and `proposeBirthCertificate` returned early on the same reading. The key
state blocked the same way, and `createAdminGroup` is gated behind it, so the second
subgroup could not be made at all.

**It did not need two subgroups.** Adoption signs a key state in the child's own room
(`proposeAcceptance`, `localChatRoom = childRoom`), so a group that has ever been
adopted already holds a `GroupKeyStateEvent` session in its room -- enough to block the
first subgroup it tries to make.

What makes this a half-finished job rather than an oversight is that the *signed* facts
were already right. `isCertified` goes through `observeBirthCertificate`, scoped by
session id, and `isKeyStateSigned` through `observeSignedGroupKeyState`, whose own
comment records this exact lesson being learned once already: "A ceremony room holds
more than one ceremony the moment a subgroup's admins are the whole group." The
in-flight lookups never got the same treatment, and the comment above the key-state
watcher still asserted the premise that had stopped being true -- "the room a ceremony
runs in signs nothing else".

**The key state is matched on the ceremony the proposal names.** `GroupKeyStateEvent`
carries `FrostKeyTag` from the moment the proposal is assembled, so
`parseDkgSessionId(tags) == dkgSessionId` discriminates before the ceremony has a key
and before anybody signs. The watcher moves out of `observeKeyState` -- deleted, with
its `Job` -- and into `observeForCeremony`, which is already cancelled and re-pointed
per ceremony and is where every other per-ceremony watcher lives. `observeForCeremony`'s
null branch now clears `keyStateSession` too, which it could not before because the
field was not its to clear.

**The certificate is matched on the child it names, through a new `addresses`.**
`certifies` could not be reused: it ends on `GroupKeyStateEvent.isSignedByRoom`, a real
signature check by the parent's quorum, and the thing being matched here is a proposal
that quorum has not signed yet -- that is what the rung is waiting for.
`SubgroupBirthCertificateEvent.addresses` is the structural half (kind, content, the `d`
tag, the parent tag), `certifies` now calls it so the two cannot drift, and its doc says
outright that nothing may decide a lineage on it. The three values it reads come out of
`assembleTags` together, so they agree by construction rather than by agreement.

Correctness is not at stake in either: these two fields only decide which progress bar
is drawn and which button is live. Whether the parent actually agreed is `isCertified`,
which does check the signature.

**`DkgRepository.roomDerivedBy(dkgSessionId)`** is the session-id-to-room derivation
that `observeBirthCertificate` and `observeSignedGroupKeyState` each carried inline.
Three callers now, one of them new, and the no-op repository answers null -- which is
also the honest answer for a ceremony that has not finished, and the reason all three
resolve it per emission rather than once.

**Tests: `DkgRitualSecondSubgroupJvmTest`, 3 cases, each checked against the bug
reinstated rather than only against the fix.** Two assert that the screen follows its
own ceremony's signing when a sibling's is newer. The third is the permanent block, and
it is driven in two emissions on a hot flow for a reason worth recording: written as a
single emission it asserted "still null" and **passed against the bug**, because at
that moment the watcher had not started. Nothing is proved by a field that is null
before anybody has looked at it.

**One test from the commit before this had to be rewritten, and the change is this
commit's fault.** `DkgRitualStartOfferJvmTest`'s lost-update case forced its interleaving
by parking the session watcher inside `pendingApproval` while the key-state watcher
wrote. Those two are no longer concurrent -- the key-state watcher now starts *from* the
session collector, after the park -- so the setup could not happen. The claim was
untouched by that, so it is now made against the session watcher versus the room
watcher, which are genuinely concurrent: both collect before either writes, neither
waits on the other, and the field they contend over is `roomHasCeremony`, which is the
one that decides whether the offer to start appears. Re-confirmed failing against the
lost update reinstated.

973 jvm tests, 454 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:12:43 +02:00
Kgothatso Ngako
2fb8d6921b fix(projects): let a membership's foreign key violation out, so the sweep retries
A project's memberships are foreign keys into both the project and the version
they name, so a chronicle page carrying a membership whose project is in a
*later* page cannot apply it yet. `ChronicleManager.applyPage` handles exactly
this: it catches the violation, counts the payload `failed`, and `sweep` re-runs
every page until failures stop falling.

Phase 3 broke that for projects by catching the violation inside the apply arm.
`applyInnerEvent` then returned normally, `applyPage` counted the payload
applied, the sweep saw nothing left to do, and the membership was dropped --
silently, permanently, on the device a chronicle exists for.

The catch was added so that `FrostSigningManager.applySignedEvent` would not
report a succeeded session as failed. That reasoning was already satisfied:
`applySignedEvent` wraps the call in its own try/catch. And no sibling arm
catches -- a translation chapter naming an absent chapter throws today -- so the
catch made projects the only kind a chronicle could not retry. It is gone.

Pages are 128 events or 64 KiB, and projects sort last in APPLY_ORDER, so a
boundary falling between a project and its memberships is the ordinary case for
any room with a chapter's worth of chunks in it.

`pages arriving out of order still converge` already modelled this -- one page
per payload, delivered backwards, comparing every row count on both devices --
and passed anyway, because `rowCounts` did not look at the project tables. It
does now, which is what makes that test the one that would have caught this.
`ProjectApplyJvmTest` asserts the throw rather than the swallow, and adds the
retry in miniature: refused while the project is missing, accepted once it is
there, same event both times.

1404 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 20:05:28 +02:00
Kgothatso Ngako
a0bad7c8da docs: the chapter uploads plan has been built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:54:03 +02:00
Kgothatso Ngako
1673ce0e5e test(chapters): an upload end to end, and the callback it found missing
Phase 9 of docs/chapter-uploads.md.

ChapterUploadJvmTest drives the view model over a real database: five files
become five sessions, one chapter leading its own chunks in each -- never two
chapters sharing a batch -- numbered contiguously from zero, which the old
`.size` numbering could not do because nothing is signed while the pass runs.
Each chapter's chunks are the blocks of its own file, markers off. Four
chapters in a three-dialect group ask for one scaffolding session of twelve
rows, not four.

It found one thing: propose() with nothing proposable returned without ever
calling its callback. The FAB is disabled in that case so the screen never
reached it, but a callback that sometimes never fires is a trap for the next
caller, so it now fires onDone(0, 0). That is the whole reason for writing the
end-to-end test rather than assuming the parts composed.

One thing it deliberately does not test the way the plan asked. "A failure on
file 4 leaves files 1-3 proposed" is unreachable by input: both caps are
checked in the form against the same functions the manager enforces, and
ChapterProposalSizeTest asserts the form's estimate is never under the
manager's measurement, so no file passes the review and then fails the
proposal. It is reachable by state, which is what the test does -- a room with
no shared key fails all three, every row says so and stays retryable, and a
second pass after the key arrives proposes all three from zero, the failed
pass having spent no indices because it created nothing.

995 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:53:54 +02:00
Kgothatso Ngako
aceef313ed docs: record what building projects changed about the plan
Five places the build chose differently, and one thing the plan did not
anticipate: a project reaches a chronicle only off the GroupSignedEvent record,
because the deprecated rebuild has no `toProjectEvent` and deliberately never
will. `recordSignedEvents` catches its own failure so a ceremony that succeeded
is not reported as failed, which leaves a narrow window where the row exists and
the signed event does not -- a project this device can show and can never hand
on. There is a test holding that case open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:50:41 +02:00
Kgothatso Ngako
3668b39575 feat(projects): add to an existing project, and names instead of hashes
Phase 7c of docs/project-feature.md, and the last of the phases this plan
builds. 8 and 9 -- removal, and projects that span rooms -- stay deferred.

The resolver comes first because the screen depends on it. A membership event
carries two 64-character ids and nothing else, so proposing one on its own
means every other member is shown "sign a4f1be55d0c3" and asked to agree. That
is not a question anybody can answer, and the plan said not to ship this screen
without the fix.

So `FrostSigningViewModel` resolves what the proposed events refer to -- the
profile behind a creator or an owner, the artifact and label behind a version,
the name behind a project -- and hands `summarize` a lookup. Synchronous, because
two of the four call sites are composables and cannot await a database;
pre-resolved in the view model for the same reason. Only what this device
already holds: a key with no profile and an id with no row are left out and fall
back to a short hash, which is truthful rather than a gap.

A project's own id still resolves to nothing while it is being proposed, since
nothing has applied it yet. That is why a membership's summary says the version
first.

`AddToProjectScreen` is the simpler half of founding a project: the project
exists, so its id is known and there is no lead and no batch -- one event, one
session. Candidates are filtered to what the project does not already hold
rather than shown and refused, because a duplicate membership is a second event
with its own id and nothing downstream would stop it; it would just cost a
quorum to say what the group has already said.

One at a time, by radio button rather than checkboxes. A quorum per thing added
is the grain this app signs at, and a multi-select here would quietly be a batch
whose items a member has to weigh separately anyway.

M3 budgets met. 1403 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:49:57 +02:00
Kgothatso Ngako
7c76359b34 feat(chapters): a chunk is a markdown block, not a blank-line paragraph
Phases 7 and 8 of docs/chapter-uploads.md.

Measured against markdown-it -- the parser mantra.press translates with --
this app's blank-line split disagreed on ten cases in eleven, and on the text
as well as the boundaries. markdown-it's content is a leaf block's inline
source with the BLOCK marker removed, so `# Chapter one` is "Chapter one"
while inline markup is kept. That is the half that matters to a translator:
handed "# Chapter one" they can break the document by mistyping the hash, and
handed "Chapter one" they cannot.

So splitBlocks adopts it, boundaries and stripped text both, with three
deliberate departures. Code blocks yield no chunk -- markdown-it's fence and
code_block carry content, so mantra.press asks a translator to translate
`code();` and the group to sign it as prose. A table is one chunk of its own
source rather than one per cell, because four envelopes to carry one word
each is a poor trade and a table's alignment is structure per-cell
translation cannot preserve. And a link reference definition yields nothing,
which is free: it is markdown-it's own behaviour with the `reference` rule
left enabled, where mantra.press disables it -- reaching for a block rule to
stop links rendering -- and makes a translatable chunk of a URL.

Hand-rolled, not org.jetbrains:markdown, for one reason specific to this
function: its output is hashed into ids a quorum signs, so a version bump
that moved one boundary would silently re-split every chapter proposed
afterwards and nothing would notice. What pins it instead is thirty golden
fixtures generated FROM markdown-it by docs/scripts/markdown-block-fixtures.js,
which also encodes the three departures so the Kotlin test is plain equality.

MarkdownProducer is new and is what makes stripping markers safe: nothing in
the app could put a `#` back before. It substitutes by index, which is
strictly better than the thing it mirrors -- mantra.press matches on text, so
a chapter with two identical paragraphs puts the first one's translation into
both. The scanner segments every line rather than only the chunks, which is
what makes the byte-for-byte round trip exact by construction rather than by
getting every markup rule right.

ProposedEvent.summarize stops re-splitting a chapter to count its chunks and
takes the count from the batch. That number is a count of the batch dressed as
a fact about the text: across a splitter change the figure in the approval
gate would disagree with the events being approved, and it re-scanned every
open proposal's prose on every emission of the room's list.

Phase 2's constant was flagged as provisional pending this, and measured it
did not move: prose separated by blank lines splits the same under both rules,
so the fixture pinning 24 KiB over forty paragraphs is the shape this does not
touch. The narrower claim holds -- a heading- or list-heavy chapter now reaches
both caps sooner, visibly, in the form's own counter.

987 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:48:45 +02:00
Kgothatso Ngako
a14ea3d9d3 feat(dkg): every key ceremony a room holds, pending first
A room holds more than one ceremony as a matter of course, and has done since a
subgroup's ChillDKG moved into its parent's Marmot room: the parent's own, long
finished, plus one for every subgroup the group has ever made. Nothing in the app
could name the second of them.

`DkgRitualRoute` without a session id shows the room's *newest*, which is routinely the
finished one with the subgroup's request buried underneath it. The group's details
reached only that. The transcript could name one, by scrolling to the line it was
about -- which for a subgroup means scrolling past whatever else the room has been used
for, and is impossible for a ceremony whose line never arrived.

**`DkgRitualListScreen`, deliberately the same shape as `ProposalListScreen`.** One row
per ceremony off `observeSessionsForChatRoom`, split into "waiting for you" and
"everything else". Split rather than sorted, for the reason `ProposalListUIState`
gives: the rest of the list is what the room has done, these are what it is waiting on
the reader for, and they are a different kind of thing to read. The finished ones are
kept, because how this group came to hold the keys it holds is the other half of what
the screen is for.

**What "waiting on you" means is two things, and they are owed by different people.**
An approval gate this member cannot delegate, and a finished ceremony whose room only
its coordinator can create -- a ceremony reaching COMPLETE finishes nothing on its own,
and nothing else in the app would tell the coordinator so. Both are asked of
`DkgRepository` rather than re-derived, the same readings the transcript's standing
notice and the ceremony screen make, so the three cannot disagree. An abandoned
ceremony is asked neither: there is no step left to approve and no key to build a room
from, and a row claiming otherwise would send a member to a screen with nothing on it.

**A row says what the key is for before it says where it has got to**, because a room
holding two holds them for different groups and a row that only reported progress would
leave a member guessing which. A subgroup is named by its proposal, so there is a third
branch for the member who arrives before that name has -- it still says which of the
two kinds of key this is. The quorum is on the row too: a ceremony fixes it and it
cannot be changed afterwards.

**Three entry points, two of which were gaps.**

`ChatRoomDetailScreen` gets a "Key ceremonies" row in *every* room, including the
Marmot ones the `mlsGroupState` check keeps "Shared key" out of. The two are different
questions and both are kept: "Shared key" offers a room a key of its own, which only a
NIP-17 room can want; this lists the ceremonies a room *holds*. A parent's Marmot room
has never had a door to its subgroups' ceremonies at all.

The transcript's `CeremoniesAwaitingYouNotice` says "3 key ceremonies need you" and
then opened `ceremonies.first()` -- answering a different question from the one it
asked, and leaving the other two exactly as hard to find as before. It now opens the
list whenever it stands for more than one, which is what the signing notice beside it
already did.

A ceremony line in the transcript routes to the list when it cannot name a session -- a
line written before chat rows carried one -- or when the ceremony it names is not the
only one waiting on this member. `hidesOtherCeremonies` mirrors `hidesOtherDecisions`
exactly, including its restraint: only for a ceremony that is itself waiting on them,
because a line about a finished one is history and a reader who taps it is asking to
see that, not to be handed a queue.

**An empty list offers the way out of itself**, rather than ending in a blank page that
sends a member back to the group's details to find the other door. It only offers to
ask; the ceremony screen still decides whether the group is in a position to hold one.

`DkgRitualListViewModel` is a `MutableStateFlow` from the start rather than a
`by mutableStateOf`, for the reason the commit before this one found.

**Tests, 6 cases in 2 files.** `DkgRitualListViewModelJvmTest` covers the three
decisions nothing else can check: that a ceremony wanting something is at the top even
when its date is not, that only the coordinator is told to finish setting up a finished
one, and that an abandoned ceremony is waiting on nobody even when both lookups say it
is. `DkgRitualListScreenJvmTest` renders it and checks the thing that is only true once
drawn -- that a row opens the ceremony it names rather than the room's newest, which is
the whole reason the screen exists -- plus the two headings and the empty state's offer.

**Not done.** Nobody has looked at this screen running; the render tests prove it
composes, sections and routes, which is not the same thing.

970 jvm tests, 454 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:46:06 +02:00
Kgothatso Ngako
4e6ca0a080 fix(dkg): stop the key ceremony screen offering to start the ceremony it is showing
"Start key ceremony" is not a button, it is the whole no-ceremony screen -- the
explainer, the quorum picker and the FAB together -- so anything that leaves
`DkgRitualUIState.Loaded` saying there is no session puts that offer in front of a
member watching one run. Two unrelated things did, and they fail differently: one
flashes, one sticks.

**The state went up before the rows were read.** `initiate()` published a bare
`Loaded(localChatRoom)` -- session null, `roomHasCeremony` false -- and *then* started
the two flows that answer both questions. A cold open is the only time those queries
are slow, and it is exactly when a member arrives from the transcript's notice, so the
offer was drawn over a running ceremony for as long as they took. Both answers are a
row lookup, so they are now read with `first()` before anything is published and the
state goes up already correct. The flows still run; they only keep it current.

**Seven flows were folding into one `var ... by mutableStateOf`.** The ceremony, the
room's other ceremonies, the ceremony's messages, the key state, whether the key state
is signed, the certificate and the certificate's signing -- each of them a read, a
`copy` and an assignment, every one emitting on Room's own executor. Two emissions
arriving together silently dropped one: whichever read first wrote last, over the top
of the other's change.

The field that went missing most visibly was `session`, copied back to null by a
watcher that had read the state before the ceremony landed in it. That is not a flash.
The session flow only re-emits when the `DkgSession` table changes, and a ceremony
waiting on a member who has not opened the app changes nothing, so the screen sat there
offering to start the ceremony it was showing.

`MutableStateFlow` with every write through `update { }` -- a compare-and-set that
retries on a loss, so a change can only ever be folded into the state that is current.
The one rule it imposes is that the block re-runs, so it must be pure: `pendingApproval`
is a suspending database lookup that used to sit *inside* the `copy`, and it now
happens above the call. `updateLoaded` carries the `as? Loaded ?: return` that all
twenty call sites used to write by hand.

**Two knock-ons.** `canStartRitual()` and `quorumRange()` were read from the composable
and are now passed the state, because reading a flow's `.value` inside composition
observes nothing -- the offer would have been decided once and never revised. Passing
the rendered state is also more correct while `ScreenStateTransition` still holds the
outgoing one. `DkgRitualScreen` and `DkgApprovalScaffold` collect with
`collectAsState()`.

**The button itself is right and stays.** Creating a robust group already opens the
ceremony -- `SelectChatRoomTypeViewModel.createNip17ChatRoom` calls `proposeRitual`
immediately after `createNip17ChatRoom` -- and this screen's offer is the deliberate
fallback for the case where that failed (`keyCeremonyNotStarted`) and for rooms created
before it existed. What was broken is when it appeared, not that it exists.

**Tests.** `DkgRitualStartOfferJvmTest`, two cases, and each was checked against its own
regression reinstated rather than only against the fix. The first asserts the *first*
`Loaded` already knows the room is running a ceremony, with a deliberately slow query so
the window is proved gone rather than hidden. The second forces the lost update to
happen rather than racing into it: the session watcher is parked inside `pendingApproval`
while the key-state watcher writes, and the write that follows must not carry the stale
copy over it.

**Not done.** `HomeViewModel`, `ChatMessageListViewModel`, `ProposalListViewModel` and
others still declare `by mutableStateOf` in a view model constructor. They are only at
risk where more than one flow folds into one state object; none was audited here.

964 jvm tests, 454 common tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:45:28 +02:00
Kgothatso Ngako
54249e3c4f feat(projects): a member can ask the group to sign a project into being
Phase 7b of docs/project-feature.md -- the write path. The room's "Create
project" button now opens a form instead of `ImplementationPendingRoute`.

Nothing is created here. What goes out is a proposal, and the project appears
on every member's device at once -- authored by the room's own key -- when
enough members sign. That is the difference from submitting one: a submission
says "I am putting this in front of the group" and the group's only recourse
afterwards is social; a signature is the group saying it, and it takes a quorum
to say.

One session over the project and its contents. The memberships cannot be built
by this screen, because each carries the project's id and that is a hash over
the group's key at the room's derivation path -- it does not exist until the
proposal is made. So the project leads and the memberships are derived from it,
the same shape `AddArtifactViewModel` uses for an artifact and its first
version.

The creator is not a field: it is whoever is filling the form in, the view model
already has it, and a picker would only let somebody credit the wrong person for
something the group is about to sign.

The owners are a field, pre-filled with this group. Most projects never touch it
and that is why it is pre-filled rather than hidden -- a group making something
for somebody else needs the question asked, and a group that is not needs to see
the answer it is giving. Two guards, both because the alternative is silent: an
entry that is not 64 characters is refused on the way in, since
`ContributorTag.parse` would drop it and the owner would vanish between the form
and the signature; and the last owner cannot be removed, since
`ProjectEvent.build` refuses an empty set and a button that throws is worse than
one that is off.

A project with no contents is allowed, unlike an artifact with no version. An
artifact with no version is inert because nothing can attach to it; an empty
project is a name waiting for work.

M3 budgets met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:42:28 +02:00
Kgothatso Ngako
3912631b2e feat(projects): a room lists its projects, and one can be opened
Phase 7a of docs/project-feature.md -- the read path. The write path and the
screen for adding to an existing project follow.

The room's project section was already there as a stub: a heading, a permanent
"No projects exists in this group", and a button to
`ImplementationPendingRoute("Add new project")`, under a `// TODO: Add
projects...`. That is what this replaces, so the strings it was already using
are reused rather than added beside near-identical ones -- which is also how
the duplicate `projects` entry I first wrote got caught.

`ProjectDetailScreen` shows the contents and then who the project belongs to.
The second half is the point of the screen as much as the first: the creator
and the owners are claims the group put its signature to, nobody else checks
them, and the signed event is the only place they exist. So they are read here
after the fact, resolved to profiles in the view model -- a composable cannot
await a database, and these are a handful of keys read together.

An owner that is the room's own key reads "This group" rather than printing the
room id back at a member standing inside it, and sorts first, so the ordinary
single-owner case is one unremarkable line and an outside owner is the one that
stands out.

M3 budgets met: spacing off the scale, strings in the catalogue in sentence
case, `NavigateBackButton` on a pushed screen, `readableContent()` on the
content root, all four states through `ScreenStateTransition`, every icon with
a decided description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:36:42 +02:00
Kgothatso Ngako
f8a0ee1cdb test(chapters): twelve proposals each keep their own summons
Phase 6 of docs/chapter-uploads.md, which asked for three things to be
checked rather than assumed. All three already hold, so this is their tests
and the record of it.

The proposal list already groups: a "Waiting for you" header over the
sessions isAwaitingApproval picks out, "Everything else" under them, over a
query already ordered newest first. The chapter summary is already
`name - N words - N chunks` rather than "Chapter", so twelve rows are twelve
distinguishable rows. And isAbout already compares frostSigningSessionId when
both rows carry one, falling back to the clock only when either does not.

The last of those is the one with a history -- two concurrent sessions broke
it once, which is why the column exists -- so it is now tested at twelve, with
the answers given in the opposite order to the asking, so that neither the
clock nor the list order could produce the right answer by accident. Plus one
proposal failing and settling only its own row, which is the transcript's
version of why an upload is N sessions and not one.

Noted for Phase 8 rather than fixed here: twenty open chapter proposals is
roughly 900 KB of unsignedEventJson parsed and summarised on every emission of
that query, and summarize then re-splits each chapter's text to count its
chunks. Phase 8 replaces that derived count with the session's own item count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:36:14 +02:00
Kgothatso Ngako
428026cdfd refactor(chapters): one scaffolding batch for every chapter, not one each
Phase 5 of docs/chapter-uploads.md, and the third fix to something wrong
today.

AddChapterViewModel was right that translation scaffolding needs its own
session -- a chapter's paragraph budget should not be spent on the group's
dialects, and how long a chapter may be has nothing to do with how many
languages it is read in. It was wrong that it needs one session per chapter,
which only becomes visible above one chapter: twelve chapters in a group with
three dialects is thirty-six join rows, which fit in a single batch, where the
per-chapter shape opens twelve sessions to carry three rows each. Twenty-four
proposals where thirteen will do.

ChapterScaffolding.propose is now the one answer to what a chapter's
scaffolding is, and both view models call it. Two copies of a cross product
would be two chances to disagree about what a translation covers.

It also fixes a latent bug one chapter could never show. The old code chunked
the *translations* by MAX_BATCH_SIZE -- right by coincidence at one chapter,
where the cross product and the translation list are the same length, and
wrong at any more. 25 chapters in a three-dialect group is 75 rows, which the
translation axis would have put in one batch of 75 and proposeSigningBatch
would have thrown. Chunking the product is the generalisation, and the test
for it is the only one here that fails against the per-chapter shape.

Two smaller decisions. The batch takes one timestamp, the latest of the
chapters' own, because that is the moment the act finished and the clock would
be a third time unrelated to either end of it. And a session whose lead event
cannot be read is left out rather than guessed at: a join row naming the wrong
chapter is worse than a missing one, because the catch-up can find a missing
one.

Failure policy unchanged and for the reason it already gave: logged and
swallowed, since the chapters are what was asked for. At N the argument is
stronger, not weaker -- one cross-product batch is only as available as its
worst item.

970 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:33:43 +02:00