Commit Graph

840 Commits

Author SHA1 Message Date
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
Kgothatso Ngako
9bd4216867 feat(projects): the repository reads a project and what is in it
Phase 6 of docs/project-feature.md. Six methods on `MantraRepository`, with
`DatabaseMantraRepository` behind them and `NO_OP` returning empty for previews.

The two contents queries return the versions rather than the join rows. No
screen wants a join row, and the DAO resolves it in one indexed query instead of
leaving an N+1 in a view model. Both inverses are here too -- which projects a
version is part of -- because an artifact's own screen is where "what is this
for" gets asked.

Nothing in `MantraDao`. That path -- `addDialect`, `addArtifactVersion`,
`rumorOf`, `submitToGroup` -- is the old submit flow, reached now only from its
own test, and every entity a member creates is proposed for signing instead. An
`addProject` there would be a second way to create a project that bypasses the
quorum, which is the thing this feature is for.

No query by creator or owner. Both are columns and a screen that wants them has
the row; `getProjectsOwnedBy` would be a query with one caller and an index to
go with it, and a room's project list is short. Add it when a screen asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:30:18 +02:00
Kgothatso Ngako
5fac2fc7b7 feat(projects): a late member is told what the room's work is for
Phase 5 of docs/project-feature.md. Three kinds appended to
`ChronicleEvent.APPLY_ORDER`, which is all the mechanism needs:
`CHRONICLABLE_KINDS`, `applyRank`, `inApplyOrder` and `isChroniclable` all derive
from that one list, and `signedEventsOf` already filters on it.

Appending is not arbitrary. A membership is a foreign key into both a project
and a version, so it has to apply after everything it can name, and putting the
three on the end gets that right by construction -- everything a project groups
is already above it. A page in the wrong order is a refused insert on the one
device with nothing to fall back on.

Then four existing tests failed, and each was telling the truth.

Three of them compare what a seeded room chronicles against CHRONICLABLE_KINDS
itself, so a kind added to the set and not to the seed fails them. That is the
point of writing them that way, so the project went into the canonical seed in
both chronicle suites rather than into a helper beside them.

The fourth is the one worth reading. `every chroniclable kind has a round-trip
test above` asserts that every kind a chronicle carries can also be rebuilt from
its row -- and projects deliberately cannot, because the rebuild exists for
rooms older than schema v13 and projects arrive at v21. So the meta-test now
excludes them explicitly, and the gap is pinned where it actually bites: `a
project is only ever chronicled off the record` holds a project whose
`GroupSignedEvent` is missing and shows the row is there and the chronicle is
not. That is reachable -- `recordSignedEvents` catches its own failure so a
ceremony that succeeded is not reported as failed -- and it is worth knowing is
possible rather than worth preventing.

`the rebuild alone reaches everything but the projects` pins the other side, so
anyone adding a `toProjectEvent` fails a test that tells them where the comment
explaining why is.

Also corrected a stale count in the allowlist's own comment: it said six of
thirteen nip30303 kinds reach a signing session while the list below it had
eight. Eleven of sixteen now, with `SubmissionEvent` named among the exclusions
since it is the envelope rather than the work.

1403 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:27:02 +02:00
Kgothatso Ngako
9430396e5a feat(projects): say what a project is to the member being asked to sign it
Phase 4 of docs/project-feature.md. Three branches in `ProposedEvent.summarize`,
or members would be asked to sign "Event of kind 30330".

The project's branch shows the creator, and that is not decoration. The creator
tag is the group attesting that X started this; the creator signs nothing, the
room does, so a member could propose a project crediting somebody else and a
quorum that does not read would sign it. Nothing checks this and nothing can --
the only mitigation is that the claim is in front of the person agreeing to it.

`summarize` gains two optional parameters. `chatRoomId` is read by one branch
and only to leave something out: a project owned by the room it is in is the
ordinary case, and saying "owned by this group" every time is how a member
learns to stop reading the line that matters when somebody else is on it. All
four call sites already had the room, so this is threaded rather than plumbed --
including through `WhatIsBeingSigned`, which took the events and not the state.

`resolve` is the seam for turning keys and ids into names, synchronous because
two of the four call sites are composables and cannot await a database. It
defaults to resolving nothing, which is honest rather than finished: a
membership event carries two 64-character ids, so without it the summary is a
truncated hash. That is tolerable in the batch that founds a project -- the
project's own line says what is happening and the memberships read as its
continuation, and the project id is unresolvable there anyway since nothing has
applied it yet. It is not tolerable for a membership proposed on its own, which
is Phase 7's second screen, and that is where the resolver gets filled in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:16:21 +02:00
Kgothatso Ngako
2f8df359a8 feat(chapters): upload several files, one proposal each
Phase 4 of docs/chapter-uploads.md: the feature.

A review step rather than a submit button, and that is the one place this
departs visibly from the web app it mirrors. mantra.press can post twelve
files and redirect, because what it creates belongs to the uploader and the
uploader can fix it. Here each file becomes a request for the group's own
signature, so a filename standing in for a title, a file that decoded to
nothing, or a chapter too long to carry is wrong in something a quorum is
about to put its name to. Picking files produces a list; the list is what is
proposed.

Per-row problems, because a bad file should not cost the other eleven: not
text, too large, nothing to translate, no name, more chunks than a session
signs, more bytes than a proposal carries -- and a duplicate, which warns
rather than blocks. A book can repeat a page, but the common cause is picking
the same directory twice, and two chapters with the same text are two event
ids nothing later can merge.

Proposals go out sequentially, never concurrently: each writes a session and
publishes group events, and a group event that arrives a moment out of order
is dropped for good (docs/mls-skipped-keys.md). Indices are allocated once
from a freshly read base, so numbering does not depend on how fast the
sessions are written.

Partial success is the expected outcome and not an error. Nothing can retract
a proposal, so if the fourth fails the first three are real and the group can
still sign them: no rollback, no navigating away, the rows that went are
marked and the rest keep their reason, which is the only place a retry can be
aimed from. A pass that fully succeeds lands on the proposal list rather than
a signing screen -- at N there is no one session to watch.

Two shapes the writing settled. UploadReview holds the row building and the
problem decisions, which are pure functions of the files, the names typed
over them and the chapters the version already has; the view model keeps only
the orchestration, and the 13 tests need no view-model harness. And uiState
is a StateFlow, not `by mutableStateOf`: initiate() writes from
Dispatchers.IO after a primary-key lookup, which is exactly the few
milliseconds in which a write from another thread into a composition's
snapshot is silently discarded -- the bug that once left this app on
"Decrypting..." for ever.

Scaffolding into translations is not here yet; Phase 5 lifts it out of
AddChapterViewModel so one batch covers every chapter rather than one session
each. Until then an uploaded chapter reaches a translation through that
translation's own catch-up.

m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:12:34 +02:00
Kgothatso Ngako
6e97735e47 feat(projects): apply a signed project, and refuse the three ways it can lie
Phase 3 of docs/project-feature.md. Three arms in `ChatMessage.applyInnerEvent`,
which is where every nip30303 kind lands whether it arrived over the wire or was
signed locally -- `FrostSigningManager` reuses this dispatch rather than
repeating it. A project can now become a row; nothing proposes one yet.

The project writes a chat line naming the project and not its creator. On a
group-signed row `senderPublicKey` is the session's coordinator, so "X started
the project" would be the transcript guessing at something the event states
exactly. The memberships write no line, the way a version writes none beside its
artifact: they arrive as later items of the batch that founds the project, which
has already said so, and one added later is covered by the "The group signed
..." line `FrostSigningManager.announce` already writes.

`fromProjectEvent` refuses an event with no name, no creator or no owners. The
third refusal is the one worth having a test for and it is not about malformed
events at all: it is what `ContributorTag.ROLE_OWNER` missing from `KNOWN_ROLES`
looks like from here. The role parses as null, `owners()` comes back empty, the
signature still verifies and nothing is logged, so the refusal is the alarm.
`ProjectApplyJvmTest` reproduces it with a role deliberately not in the set.

A membership whose version or project this device does not hold is refused by
the foreign key, and the refusal is caught here rather than left to escape.
`applySignedEvent` swallows throwables because the signature is valid either
way, so an escaping one would report a session that succeeded as a session that
failed and tell the group to abandon a ceremony whose signature is good. No call
path wraps `applyInnerEvent` in a transaction -- checked, all three -- so
catching it does not poison one.

1381 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:12:30 +02:00
Kgothatso Ngako
7afda9cf80 feat(projects): three tables, schema 20 -> 21
Phase 2 of docs/project-feature.md. Still nothing that writes a row: this is the
shape, the keys and the queries each screen will want.

A project's contents are real foreign keys into this room's own rows, with
CASCADE, like every reference in this family. That is the one-room scope
decision made load-bearing rather than documented: a membership naming a version
this device does not hold fails at insert, loudly, instead of landing as a row
pointing at nothing that every later read has to be defensive about. Projects
that span rooms take those keys away again -- Phase 9, following
`parentChatRoomId`, which is deliberately not a foreign key because a dangling
value is the normal state there -- and that is a migration rather than something
to carry unused until then. `MantraProjectDaoJvmTest` asserts the refusal, which
is also what says Room has foreign keys switched on at all.

The owners are a comma-joined sorted string with an `ownerPublicKeySet()`
accessor beside it, which is what `DkgSession.participantPublicKeys` already
does with a set of keys, sort included and for the same reason. A fourth table
would buy querying by owner, which no screen does. A set stored as text is a
shape that rots, so the accessor is tested against spacing, empty entries and
the single-entry case rather than the happy path.

`fromProjectEvent` refuses an event with no name, no creator or no owners rather
than filling the gap from local knowledge -- which is exactly the failure the
tags exist to prevent. The empty-owner refusal does double duty: an owner list
that comes back empty is what `ROLE_OWNER` missing from `KNOWN_ROLES` looks like
from here, so the guard against a malformed event is also the alarm for that
bug.

No `toProjectEvent()`. Those eight rebuilds serve rooms whose work predates
`GroupSignedEvent` at v13; projects arrive at v21, so every project this app
will ever hold is on file as a signed event. Adding three more would be adding
to a mechanism with a removal checklist already written for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:07:01 +02:00
Kgothatso Ngako
2d0a5ce50f feat(projects): three kinds, a creator tag, and the owner role the parser needs
Phase 1 of docs/project-feature.md. Nothing emits these yet -- no screen, no
repository, no apply arm -- so this is three kinds the app can build and parse
and nothing else.

The kinds are 30330-30332, not the next free number in the nip30303 band. That
band is not merely full: 30310, 30311 and 30312 are double-booked with the
ceremony family, survivable only because none of the three nip30303 kinds has a
producer. 30317-30319 is the ceremony family's growth room. So projects start
after the highest kind in use.

A project names people two ways because they are two shapes. One creator, so
`CreatorTag` -- a single-value tag like every other field here, and
multi-character on purpose: NIP-01 indexes single-letter names, so a `p` tag is
an address, and a broadcast project should carry its creator as data rather than
as a recipient. Several owners, so `ContributorTag` with `ROLE_OWNER`, which is
what that tag is for.

`ROLE_OWNER` goes into `KNOWN_ROLES` in this same commit, which is the whole
reason that line is first. `ContributorTag.parse` drops a role it does not know
-- rightly, since a stale relay hint would otherwise render as a role -- and the
consequence for an unregistered one is that the tag parses, the role comes back
null, the string stays inside the signed event id, the event still verifies, and
the project lands owned by nobody. Nothing is logged. `ProjectEventTest` has the
three-line test that would say so.

Two other things that fail quietly, both in `ProjectEvent.build`.
`addUniqueValueIfNew` rather than `addUnique`, because `addUnique` keeps one tag
per *name* and three owners would come out as one. And sorted, because the id is
a hash over the tags as emitted and a multi-select hands its selection over in no
particular order -- the same reason `DkgSession.participantPublicKeys` sorts.

A membership is its own event rather than a line in a list the project carries.
The only list-shaped kinds in this family are the three contributor lists, which
are precisely the ones nothing applies; and one quorum per decision is the grain
this app signs at, which a replaceable list would lose. `membershipsOf` mirrors
`ArtifactVersionEvent.initialVersionOf`: built from the project *after* the
session has authored it, since the id it carries is a hash over the group's key
at the room's path and no screen can know it beforehand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 19:00:51 +02:00
Kgothatso Ngako
de4f15794a docs: a phased plan for projects, and the four keys it has to re-answer
The platform's server schema has had `project` and its two join models since
before this app existed. Bringing them here is not a port: the server answers
"whose project is this" out of a table of accounts, and this app is a group with
no accounts, no server and no authority but a quorum of its own members.

So every foreign key on `project` is re-answered. The creator becomes a `creator`
tag and the owners a set of `p` tags with an owner role -- written into the
template before it is proposed, so they are inside the id the quorum signs rather
than one device's note about who inserted a row. The owner of record is still
structurally the room, and `chatRoomId` carries that; the tags are what let a
project be the group's work and somebody else's property. Campaign is out of
scope entirely.

Two things the plan is mostly about, because neither is recoverable from the
code. The nip30303 kind band is not merely full -- 30310, 30311 and 30312 are
double-booked with the ceremony family, survivable only because none of the three
nip30303 kinds has a producer -- so projects take 30330-30332 and the collision
gets written down. And `ContributorTag.parse` drops a role that is not in
`KNOWN_ROLES`: the tag parses, the role comes back null, the string stays inside
the signed event id, the event still verifies, and the project lands with no
owners. Nothing is logged. `ROLE_OWNER` reaching that set is Phase 1's first
line, and three tests point at it.

Ten phases, 0-7 to build and 8-9 deferred. The one decision to make before Phase
1 is the scope fork: a project's contents are same-room, with real foreign keys,
because the FK is what makes a bad proposal fail loudly. Cross-room is Phase 9
and follows `parentChatRoomId`'s precedent -- a plain column, never a foreign
key, because a dangling value is the normal state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:55:23 +02:00
Kgothatso Ngako
83bd91f46c fix(chapters): number a chapter past the ones already proposed
Phase 3 of docs/chapter-uploads.md, and the second fix to something wrong
today.

A chapter's index is signed into the event and cannot be recounted on
arrival: a device applying one cannot recount from a version other members
may have added to in a different order. So the number has to be right when
the chapter is proposed, and `getChaptersForArtifactVersion(...).size` is not
it -- a proposed chapter is not a row until a quorum signs it, so two
proposals a second apart read the same table and are signed at the same
index. At one chapter at a time that is a race nobody hits. An upload of
twelve files hits it eleven times out of eleven.

The information was already published. A session's items carry the unsigned
events it is signing, and an unsigned event's fields are settled at proposal
time -- which is the whole reason FrostSigningRepository.unsignedEvents
exists rather than only signedEvents. So ChapterIndexing.nextIndex reads the
table and the room's proposals, and returns one past the highest rather than
a count: a count is wrong the moment the sequence has a gap, and a chapter
that failed to reach a quorum leaves one.

Three things that are easy to get wrong and are tested. Every session counts
whatever stage it is at, because filtering would trade a harmless gap for a
collision with a stalled session that still completes. Items are filtered by
kind, because a chunk carries an index too -- its position in the chapter --
and reading those as chapter indices puts the second chapter of a
two-paragraph first chapter at 2. And proposals are scoped by the
artifactVersionId tag, because the row query is scoped by version while
observeSessionsForChatRoom returns the whole room.

This fixes one device, not the group: two members proposing at the same
moment still collide, and no amount of local reading will change that. That
is tolerable where this was not -- an index orders reading, two chapters at
index 4 sort arbitrarily between themselves, and nothing else breaks.

951 tests, no failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:46:09 +02:00
Kgothatso Ngako
dbc7940618 feat(frost): a signing proposal has a size, and the form says what it is
Phase 2 of docs/chapter-uploads.md, and a fix to something wrong today.

MAX_BATCH_SIZE bounds how many events a batch holds. Nothing bounded how
large they are, and those are not the same limit: sixty-four dialects are a
few kilobytes and sixty-four chunks of a chapter are eighty. The proposal is
the largest message in this protocol by an order of magnitude -- a signer's
nonce payload is k*133 bytes, their partial k*65, the proposal k whole events
-- so a legal batch could exceed what a group event carries, and when it did
the failure was silent: the proposal went out and the session stalled with
nothing to blame.

MAX_PROPOSAL_BYTES sits beside MAX_BATCH_SIZE and is enforced the same way:
when proposing, against the real encoding, and independently in
acceptProposal, before parsing. That second check is the one that matters,
for the reason the count check already gives -- the proposal is where a
remote party decides how much work everybody else does.

The chapter form needed the same limit early enough to be useful, which the
exact answer cannot be: it needs the chapter built and the chunks cut, one
line from opening a session. So ChapterProposal estimates it. Measured over
chapters from 1 KiB to 24 KiB and 1 to 63 chunks, the per-event cost of an
encoded proposal runs 357-388 bytes; the estimate pays 448, because
over-estimating costs an author a shorter chapter and under-estimating costs
the group a stalled session. A test asserts it never falls under the real
encoding and stays within a tenth of it.

The plan asked for a flat MAX_ORIGINAL_TEXT_BYTES and the measurement says
that is wrong by half: a 63-chunk chapter fits about 20 KiB of markdown and a
10-chunk chapter about 30, because the envelopes are what varies. The chunk
count is known as the text is typed, so the limit is too.

While the screen was open, its last two hardcoded English literals moved to
the catalogue, and the too-long message compares like with like -- what
proposing would send against what a group message carries -- rather than a
UTF-16 character count against a byte budget, which is what the first draft
of it did.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:41:20 +02:00
Kgothatso Ngako
eb191d945a feat(chapters): a chapter can be loaded from a file
Phase 1 of docs/chapter-uploads.md. The picker and the decode, with one file
going into the add-chapter form that already works -- so the platform seam is
exercised on its own, before anything knows about several chapters at once.

Three actuals rather than FileKit. This is the app's only dialog, the calls
are a dozen lines each, and the project already keeps four platform seams of
this shape; the library's Compose-version coupling buys nothing here, and the
ios target cannot be compiled on a linux host either way.

ChapterText.decode is the part that matters. The web app does
`file.data.toString()`, where a .docx becomes a chapter of zip-header garbage
the uploader can go back and delete; here the next thing that happens to the
text is that a quorum signs it. So: strict UTF-8, because a lenient decode
hides U+FFFD inside something permanent; a NUL sniff before decoding, because
mostly-ASCII binary decodes cleanly; the byte-order mark stripped and CRLF
normalised, because both change the bytes without changing a word and the
same prose from two machines has to give the same chunk ids.

The pickers stop one byte past the cap rather than at it, so an over-size
file reads as over-size instead of as a chapter with its ending missing.
Android registers both open contracts so asking for one file does not offer a
picker that returns six. The dialog title is a parameter read from the
catalogue, since stringResource is composable and a FileDialog is not.

ChapterNaming came out of the two answers the web app gives on its two routes
-- the filename on the upload, the first chunk on the paste form -- both of
which are right some of the time. The filename minus extension and ordinal
prefix, falling through to a heading and then the first line, because `01.md`
is not a chapter title and `03 - The Wreck.md` is.

FilePicker.ios.kt is unverified: the ios targets are declared only on macos,
so it is not compiled here. Written to the shape of the eight .ios.kt actuals
beside it and says so in its KDoc. A stub returning no files would read as a
working picker that a reader's chapters vanish into.

22 new tests, all pure; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:29:20 +02:00
Kgothatso Ngako
6ab4f0e557 docs: a phased plan for chapter uploads, group-signed
Mirrors mantra.press's multi-file chapter upload under this app's rule that
the group, not the uploader, authors the work.

The web app's shape -- N files in, N chapters out, one transaction, one
redirect -- is the one shape this app cannot have. A signing session is one
quorum, all-or-nothing, 64 items and about 64 KiB on the wire, so twelve
chapters in one batch does not fit, should not fit, and would ask every
member to read twelve chapters in a single approval gate. One upload is N
sessions, one per chapter, and that cost belongs in front of whoever is
picking files.

Reading both trees turned up four things the single-chapter path gets away
with at N = 1 and an upload does not: the index is read from rows and no
proposed chapter is a row yet, so twelve proposals all sign the same index;
nothing checks the proposal's size, which puts a silent ceiling near 28 KiB
of markdown; there is no file picker in the app at all; and translation
scaffolding opens a session per chapter where one batch would do.

The splitter is measured rather than assumed. Run side by side, markdown-it's
block tokens and this app's blank-line rule disagree on ten cases in eleven,
and on the text as well as the boundaries -- markdown-it strips the block
marker, so `# Chapter one` is `"Chapter one"`. Phases 7 and 8 adopt it, with
three deliberate departures (no chunk for code blocks, one chunk per table,
none for a link reference definition) and a producer, which the app has never
had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:29:04 +02:00
Kgothatso Ngako
7a28b227d9 docs: record what the adoption plan built, and the eight places it chose differently
The note shipped as research and is now five commits of code, so it says so, in the
shape docs/subgroups.md uses: the phases kept as written because they are the
reasoning, and a table of where the build disagreed with them.

Eight entries, and two are worth more than the rest.

**A group can sign, truthfully by all six of `certifies`' questions, that it is its own
parent** -- author, subject and named parent one room, the id deriving from the key, the
signature verifying. The note never considered it. The self-parent guard began inside
the offer branch, where it never ran, because the child branch catches such a
certificate first and reads it as an ordinary child. A test caught it, and the gap
predates this work: `subgroupsOf` would list the group as its own subgroup today.

**The certificate's roster.** The note was careful that a founding certificate's name
and `p` tags are the founding roster, frozen, and said nothing about adoption -- where
there is no founding. The adopting group's admins are being asked about a group that
exists, so what they can weigh is who runs it now, and the current roster is the honest
value rather than a compromise. Still frozen the moment it is signed, still never read
as live.

The rest are smaller and all one shape, which is a note counting a thing and missing
its thread: dropping `ChatRoom.parentChatRoomId` also drops the parameter that
travelled through four files to reach its only writer; `stateFrom` returning "a state
and a lineage" wanted a named type, and `LocalGroupKeyState` is a Room read model that
is also a plain data class, so the pure verifier builds one with no database near it.

One thing the note asked for is **not** done and cannot be: the key-state summary saying
which parents are being *dropped*. A summary is pure and a disavowal is a difference
against the row this device holds, so it belongs to the screen.

The rough-size table becomes estimated against actual. It guessed about two dozen files
and it was 39, with the UI three times its estimate -- a picker needs a route, a screen,
a view model and a state, which the note had as one line -- and the repositories half of
it, because `DkgRepository`'s changes were return types it never counted. Tests: 7 files,
53 new cases.

`docs/README.md` moves the note from research to a phased plan that has been built, and
its one-line summary now ends on the self-certificate rather than on the consent gap,
which is closed.

Docs only -- no code changed, so there are no test counts of this commit's own. The
five it describes: 454 common tests, 962 jvm tests, `m3Audit` meeting every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:18:17 +02:00
Kgothatso Ngako
e15d31b832 feat(subgroups): ask a group to adopt this one, and accept the offer when it comes
Phase 5 of docs/subgroups-multiple-parents.md, the UI. Phases 1-4 were dark; this is
the flag, and it is the button. A group can now be given a second parent from the app.

**Two affordances, on the child's own detail screen, under the parent cards.** Both
belong there and not on the adopting group's screen: the child is what acquires the
relationship, its quorum is what decides, and it mirrors the parent screen's existing
"add subgroup".

`SelectAdoptingGroupScreen` is the only new screen, because the acceptance needs none
-- by then there is something to accept and a card does it. Its list is the **profile's
own rooms** and cannot be anything else: a certificate is proposed inside the adopting
group, so a candidate has to be a room the key in the chair can act in.
`adoptingCandidatesFor` filters through `refuseAdoption` itself rather than through a
copy of its rules, so screen and manager cannot drift. An empty list is a fact about
this member rather than a failure, and the empty state says the reason outright -- a
group cannot be offered as a parent by somebody who is not in it, so the remedy is a
person rather than a button.

**Tapping a row asks; it does not adopt, and the words have to carry that.** The
snackbar says "asked that group to adopt this one", and the screen hands straight back
to the room rather than going anywhere new. The offer card then says a group is a
subgroup of another *only once its own quorum says so*, because rendered as a fact
about the lineage it would be the consent gap reopened from the other end -- the one
Phase 3 closed in the transcript.

**The accept button is offered to whoever opens the screen and can act**, not only to
the member who asked, for the reason docs/subgroups.md gives for the ritual screen's
rung: every step after a ceremony is open to any admin holding a share. After it, the
screen reloads rather than crossing the offer off -- the offer leaves the list when the
state is signed and filed, which is a quorum away, so the card stays until the group has
actually agreed.

`canAddParent` wants a key state as well as a share, which `canAddSubgroup` does not:
the certificate carries this group's threshold key and the acceptance re-announces that
very state, so a room that has agreed nothing has neither half to work with. Hidden
rather than disabled, the way the subgroup entry is.

The picker is a pushed screen, so it takes an `onNavigateBack` and puts
`NavigateBackButton` in its app bar's leading slot -- the rule `CLAUDE.md` gained while
this was still a note, and the desktop target has neither a system back nor an edge
swipe to fall back on.

`offersTo` reads every certificate this device holds in **any** room rather than asking
this one, because a certificate about this group is signed in the other group's room and
filed against it -- asking this room would never find it. Each is checked by `classify`,
and parents already in the lineage drop out, since an offer that has been accepted is
not an offer.

Ten strings in the catalogue in sentence case, spacing from `MaterialTheme.spacing`,
colour from roles, `Decorative` on every icon beside a label, and the notifier read
outside the handler with the caller's scope -- an acceptance is shown while the screen
reloads around it.

`inComparableGroups` stays private to `ChatRoomDetailScreen`: the upstream commit that
moved it to the key extensions is in the fork's tree and not this one, and a second copy
to add three spaces to an id is not worth having.

**What is reachable and what is not.** Adding a parent and accepting one are both
wired. `SubgroupManager.proposeDisavowal` is built, tested and has no button -- dropping
a parent is a destructive-looking act that wants a confirmation step, and it belongs in
its own change rather than bolted onto this one.

454 common tests, 962 jvm tests, `m3Audit` meets every budget with 0 title-case strings
and 0 dp literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:16:58 +02:00
Kgothatso Ngako
b54ff61eb6 feat(subgroups): adopt an existing group, accept a parent, and stop claiming one
Phase 4 of docs/subgroups-multiple-parents.md, the operations. Two signings and no
ceremony, which is the whole of why this is cheap: the key exists, so there is no
ChillDKG, and the room exists, so there is nothing to create. Still unreachable -- no
screen offers any of it yet.

```
 1. birth certificate, in the adopting group -> its quorum signs the child's id
 2. key state, in the child itself           -> the child's quorum re-announces,
                                                carrying every certificate it accepts
```

**The order is forced and it decides who is deciding.** The key state carries the
certificate, so the certificate has to exist first -- which means the parent commits
first and the child answers second. Step 1 is an offer; step 2 is the acceptance, and
it is the only half that settles anything. Picking a parent is not choosing on the
child's behalf, and the wording of every message here says so.

**`proposeAdoption` is the founding certificate with the child already there.** Same
kind, same six checks on the other side, same room it has always been proposed in. What
differs is that the child's id and threshold key are read off its key state rather than
derived from a ceremony this device may not hold. The roster and the name are the
child's *current* ones -- on a founding certificate they are the founding roster
because there is nothing else they could be, and here the adopting group's admins are
being asked about a group that exists, so what they can weigh is who runs it now. Still
frozen the moment they are signed, and still never read as a live roster.

**`proposeAcceptance` carries the resulting set, not the addition.** The footgun is
real: a key state is replaceable and the newest wins *wholesale*, so a state naming
only the new parent would silently drop every existing one. The current lineage is read
off the row here rather than taken from the caller, and `SubgroupLineage.plus` is what
makes an addition an addition. It signs in the child's **own** room, which it can
because the room exists -- `propose`'s first branch holds and `signingPath` resolves off
the child's own key state, neither of which founding can do.

**`proposeDisavowal` is the same act with the set the other way**, and it revokes
nothing. A certificate is a signature and exists forever; what this removes is the
child's half of the link, which is the half that decides, since a lineage is what the
child's quorum most recently declared. The parent's own list does not follow --
`subgroupsOf` reads that group's certificates -- and the comment says so where somebody
will wonder.

**`refuseAdoption` is seven refusals and the picker is the same function.**
`adoptingCandidatesFor` filters the active profile's rooms through the guard itself
rather than through a copy of its rules, so screen and manager cannot drift. The list is
the profile's own rooms and cannot be anything else: a certificate is proposed inside
the adopting group, so a candidate has to be a room the key in the chair can act in, and
`getChatRoomListByUserPublicKey` is keyed by identity. An empty answer is a fact about
the member rather than a failure -- the remedy is a person, not a button.

Two refusals are new in kind. "This group has not agreed what it signs with" is the
overlap requirement surfacing: the certificate carries the child's threshold key, which
only the child's members hold, so a would-be parent with no sight of it cannot certify
anything. And a cycle check, because a lineage is a DAG now rather than a tree -- with
one parent nothing was ever tempted to recurse. `isAncestorOf` is depth-bounded and
visited-set guarded; it is advisory, since a device sees only the lineages it holds
states for, and the real protection is that an adoption needs the child's own quorum.

Fifteen tests. Every refusal once, including the cycle and the picker's filter, and then
the three operations by reading the proposed event back out of the signing session --
which is what catches the wholesale-replacement footgun: accepting a second parent has
to carry the first, and disavowing the only one has to leave a state that claims none
rather than one that claims nothing readable.

`parentWith` is now one case of a `marmotRoom` helper taking the id and the key, since
adoption needs two real group rooms at once rather than one; it keeps its own doc
comment and the warning it carries about `nostrGroupId` lengths, which is still the trap
it was. `certificate` gains a parent id so a sibling group can sign one.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 18:05:15 +02:00
Kgothatso Ngako
e81659bf3b feat(subgroups): say what a key state proposal is asking, and tell a child it has been offered a parent
Phase 3 of docs/subgroups-multiple-parents.md, the two consent surfaces. The note
calls the first of these the one change in the design that is not plumbing, and it is
the only one that would have shipped a member a question they could not read.

**`ProposedEvent`'s 30326 arm showed a derivation path and a ceremony id.** That was
right while a key state was proposed once, at founding, by the coordinator who chose
its lineage: the only question was which key the room signs with. A key state can be
re-announced now, and that is exactly how a group is adopted -- its own quorum accepts
a parent by signing a state that names one. A member approving that is deciding whether
their group should answer to another, and `m/9420/0/0 · a1b2c3d4e5f6` answers a
question nobody asked. The proposal is their only chance to check, since after it a
quorum has spoken.

So the parents lead when there are any, counted when there is more than one, in the
lineage's canonical order so two devices describe one proposal the same way. The
certificates are already in the tags the signature covers, so this costs a reader
nothing it does not hold. What the summary cannot say is which parents are being
*dropped*: that is a difference against the row this device holds and a summary is
pure, so it belongs to the screen, and there is a comment where somebody will look for
it.

**And the child's room now hears the offer.** `ChatMessage`'s 30329 arm assumed a
certificate in a room was that room's own act, because at founding it always is. An
adoption signs the certificate first, in the adopting group, and the child's admins had
no notice of it at all -- the first thing they would see is a proposal wanting their
signature. Founding at least shows them the intent, since `DkgRitualEvents` puts
`parent_group` on the ceremony proposal and the ritual screen names the group; adoption
has no ceremony and so no hint.

`SubgroupManager.classify` is the decision, pure and needing no database -- `certifies`
checks a signature against a room id, and a room id is a public key, so an offer can be
believed by a device that has never held the offering group's room. Two true things a
certificate can be, and the room it arrived in is the whole of the difference: this room
signed it about another, or another signed it about this one. A third room's business is
nothing here whoever signed it.

`TYPE_SUBGROUP_ADOPTION_OFFERED` writes a line and files nothing. It cannot file
anything -- `GroupSignedEvent` declares a foreign key onto `ChatRoom`, so a child device
with no row for the parent could not store the event -- and it does not need to, because
the certificate reaches these devices for real inside the key state that accepts it. The
wording carries that: a subgroup of theirs *only once this group agrees what it signs
with again, naming them*. An offer rendered as a fact about the lineage would be the
whole gap reopened from the other end.

**A test found a real one while writing this.** A group can sign, truthfully by every
check `certifies` makes, that it is its own parent: author, subject and named parent one
room, the id deriving from the key, the signature verifying. The self-parent guard
started out inside the offer branch, where it never ran -- the child branch catches such
a certificate first and reads it as an ordinary child, which would put a group in its
own list of subgroups. It is refused ahead of both branches now, and the test is named
for what it pins rather than for the branch it was written against. That case predates
this commit: `subgroupsOf` would have listed it today.

Eleven tests. Six for `classify` in `SubgroupBirthCertificateEventTest`, which is where
the three real quorums already are and where every case it decides is a question about a
certificate; four for the summary in `ProposedEventTest`, including a state claiming a
lineage it cannot state, which must read as the key alone rather than guess. One icon arm
in `ChatTranscript`, because a subgroup type missing from that mapping renders as a chat
bubble and looks exactly like a member having said it.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:57:49 +02:00
Kgothatso Ngako
eba973331b feat(subgroups): schema 21 -- a parentage is a row, so a room can have two
Phase 2 of docs/subgroups-multiple-parents.md, the storage. Still dark: a lineage of
two now verifies and is filed, and nothing in the app creates one until the adoption
flow lands.

`GroupKeyState` carried `parentChatRoomId` and `birthCertificateJson` because a group
could only be founded with a parent, never adopted by a second. Those are
`GroupKeyStateParent` now, one row per parent, and nothing about what they mean
changed in the move -- including that the parent side is never a foreign key onto
`ChatRoom`, for the three reasons that entity's comment now carries.

**The primary key is the rule.** `(chatRoomId, parentChatRoomId)` is "one certificate
per child per parent", which `SubgroupLineage.of` already refuses to break; the key is
what makes it a property of the store rather than of a reader remembering. The cascade
points at `GroupKeyState`, which is the one cascade here that is wanted: a lineage must
not outlive the state that claimed it, or a room recreated at the same derived id would
inherit a parentage nobody signed for it.

**`replace` rewrites the lineage rather than merging it, and that is why it is a
transaction.** A key state is replaceable and the newest wins *wholesale*, so the set
of parents it names is the set. That is what makes a disavowal expressible at all -- a
room drops a parent by re-announcing without it -- and merged rows would leave the
dropped parent surviving the statement that dropped it. Deleting before inserting
inside one transaction also stops a reader seeing a state beside somebody else's
parents: were the writes separate, a concurrent read could land between them and get a
state with the old lineage, which looks exactly like a true answer. An older state
still changes nothing, now including its parents.

**`stateFrom` returns `LocalGroupKeyState`, because a state and its lineage are one
signed statement.** `lineageOf` checks every parent and drops the whole state if any
one certificate fails -- the all-or-nothing rule generalised, for the reason it was
written: keeping the provable half would file a group as having one parent when its own
members see two. Each link is checked on its own, which is what lets a lineage be
assembled a parent at a time, and there is a comment where somebody will look for the
check that is deliberately absent: nothing requires the certificates to agree on the
set. An earlier certificate cannot name a later co-parent, so requiring agreement would
make every adoption cost a fresh quorum in every group that had already certified the
room. The child's key state is the sole authority.

**`ChatRoom.parentChatRoomId` is dropped, unread.** It was a cache of the verified
value, written by `MarmotGroupCreation` and read in production by nothing at all -- the
live answer went through `GroupKeyState`. Keeping it would mean a column holding *one*
of several parents, which is worse than unused: a cache that is wrong. Its one query,
`ChatRoomDao.observeByParentChatRoomId`, had only tests for callers and goes with it;
the comment left in its place says where the two live answers are. `create`'s
`parentChatRoomId` parameter threaded through four files to reach the write that is now
gone, so it goes too rather than sitting dead.

`MIGRATION_20_21` creates, copies, then drops -- the shape MIGRATION_9_10 established,
and for the same reason it gives: `DROP TABLE` fires cascades and would delete the rows
the migration had just written, so `ALTER TABLE ... DROP COLUMN` is used instead.
`BundledSQLiteDriver` is pinned on every platform, so 3.35 is ours rather than the
host's. The `WHERE` takes both halves of a parentage: a half row should not exist, since
`stateFrom` has always dropped such a state rather than filing it, but this is the one
moment the data is reshaped rather than re-derived and a half row copied forward would
become a parent whose certificate is the string "null". Better to leave it behind -- the
state keeps saying what the room signs with, and loses a lineage it could never prove.

`parentOf` is `parentsOf` and returns a list, through the repository to
`ChatRoomDetailUIState`; the parent card becomes one card per parent, keyed, because
they are a set rather than a path -- neither is "the" parent and the order says nothing
about which came first.

Thirteen tests in `SubgroupDaoJvmTest`, rewritten for the table: the two cascades in
both directions, a child with two parents appearing in both their lists, a
re-announcement that drops a parent and one that clears the lineage entirely, an older
state changing neither the state nor its parents, and the primary key collapsing a
repeat to one row. In `GroupKeyStateTest`, Phase 1's "two parents are dropped" becomes
"two parents are kept, each with its own certificate" -- it needed a third real group,
since the earlier draft's second parent was the child certifying itself -- beside a new
case where one bad certificate of two drops the whole state.

444 common tests, 937 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:50:57 +02:00
Kgothatso Ngako
0690932252 feat(subgroups): a lineage is a set, and the tags that carry one in pairs
Phase 1 of docs/subgroups-multiple-parents.md, the wire. It ships dark: the event
can now state a lineage of any size and nothing in this build produces one of more
than one, because the row that would hold it does not exist until the next phase.

A room's lineage was never really a field. `GroupKeyState` carried one parent and one
certificate because a group was founded with a parent and never gained another; the
moment it can be adopted, "the parent" is a set that had only ever held one thing.

**`SubgroupLineage` is that set, and it holds the two invariants a caller cannot be
trusted to remember.** No parent twice -- two entries naming one group is one parent
asked twice, and `of` refuses rather than folding them, because each carries its own
certificate and picking one would be this device deciding which of the parent's
statements counts. And one canonical order, sorted by parent id, so two devices
assembling the same lineage from the same certificates emit the same bytes. `plus` and
`minus` are the two edits an adoption and a disavowal make; `minus` returning null is
"nobody's child" rather than a failure, and revokes nothing -- a certificate is a
signature and exists forever, and what it removes is the child's half of the link,
which is the half that decides.

**The pairs are adjacent, and that is for other people's builds.** A reader that
predates this takes the first `parent_group` and the first `birth_certificate` off the
tags and checks them against each other. Emitted as `id, certificate, id, certificate`
it gets one real parent and that parent's own certificate, verifies them, and files a
true but partial lineage -- one parent, correctly. Emitted as all the ids and then all
the certificates it would pair the first id with a certificate that may belong to
another parent, fail `certifies`, and drop the **whole state** -- losing the row that
says which ceremony's share signs for that room, which is a worse outcome than not
knowing about the second parent. The sort is what makes the pairing deterministic
rather than incidental.

**`parseLineage` counts each half twice, by tag name and by successful parse.** A half
that is present and broken reads as absent through a parser, and only the broken kind
would slide the pairing along by one and pair an id with somebody else's certificate.
So an uneven number of halves, a half that does not parse, and a repeated parent are
all refused, and `claimsParentage` -- unchanged, reading tag names alone -- is still
what tells "claims nothing" from "claims something unreadable".

**Two places refuse a second parent for now, and say so rather than truncating.**
`propose` checks the lineage is one before it builds a row that can only hold one, and
`parentageOf` drops a state claiming more. Taking the first of two would file a lineage
the group did not sign and leave the device believing a group has one parent while its
own members see two -- the same reasoning the existing all-or-nothing rule is built on.
Nothing emits a second yet, so neither fires except on a state from a newer build,
which is exactly the case that wants refusing rather than guessing.

`SubgroupParentage` is untouched: still one pair, still unchecked, still the thing a
lineage is made of.

Nineteen tests. `SubgroupLineageTest` is new and pure, with no crypto at all -- the
certificates in it are deliberately nonsense, because whether one is good is
`certifies`' question and is asked on every read rather than at the door; it pins the
two invariants, case-insensitivity on both, and that a certificate rides along with its
own parent through the sort. Five go to `GroupKeyStateTest`, and the one worth keeping
if any of this is rewritten asserts the *tag order* and then that the first id and the
first certificate an older reader would take are a matched pair.

443 common tests, 933 jvm tests, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:31:24 +02:00
Kgothatso Ngako
d97ba7228b docs: reconcile the adoption note with several profiles and a broadcast button
The seventy-one commits left the subgroup code byte-identical, so the first pass over
this note changed only a schema number. That was too quick a reading. None of what
arrived is *about* subgroups and four pieces of it bear on this design anyway.

**A built, unreachable path would put a birth certificate on public relays.**
`BroadcastGroupSignedEventViewModel` sends one group-signed event to relays a member
picks, and its own comment names the property that makes it dangerous here: the
signature is the group's, so any member holding the event can send it and no share of
the key is needed. It is not restricted by kind -- "the seed is the same for every
event of every kind" -- and nothing opens it today only because the rows that carried
the button went with the curated lists. The route is still registered and
`BroadcastButton` still built.

A certificate is the worst event in the app to send: the parent-child lineage, the
child's threshold key, and the founding admin roster in its `p` tags, which is a
membership list. `docs/subgroups.md` puts kind 30329 in the private 303xx range with
everything that never leaves an encryption, and
`SubgroupBirthCertificateEvent`'s own comment leans on that to say the addressable
semantics of the 3xxxx range never fire -- published they do, since it is `d`-tagged on
the child's room id, so relays would start treating certificates as replacements of
one another. So the note now carries a negative rule: the subgroups list and the parent
cards must never grow a broadcast button. It is also why relays are not the answer to
the overlap requirement, which they otherwise look like -- the price is publishing the
lineage and the roster to anyone, to save one person being in two groups.

**"One person in both groups" has a second reading now, and the mechanism wants
care.** The same human can be a P2 admin under one profile and hold C's share under
another, which satisfies both steps with no single key in both groups. `ChatRoom` is
keyed by the group id alone -- 679d5249 says so, and the MLS state, the ceremonies and
the shares all hang off the room -- so the two rooms are two rows with different
holders in one database, and `getById` on the certificate is not scoped by identity.
It works by reading across a boundary the app has already had to defend once: that
commit exists because the Welcome branch could not tell a redelivered Welcome from one
for a room another profile holds, and told nobody.

**And the check this design leans on does not draw that boundary either.**
`DkgSession` carries a `userPublicKey`, but `getKeyHoldingSessions()` has no identity
predicate and `getLatestOwnSessionForChatRoom` is scoped only by room and
`parentChatRoomId IS NULL`. So `canSign` answers a device-wide question where every
sentence in this note saying "this device holds no share" means the profile in the
chair, and both guard tables now say so. Device-local and pre-existing -- both
profiles' secrets are on one disk, owned by one person, so nothing crosses a user
boundary -- and a profile-isolation gap rather than an escalation. Flagged for its own
look rather than fixed here, since it is load-bearing for every "can this act" check
in the app and not only for these.

**The parent picker is a pushed screen, and pushed screens gained a rule.** `CLAUDE.md`
now requires `NavigateBackButton` in the leading slot and an `onNavigateBack` on
anything reached by `navigate(...)`, because the desktop target has neither a system
back nor an edge swipe. Twenty-two screens were dead ends; this one must not be the
twenty-third.

Checked and needing nothing: the subgroups section and the parent card are still inline
on `ChatRoomDetailScreen`. `c5c89c8d` turned five sections of cards into rows that open
their own pushed screens, and it is worth knowing that pattern exists, but it took the
group's profile, relays, posts, schemas and entries -- it left the members and the
subgroups where they were, which is where this note puts its two rungs.

Docs only -- no code changed, so there are no test counts to report.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:16:13 +02:00
Kgothatso Ngako
acbf089378 docs: stop the adoption note naming a schema version, since two landed under it
Seventy-one commits of `mantra` merged in -- npub and nsec sign-in, several profiles
on one device, three pulls from the Curated fork, a profile preview and a FAB sweep --
and none of it touches subgroups. `docs/subgroups.md`, `SubgroupManager`,
`GroupKeyStateManager`, `GroupKeyStateEvent`, `nostr/subgroup/`, `GroupKeyState` and
`ProposedEvent` are byte-identical, so the design stands as written. The only
`FrostSigningManager` change is a comment, recording that putting a signed event on
relays is now a member's separate act from `BroadcastGroupSignedEventScreen`.

**The note said schema 19 -> 20, and 20 was taken while it sat there.** 19 was Phase
10's, for `DkgSession.participantPublicKeys` and `subject`; 20 is the several-profiles
pull's, putting an owning identity on the queues. This is the second time the number
has moved under a note that is not being built yet -- it was 18 -> 19 when first
written three weeks ago -- which is twice too often for a figure nothing depends on.
So it now says to read the version off `MantraDatabase` at build time, and the test
line that named one does not.

Everything else re-checked against the merged tree and unchanged: the four
parent-indexed DAO reads are still test-only, `ChatRoom.parentChatRoomId` still has no
production reader, `describe(purpose, path)` still takes no parent, and
`GroupSignedEvent` still declares its foreign key onto `ChatRoom` -- the last of which
is load-bearing for this design, since it is what forbids a child device storing a
certificate signed by a group it is not in, and so forces the certificate to ride
inside the key state.

`docs/README.md` merged without a conflict: the row for this note sits where it was
put, under the subgroups row, and the seven rows the sign-in and pull notes added
append below it.

Merged through the app's host sync rather than `git merge`, because the merge brings in
a `.gitmodules` line -- `branch = master` on the lightning-kmp-app submodule -- which
the sandbox is not meant to write.

Docs only -- no code changed by this commit, so there are no test counts to report.
The merged changes have not been built here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 17:10:16 +02:00
Kgothatso Ngako
3372023829 Merge remote-tracking branch 'origin/mantra' into claude/subgroups-multiple-parents-053be4 2026-10-02 17:08:52 +02:00
Kgothatso Ngako
76055bc0a8 docs: record the third pull, the profile preview and the extended FAB
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Eleven commits reached the fork after the second pull had landed, and
this note is their inventory, verdicts and record: the profile-preview
line pulled whole, the extended-FAB sweep pulled with its seven removed
screens resolved as still deleted, and d2a4298d -- one commit half in the
curated-entries line Mantra took out and half in the broadcast line it
kept -- pulled in part, by a PARTIAL rule that applies the kept paths and
writes the rest into the note.

Three things differed from the pulls before it and are set out: a brand
token the normaliser could not see, NotYetOnCurare, fixed by a rule and a
second rewrite that left the earlier forty-nine commits hash for hash the
same; a merge whose join files carried only the dropped half, so that for
the first time a join was not taken from the merge; and two conflicts the
compiler found where git saw none, an import Mantra's projects section
still needs and a jvmTest fixture recreated reduced to the room so that
an upstream test lands byte for byte.

The record is two phases: jvmTest 871 -> 909 -> 914, testDebugUnitTest
420 -> 424, every audit budget met at both cuts, the schema unchanged at
version 20, and an exactness check whose every movement is named. The
README gains the row and a sentence; the second plan points forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:43:20 +02:00
Kgothatso Ngako
4899dcbca7 feat(ui): a member on the room's screen opens the profile preview
The member rows on the chat room detail were the last "Profile detail" stub
in the app: each pushed ImplementationPendingRoute. They now push
ProfilePreviewRoute by the member's public key, which the nav host already
serves, so a member arrives with the cached profile at once and the relays
refreshing it, and the bar's action opens the chat that exists with them or
starts one.

The preview rather than the profile detail, because the detail is keyed by a
kind 0's event id and a member whose profile has not arrived yet -- the
"LOADING..." row MemberProfileSync exists for -- has none. The public key is
the one thing every row has. A test pins both members opening the same
screen, the named one and the one still named by its key.

Replayed onto Mantra by docs/curated-to-mantra.md: ChatRoomDetailScreen.kt: the ImplementationPendingRoute import this commit removes is kept -- upstream's file had no other use for it once 808a3459 dropped the library, dialects and projects sections, and Mantra's projects section, which it kept, still routes "Add new project" through it. A semantic conflict rather than a textual one: the pick applied cleanly and the compiler found it; GroupSignedWorkFixtures.kt: the test this commit adds composes the screen against the fork's fixture of that name, which went with lines B, D and H -- it is recreated here reduced to the room alone, under the same names, so the test lands byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@272b631633
2026-09-13 16:39:18 +02:00
Kgothatso Ngako
e66c174b4f feat(ui): the preview's action is the app's extended FAB
curated gained ExtendedFab while the preview was being built: every screen's
bottom-bar action, with an enabled that borrows the disabled colours, marks the
node disabled and drops the press. The preview had chosen a Button for exactly
the reason that widget removes -- a FAB had no disabled state -- so after the
merge it was the one screen whose primary action was shaped somewhere else.
Now it is that FAB in a bottom bar, the indicator in its icon slot while the
key package is still owed, and the reason line stays in the body above it.
Hidden, bar and all, for a read-only identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@80735df86a
2026-09-13 16:35:32 +02:00
Kgothatso Ngako
883bf1c84c refactor(ui): one extended FAB for every screen's primary action, with a gap in it
Twenty screens end their bottom bar in an `ExtendedFloatingActionButton` --
twenty-one buttons, one screen having two -- and eighteen of those were built
from the content-lambda overload with an `Icon` directly followed by a `Text`.
That overload puts nothing between the two, so the icon touched the label on
every one of them: visible in any render, and unnoticed because every FAB in
the app looked the same. Thirteen of the eighteen also carried the same
twenty-line block for looking disabled, because M3 gives a FAB no `enabled`.

**`ExtendedFab`, once, in widgets/buttons.** A label, an icon slot, `enabled`
and `onClick`. It draws `space150` between icon and label, which is the 12dp
M3 names `ExtendedFabEndIconPadding`; it borrows the disabled colours every
other button in the app uses, marks the node disabled so a screen reader does
not announce a button it is happy to press, and drops the press -- so the
thirteen call sites lose their `if (!canX) return@…` guards along with the
block and the `buttonColors` each of them declared. An icon slot rather than
an image, because two screens put a progress indicator where the icon was
while the action they started is out, and they still do.

**Not M3's `text`/`icon` overload, which would have spaced them for free.**
Its source wraps the label in `clearAndSetSemantics {}`: the label leaves the
merged semantics tree, a screen reader is told only what the icon says, and a
test cannot find the button by what it says -- five screen tests do. The
three FABs that were on that overload move to the widget too, and two of them,
Home's "New chat" and "Start the key ceremony", had a decorative icon beside
the cleared label and so no accessible name at all until now.
`ReadOnlyEntrancesJvmTest` had gone to the unmerged tree to find "New chat"
for this reason; the habit is harmless and stays. Every icon's description is
as it was, since several tests reach their FAB by it.

`ExtendedFabJvmTest` pins the decision: found by its label in the merged tree,
the icon before the label with a gap between, and disabled both announced and
refusing the press. The two docs that recorded the old state -- the
conformance account's "left alone" and the npub plan's unmerged-tree note --
each say what superseded them.

Verified with :composeApp:m3Audit (all budgets met, 0 dp literals in spacing
positions), :composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest
(1088 tests, 3 new).

Replayed onto Mantra by docs/curated-to-mantra.md: AcceptCuratedSuggestionScreen.kt: deleted, as this commit deletes it upstream; AddGroupPostScreen.kt: deleted, as this commit deletes it upstream; EditGroupCuratedSchemaScreen.kt: deleted, as this commit deletes it upstream; EditGroupNostrProfileScreen.kt: deleted, as this commit deletes it upstream; EditGroupRelaysScreen.kt: deleted, as this commit deletes it upstream; GroupCuratedEntriesScreen.kt: deleted, as this commit deletes it upstream; ProposeGroupEventScreen.kt: deleted, as this commit deletes it upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@ca6c16beb0
2026-09-13 16:35:32 +02:00