Commit Graph

865 Commits

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

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

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

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

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

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

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

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

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

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

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

1403 tests, no failures.

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

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

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

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

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

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

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

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

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

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

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

m3Audit's budgets all met.

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

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

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

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

1381 tests, no failures.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

951 tests, no failures.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@ca6c16beb0
2026-09-13 16:35:32 +02:00
Kgothatso Ngako
b3379f8117 feat(groups): broadcast every accepted entry from one button, each to where its own would go
The entries screen had one "Broadcast" under each card, and each opened
`BroadcastGroupSignedEventScreen` for that one entry: a group that has accepted
twenty entries into its lists sent them with twenty trips through the relay
picker. This is the other button. "Broadcast all", an extended FAB in a bottom
bar, sends every entry on the screen, newest first, one after another, and says
under each card what its relays answered. Behind no gate, like the button under
each card: sending what the group has already signed takes no share of the key.

**Each entry goes where its own broadcast screen would have started.** The list
that screen seeds -- `defaultRelaysFor`: the group's General write relays or the
app's publish set, plus the list's own relays, minus what the group has blocked
-- is what a member who reads nothing and presses the button sends to, and this
button is that member with twenty entries. The group's relay lists are read
into the loaded state for it; the list's relays come off the entry, which
carries its schema, so the rule is handed that one schema rather than all of
the group's. Nothing is shown before the press, which is the difference from
the one-event screen and the reason that screen stays: a refusal is answered
there, where the relay can be changed and the send tried again.

**One entry at a time, on purpose.** The rows fill in top to bottom, so the
member can see where the batch is; and a public relay sent twenty events in
one burst answers "rate-limited" to most of them, which would make the button
worse than the presses it saves. Each entry's send has the same ceiling as a
single broadcast and the batch has no other, since a long list is meant to
take a while. It goes to the pool directly rather than through the queue, for
the reason `BroadcastGroupSignedEventViewModel` gives, and it is only as
durable as the press: leaving the screen ends it where it is.

**The send is shared rather than copied.** What a relay's answer becomes -- an
OK, a rejection in the relay's words, a failure, or no answer when the whole
send runs out of time -- was decided inside `broadcast()` on the one-event view
model. It is now `sendToRelays` on that model's companion, beside
`defaultRelaysFor` and `wireEventOf`, and `broadcast()` keeps only the state
around it; the thirteen tests on it pass unchanged. The words for an answer
moved the same way, out of the relay row and into `RelayOutcome.label()`, which
both screens now use.

**What the member is told.** A line above the cards -- "Sending entry 3 of
12…", then "Accepted by every relay for 11 of 12 entries." -- persistent rather
than a snackbar, because it is the answer the member came for and a snackbar
would be gone before the last card had been read. Under each card, "Accepted
by n of m relays." in the one-event screen's words, and one line per relay
that did not say OK, named with its reason, because "blocked: not on the allow
list" is something a member can act on and a count of two out of three is not.
An entry with nowhere to go -- everything the seed would name blocked -- is
marked so rather than sent to nobody. The FAB looks disabled while a batch is
out, and a second press is refused.

`GroupCuratedEntriesViewModelJvmTest` pins where each entry goes, that the next
waits for the last to settle, what the answers become, and the refusals; the
screen test drives the button end to end and checks that each answer sits
between its card and that card's own broadcast. The shared fixture entry now
carries the `a` root a real entry has -- without it the seed rule could not
find the list's relays, which the first run of these tests found.

Verified with :composeApp:m3Audit (all budgets met),
:composeApp:compileDebugKotlinAndroid and :composeApp:jvmTest (1085 tests,
7 new).

Replayed onto Mantra by docs/curated-to-mantra.md, in part: only its broadcast half: sendToRelays lifted into the view model's companion, the RelayOutcomeLabel widget and the screen reading it, and the GroupSignedEvent comment. The entries screen's Broadcast-all button, its view model and state, its tests, its five strings and its nav-host wiring are line D, which Mantra took out (docs/curated-to-mantra.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@d2a4298d25
2026-09-13 16:35:31 +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
2adc503613 feat(chat): the preview's button knows whether it can be pressed
Phase 4 of docs/npub-profile-preview.md. Phases 1-3 moved the twenty-second
dead end one screen later: a person found on an indexer, a press, and
InitiatingNewChat waiting for a key package that someone who has never opened
Curare will never have published. The preview now reads the answer before the
press. A room that already exists opens; a key package that is here enables
"Start new chat"; one that is not, once the relays have had their time,
disables it with the reason under it and a try-again that asks the DM relays
alone.

The existing-room check was a loop with a TODO inside initiateNewChat; it is
ChatRepository.findDirectMessageRoom now -- a room whose members are exactly
the two keys, or the one key when they are the same -- read by both screens and
pinned by a test on an in-memory database. The old size-one branch would have
opened any room the peer sat in alone, whoever they were.

Both halves are observed together, as the chat screen does: a key package
arriving after the profile touches a different table, and a collect on the
profile alone would never see it. A room is read once up front, because a key
package is meant to be used once and the relays need not hold a fresh one for
someone you already talk to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@d649098504
2026-09-13 16:32:39 +02:00
Kgothatso Ngako
5ba888d9f9 feat(ui): the new-chat dialog opens the person, and no longer starts the chat
Phase 3 of docs/npub-profile-preview.md, and the user-visible change: "Direct
message via npub" now shows who this is before anything is created. The
dialog's button reads "Find profile" and pushes ProfilePreviewRoute; the chat
is started from there.

The field keeps what it parsed rather than whether it parsed. Its check and its
decode used to be two different functions, which is how "nostr:npub1…" passed
the one and failed the other, closing the dialog with nothing to show for it;
CredentialParser.npubOrNull is the sign-in decode exposed for an address field
-- an nsec is null there, because a field asking whom to write to must never
take a secret for an answer -- and the enabled button and the route it pushes
read the same key. The nip05 branch still lands on the placeholder, and the
test says so. "Start chat" goes with its only reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@923b59ec32
2026-09-13 16:32:39 +02:00
Kgothatso Ngako
39c056a2f2 feat(ui): the profile preview screen, reachable by nothing yet
Phase 2 of docs/npub-profile-preview.md. ProfilePreviewRoute is keyed by public
key, because that is all the entrance has, and ProfilePreviewScreen draws the
four states over a constant bar: the npub above the indicator while the relays
are asked, the person -- avatar, name, nip05, the whole of the about, the npub
that was pasted -- and under them "Start new chat", which hands over the route
the dialog pushes today and creates nothing here. Not found is the sign-in
screen's sentence with a try-again; a read-only identity is shown the person and
nothing to do, and ReadOnlyEntrancesJvmTest gains the row.

Registered in the host with the room replacing the preview, as it replaces the
profile detail. No button navigates to it until Phase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@8973c0058e
2026-09-13 16:32:39 +02:00
Kgothatso Ngako
f84ae13394 feat(chat): the lookup behind a profile preview, and the sync it shares with the chat screen
Phase 1 of docs/npub-profile-preview.md. No screen yet: a view model that finds
the person behind a pasted npub and says when it has stopped looking, over the
row the device already holds -- cached first -- and two asks of the relays.

The three-kind filter the chat screen queued privately is now
DirectMessagePeerSync.requests, and the kind 0 alone goes to the indexers as
profileRequests: a key pasted from elsewhere has lived elsewhere, and a
three-kind filter sent to an indexer comes back with the kind 0 and nothing
else. "A placeholder is not a profile" was spelled out twice as a comparison
against GENESIS_AT; it is Profile.isResolved() now, and both sites read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@37c6f7eabb
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
679d5249b2 fix(groups): refuse a welcome for a group another profile on this device holds, and say so in the room
Phase 6 of docs/curated-to-mantra-profiles.md, the fourth decision's
follow-up, written natively before the several-profiles line reaches
users. ChatRoom is keyed by the group id alone, and the MLS state, the
DKG sessions and the FROST shares all hang off the room; before several
profiles on a device the second local member could not exist, and after
it a user can invite their own second profile into their own collective.

What the DAO did with that Welcome was already a refusal, by accident:
the welcome branch of indexNostrEvent checks for an existing room before
it writes one, so the joiner's state never overwrote the holder's -- but
the branch could not tell a redelivered Welcome for our own room from a
Welcome for a room another profile holds, logged "Chat Room already
exists" at warning level for both, and told nobody. The invitee never
joined, the inviter's device believed the invite had landed, and the
failure had the exact shape of "the other device never got it".

The branch now distinguishes the two by the room's userPublicKey. The
same profile's redelivery stays the quiet no-op it was. Another profile's
Welcome is refused with a warning naming both keys, and a membership line
is written into the room that exists -- the holder's, because it is the
only room on the device the group has and the holder is the one who can
act on it: leave, or tell the inviter which profile to invite instead.
The line is TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES so the
transcript draws it as a notice rather than a bubble, with the error tint
and a PersonOff icon since the person and not the invite is what it is
about; its content is a whole sentence naming the inviter, the invitee
and the holder, resolved at write time the way the other membership lines
are. The invitee's key package bundle is left unconsumed, as the branch
always left it; a second Welcome for the same package arrives here again
and writes a second line.

The test is two devices for real: an MLS group on the inviter's database,
a key package whose private keys the invitee's database holds -- the
fixture now returns the bundle beside the public package, and
marmotKeyPackageFor is a projection of it -- and the Welcome the invite
queued, sealed the way sealGiftWrapPayload seals one and stored through
storeNostrEvent under the invitee's key. Three cases: the group held by
another profile is refused, its row and MLS state untouched, the line in
the room naming all three and the bundle unconsumed; the same profile
welcomed twice writes nothing; and with no profile in the way the Welcome
is a join, which is what shows the fixture is real. jvmTest 868 -> 871,
both compilers clean, every audit budget met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:07:45 +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
72b9d3d2ed test(identity): several profiles, both ways, and a boot
Phase 8 of docs/multiple-profiles.md: the seams of Phases 1 through 7 in a
row, through the real view models on an in-memory database with a real key
store.

A is made from a phrase through the create view model and the seed writer,
the way Landing makes the first profile: both files written, and listed
once as a profile with a wallet attached, under the wallet's id, with the
credential's key. A is opened with a node behind it -- a PhoenixBusiness
that was never started, which is enough to make it one the switch has to
stop. B's npub is signed in from inside through the sign-in view model; the
switch stops A's node, B is open and read-only, the machine routes it from
its placeholder, and nothing was ever started for a public key. A kind 1
queued for A while B is open waits three seconds unsigned; back to A, and
it goes out, with nothing more stopped since B had no node. C is created
from inside through the bare-key writer: a key, not a second wallet, routed,
with no node, and the switch to it stops A's node again. C and B are
forgotten through ForgetIdentity with the view model's writers, the default
is cleared and the selector reset: A is listed alone, its credential is the
only entry left in the file, B's and C's account rows went with them, and A
itself cannot be forgotten because a wallet is attached. Then a fresh view
model over the same directory, which is what a cold boot is: the repair
finds nothing to do, A is listed from its credential rather than derived,
and StartupChoice opens it without asking.

A navigation state is matched to a profile by whichever step it is at --
UnsignedProfile, UnqueuedProfileSynchronization, UnannouncedProfile through
the event the broadcast request names, ProfileLoaded -- because with the
notary running, A's account moves from unsigned to announced-pending while
the test looks, and what the test asserts is whose account it is, not which
step it reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@88577bb5c3
2026-09-13 15:54:37 +02:00
Kgothatso Ngako
8edb52fff3 feat(ui): leaving one profile among several
Phase 7 of docs/multiple-profiles.md. Small, because the forget sequence was
built right.

Sign out of a read-only identity and "forget this key" for a bare key both
run ForgetIdentity and then the tail -- re-list, clear the default since
Phase 3, show the selector with what remains or Landing if nothing does --
and that is the right behaviour with several profiles as well; nothing in
the sequence changes. A profile with a wallet attached answers
WalletAttached from Phase 1 and is offered neither exit: its sign-out row
stays the pending route, for the reason both sign-in plans gave, and it is
now also the only way such a profile can leave, since the repair would
write its credential back. What changes is that the user is no longer stuck
behind it -- "switch profile" is the row above.

The not-found screen learns about the others. It offers "try again" and,
for a read-only identity, "use a different key", which forgets it; with
another profile on the device it now offers "switch profile" as well, for
every kind, pushing the switcher -- which has a back button, so the screen
is still there if the user changes their mind. The identity that was not
found stays listed; forgetting it remains a separate decision. The nav host
passes the callback only when the listing holds more than one.

Tests: ReadOnlyEntrancesJvmTest -- with another profile on the device the
not-found state offers the switch beside the read-only exit and it calls
through once; a signing identity that was not found gets it above the
set-up form; with one profile there is nothing to switch to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@9bb340042d
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
72afe8aa40 feat(sync): a fetch queue per identity, and the inbox reopened on activation
Phase 6 of docs/multiple-profiles.md. The one data problem, closed from both
ends.

The two queues that fetch -- SynchronizeNostrEventRequest and
NegentropySynchronizeRequest -- gain a nullable ownerPublicKey, the identity
that asked, because what a fetch brings back is opened with the fetcher's
key. The pumps ask for their own: the head of the queue among this
identity's requests and the ones nobody owns, so that a request another
identity queued waits for that identity. Rows from before the column read
back null, meaning "the device's", which is what they were, and any identity
may drain them. The broadcast queue deliberately gets no owner: a signed
event is anyone's to carry, and holding A's outgoing message until A is
opened again would be a delivery failure the user would never be told about.
Room version 20, an AutoMigration for two nullable columns, with the schema
export committed beside its predecessors.

The stamp is the repository's, not the call site's. Eighteen view models and
two DAO paths queue requests, and every one of them does so as the active
identity -- a screen cannot queue anything as anyone else. DatabaseNostrRepository
takes the identity flow at construction, which meant moving the view model
above the repositories in the nav host, and stamps every request it queues
where the caller stamped nothing; a negentropy request carries its owner
into the REQ it becomes. The DAO's own requests -- the placeholder-profile
syncs it plants while indexing, and the participant syncs of a Marmot join
-- stay unowned, on purpose and against the plan's sketch: they fetch public
kinds that need no key to open, and any profile that is open may as well
fetch them.

The other end: wraps that were fetched under the wrong key -- before this,
or by a read-only identity whose nsec was pasted later, the case the npub
plan's DAO guard left with the words "the key that opens this one may be
signed in later" and no code behind them. storeNostrEvent never indexes an
event it already holds, so a wrap that arrived under the wrong key stayed
closed for good: the live subscription re-received it, the DAO saw a known
id, and returned. NostrDao.reopenInbox finds every wrap addressed to the
active key that no seal names and runs each through indexNostrEvent again
under the right key, one transaction per wrap so that one that cannot be
opened rolls back its own changes and the next is still tried; one pass,
since wraps do not depend on one another. It runs from the sync pumps'
collector once per activation, for an identity that can sign, before the
live inbox and the broadcast pump are launched -- so the sweep and the live
subscription are not opening the same wrap at once; both are idempotent by
event id.

Found on the way: GiftWrapSeal.giftWrapMessageId, a foreign key to the wrap
a seal came out of, existed and was never written, so nothing in the
database could say which wraps had been opened. decryptGiftWrapSeal now sets
it. A seal from before reads back null, is swept once, and comes back with
the link -- the reindex is idempotent, and persistInboundChatMessage already
files a message it has once.

Tests: ReadOnlyGiftWrapDaoJvmTest -- a wrap for us stored under another
profile's key is stored and not opened, a re-delivery under our key changes
nothing, the sweep opens it once and finds nothing the second time; the
other profile's sweep and a read-only pair open nothing; a wrap stored
read-only is opened once the key is here. OwnedRequestQueueJvmTest -- A's
request is offered to A and not to B, nobody's to both, oldest first; the
repository stamps what it queues with the identity that is open, nothing
when none is, and keeps an owner a caller named.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@759199f2af
2026-09-13 15:51:48 +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
ccb9942a18 feat(identity): adding a profile from inside, as a key and not a second wallet
Phase 5 of docs/multiple-profiles.md. The two entrances, and the two things
that were only safe with one profile.

Under the list, on the switcher and on the startup selector alike, two rows:
"sign in with a key", to the sign-in screen, and "create a new profile", to
the create screen. Both push a screen that has a back button. Landing is not
one of them -- it has no back button and clears the stack on its way out,
because it is the first-run screen, and pushing it from inside would make it
a pushed screen on some days and a root on others. A device with one profile
can now be given a second, which it could not: Landing showed only when the
device held nothing.

A profile created from inside is a bare key. CreateProfileScreen generated
twelve words behind its form -- a seed, a node, a wallet, and at the NIP-06
path the key that becomes the profile -- which is right for the first
profile on a device, the one the wallet will belong to, and wrong for the
second: a user who wants another profile has not asked for another
Lightning node, another set of channels, or a second phrase with funds
behind it. The screen now takes a NewProfileWriter and does not know which
it was given: seedProfileWriter from Landing, bareKeyProfileWriter from the
switcher, thirty-two random bytes written the way a pasted nsec is.
CreateProfileRoute carries the choice. The view model writes the secret
first, then the six bootstrap events for the key it derives, then hands the
id to the tail -- so the events are only ever queued for a key the device
holds, and they are in the database before the tail activates the identity,
an ordering the sign-in screen documents as load-bearing and the create flow
used to get away with by luck. The tail gains the popUpTo(0) the sign-in
tail has. A second wallet is not offered from inside; a pasted recovery
phrase still brings its own.

"End this" goes. It wiped the database -- every profile's rooms, MLS state,
key packages and queues -- from the screen whose purpose is to add a
profile; it was only ever a development exit, and with several profiles on
a device it was a way to lose the others. wipeDatabase leaves the view model
and stays on the repository for the tests that use it.

A key already on the device is refused with the id it is listed under.
WriteNostrCredentialResult.AlreadyExists and WritingSeedState.Error.
SeedAlreadyExists carry it -- the wallet's id where a seed derives the key
-- and the sign-in screen's error state gains one action, "switch to it",
which is the sign-in tail with that id. From Landing "already on this
device" was the whole message; from inside it is a sentence with an obvious
next step, and a duplicate paste becomes the fastest switch in the app.

Found on the way, and fixed in the jvm actuals: DataStoreManager caches one
GlobalPrefs per process behind an unsynchronised check-then-set, and
DataStore refuses a second instance over the same file. Two first calls at
once -- the listing on IO and a sign-in's write, or the startup screen on
Main -- could both see the empty cache and both build one, and the loser
threw "multiple DataStores active for the same file" at first use. The test
for this phase hit it one run in three. JvmGlobalPrefs is now the one way
the jvm target reaches the prefs, under a lock; Android goes through the
Application's single instance and never raced; the library's own callers
come at node start, when the cache is long populated.

Tests: AddProfileFromInsideJvmTest, through the real view models on an
in-memory database -- A open, B's nsec committed and switched to, and back,
both accounts intact with their own kind 0; A's key pasted again refused
with A's id; a profile made through the bare-key writer lists as a bare key
with the kind 0 first of its six bootstrap rows, none signed yet, and no
node ran. SignInToProfileViewModelJvmTest and IdentityWriterJvmTest assert
the ids the three refusals carry. ProfilesScreenJvmTest: the two rows call
their callbacks and switch nothing, under the caption that the open profile
stays. CreateProfileScreenJvmTest: the state that carried "end this" is
composed and the words are not there.

Replayed onto Mantra by docs/curated-to-mantra.md: CreateProfileViewModel.kt: this commit's import block taken whole -- Mantra had already moved the nostrPublicKey() import to the library's (39fb64b6), and this commit removes the seed derivation that import served; the file is identical on both sides afterwards (docs/curated-to-mantra-profiles.md, the third decision).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@2f42023081
2026-09-13 15:49:11 +02:00
Kgothatso Ngako
5654e4624b feat(ui): the switcher, showing the nostr profile and what the device holds for it
Phase 4 of docs/multiple-profiles.md. One pushed screen, and the startup
selector brought up to match it.

ProfilesRoute, reached from the profile tab's row -- "Switch profile", where
it said "Change account" and went to the pending screen -- and drawn by
ProfilesScreen: a top app bar with a back button, and WalletsSelector as its
whole content with the open profile above the divider and the others below.
Tapping another row is switchToIdentity and nothing else; the observer
restarts the app as that identity through its lock gate, so the screen is
never around to see the result. Tapping the open row does nothing. Three
states, said so: loading while the list is read, which in practice is never
seen; an unreadable key file, through ErrorState with a retry that re-lists;
and the list. Empty cannot happen while something is signed in and the screen
does not pretend it can. One column at every width. The row is offered to
every kind: a read-only identity can leave for another profile the same way
a signing one can.

ProfilesViewModel joins the flows the app already holds -- the listing state,
the identities, the active identity, the wallet metadata -- to the profile
rows for the listed keys, through NostrRepository.observeProfilesOf, one map
that moves when any of them does. Nothing in it writes.

What a row shows changes. WalletsSelector showed "Default name" over a random
emoji for every row, because nothing in this app writes the wallet metadata's
name, and two profiles side by side both called Default name and told apart
by a bech32 string is not a switcher. The row now takes the nostr profile --
humanReadableNameOrPubkey over ProfileAvatar, the widgets the profile tab
draws itself with -- with the npub on the second line, and falls back to the
npub over the metadata's emoji for a key the device has no kind 0 for yet:
one just created, or a read-only one that was never found. And it says what
the device holds for the profile before the tap: "Wallet" for a seed
attached, beside the "Read only" that was already there, and nothing for the
plain case of a bare key. It is the difference between a profile that can be
signed out of and one whose sign-out is a wallet question, and between two
profiles with the same name. The startup screen draws the same widget and
gets the same map through rememberProfilesOf, so both lists look the same.
The selector's dead globalPrefs parameter goes with the rewrite.

Tests: ProfilesScreenJvmTest, on the unmerged tree, with a wallet-attached A
open, a bare B and a read-only C -- A by its nostr name with its npub and
"Wallet" under it, B and C by their npub with only C labelled, no "Default
name" anywhere, the open one drawn above the others; tapping A calls
nothing and tapping B calls the switch once with B's id; an unreadable key
file is an error whose retry is counted; the bar has a back button.
ReadOnlyEntrancesJvmTest asserts "Switch profile" for both kinds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@25464595f4
2026-09-13 15:49:11 +02:00
Kgothatso Ngako
73a0e5b9c2 feat(identity): which profile opens, on launch and after a switch
Phase 3 of docs/multiple-profiles.md. The startup screen learns to agree with
the switch.

The table that picks what to open is lifted out of the screen's remember into
StartupChoice.resolve, where it can be read and tested, because its order was
the whole decision and it was wrong in the one way a switch would hit:
"!startWalletImmediately -> null" sat above "desiredWalletId != null", and
nothing ever set the flag back to true. So after the first visit to the
selector, every sign-in on that device landed on the selector instead of the
profile it had just signed in. Two rows move: what a switch or a sign-in
named goes above the user's earlier "show me the list", since it is the more
specific instruction; and the single-identity row drops below it, since when
both are set they name the same thing.

The default is written. GlobalPrefs.getDefaultWallet has been read by startup
since Phoenix and saved by nothing in this app, so a cold boot with two
profiles was the selector every time with no memory of which one was open.
setActiveIdentity now saves the id it activates -- every activation goes
through it, startup for all three kinds and the two tails through startup --
so the default is always the profile most recently open. The two forget tails
clear it, since the default is the one memory of an identity that would
otherwise outlive it. Both writes are wrapped: a default that could not be
recorded is a selector on the next boot, not a crash now. The screen reads
the saved value as a default only when it names a wallet, which is the one
thing left to decide at the call site.

The startup screen's literals go to the catalogue, and "wallet" goes with
them except in the one place a wallet is what is starting: "Starting wallet"
is shown from StartupViewState.StartingBusiness, which only the branch with
a wallet attached reaches, and stays. The rest become "Preparing profiles",
"Opening profile", "Decrypting", "Loading preferences", "Unlock to continue"
and "Could not load the profiles on this device"; the selector's title is
"Choose a profile", and select_a_wallet goes.

Tests: StartupChoiceTest in commonTest, one case per row and the two
orderings this phase is for -- a desired id opens even after the user once
asked for the list, and one identity with the list asked for still shows it.
IdentitySwitchJvmTest gains the cold boot: activate A then B, and a fresh
view model over the same directory resolves B without asking; forget the
default, and it resolves nothing. Found on the way and recorded in the test:
DataStoreManager caches the global preferences once per process, bound to
whichever directory was current when the first test in the JVM asked -- the
same trap the nsec plan recorded for user preferences -- so the test reads
the default through the view model's own instance rather than one of its
own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@00fc996995
2026-09-13 15:49:11 +02:00
Kgothatso Ngako
84ac84ccaf feat(identity): a switch that tears down what it should
Phase 2 of docs/multiple-profiles.md. The transition, made correct, with
nothing yet calling it from a screen: the view model, one manager, and three
one-line actuals.

switchToWallet becomes switchToIdentity, and does what a switch is: remember
which one, set startWalletImmediately back to true -- a switch is the user
saying which one, and the flag was only ever the user saying "show me the
list"; nothing set it back before because nothing could switch -- clear the
active identity, and stop the node of the profile being left, if it had one.
The identity is cleared before the node is stopped, so that every collector
that could reach the business is cancelled before the stop runs. The test is
business != null, not the kind: whether the profile being left has a node
behind it is the fact, and the kind is how it currently comes to be true.
stopPlatformBusiness is an expect beside updateBusinessActiveInUI, for the
reason that one is: BusinessManager is a per-platform object. Its three
actuals call stopBusiness, which existed on every platform and nothing
called. The manager is injected into the view model as a function so that
the branch can be pinned without a node.

RelaysSocketManager.observeActiveUserId becomes a child of collectLatest --
the shape the pumps and the notary already had. It used to keep one job per
pubkey on its own scope and cancel only the job for the pubkey being
started, which meant a switch left the previous identity's observer running:
two observers feeding updateRelayPools, and the one pool following whichever
relay list emitted last. The map goes; a null identity closes nothing, since
the pool is shared by pumps a null identity has already cancelled, and the
next identity's list replaces it through changeRelays as it always did. The
scope is injectable and relayUrls is exposed, both for the test.

The sign-in and create tails keep their own navigate beside the observer's,
now with a comment saying why: the navigation state is a StateFlow, an
update to a state equal to the current one emits nothing, and the explicit
navigation is what guarantees the stack moves even on the day the state
does not.

Tests: IdentitySwitchJvmTest, through the real SovereignWalletViewModel with
a real notary and navigation machine on an in-memory database -- A open and
a kind 1 queued for A is signed; switch to B, the identity clears, the
machine goes to startup, B is activated the way startup does and routed from
its account; a kind 1 queued for A now waits three seconds unsigned, and
goes out when A is back. Leaving a profile with a PhoenixBusiness behind it
-- constructible without a node, since everything in it is lazy -- stops
that node once; leaving nothing, or a bare key, stops nothing.
RelaysSocketManagerSwitchJvmTest over a fake relay repository and fake
sockets: after the switch the pool holds B's relays, a re-emission of A's
list changes nothing -- the line that fails against the old observer -- and
an identity whose list is still empty leaves the pool as it was, which is
the behaviour updateRelayPools has always had for an empty list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@9b3a19278f
2026-09-13 15:49:11 +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