Commit Graph

127 Commits

Author SHA1 Message Date
Kgothatso Ngako
a998cf20a9 docs: the plan for an ios build, phased against the route Curated walked
Curated reached a device in eight commits. Five defects stand between this
tree and the same place -- the native chain not configuring on a mac, Room
with no way to construct the database on native, a cancellation Swift cannot
see, two separate ios link failures, and CIO having no TLS on kotlin/native.
Each is diagnosed upstream, so the work is porting a known answer.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 23:37:27 +02:00
Kgothatso Ngako
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
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
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
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
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
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
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
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
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
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
Kgothatso Ngako
d7d6f5a922 chore(scripts): a partial pick, and the seven deletes the FAB sweep needs
Two rules for the third pull. ca6c16be moves twenty screens onto one
ExtendedFab; seven of them -- the curated-list and group-identity screens
-- were taken out with lines B, D and H, so each is a modify/delete
conflict that resolves as "still deleted", the DELETE rule it already had
for one test file, now for a set. d2a4298d is half in line D and half in
a line Mantra kept: its Broadcast-all button on the entries screen is
gone here, but the sendToRelays it lifted into the broadcast view model's
companion, the RelayOutcomeLabel widget and the screen reading it are the
broadcast line Mantra holds. PARTIAL applies the diff restricted to the
kept paths, commits it under the original message and trailer, and writes
what was left out into the note, so the four shared files stay identical
to the fork's and the FAB commit lands on them cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:35:31 +02:00
Kgothatso Ngako
a8ba589ecc docs: record what the profile preview plan built, and the places it chose differently
Two strings that already existed, a room rule that follows the old code's
intent rather than its branch, a retry that never takes an open chat back to
checking, the flag the ordering cases asked for, and the test harness the other
view models use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@b3bcc06047
2026-09-13 16:32:39 +02:00
Kgothatso Ngako
69aa43a108 docs: plan a profile preview before a chat, starting from what the button is allowed to do
The "Direct message via npub" option goes straight from a pasted string to an
MLS room. The plan puts a screen between them, keyed by public key because that
is all the dialog has, and keeps the room's creation where it is: four decisions
-- where the preview lives, what "found" means when a row can be a placeholder,
that the button hands over rather than creates, and that it waits for the key
package so the twenty-second dead end is answered before the press -- and four
commit-sized phases with the tests for each.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@db268a53d3
2026-09-13 16:32:39 +02:00
Kgothatso Ngako
0728f3f969 chore(scripts): teach the normaliser the brand inside a CamelCase identifier
The fork's profile-preview line names a Readiness state NotYetOnCurare,
and the normaliser's \bCurare\b rule cannot see a brand that has no word
boundary in front of it: the first rewrite of the eleven new commits left
seventeen occurrences of it in code, tests and the note. One rule, for a
brand preceded by a lower-case letter or digit and followed by an upper
case letter or the end of the word, maps it to NotYetOnMantra. Only the
Curare generation gets the rule: "Curated" is product vocabulary at the
tip (CuratedSchemaEvent) and a CamelCase-embedded Curated is therefore
not a brand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:29:23 +02:00
Kgothatso Ngako
213535278a docs: record what the profiles pull built, and the reverse direction measured
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
The plan's top now says it was built, the same day it was written: the
ten commits in four phases at the dry run's numbers exactly, Phase 6's
two native items done, and its other three -- pushing the removal, the
two reverse-pulls, the fork decision -- named as the user's. Phase 6
gains its own Built paragraph: what the refusal changed and did not
change in the DAO's welcome branch, the four re-pointed anchors, and a
measurement the plan had only asserted -- that a Mantra commit offers
itself to the fork cheaply. git format-patch on the refusal commit, four
inverse rules over the patch text, and git apply --check on a scratch
worktree of curated/curated at 29027f2b: it applies cleanly, five files.
That is the shape a normaliser running the other way would take, and it
is recorded so the fork can take the commit in seconds rather than by
hand. The README's reading-order sentence and the first plan's pointer
say "built" where they said "planned".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:09:22 +02:00
Kgothatso Ngako
2620e43d57 docs: re-point the profiles note's four line anchors at Mantra's tree
The note arrived with the pull and anchors four of its links to line
numbers -- three into MantraNavHost.kt and one into
DatabaseNostrRepository.kt -- that were the fork's. Mantra's nav host is
264 lines shorter than the fork's, having lost the group-identity block
in the removal, and its repository grew a constructor parameter in Phase
6 of the same plan, so all four pointed a few lines wide of what the
sentence around them describes: the key read off the current route (now
:404), the sign-in and create tails (:741 and :537), and the pump reads
that used to drain whatever was pending (:118). They describe the tree
before the line was built, which is what that section of the note is
about, and each still lands on the thing described; only the numbers
moved. Phase 6, item 2, of docs/curated-to-mantra-profiles.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:08:16 +02:00
Kgothatso Ngako
310d3bbbd3 docs: record Phase 5 of the profiles pull, which completes the ten
9bb34004, 88577bb5 and 29027f2b landed as 8edb52ff, 72b9d3d2 and
ba5ef723, all clean -- including the two picks into docs/README.md, where
the driver's UNION rule was armed and did not fire: git placed the fork's
row between the npub and jvm-target rows and its reading-order sentence
after the npub note's, beside the sentence this plan's own commit had
added there.

The ten are landed. Exactness at the tip is the dry run's: 135 residual
files between Mantra and the rewritten fork, CreateProfileViewModel.kt
the only file that left the residual since the base, and no other file's
residual moved. Both compilers clean, jvmTest 864 -> 868, testDebugUnitTest
420, every audit budget met -- the dry run's numbers exactly, as a
deterministic rewrite replayed onto an unchanged base should give.

One review point, read: MultipleProfilesRoundTripJvmTest reaches the
saved default through the app's getGlobalPrefs rather than building a
GlobalPrefs of its own, and says why in a comment, so the per-process
cache trap is written at the place a future test would copy from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:56:26 +02:00
Kgothatso Ngako
ba5ef72385 docs: record what the multiple-profiles plan built, and the places it chose differently
Phases 1–8 are implemented, in order, one commit each; Phase 9 is the
rollout and stays as written. The plan's phases are kept as the reasoning,
and the table at the top says where the build chose differently: the
repair reads before it writes and hands the listing its result; one
WalletAttached outcome with two ways in; the node stop and the relay scope
injected for their tests; the default save that cannot crash; a
ProfilesViewModel over flows; a NewProfileWriter and a route flag where the
plan expected the create screen's existing writer to serve; the colliding
id on the outcome rather than on the enum; the DAO's own requests left
unowned because they fetch public kinds; the inbox reopened by re-indexing
each wrap in its own transaction rather than by lifting the unseal branch
out; and the round trip's A made through the create view model with a
never-started PhoenixBusiness for the switch to stop.

Three things found on the way and in no phase are recorded beside the
table: the library's unsynchronised global-preferences cache, reached by
two threads at once for the first time, now behind JvmGlobalPrefs on the
jvm target; the same cache's consequence for tests that construct the
sovereign view model; and the gift wrap seal's link to its wrap, a foreign
key that existed and was never written until the inbox sweep asked the
question it answers.

The README's row and reading order say the note is built, and name
switchToIdentity where they named switchToWallet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@29027f2b92
2026-09-13 15:54:37 +02:00
Kgothatso Ngako
b92659fe92 docs: record Phase 4 of the profiles pull, the migration to version 20
759199f2 landed as 72afe8aa, a clean pick, and with it the database's
first migration since the fork: version 20, two nullable owner columns on
the fetch queues by AutoMigration, the export committed as
composeApp/schemas/press.mantra.compose.database.MantraDatabase/20.json.

The check this phase exists for passed: after a full build of both
targets, git status shows nothing under composeApp/schemas/, and the
committed 20.json is byte-identical to the rewritten fork's, identity
hash 4414c373. Room derived the same twenty tables from Mantra's entities
as from Curare's, which is the second decision's rule -- whichever tree
migrates first owns the number, the other pulls before it adds its own --
holding on its first use.

Exactness unchanged at 135 residual files with nothing moved. Both
compilers clean, jvmTest 858 -> 864, testDebugUnitTest 420, every audit
budget met. Read in review rather than assumed: the inbox sweep runs
inside the canSign branch before the broadcast pump and the live
subscriptions launch, returns zero without a query for a key pair that
holds no private key, re-indexes each wrap in a transaction of its own
and logs rather than throws when one cannot be opened; the DAO's own
placeholder and participant syncs carry no owner; the seal now links to
the wrap it came out of; and the migration's comment says why the
broadcast queue was given no owner column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:54:28 +02:00
Kgothatso Ngako
ae8cde889e docs: record Phase 3 of the profiles pull, and the one resolution
Four commits landed as 84ac84cc, 73a0e5b9, 5654e462 and ccb9942a. Three
were clean picks; the fourth is the conflict the dry run found and the
third decision explained -- CreateProfileViewModel.kt at 2f420230, where
Mantra's 39fb64b6 had moved one import and upstream's commit rewrites the
whole block and removes the derivation that import served. Taken theirs,
by the rule added to the driver, and the file is now identical on both
sides: the residual between the trees went from 136 files to 135, that
file being the one that left, and no other file's residual moved.

Both compilers clean. jvmTest 837 -> 858, testDebugUnitTest 413 -> 420
(StartupChoiceTest), every audit budget met, and the audit's count of
composable string literals -- reported without a budget -- fell from 40
to 30 as the startup screen's wallet-worded literals moved into the
catalogue; strings.xml 444 -> 455. The review points the phase named were
read rather than assumed: the relay observer is a single collectLatest
child with no per-pubkey map; the switch sets the desired id, sets
startWalletImmediately back to true, clears the identity and only then
stops a node, and only when the profile being left had one; the startup
precedence reads force, desired, "show me the list", the only one, the
default; CreateProfileRoute(withWallet = true) appears once, under
Landing; the jvm target reaches the global preferences through
JvmGlobalPrefs alone; the switcher's bar carries NavigateBackButton.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:51:38 +02:00
Kgothatso Ngako
d6d87ed208 docs: record Phase 0 and Phase 2 of the profiles pull
Phase 0 needed one thing this plan did not predict in detail: the
worktree's library checkout sat at 01489b8, an older master whose object
store did not even hold the pinned 84cc44c, while its nested chain was
already at the pinned four. One fetch from the main checkout's submodule
and a detached checkout made the tree clean. The rewrite was rebuilt from
the fork point and produced the same forty-nine rewritten commits as the
dry run, hash for hash: filter-branch with the normaliser is deterministic
for the same input, so the dry run's numbers are the run's numbers rather
than an estimate of them. Pushing the removal to origin/mantra is the
user's step and is recorded as still open.

Phase 2 landed a5ff2641 and 3008137e as 44324c90 and d957ee8d, both clean
picks. The residual between the trees is 136 files before and after --
the dry run's 135 plus this document -- and no file's residual moved.
Both compilers clean, jvmTest 826 -> 837, testDebugUnitTest 413, every
audit budget met, and nothing under composeApp/schemas/ changed after a
full build. The release note the phase owes is written out in one line,
since it is the one downgrade hazard in the pull.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:48:55 +02:00
Kgothatso Ngako
d957ee8de5 feat(identity): a seed's key is a credential, and the seed is the wallet attached to it
Phase 1 of docs/multiple-profiles.md. No library change: the file format, the
encrypted writer and the manager all exist, and the app already wrote the
credentials file from the seed writer -- to delete a public entry a seed
superseded. This changes what it writes there, and what the listing believes.

Until now a seed's nostr key was never in nostr-credentials.dat. It was
derived from the words at listing, to know which npub to show, and from the
running node at activation, to know which key to sign with -- so a seed-backed
profile existed only as a derivation, and the app had to start a Lightning node
to find out who it was. Both sign-in plans made "one key, one file" an
invariant, and it was the wrong one: it said a wallet is a profile. Now the
credentials file is the list of profiles and a seed is a wallet attached to the
entry its key derives.

writeMnemonic writes two things, credential first: a Secret for the derived key
under its x-only pubkey, replacing a public entry where there is one, and then
the seed. Credential first because a crash between the two leaves a bare-key
profile the phrase completes, which is a valid thing to hold and says the model
out loud -- the profile exists, then a wallet is attached to it. The one
refusal it drops is the phrase of a key held as a bare secret, SeedAlreadyExists
under the nsec plan: the device did not have that wallet, so this is the
profile acquiring the wallet that derives it. The entry stays a secret for the
same key, the id becomes the wallet's, and the bare key's preference files go
with the old id -- the profile's preferences are the wallet's now, fresh, which
is right since there is a new secret to back up. The same seed twice is still
refused, by wallet id, and is the only way a profile with a wallet attached is
offered its phrase again.

SeedCredentials.reconcile is the repair for every seed already on a device,
beside migrateFromNostrKeys in listIdentities and shaped like it: a named,
idempotent write, one file write for however many seeds are missing, nothing at
all on a device with none or one already repaired. A failed write is a result,
not a throw, and carries the map that was read: the listing goes on with it and
merge derives the key of a seed that has no credential, so a seed this could
not repair is still listed. A failed write must never hide a wallet. The plan
had the repair running before the credentials file was read; it runs after,
and hands the listing what it returns, so the file is decrypted once -- the
doc now says so.

StoredIdentity.merge inverts: the credentials are the list, and each seed is
attached to the Secret its key derives -- listed once, as Mnemonic under the
wallet's id, carrying the credential's key -- where before the seeds were the
list and a secret for a seed's key was listed twice under two ids. Mnemonic
gains privateKey. A Public for a seed's key is skipped with a log line, since
the next repair upgrades it; a seed with no credential is listed by derivation,
with a log line. IdentityKind.Mnemonic's doc changes to what the kind now
means: the name records the attachment, not the source.

setActiveWallet takes the StoredIdentity.Mnemonic and builds the identity from
the credential's key, with the node's derivation as a cross-check -- a check(),
because a node disagreeing with the credentials file is the one corruption
worth refusing to run under, and it cannot fail for a file the repair wrote.
The node still starts for a profile with a wallet attached: not for the key any
more, but for what startNewBusiness does besides -- metadata, preferences,
last-used build, and on Android the channel watcher a restored Phoenix phrase
may need. Making it lazy is now one branch and is named as its own decision.

forgetNostrCredential refuses a second thing: a key a seed derives. The seed
would derive it again and the next repair would write it back, so a forget
that succeeded would undo itself. NotACredential becomes WalletAttached in the
writer's result and ForgetIdentity's outcome, since that is now the only reason
a signing profile cannot be forgotten -- a key not in the file at all can, since
the repair, only be a seed's key the repair could not write.

The two sign-in docs' tables each gain a row pointing here for the invariant
this supersedes.

Tests: SeedCredentialsJvmTest against a device from before -- seed.dat written
directly, no credentials -- writes exactly what is missing in one write; a
device already repaired is not written to again, checked by the file's bytes
since a rewrite would carry a fresh iv; a public entry for a seed's key is
upgraded; bare keys are untouched; and a write that fails, with the key store
locked, is reported with the map that was read and the wallet is still listed.
IdentityWriterJvmTest drives the callback-shaped seed writer through a
CompletableDeferred on a real Main dispatcher, since it reports after a real
one-second delay: a phrase writes both files, its nsec and npub are then
duplicates and its forget is WalletAttached; the same phrase twice is refused;
the phrase of a bare key attaches, under the wallet's id, with the bare key's
preferences gone; the phrase of a key held read-only attaches and the entry
becomes a secret. StoredIdentityJvmTest lists a secret-plus-seed once with the
credential's key, a seed without a credential by derivation, and a public entry
for a seed's key as the wallet only. Not under test: the activation's
cross-check, because a PhoenixBusiness cannot be built without a node.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@3008137e3f
2026-09-13 15:46:53 +02:00
Kgothatso Ngako
44324c902b docs: plan several profiles on a device, starting from what a profile is
The switch already exists as a state transition -- switchToWallet,
resetToSelector, a null identity sent to startup with popUpTo(0), every
collector a child of collectLatest -- and is reachable from nowhere: Landing
shows only when the device holds no identity, so a second can never be
added, and the profile tab's "change account" is a pending route. This plan
builds the two entrances and fixes what the transition gets wrong.

Before either, it changes one rule the two sign-in plans share. A seed's
nostr key is not written to the credentials file; it is derived from the
words at listing and from the running node at activation, and the writers
keep one key out of two files. That puts the wallet where the profile should
be: the list is a merge of two files with opposite ideas of what a row is,
and the node has to run for a profile to know its own key. Phase 1 makes a
profile a credential and a seed a wallet attached to one -- the credential
written when the seed is, repaired into the file for every seed already on
the device, merge inverted to list credentials and attach seeds by pubkey,
the identity's key read from the credential with the node's as a
cross-check, and a second refusal on forget for a key a seed derives. The
node still starts for a profile with a wallet attached, for the channel
watcher rather than for the key; making it lazy is now one branch and is
named as its own decision.

The other decision is that a switch is a restart of the signed-in graph,
not a swap under it: every route carries the key it was pushed for. That
settles the switcher as a pushed screen behind one tap, the previous node
stopped, the last-used profile as the one that opens on launch, and a
profile added from inside switched to.

Nine phases: the credential; the switch, with the relay observer that never
cancelled its predecessor and the node that kept running; the startup
precedence, which put "show me the list" above "open this one" and never
saved a default; the switcher, showing the nostr profile rather than
"Default name" and labelling a row with a wallet attached; the add rows,
where a profile created from inside is a bare key and not a second wallet,
and "end this" -- which wipes every profile's database -- goes; an owner on
the two fetch queues and an inbox sweep on activation, because a gift wrap
fetched under the other profile's key is stored and never opened again; the
exits; a round trip; rollout, with the one downgrade that lists a seed's
profile twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@a5ff264164
2026-09-13 15:46:53 +02:00
Kgothatso Ngako
5f2414dcb5 docs: plan the second pull from the fork, with its dry run already done
The upstream moved the day the first pull landed: ten commits on
curated/curated, 86cb876b..29027f2b, that let a device hold several
profiles and move between them, and with them the fork's first schema
migration, version 20. The first plan named them the next pull and said
the schema change earned a decision of its own. This is that plan, and
unlike the first it was written after the dry run rather than before it,
so its numbers are measured: rewrite from the fork point in 103 s, ten
commits replayed onto 776455ec with nine clean and one resolved by rule,
both compilers clean, jvmTest 826 -> 868 and testDebugUnitTest 413 -> 420
with no failures, every audit budget met, and 20.json regenerated
byte-identical after a full build.

The verdict is all ten, because the line is one feature and seven fixes
braided together and four of the fixes are live on Mantra today with one
profile: the relay observer that is never all cancelled, the read-only
inbox that stays closed after its nsec is pasted, the DataStore race on
desktop, and the startup screen's wallet-worded literals. Four decisions:
take the whole line rather than carve the fixes out of five commits; take
upstream's version 20 verbatim and adopt the rule that whichever tree
migrates first owns the number; the one conflict is Mantra's own import
from 39fb64b6, resolved theirs, which closes one of the first plan's owed
reverse-pulls; and two of the device's profiles in one group is a Mantra
follow-up before release, since ChatRoom is keyed by the group id alone.

What changed in the method: Mantra's tree is no longer a superset of the
fork's, so the exactness check becomes "the residual between the trees is
the same before and after the pull, file for file", and the plan gives the
commands. The replay driver gains the one rule the dry run needed, with
its reason. The README gets the row and a reading-order sentence, and the
first plan points forward from the paragraph that predicted this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:41:24 +02:00
Kgothatso Ngako
776455ecb0 refactor(groups): take out the group's nostr identity and the curated lists, and keep broadcast unreachable
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Lines B, D and H of docs/curated-to-mantra.md, pulled from the Curated fork this
morning, go again this afternoon: the group's nostr profile (kind 0), its four relay
lists (NIP-65, NIP-17, NIP-50, NIP-51), its posts (kind 1), the curated schemas it
publishes (31889), the suggestions it reads (31888) and the entries it accepts
(31890). Eighty-two files, twelve screens, the `nostr/curated/` package, the readings
(`GroupNostrProfile`, `GroupRelayList`, `GroupPost`, `GroupCuratedSchema`,
`GroupCuratedEntry`, `CuratedSuggestion`), the `applyInnerEvent` arms for all eight
kinds, the `ChatRepository` and `NostrRepository` reads that fed them, 226 strings,
and every test that came with them. A translation collective has no list to curate
and no reason to describe itself under a key no member controls, and five rows
saying so on every group's screen were five rows about somebody else's product.

**The pasted-event proposal goes with them, because it was theirs.**
`GroupEventProposal.ACCEPTED_KINDS` was exactly kinds 0, 1, the four relay-list
kinds and 31889 -- "the kinds the group's screen has a place for", in its own words --
so with the five rows gone it would have refused every paste. Keeping it as a
generic "sign any event" was considered and rejected: the screen's whole argument
was that a member goes to the row to see the event landed, and there is no row. The
`ProposedEvent` summaries for the same kinds go too; the one test of its pre-existing
fallback ("Event of kind N") is kept, as the only line of `ProposedEventTest` that
was about code this repository had before the pull.

**Broadcast stays, and nothing opens it.** `BroadcastGroupSignedEventScreen`, its
route, view model, state and `BroadcastButton` are kept, on the decision that a way
to send a group-signed event to relays is worth having against the day something
wires a button in -- the button's only call sites were the five removed screens.
Two things had to change for it to compile against a tree with no relay lists:
`defaultRelaysFor(kind)` is now the app's own publish set for every kind, since the
General list it preferred, the Blocked list it filtered by and the schema relays it
widened to no longer exist; and `relayUrlOrNull` moves into the view model's
companion from the deleted `GroupRelaySet`, unchanged. The hint on the screen says
"the relays this app publishes to" rather than "where the group has said it lives".
Its two tests are rewritten around the new seed: the screen test answers for the
first two seeded relays and counts the rest as asked, and empties the list by hand
to see the empty state, since the seed always has something in it. Deleting
broadcast outright -- the tidier tree -- was the recommendation and was declined.

**Every pre-existing file is back at its pre-pull content plus the kept lines'
hunks, and nothing else.** Eleven files -- `ChatMessage.kt`, `NostrEventDao.kt`,
`NostrEvent.kt`, `LocalChatRoom.kt`, `Member.kt`, `ProposedEvent.kt`,
`ChatTranscript.kt`, `ProfileAvatar.kt`, the group screen's view model and state,
and `m3-title-case.py` -- were touched by no kept commit and are restored from
ba0830a3 byte for byte, so `inComparableGroups` is a private helper of the group
screen again rather than a shared extension one deleted screen needed, and
`ProfileAvatar` has one overload again. The rest were restored and had the kept
hunks re-applied: the back button's three on `ChatRoomDetailScreen`, broadcast's
`groupSignedEvent` read on the two chat repositories, and on the two nostr
repositories the sign-in reads, by reverse-applying the queue commit's hunk. The
check is `git diff ba0830a3 -- <file>`, which shows only those. Reverting the eight
commits was rejected because the back button and the read-only identity work landed
on top of them and would have conflicted in every one of the shared files; editing
the current files by hand was rejected because it leaves residue that a diff against
the base cannot distinguish from a decision.

**The strings that went are exactly the ones nothing references any more and that
the pull added.** Six strings were unreferenced before the pull and stay; the header
sentence and one capitalised "Mantra" that the queue commit's join carried are prose,
not feature, and stay too. The seven section comments that described removed blocks
go; the broadcast block's stays.

**The plan's third decision said "hide the two rows behind a constant".** That
covered the curated rows and not the profile, relays and posts beside them, and the
call was to take the whole identity block out. The record of what went and what
stayed is the paragraph after the built table in docs/curated-to-mantra.md, with the
seam it leaves for the next pull: an upstream commit that touches the identity block
conflicts at `ChatRoomDetailScreen`, `MantraNavHost` and `strings.xml`, and is
dropped. docs/README.md says the same in a sentence. The nsec and npub notes still
name `EditGroupCuratedSchemaViewModel`; they are records of what was built upstream
and are left as written.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:compileKotlinJvm,
:composeApp:jvmTest (826 tests, from 1,039), :composeApp:testDebugUnitTest (413,
from 530) and :composeApp:m3Audit, every budget met, 12 adaptive uses at the floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:45:15 +02:00
Kgothatso Ngako
0fa807a6fb docs: record what the pull built, and the seven places it chose differently
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Phases 0 to 5 of docs/curated-to-mantra.md are landed on this branch: thirty
commits pulled from curated/curated, each carrying a `Pulled-From` trailer
naming the commit it came from, on top of Phase 0's two native ones. The plan
is kept as written and its status line now says what happened, with the table
the house keeps for a plan that has been built -- what it said against what
it turned out to be -- and the README's sentence about it follows.

**Seven differences, and none of them are corrections to the plan's
decisions.** The three decisions -- drop the brand line but keep its
reasoning, keep Mantra's sections by dropping 808a3459, take the curated
lists -- all held, and the measured predictions about them (the screen's
order, the tests, the audit) came true to the number. What changed was
mechanics: the phased replay is a cherry-pick per commit rather than a rebase
of each cut, because a recreated merge cannot reach a parent that was replayed
in an earlier phase; the library pin follows the commit rather than staying
put, because the compiler said the nsec line does not build against 84cc44c;
the join files are taken from upstream's own merges rather than unioned, for
three reasons that each took a failed attempt to learn; and the exactness
residual is seventeen files rather than sixteen plus a logo, because Mantra's
own side had moved too. Each is written in the table with the reasoning
beside it, so the next pull starts from what happened.

**The numbers are per phase, so a regression later can be placed.** jvmTest
736, 864, 905, 1,007, 1,039 and testDebugUnitTest 403, 486, 495, 530, 530
across Phases 0 to 5; both compilers clean and every m3 audit budget met at
each; the final jvmTest executed rather than restored from the build cache,
1,039 tests in 46 seconds of test time, 0 failures. The plan's own dry run
predicted 1,036; the three extra are 39fb64b6's pin of the library's
nostrPublicKey() against NIP-06.

**What is not done is named rather than implied.** Phase 6's two reverse-pulls
-- 39fb64b6, since the fork still carries the app-side WalletManagerExtension.kt
it made redundant, and Phase 0's Torch retirement -- land in the other
repository and are not this branch's to make. Its last item is a decision
about what the fork becomes, and the plan's recommendation stands: converge the
source package so the next pull is a plain cherry-pick. And the upstream has
already moved on -- ten commits for several profiles on one device, one of
them the fork's first schema migration -- which are the next pull, kept
separate because a migration landing on Mantra's database deserves a decision
of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:06:46 +02:00
Kgothatso Ngako
89e364c1bd chore(library): base the credentials branch on the library's master
The Phase 2 library commit was made on a branch cut from 59c11ed -- the
nsec key-store commit, which is what the app's curated branch pins -- but
that commit is not the library's default branch's tip: master is at
e51e3ae, the merge of PR #1 that brought 59c11ed in. The two trees are
identical, so the rebase is content-free; what changes is that
claude/nostr-credentials is now one commit ahead of master rather than a
sibling of its tip, and the app's pointer follows it to 84cc44c.

The plan said the nsec library commit "sits on a remote branch called
detached", which was true of where it was found and false of where it is:
it reached master through the PR. What is still true, and still the
rollout's one hard step, is that it is untagged -- the library has no tags
at all -- and JitPack consumers resolve by tag. The two sentences that said
otherwise are corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@00c36ec509
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
042c5db5f4 docs: record what the npub sign-in plan built, and the twelve places it chose differently
Phase 8 of docs/npub-sign-in.md is the rollout, which is process rather than
code; what is left to write down is what the seven phases before it turned
out to be. The header moves from "Not built" to "Built", with the table the
other phased plans keep: what the plan said against what the implementation
did, twelve rows, each a decision worth reading before touching the code
it describes -- the startup branch one phase early because the sealed type
asked for it, the migration's three outcomes, the DAO guard that was
belt-and-braces on paper and load-bearing for two phases, a null identity
answering "can sign" with true because nobody is not read-only, and a
round trip run against a real database with a contrast case so that
"nothing was signed" is known to be a claim the harness can refute.

One finding that belongs to no phase is recorded with it: a compose test
looking for a floating action button's label has to search the unmerged
tree, or its "does not exist" is vacuously true. And the one rollout fact
that is not process: the library commit is on claude/nostr-credentials at
01962f3, bumped in by Phase 2, and like the nsec plan's before it has to
be pushed and tagged before any of this leaves the machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@f866b9d17b
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
10df01f4b7 docs(npub): one credentials file, not two, and the write that makes the upgrade atomic
The plan's first design kept public keys in a plaintext file beside
nostr-keys.dat, app-side, so that nothing touched the library. Review asked
why the key file was not simply made a credentials file with a read-only
kind, and the answer is that it should be. The upgrade -- pasting the nsec
of a key held read-only -- was the one operation spanning both files, so it
was two writes with a crash window between them and a "secret wins" rule in
merge to repair it; with one file keyed by pubkey it is a single write that
replaces {type: public} with {type: secret}. Forget collapses to one path
with it. And the advantage that paid for the two-file design was worth less
than it looked: the library commit that introduced nostr-keys.dat is
untagged, so a credentials format rides the tag that work already owes.

Phase 2 is rewritten for nostr-credentials.dat: a typed entry per pubkey in
the same envelope, renamed rather than versioned in place because an older
build's listIdentities returns on SerializationError before it publishes
anything and would list no wallets at all; a migration that reads the v1
file once and deletes it, since a frozen copy would resurrect a forgotten
key on a downgrade; and the one upgrade that still spans two files -- a
recovery phrase over a public credential -- with its order stated. The
header, Phases 3, 6, 7 and 8, the estimate and the appendix follow, where
the two-file design now sits as the rejected alternative with the reason.

One overclaim of the revision's own is corrected in it: the library does not
use a class discriminator anywhere -- its cloud payloads pick a variant by a
version field -- so the plan says the discriminator is chosen here, not
inherited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@dc8a1091d3
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
69050bb743 docs: plan npub sign-in, starting from what a read-only identity is for
The nsec plan's out-of-scope note said a read-only mode is a product, not a
branch. This plan takes that at its word: what a public key can see here is
thin -- a profile card, its follows as search results, no feed, no rooms,
since every room is MLS or a gift wrap to the key that was not pasted -- so
the first section decides what such an identity is for before anything is
designed. It is a preview: the app as your own profile, before you paste a
secret into it. That one word settles the Messages tab (an empty state that
offers the upgrade), pasting the nsec of a read-only key (an upgrade in
place under the same id, not "already on this device"), sign out (real for
this kind only), and not-found (try again or a different key, never set one
up).

Eight phases: a nullable key on Identity, with the note that the compiler
will be silent about it; a plaintext list beside the two key files, app-side
through the public getDatadir and AtomicFileWrite so nothing needs a JitPack
tag; the third startup branch, a read-only KeyPair built in one place, and
only the two pumps that read; the sign-in screen, where hex stays a secret
because an x coordinate is almost always also a valid scalar; a LocalCanSign
capability and an inventory of every write entrance one tap from the three
tabs; two exits; two round trips; rollout.

Two traps found on the way are recorded where they bite: quartz's
KeyPair(privKey = null) generates a fresh key rather than meaning "no key",
and decryptGiftWrapSeal forwards exactly that; and signInToProfile plants a
second kind 0 on a second call, which nothing reached until the upgrade
path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@78807fe956
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
b127647145 feat(groups): the queue of a list the group curates, read off the relays it named
`930d37c8` gave a group the schema: the definition of a list, signed by the room's
own key, for strangers to answer. This is the answers. Under every schema card on the
group's screen there is now a "View suggestions" button, and it opens the list's
*queue* -- every kind 31888 anyone has published in reply to the schema's coordinate,
newest first, whoever signed it, with a mark on each one the group has already taken
up into the list. It is the second half of the curated-list NIP in this app, the
read side of it: suggestions (31888) and canonical entries (31890) are now read and
checked here. They are still not written; that is the third half.

The screen is bitcoin.mov's `/suggestions` page, which does the same thing for that
site's one list: one row per suggestion event rather than one per film, so that a
reader can see what is waiting before anything is added and whether the curator has
taken it up. `SuggestionList.tsx` and the store behind it, `useVideos.ts`, are the
reference for everything the screen decides -- one row per event, the newest
version per coordinate, "Curated" where a canonical entry shares the `d`, and a
timer standing in for an answer that never comes.

**The button is behind no gate, and it is per card.** Every other button in the
identity block is hidden from a member holding no share of the key, because each of
them proposes a signature by the group. The queue is the one thing in the block the
group did not write. A schema exists to be answered by strangers, and reading the
answers takes no share of anything, so the button is there for whoever is looking
-- `GroupNostrProfileSectionJvmTest` puts a member with no share in front of a
schema and finds it. It sits under its own card rather than once for the section,
in the same `item {}` as the card, because each list has its own queue and a button
between two cards would say nothing about which one it opened.

**The protocol is ported rule for rule, and the fixtures are the NIP's own.**
`nostr/curated/CuratedEntryEvent` is the reference module's `verifyEntry`,
`verifyCuratedCanonical`, `checkValue` and `valuesOf`, in the NIP's order: the kind
is the one the schema names; every required field is present; no field without
`repeat` appears twice; every value matches its field's type and config; every
`require-any` group is met; the `a` root names this schema and no other; and the
author is somebody the visibility admits. A canonical entry gets two rules on top --
its author is the schema's author, because curation is the one power a schema does
not delegate, and a source pointer, when there is one, is a well-formed suggestion
coordinate, because a malformed one credits the wrong person. `CuratedEntryEventTest`
takes *The Rise and Rise of Bitcoin* as `eebb74ab…` suggested it in the NIP's
example, and the curator's sign-off on it, and checks both against the bitcoin.mov
schema from the same document; then the doc's own minimum suggestion, which has an
IMDb link and no watch link and satisfies `require-any` that way; then every
rejection the doc lists and says why it matters -- an unknown type, a year outside
1900--2100, a non-https poster, a `javascript:` link, a title over 200 characters, a
`d` with a space in it -- plus the reply rules, the repeat rule, the closed-list
rule and the two canonical rules.

**Rejected, not repaired, and read by field.** The NIP says a client MUST verify an
entry against its schema and MUST reject one that does not satisfy it rather than
showing it with the bad parts blanked out; relays carry malformed, partial and
hostile events from other applications. So `suggestion` and `canonical` return the
entry whole or return null, and `verifySuggestion` and `verifyCanonical` return the
reasons as typed problems -- a field name and a `Reason`, the way `CuratedSchema.
problems` is an enum rather than a sentence -- so that a form later can show them
inline. What comes back is a `CuratedEntry` keyed by the schema's *field names*
rather than by tag, because a field is what a form and a screen know an entry by and
the tag is only where it was kept: the two `r` tags of the example land on
`watchUrl` and `imdbUrl` by their markers, the two `t` tags on `hashtags`, the body
on `description`. The `director`, `i` and `lang` tags on the NIP's example are on no
field of the NIP's schema and are not in the reading -- "tags the schema does not
define MAY be present and MUST be ignored".

**A pattern this platform cannot compile counts as not matched.** The reference
builds `new RegExp('^(?:' + pattern + ')$')` and would throw out of its verifier on
a bad one; JavaScript and Kotlin regex dialects also differ at the edges. Ignoring
an uncompilable pattern would accept entries the site refuses; refusing them is the
conservative reading, and it is documented at the check.

**A coordinate splits on the first two colons only.** A `d` may itself contain
colons -- `imdb:tt2821314` does, and it is the identifier the NIP's example uses --
so `CuratedCoordinate.parse` is the reference's regex rather than a `split(":")`,
and the test pins the identifier surviving its own colon and the pubkey being
lowercased on the way through.

**The queue is a reading over stored rows, with three things to close and one to
add.** `CuratedSuggestion.queueOf` takes whatever the local table holds for the
coordinate -- kinds 31888 and 31890 with an `a` tag naming it -- and reads the
queue out of it. Closed: an event that does not satisfy the schema; a kind 31890
signed by anybody but the group, which is somebody else's list and marks nothing;
and an older version of an entry its author has since replaced, since both kinds
are addressable and different relays hold different latest versions, so the merge
that realises an edit happens here, newest per `kind:pubkey:d` with the event id
breaking ties. Added: `isCurated`, true where a canonical entry the group signed
shares the suggestion's identifier -- the coordinate both land on, and the
reference page's rule. Newest first, because that is the order a queue is read in.

**A stranger is named by their profile, and by their key until one arrives.** The
indexer writes a placeholder profile named "LOADING..." the moment a pubkey is
first seen, and a queue full of that would be a queue of nobody.
`suggesterName` reads the profile only when it is a real one -- `createdAt` past
`GENESIS_AT` -- and otherwise the short key, which is at least stable and
distinguishing. The row's avatar is tinted from the key like every avatar here, so
two suggestions by one stranger read as one stranger.

**Read from where the schema says, and the screen says where.** The schema's
`relay` tags are signed into the event for exactly this: a client that finds the
list anywhere knows where its replies live and cannot be sent elsewhere by an
unsigned config. A schema naming none -- the NIP allows it and lets a client pick
-- is read from the group's general relay list, the *read* relays of it, since this
is a read; and failing an agreed, non-empty one, from this build's own relay, the
same fallback the schema editor seeds from. Spellings are normalised so one relay
written two ways is one request. The header names the relays asked, because a
queue read from the wrong relays and a list nobody has written to look exactly
alike from here, and a member should be able to tell them apart.

**Nothing here talks to a relay; it is a pull like every other one-off read.**
`CuratedSuggestionListViewModel` queues one `SynchronizeNostrEventRequest` per relay
for the sync pump to drain, carrying the NIP's two queries as one request's two
filters -- everything anyone published in reply to the coordinate, and what the
*group* published in reply to it, the second scoped by `authors` to spare the relay
the work `queueOf` does regardless -- and watches the local table for what comes
back. That watch needed a way to observe an arbitrary NIP-01 filter, which the
repository did not have: `observeNostrFeed(filter)` recognises a handful of shapes
and falls back to text notes for the rest, and none of its shapes is "these kinds
with this `a` tag". `observeNostrEventsMatching` applies the filter whole through
`NostrEventFilterQuery`, the builder negentropy already uses, so the `#a` match is
anchored and escaped rather than a substring scan; the DAO method behind it names
its observed tables explicitly, the events and the profiles joined onto them, so a
row whose author's kind 0 arrives after the row does re-emits with the name filled
in. What the screen shows is therefore what this device has *stored* for the
coordinate -- which is also what it shows with no network at all, and what it
showed last time until the relays answer again. `NostrEvent.toEvent` is the small
conversion the reading needs to hand a stored row to a verifier written against
the wire shape, beside `GroupSignedEvent.toEvent` which does the same for the
group's own.

**"Still looking" is bounded by time, because nothing else bounds it.** The pump
marks a request sent when the REQ goes out and processed when an event comes back.
A relay holding nothing for the coordinate answers with an EOSE the pump does not
record, so there is no signal for "asked and empty", and a screen that showed the
local table's first, empty read would call every list empty for the second before
its rows arrived -- which teaches a member not to believe it. So the queue is
"being asked" from the request going out until either something lands or ten
seconds pass, and only after that is an empty queue empty. The reference store does
the same with a six-second timer; ten here because the request queues behind the
pump's four subscription slots before it goes out. Rows arriving end it early, a
late arrival after it is still shown, and `withQueue` -- the one state merge --
pins all three in `CuratedSuggestionListViewModelJvmTest`. The empty state offers
"Ask again", which re-queues the same requests: the one thing the watch cannot do
on its own is make more arrive.

**The row shows what an entry is, not where to find it.** The screen is driven by
a schema it has never seen, so what goes on a row is a decision rather than a
layout: the title as the headline, since every list has one; a glance line of the
form fields that say what the entry is, as `Label: value`, because a bare
"2014 · en" says nothing to somebody who does not know the list's fields; the body
where the list has one; and who suggested it, when. Links are left to the sheet --
a URL on a row is a line of noise. "Curated" sits on the row as the one thing on it
a member might act on: it says the group already took this up, and re-curating it
would revise the entry rather than add one.

**The sheet has the whole entry and the event it came as.** Every field the entry
answered, labelled as the schema labels it and in the schema's order, so it reads
as the form would have; the title not repeated, since it is the heading; tokens and
links monospaced because those are compared and copied rather than read. Then the
signed event, byte for byte, with a copy button -- the way the key state sheet
shows the group's own statement and for the same reason: a prettier rendering is a
different string to the one whose id was hashed. It is also the thing a canonical
entry is built from, when that half arrives.

**Four states, and the transition between them.** Loading while the room and the
schema are read; an error, with nothing to retry, for a schema this device does
not hold, since the identifier came from a navigation argument and reading it
again fails the same way; and a loaded state that is either the rows, or looking,
or empty with something to do. `ScreenStateTransition` wraps the `when`, which is
the Scaffold's whole content.

**The audit's proper-noun list grows by two films.** The preview suggests *The Rise
and Rise of Bitcoin* and *Magic Money*, both title case for the correct reason, and
`m3-title-case.py` excludes sample data by name rather than by pattern.

**Still nothing has reached a relay, and it bites once more.** Group-signed events
are not yet published, so a group's schema has not been where a suggester could
find it, and its queue is empty until it has. The screen reads the coordinate
`31889:<group>:<d>` all the same and shows whatever a relay holds for it; the
suggestions on bitcoin.mov's relay reply to *that* curator's coordinate and are
correctly not a group's. `GroupCuratedSchema`'s caveat now says so and points at
the reading.

32 new tests. `CuratedEntryEventTest` (15) works from the NIP's example suggestion
and canonical entry against the NIP's schema: the reading by field, the minimum
suggestion, the source pointer and its absence, curation not being delegated, a
malformed source, the wrong kind, the reply rules, a missing required field, the
repeat rule both ways, every value rule the doc lists, `require-any`, the closed
and private lists, `checkValue` on durations, patterns and https, and the
coordinate split. `CuratedSuggestionTest` (4) reads a queue out of stored rows:
verified suggestions newest first with the broken, the foreign and the wrong-kind
ones dropped, an edit collapsing to its newest version, the curated mark placed by
the group's canonical entry and not a stranger's, and the naming of a suggester
with a profile, a placeholder and nothing. `CuratedSuggestionListViewModelJvmTest`
(6) covers the relay order and the read-only filter, one request per relay carrying
both queries and the watch covering both kinds, the three outcomes of `withQueue`,
an `initiate` run end to end against fakes -- the relays asked, the filter watched,
the rows shown, the looking ended -- and a missing schema being an error rather
than an empty queue. `CuratedSuggestionListScreenJvmTest` (6) renders the header,
the row's glance line with the link kept off it, the mark on the right row and only
that row, looking against empty, and the sheet with its fields, its link and its
event. One new case and one extended in `GroupNostrProfileSectionJvmTest` put the
button under each card in turn and in front of a member with no share.

1398 tests pass -- 893 in `:composeApp:jvmTest`, 505 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.

Replayed onto Mantra by docs/curated-to-mantra.md: strings.xml: taken as the original merge c8de3a1f left it, this being the branch join.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@1e52fc8f25
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
14ffa04e7c docs: record what the nsec sign-in plan built, and the twelve places it chose differently
Phase 8 of docs/nsec-sign-in.md is the rollout, which is process rather than
code; what is left to write down is what the seven phases before it actually
turned out to be. The header moves from "Not built" to "Built", with the
table the other phased plans keep: what the plan said against what the
implementation did, twelve rows, each one a decision worth reading before
touching the code it describes -- the not-found exit as a screen state rather
than a route, input problems on the field rather than through ErrorState, the
kind 0 written over the placeholder rather than beside it, forget taking the
account rows too.

Two findings that belong to no phase are recorded with it: the android source
set's file-name collision that put the failure classifier in its own file,
and DataStoreManager's process-global preferences cache, which only a test
reusing a key across directories could have found. And the one rollout fact
that matters: the library commit is on claude/nostr-key-store in the
submodule, bumped in by the Phase 3 commit, and has to be pushed and tagged
with this branch.

The docs index now says the plan has been built and points at the table.

Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@2326839e48
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
c49914818a docs: plan nsec sign-in, and the ten call sites that make it small
An nsec is a BIP32 leaf of the seed at m/44'/1237'/0'/0/0, and the
derivation runs one way, so an nsec can never have a wallet behind it. What
makes the feature tractable anyway is that nothing in the app reads the
wallet except the nostr key -- ten sites, all the same expression -- so the
plan puts an Identity in front of the wallet and gives an nsec an identity
with no node behind it.

Eight phases: the identity type; a sibling key file in the library under the
one keystore alias Android will accept; listing and starting both kinds; the
sign-in screen for a phrase or an nsec, deduped on pubkey rather than wallet
id; indexer relays and a not-found exit for the sync that today asks one
relay and never finishes; recovery for a key with no phrase; tests; rollout.

Two findings along the way are recorded as pre-existing rather than new:
loadNostrProfile(startupRoute) keys off a Profile row a placeholder account
does not have and answers Landing while observeProfile answers the sync
screen, and the library's LocalKeyManager.nostrPublicKey() returns the
33-byte compressed key, not a nostr key.

Replayed onto Mantra by docs/curated-to-mantra.md: README.md: line-set three-way merge, both sides' additions kept and this commit's deletions applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@daae63d5c5
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
ce951bb5d0 docs(scripts): the pin follows the commit even when git fast-forwards the gitlink
Found landing Phase 3 for real, one commit after the trial had passed it. In
the trial worktree the submodule directory was an empty placeholder, so when
366b0177 moved the pin from 01489b8 to 59c11ed against a branch already at
84cc44c, git could not compare the three and reported a conflict, and the pin
rule set 59c11ed as intended. On the real branch the submodule is populated,
git can see that 59c11ed is an ancestor of 84cc44c, and it resolves the
gitlink by fast-forward -- cleanly, silently, and to the newer pin, which is
the one the commit does not compile against.

So the pin rule no longer waits for a conflict: after any pick of a commit
the table names, the gitlink is set to what the table says, and the note says
git had fast-forwarded it. The same code path now covers 5efeae76's mapping of
the unfetchable 01962f3 onto 84cc44c, which used to be a special case.

Phase 3 was reset to the Phase 2 tip and is landed again with this in place;
nothing else about those eight commits changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:00:07 +02:00
Kgothatso Ngako
2a02fbc14f docs: record the day's dry run, and the three things it changed in the plan
Phase 1 of docs/curated-to-mantra.md, run on 2026-09-13 on top of Phase 0, on a
scratch worktree that landed nothing. The plan asked for exactly this -- redo
the dry run the day you land, because the numbers drift -- and it was right to:
the upstream had moved, the pipeline as written could not do phased landings,
and the library pin turned out to have to move backwards. All three are now in
the plan, in the Phase 1 section, as a record beside the plan rather than a
rewrite of it.

**The replay lands by cherry-pick in topological order, not by
`rebase --rebase-merges`, and the reason is a property of git rather than a
preference.** The plan's phases are cuts through a graph with four merges in
it. Rebasing a cut whose merges have one parent in an earlier cut recreates
each such merge against the *pre*-replay parent -- the commit in the rewritten
source branch, not the one already landed -- and drags the unrebased lineage in
beside the rebased one. A single whole-range rebase, which is what the plan's
dry run measured, never meets this because every parent is inside the range.
So: the ordinary commits are picked one at a time, the merge commits land as
nothing, and their content, which is only ever conflict resolutions, is folded
in where the conflicts actually surface. The driver that does it is
docs/scripts/curated-replay.py, added here; every commit it lands carries a
`Pulled-From: curated/curated@<sha>` trailer stamped by the rewrite, and a
paragraph naming any resolution made on the way in.

**At a branch join the join files are taken exactly as upstream's own merge
left them, and a union was tried and rejected three times before that rule
was reached.** A union -- keep both sides of the conflict -- is the obvious
resolution for two branches appending strings to the same file, and it was
wrong three ways, each caught by the exactness check against the rewritten
tip and none of them visible in a passing build. It duplicates lines both
sides already hold when a conflict hunk widens (thirteen string keys, twice).
It never applies the other side's deletions, so the two copy-suggestion
strings that fcc19f95 removes survived. And, the one that took longest to see,
a join resolved in a different *order* from upstream's merge leaves every later
commit that edits the block unable to find its base, so its deletions fail
silently while its additions land -- which is why the second fix still left
the same two strings behind. The rule that survives: files whose lines are
unique by construction (string keys, imports, route registrations, table rows)
get a line-set three-way merge over diff3 hunks -- ours, minus what theirs
deleted from base, plus what theirs added that the file does not already hold
anywhere -- and never at a join, where the file upstream's merge produced is
the answer. The driver's docstring says all of this so the next reader does
not rediscover it.

**The library pin goes back to 59c11ed for Phases 3 and 4 and forward again
in Phase 5, and the compiler was asked before deciding.** The nsec line was
written against lightning-kmp-app 59c11ed and writes through its
`NostrKeyManager`; 84cc44c, Mantra's pin, renamed that class to a read-only
`LegacyNostrKeysFile`. Reasoning said the Phase 3 cut would not compile against
84cc44c; the trial worktree was put at that cut and built against it, and
produced eight `Unresolved reference 'NostrKeyManager'` errors in
IdentityWriter.kt. So 366b0177 moves the pin to 59c11ed, whose nested
lightning-kmp -> bitcoin-kmp -> secp256k1 chain is the same commit as
84cc44c's and rebuilds nothing native, and 5efeae76 brings it to 84cc44c --
not to the 01962f3 it named upstream, which is a branch commit since rebased
onto the library's master and no longer fetchable, but whose tree 84cc44c
reproduces exactly. Every commit on the branch will compile against the pin it
records, which is what upstream's history had and a fixed pin would have
thrown away. Alternatives rejected: adapting the Phase 3 commits to the
renamed class (rewriting upstream's work on the way in, and inventing an
intermediate state nobody built) and landing Phases 3 to 5 as one unit whose
inner commits do not build (bisect would hate it, and so would review).

**The upstream moved, and the new line is the next pull rather than part of
this one.** curated/curated went from 86cb876b, which the plan was written
against, to 29027f2b: ten commits for several profiles on one device, one of
which (759199f2) adds schema version 20, the fork's first migration. They are
recorded and left out on purpose; a migration landing on Mantra's database
deserves its own decision, and the plan's own principle -- pull the whole
non-brand tree so the next pull is a replay -- says how that decision goes
once it is made.

The trial, with the committed driver: 30 commits replayed (4, 8, 6, 12), 21
clean and 9 with a resolution note, 0 stuck; the driver's own rerun from the
Phase 0 tip reproduces the tree and improves on the hand-run trial by the one
README line the old union had wrongly kept; residual diff against the
rewritten 86cb876b exactly the known set -- Phase 0's prose, 39fb64b6's files,
808a3459's three, this document and its scripts, the logo.
:composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean;
:composeApp:jvmTest 1,039 tests, 0 failures; :composeApp:testDebugUnitTest
530 tests, 0 failures; :composeApp:m3Audit all budgets met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:57:48 +02:00
Kgothatso Ngako
ba0830a3d4 docs: say why the two hash tags and the relay host must never be renamed
Phase 0 of docs/curated-to-mantra.md, item 3. Three strings in this tree spell
"mantra" for reasons that have nothing to do with the brand, and until now
nothing beside them said so: `SharedKeyDerivation.TWEAK_TAG` and
`ChillDkgRitualManager.HOST_KEY_DERIVATION_TAG` are inputs to hashes, and
`Relays.ephemeral` is a relay that is running. Each now carries a comment
saying what renaming it would cost, and docs/shared-key-derivation.md gets the
paragraph that ties the three together.

**The comments come from the fork, and are rewritten rather than pulled.** The
Curated fork found out what these strings were the hard way: it renamed the app
twice, and each time had to decide which of thousands of "mantra" tokens were
the brand. Its rebrand commits (3bc8be53, e6aee792) left these three alone and
wrote down why, and run through the pull's name-rewrite those commits collapse
to almost nothing but those comments. They were not taken as commits, because
what survives the rewrite is a sentence like "has survived two rebrands --
Mantra to Curated, Curated to Mantra", which in this repository describes
rebrands that never happened. The fact they state from this side is different
and worth stating plainly: the fork keeps all three byte for byte, so a Mantra
member and a Curated member of one group derive one key and talk to one relay,
and a rename *here* would split them as surely as a rename there.

**Why comments at all, when the derivation note already has the rule.** The
note's one rule is about the path a key is derived along; it never said that
the tag string itself is part of the derivation, and the failure mode of
renaming it is silent -- every room orphaned, every partial signature
aggregating to nothing that verifies, and nothing on screen to say so. A
`v2` tag is the shape a deliberate change would take, and the comments say so,
so that the next person to grep for the brand finds the answer before the
diff.

**`ComposeAppCommonTest` moves from `press.auxiliary` to `press.mantra`.** It
is the KMP template's `1 + 2 == 3`, the last file under a package the app
vacated two brands ago, and the fork relocated it rather than deleting it so
the source tree has one root package instead of an orphan under an empty one.
Same here; it is moved with `git mv` so its history follows.

No behaviour changes. :composeApp:jvmTest 736 tests, 0 failures;
:composeApp:testDebugUnitTest 403 tests, 0 failures; :composeApp:m3Audit all
budgets met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:42:56 +02:00
Kgothatso Ngako
4681ac1a03 refactor: retire the brand before mantra, and keep the three records of it
Phase 0 of docs/curated-to-mantra.md, item 2. The app has been Mantra since
before this repository had a docs/ directory, and four things still said Torch,
the brand before it: the theme composable, `TorchTheme`, at 116 sites; the
user agent, `UserAgent.APP_NAME` and `CLIENT_NAME`; two error strings a user
could actually be shown ("tell X to use Torch", "finished setting up Torch");
and iOS, where `Config.xcconfig` built `PRODUCT_NAME=Torch` under the bundle id
`ac.aux.compose.Aux` and the project's product reference was still `Aux.app` --
two brands ago. All four now say Mantra.

**This is done natively, before anything is pulled from the fork, so that the
fork's theme maps onto a name that means something.** The Curated fork retired
Torch itself in d26cf6c7 (`TorchTheme` -> `CuratedTheme`, later `CurareTheme`).
The pull rewrites every fork commit into this repository's names, and until
now the only honest target for `CurareTheme` was `TorchTheme` -- the brand
before last -- which would have had every pulled screen wrapping itself in a
name the app stopped using long ago. `MantraTheme` is what the rewrite maps to
from here; docs/scripts/curated-unbrand.py's theme, `UserAgent` and iOS rules
are changed in this commit for that reason, and the comment that said "change
here if Phase 0 renames it" goes with them. Retiring Torch as part of the pull
instead -- by taking the rewritten d26cf6c7 -- was rejected because that commit
would then be a fork commit renaming something the fork never had, and because
the iOS half of the debt was never the fork's to pay.

**The user agent went last, and the reason it could go at all is written where
the next reader will look.** `CLIENT_NAME` is the `client` tag on every relay
list this app publishes, which is why it was left alone when the top bar was
fixed: it goes on the wire, so it read as a network identity question. It is
attribution and nothing derives from it, which is the difference between it
and the two `mantra/` hash tags that must never move; the comment on the
`HomeScreen` bar and the paragraph in material-design-conformance.md that both
said "still says Torch" now say that, in order, rather than describing a state
this commit ends.

**Three records keep the old name on purpose.** material-design-conformance.md:278
records a finding -- the top bar once read "Torch" while the window title read
"Mantra" -- and changing it would falsify the record. The paragraph at :654 is
rewritten rather than deleted because it was a statement of current state, and
the current state changed. `NavigationRoutingTest`'s "Introducing... Torch"
fixture is the string a stranded user actually saw, and the test is about the
stranding. Nothing else in the tree says Torch.

**iOS is unverified.** The build gates the apple targets behind `isMacOsX` and
this was built on linux; the xcconfig and pbxproj edits are the same shape the
fork's went through, and this is the first time this repository has named
itself there. Build it on a Mac before believing it.

:composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean;
:composeApp:jvmTest 736 tests, 0 failures; :composeApp:testDebugUnitTest 403
tests, 0 failures; :composeApp:m3Audit all budgets met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:42:36 +02:00
Kgothatso Ngako
b796a32be3 docs: plan the pull of Curated's work back into Mantra, and the tool that makes it mechanical
Curated (remote `curated`, curated/curated-kmp) forked from this repository at
ba26c0b1 on 2026-09-09 and has landed thirty-nine commits since: a group's own
nostr identity, curated lists over kinds 31888-31890, sign-in with a recovery
phrase, an nsec or an npub, a back button on every pushed screen -- and two
rebrands, 744 and 765 files each, underneath all of it. docs/curated-to-mantra.md
is which of that Mantra wants, in what order, and how it lands under Mantra's
names. docs/scripts/curated-unbrand.py is the rewrite the plan depends on, and
docs/README.md gets the row.

**The plan is measured, not argued, and that is its whole claim to be read.** The
obvious way to write it was to reason from the commit list about what would
conflict. Instead the method was run to the end on a scratch worktree before a
sentence was written: Curated's history rewritten into Mantra's names, replayed
onto origin/mantra with `rebase --rebase-merges`, built for both targets, and put
through jvmTest, testDebugUnitTest and the m3 audit. Every number in the document
is from that run -- 37 of 39 commits replay, 3 conflicts with known resolutions,
1,036 jvm tests and 530 common tests at 0 failures, all audit budgets met, Room
regenerating nothing -- and the appendix records it so the next reader can tell a
drifted number from a wrong method. A plan whose mechanics had not been tried
would have been a list of hopes about seven hundred renamed files.

**Mantra has not moved since the fork, and that decides the shape of the
problem.** The merge-base of the two branches is origin/mantra itself, so
`git merge curated/curated` is a fast-forward: it merges nothing and makes Mantra
become Curare, icon, package rename and the deletion of Mantra's own library,
dialects and projects sections included. The document says so before anything
else because it is the one thing git does by default. It also means the pull is
not a merge at all but a translation -- a Curated commit written in Mantra's
vocabulary applies to Mantra as if it had been written there -- which is why a
history rewrite followed by an ordinary rebase works, and cherry-picking the
original commits (every path and every import line in every context wrong) does
not.

**808a3459, which drops the translation sections, is dropped rather than
reverted afterwards, because that was measured too.** The first plan was to
replay everything and restore the sections with a forward commit written against
the final row layout. Trying the drop instead showed git merging 930d37c8,
1e52fc8f and 55664cc7's rewrite of ChatRoomDetailScreen around it without a
conflict: the screen comes out as signing key, the five identity rows, Propose
event, then Library, Dialects, Projects, then Subgroups, with `artifacts` and
`dialects` still on the UI state, and the same 1,036 tests pass --
GroupSignedWorkRowsJvmTest only asserts that Subgroups is below the block. The
one conflict is a modify/delete on a test 55664cc7 deletes anyway. A restore
commit would have been Mantra owning a layout upstream never had.

**Curated lists are pulled although they are not Mantra's product, and the
reasoning is written out in full because it is the only real product question.**
Excluding the five commits is not deleting five commits: the paste flow routes
kind 31889 beside 0, 1 and 10002, broadcast seeds an entry's relays from its
schema, the row restructure builds five rows of which two are these, the
back-button sweep touches the suggestion screens, and 165 of the 306 strings
added since the fork are theirs, interleaved. Carving that out means editing five
other commits by hand and then owning a group screen that conflicts with
upstream's on every future pull. Including it costs no schema, no migration and
no query on a screen nobody opens. The principle underneath is stated as such:
the method only stays cheap while Mantra's tree is a superset of Curated's
non-brand tree, so every feature left out is a permanent seam. If product wants
the two rows gone, that is two lines behind a constant, not surgery.

**The brand line lands as one re-messaged commit of comments, and Torch is
retired natively first.** Run through the rewrite, the two rebrand commits
collapse from 744 and 765 files to sixteen and twenty-one, and what survives is
the reasoning the rebrands added about the names they refused to change -- the
comments on TWEAK_TAG, HOST_KEY_DERIVATION_TAG and Relays.ephemeral and the
paragraph in shared-key-derivation.md. Mantra should have those; the next person
to grep "mantra/" here is as likely to finish the rename as anyone upstream was.
The rewritten d26cf6c7 is a different case: Mantra is still Torch in TorchTheme
(116 references), UserAgent, two error strings, and an iOS config that builds
Torch.app under a bundle id from two brands ago. That is Mantra's own debt and
Phase 0 pays it as MantraTheme and "Mantra", so that CurareTheme maps onto a name
that means something rather than onto the brand before last. Then d26cf6c7' is
dropped in the replay, since its work is done -- the plan was made to say this
explicitly after Phase 0 and Phase 2 were found to disagree about it.

**The normaliser is a script in docs/scripts rather than a description, because
three of its rules were wrong before they were right and prose would have hidden
that.** `\b` does not fire inside snake_case, so `\bcurare\b` misses the string
keys that carry the brand and they need look-around rules that treat `_` as a
boundary. File names carry identifiers, so a path rule that only moves
directories leaves MantraNavHost.kt and CurareNavHost.kt side by side with equal
content. And "Curated" is two words -- the brand in generation one, the
protocol's name at the tip (CuratedSchemaEvent, nostr/curated/, the queue's
"Curated" mark) -- so the bare word is deliberately not a rule and only the
generation-one identifiers are mapped, which is exact because the two commits
written in that generation never use the word as a brand in code. Every rule
matches a brand token and none matches a Mantra one, so the twelve Mantra*
entities, the two mantra/ prefixes and ephemeral.mantra.press cannot be touched by
construction; the check is that the entity-token count does not fall (433 before,
435 after, none lost) and that the rewritten tip compiles, which no missed
identifier would survive. It has a tree mode for `filter-branch --tree-filter` and
a patch mode for `format-patch` files; the consolidated script was re-run on a
fresh export of the tip and reproduces the tree that was built and tested, to the
byte, logo aside.

**The phases are cuts through the graph, not lines of work, so no commit is
replayed twice and the merges are recreated where they were.** The natural
grouping is by feature line, but the lines interleave -- the nsec branch and the
queue fork at the same commit and rejoin at c8de3a1f, the back-button branch
joins the accept flow at fedbe724 -- and two of those merges carry hand
resolutions that a flattened replay would silently lose. Each phase therefore
replays every commit reachable from its cut that the previous phase did not,
`--onto <mantra tip> <previous cut> <this cut>`, and the two conflicts and two
reconcile files that the dry run found sit in Phase 4 with their resolutions
written down as a runbook. The exactness check closes each phase: the diff between
the replay and the rewritten tip must be exactly what was chosen to drop, and
anything else is a rule that is wrong or a resolution that is, found there rather
than in production.

**Phase 6 says what the fork should become, because otherwise this document is
needed again next week.** Every future upstream commit is written in to.curare.*,
and each pull costs the dry run, the review and the prose -- affordable once,
corrosive weekly. The options are given in the order they should be taken:
converge the source package (a Kotlin package is not a brand, and the rebrands'
own messages say the domain is Mantra* everywhere that matters), then invert the
relationship so Curated is Mantra plus a thin brand-and-product series, and only
failing both keep the normaliser maintained beside the code it maps. Two things
already flow the other way and are named: 39fb64b6, since Curated still carries
the dead WalletManagerExtension.kt and one caller, and Phase 0's Torch retirement.

**Several counts in the draft were checked against the data and corrected before
they were committed.** The entity-token figure quoted from the rebrand's message
(1,369) was measured with a different pattern and is replaced by this run's own
(433 -> 435); "roughly 150 of 293" strings became 165 of 306; the back-button
commit touches eight of Mantra's translation screens, not nineteen; and the claim
that nothing in the fork added a DAO method was too strong -- three queries were
added, and the statement now says no table and no migration, with 19.json
byte-identical after a build as the evidence.

The dry run, for the record: normaliser at the tip 812 files rewritten and 806
paths moved; filter-branch over 39 commits in 84 s; replay with the logo and
808a3459 dropped 37 commits, 3 conflicts, 2 reconcile files, residual diff
exactly the logo plus the three files 808a3459 touched;
:composeApp:compileDebugKotlinAndroid and :composeApp:compileKotlinJvm clean;
:composeApp:jvmTest 1,036 tests, 0 failures (Mantra alone: 733);
:composeApp:testDebugUnitTest 530 tests, 0 failures; :composeApp:m3Audit all
budgets met with 12 adaptive uses and 2 navigation components. The scratch
worktree and the tmp/ branches were removed afterwards; the script recreates them
in under two minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:04:35 +02:00