Commit Graph

758 Commits

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:06:46 +02:00
Kgothatso Ngako
1c59ed34d7 chore(library): track the submodule's default branch
.gitmodules names master as the branch lightning-kmp-app tracks, so that
`git submodule update --remote` follows the library's default branch
rather than whatever this git's fallback happens to be. The pinned commit
is unchanged: the app still builds against 84cc44c on
claude/nostr-credentials, one commit ahead of master, until that commit is
merged and the pointer moved to master's tip -- which is what tracking
master says should happen.

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@f866b9d17b
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
c53a6f77cc test(identity): the preview end to end, with nothing signed, and the upgrade
Phase 7 of docs/npub-sign-in.md.

NpubPreviewRoundTripJvmTest is the nsec round trip with the repository
made real: an in-memory database under the app's own DAOs, because the
assertion this exists for is about what is not in it. An npub is signed in
the way the sign-in screen does it, listed the way startup does, activated
as a read-only identity, and handed to NavigationViewModel: it lands on
UnqueuedProfileSynchronization. The kind 0 the relays would answer with is
indexed through the read-only key pair: ProfileLoaded. And with the real
NotaryViewModel watching the same rows for longer than its key package
delay, nothing was signed -- the only unsigned row for the pubkey is the
placeholder, still at genesis; nothing is queued for a signature; no key
package bundle; no broadcast request; no node.

The contrast that makes those assertions worth having: the same harness
as a signing identity does sign -- the notary makes a key package bundle
within its delay. Without it, "nothing was signed" could be true of a
harness in which nothing can be signed.

Then the upgrade: the nsec of the key held read-only signs in over it
through the same view model, under the same id, leaving one credential
that is now a secret, one listed identity, and one account -- the second
sign-in planted nothing.

The full jvm suites pass: 988 in the app, 159 in the library.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@315e93315a
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
786ac15959 feat(identity): two exits for an identity that holds no key
Phase 6 of docs/npub-sign-in.md. Leaving: one sequence, two doors.

ForgetIdentity is NostrSecretViewModel.forgetKey's sequence lifted out --
the credential out of the file, the metadata hidden, the account rows
gone, in that order so a failure partway leaves the credential on disk
rather than an identity the selector lists but nothing can open. Since the
credentials file the first step is the same call for both credential
kinds, so it is one function; a mnemonic identity is refused at the first
step, because removing a seed is a wallet question this does not answer.
The nsec view model calls it now and keeps only its own state.

Sign out on the profile tab does, for a read-only identity only, what its
colour has been promising: a confirmation naming the npub -- this device
holds no key for it, so there is nothing to lose; the profile stays on the
relays -- then the identity leaves the device and the nav host's tail
re-lists and clears the active identity, which shows the selector or, if
this was the last one, Landing. It is the first real sign out in the app.
For the other two kinds the button keeps its pending route. SignOutViewModel
owns the confirmation and the in-flight state; SignOutDependencies bundles
what the screen needs so that a caller with none of it -- previews, tests
-- passes nothing.

The not-found screen had, for a read-only identity, no exit: Phase 5 hid
the set-up form (a kind 0 has to be signed), the profile tab is not
reachable before ProfileLoaded, and a user whose npub was found on no relay
could try again for ever. So a third action, shown only where the second is
not: use a different key, which is the same sequence behind the same
dialog, reached from the other end of the identity's life.

Tests: the sequence and where it stops, the view model's states around it,
and the two screens composed as each kind -- the profile's sign out asking
first and naming the npub, the not-found screen offering the form to one
kind and the other key to the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@a586b7c116
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
f5a6f74731 feat(ui): what a read-only identity is shown, and what it is not
Phase 5 of docs/npub-sign-in.md -- the half the nsec plan called a
product.

One question, asked in one way. LocalCanSign is a composition local for the
snackbar host's reason: screens do not receive the identity,
MetadataEventDetail -- where follow and send message live -- is several
composables below anything that could be handed more, and a parameter
threaded through twenty-six lists is forgotten in the twenty-seventh.
ProvideSigningCapability sits once above the navigation suite. The default
is true rather than an error, the one way this differs from the snackbar
host: the provider cannot be forgotten per screen, so the only things
composed outside it are previews and tests. And it is a capability, not a
kind -- "can this identity sign?", not "is this an npub?" -- so that a
remote signer is not a fourth value in every when.

The inventory, hidden rather than disabled because the empty state beside
each says why: HomeScreen's New chat in both layouts, its sheet and its
npub dialog; MetadataEventDetail's follow, unfollow, follow back, edit
profile and send message (the "follows you" state still shows -- a fact,
not an action); ActiveProfileScreen's key package management and key
recovery; ShareProfileScreen's re-broadcast, which signs nothing but
queues a copy for the finder relays, and a read-only identity puts nothing
on a relay; SocialPreconditionScreen's invite and view invites, pending
today and writes when they exist; and the not-found screen's set-up form,
since a kind 0 has to be signed. Everything else that writes is behind a
chat room, which a read-only identity can never open.

The Messages tab, for a read-only identity, is an EmptyState where the
rooms would be -- one column at every width, since a two-pane layout is a
list beside a detail and there is no list -- whose message says which
absence it is and whose action is the upgrade: Sign in with the nsec,
which opens the same screen as landing's, hits the credentials file's
upgrade rule, and comes back through startup with rooms in it.
ChatRoomListViewModel is not composed, so the inbox sync and the MLS
negentropy it would queue are not queued.

Tests: ReadOnlyEntrancesJvmTest composes the home and profile screens
under each value of LocalCanSign and looks for the controls by text, on
the unmerged tree so that "does not exist" is not vacuous. The M3 audit's
budgets hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e31033e857
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
4c04d3b12b feat(sign-in): an npub, recognised, confirmed as read-only, then written
Phase 4 of docs/npub-sign-in.md. The field accepts a third thing.

CredentialParser recognises npub1... and nostr:npub1... as
SignInCredential.NostrPublicKey: thirty-two bytes under the npub prefix
that name a point on the curve -- the counterpart of the isValid() a
secret is checked with, since not every x coordinate has a point above it.
PublicKeyOnly goes, and its string with it; an npub that does not decode,
or names no point, is InvalidKey. Hex stays a secret: a private key and an
x-only public key are the same size, the confirm step shows the derived
npub so a public key pasted as hex is visible for what it is, and guessing
would never fire when it mattered, since an x coordinate is almost always
also a valid scalar. The test pins the vector's own public key, pasted as
hex, landing on a different npub.

The confirm step's third arm says what the user is about to get and not
get, once, before the choice: this profile and the people it follows;
messages closed and nothing sent; paste the nsec later to open it. The
landing caption, the field's label and its placeholder name the third
input, the last with "to look around" for a user who does not know the
word read-only. The view model takes the third writer the way it takes the
other two and commits through one shared write-and-map.

signInToProfile is idempotent by pubkey. Until now nothing called it twice
for one key -- the writers refused a second sign-in before it got there.
Pasting the nsec of a key held read-only is the first path that signs in a
pubkey whose account already exists, and a second placeholder would be two
kind-0 rows for one pubkey, two accounts disagreeing about which is this
one. A repository test signs in twice and counts one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@bfad1f39a2
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
6532bfc45f feat(identity): list, start and read as an identity that holds no key
Phase 3 of docs/npub-sign-in.md.

listIdentities runs the migration from nostr-keys.dat before its read --
there rather than inside the reader, so a listing that writes is a named
step and not a surprise, and from init, before anything else touches
either file. An old file that cannot be read is left where it is and
reported the way an unreadable credentials file is, so the user sees an
error rather than silently losing every imported identity. The selector
says "read only" under the npub of a public-key identity, before the tap:
two identities can share an avatar and a name, and only one of them will
let the user send a message.

Identity.toKeyPair is now the one place a quartz KeyPair is built from an
identity. With a secret it is the pair as before, the public key a
cross-check; without one it is quartz's read-only constructor. What it
must never be is KeyPair(privKey = null) with nothing else -- that is
quartz's "make me a new key" -- and decryptGiftWrapSeal builds exactly that
from a forwarded pair, which is why the helper has the trap on it.

The synchronization view model runs the two pumps that read for every
identity and the two that write only for one that can sign. Stated as a
rule because Phase 5 leans on it: a read-only identity never sends
anything to a relay, not a signature and not a copy. The broadcast pump
would find nothing, and the live subscriptions are the gift-wrap inbox and
the group-membership follow, both fetching what this identity cannot
open.

One guard in NostrDao.indexNostrEvent, after the isAddressedTo check: a
wrap addressed to a key the pair does not hold is kept, event and wrap,
the way someone else's mail already is. storeNostrEvent is @Transaction
and indexes inside it, so before this the unseal ran with a key nobody has,
threw, and the throw took the event with it while logging as a decryption
failure. Verified both ways: the new DAO test fails on exactly that case
with the guard removed and passes with it, and the same wrap opens once
the pair holds its key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@7db863e392
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
d5bd248364 feat(identity): one credentials file, with a read-only kind and the upgrade in one write
Phase 2 of docs/npub-sign-in.md: the library half is lightning-kmp-app
01962f3 (claude/nostr-credentials), bumped in here; this is the app half.

nostr-keys.dat becomes nostr-credentials.dat, one typed entry per public
key -- a secret, or only the public key -- so that a profile signed in to
read-only and the same profile with its nsec pasted later are one entry in
one file. StoredIdentity gains NostrPublic, whose id is the one its secret
would have (hash160 of the x-only key), and merge reads the typed map. It
keeps the one precedence it needs: a public entry for a key a seed already
derives lists the wallet only, which is the state the seed writer's two-file
upgrade can leave behind if it dies between its writes. A secret for a
seed's key is still listed twice, as before -- that is a duplicate the
writers refuse, not a state the listing hides.

IdentityWriter: writeNostrPublicKey beside writeNostrKey, both refusing a
duplicate by public key against the seeds and the credentials, with the one
exception that is the point of the file -- the nsec of a key held read-only
is not a duplicate, since the device does not have that secret. writeNostrKey
replaces the public entry with a secret one in a single write, under the id
it already had, so the read-only identity's preferences are the ones the
signing identity keeps. writeMnemonic has to span two files for the same
upgrade and removes the credential first, then writes the seed: a crash
between the two loses the read-only identity, which the npub pasted again
restores, rather than listing one npub twice under two ids. forgetNostrKey
becomes forgetNostrCredential and removes an entry of either kind;
NotABareKey becomes NotACredential and still means a mnemonic.

The startup screen's third branch is here rather than in Phase 3 because
StoredIdentity is sealed and the compiler asked for it: a read-only
identity is active the moment it is read, as the nsec one is.
NostrSecretViewModel reads the secret entry and answers a public one with
"no key for this identity", which the screen it belongs to will never show
once Phase 5 hides the row.

Tests: the writer's upgrade in both directions -- the nsec of a read-only
key accepted under the same id, the npub of a secret refused -- and forget
of either kind; merge listing all three kinds, the shared id of a public
key and its secret, and the seed-over-public precedence.

Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 84cc44c: upstream pinned 01962f3, a branch commit since rebased onto the library's master as 84cc44c with an identical tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@5efeae769f
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
cf43973b36 feat(identity): a key the identity may not have
Phase 1 of docs/npub-sign-in.md. A type change and nothing a user can see.

Identity.nostrPrivateKey becomes nullable and nostrPublicKey becomes a
constructor field, because the kind this plan adds -- NostrPublic, a bare
public key -- has nothing to derive a pubkey from. A data class would let
the two disagree, so two checks in init refuse an identity whose kind and
key disagree about whether there is one, and one whose key does not derive
the public key it was given. Identity.signing derives the pubkey and refuses
the read-only kind; Identity.readOnly builds the other; nothing else
constructs one now. The id of a read-only identity is toWalletId() of the
x-only key -- the same id its nsec would have -- which is what will let a
read-only identity become a signing one and keep its preferences.

canSign is a property of the identity rather than nostrPrivateKey != null
at each site, so that a signer with no local key has one place to answer.

Every reader of the key already reached it through ?. on a nullable
identity, so the compiler forces nothing here; the sites that need a
decision are the plan's Phase 5, found by reading. The one site that did
change is KeyRecoveryScreen, whose two whens branched on NostrSecret with an
else that meant "mnemonic": both are exhaustive now, so the new kind is not
handed the phrase option by default.

Tests: NavigationIdentityRoutingJvmTest gains a read-only fixture with the
same id and pubkey as the secret one, the routing case for it (by its
account, like any other), and the three refusals plus the factory's own.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@78807fe956
2026-09-13 12:03:15 +02:00
Kgothatso Ngako
c5c89c8d52 feat(groups): a row per kind of signed work on the group's screen, each opening its own
The nostr profile, the relay lists, the posts, the curated schemas and the
curated entries were five sections of cards on the group's screen, every card
with a button under it, which made the screen as long as everything the group
had ever said, with its members and its subgroups somewhere underneath. Each
section is now a row -- a card with an icon and a chevron, the shape of the
signing key row above them -- carrying the one line a member wants at a
glance: the group's name and address, how many of the four lists it has
agreed, and a count of posts, schemas and entries. The row opens a pushed
screen holding what the section held, unchanged: the cards, the broadcast
under each signed event, the queue under each schema, and the gated ways into
the editors, which sit inside the empty state where there is nothing yet. The
paste stays on the group's screen at the end of the block, since it is about
the block rather than any one row, and the whole block is still absent in a
NIP-17 room for the reason it always was.

Five routes, screens, view models and states, each reading only what its
screen shows plus whether this device can sign. The broadcast button becomes a
widget, since five screens draw it, and inComparableGroups moves to the key
extensions beside shortened, with the doc comment that had been orphaned from
it. The section tests become a fixture object and six tests: one for the rows
-- their order, what each says, which route each opens, their absence in a
NIP-17 room -- and one per screen for what the sections' tests used to check.

Replayed onto Mantra by docs/curated-to-mantra.md: GroupNostrProfileSectionJvmTest.kt: deleted, as this commit deletes it upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@55664cc7d4
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
b5cd41cb3d fix(ui): a back button on every pushed screen, and one spelling of it
Eighteen screens drew a back arrow in their app bar's leading slot and twenty-two
pushed screens did not. On Android the system back stood in for it and on iOS the
edge swipe, but the desktop target has neither, and a screen reached by
`navigate(...)` with no way to pop it is a dead end there: seven group forms, six
chat screens, five DKG screens, and four screens with no app bar at all.

**One widget, `NavigateBackButton`, rather than a nineteenth inline copy.** The
eighteen that had the button spelled it four ways -- `Icons.Filled.ArrowBack`, the
auto-mirrored one, `ArrowBackIosNew`, and a `"Back"` literal against a
`stringResource`. The widget decides twice: the icon is the auto-mirrored one,
because "back" points at the leading edge and the leading edge is on the right in an
RTL locale, which is what `Icons.Filled.ArrowBack` is deprecated for; and the
description is the catalogue's, because it is text. All forty-one sites use it now,
the eighteen converted mechanically with the imports they no longer need dropped.

**Each screen takes `onNavigateBack` and the host passes `popBackStack()`.** Hoisted
rather than read from a controller in the screen, so every one stays previewable
and testable without a nav host, which is how the twenty-two were fixed without a
nav host in a single test.

**Four screens had no bar to put it in, and got one.** `ChatRoomCreationScreen` is
"New chat", after the button that opens it. `CreateProfileScreen` is "Create
profile", and the two body headlines that repeated the name are gone, which is the
shape `SignInScreen` beside it on the landing page already has. `WriteNewNoteScreen`
is "New note" whether it is a reply, a quote or neither, one new string. The
"coming soon" placeholder's bar is titled with the name of what was tapped.
`AddMemberToChatRoomConfirmationScreen`'s bar had been commented out; it is back,
titled "Invite new member" since the body already names who and which room.
`NostrEventDetailScreen`'s repost and unknown-kind branches were the last two
placeholders without a bar.

**Two screens are reached two ways, and only the caller knows which.** Their
callback is nullable, and null draws no button. `ChatRoomMessagingScreen` is a
destination on a phone and the home screen's detail pane in an expanded window;
in the pane the room list is beside the transcript and a button that popped would
pop the home screen, so the pane passes null. `ImplementationPendingScreen` is
pushed from "learn more" and "edit profile" and is also where the navigation
observer lands with `popUpTo(0)` on an error; the host reads
`previousBackStackEntry`, remembered at first composition because the departing
screen reads it again after the stack has moved on. The DKG approval screens reuse
their existing `onDone`, which "Not now" already called -- the bar makes the same
leave reachable from the loading and error states, which had no other way off.

**Left alone on purpose.** The three top-level destinations have the navigation
bar, which phase 6 put there instead of app-bar icons. The onboarding and loading
screens cleared the stack to get where they are and have nothing under them.
`SearchScreen` and `SearchResultScreen` keep their `ArrowBackIosNew`: it is a search
bar's collapse control in a `leadingIcon` slot, not a navigation icon.

`NavigateBackButtonJvmTest` covers the two conditional screens by the description a
screen reader would announce: present and popping when pushed, absent in the pane
and at the root. The rule is recorded in CLAUDE.md beside the others a new screen has
to follow.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (938
tests, 4 new) and docs/scripts/m3-audit.sh --check, all budgets met.

Replayed onto Mantra by docs/curated-to-mantra.md: CuratedSuggestionListScreen.kt: taken as the original merge fedbe724 left it, this being the branch join; AcceptCuratedSuggestionScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts; BroadcastGroupSignedEventScreen.kt: brought to the state the original merge fedbe724 left it in, an edit that merge made outside its conflicts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@0634487e12
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
a204279e2a feat(groups): the entries the group has accepted, under the schemas on its screen
A section for the kind 31890s the group signed, read off GroupSignedEvent the
way the profile, the posts, the relay lists and the schemas are. The reading
gate is theirs -- the room as author, the room's real signature -- plus two of
its own: an entry is matched to the list it replies to by the coordinate in
its a root, and checked against that list's fields exactly as the queue checks
a copy off a relay, so what this section calls accepted is what the queue will
mark curated once the entry is broadcast and read back. Newest per coordinate,
since re-curating is a newer event under the same d.

Each card names the list, gives the queue row's glance of what the entry is,
credits the suggester, and dates the group's decision; under it, the same
broadcast button every signed event in the block has, gated on nothing. There
is no way to add one here, because an entry is accepted from the queue on the
schema's card above, and the empty state says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@732a4527c6
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
8d32a69bf3 feat(groups): accept a suggestion into the list, edited, as an entry for the group to sign
The queue's sheet used to end in the suggestion's JSON and a button to copy
it. Now it ends in the one thing a member can do about a suggestion: take it
into the list. That opens a form seeded from the suggestion -- an input per
field the schema asks for, enums as chips, one input per value where a field
repeats -- and a button that proposes a kind 31890 for the room's quorum to
sign, pointing back at the suggestion it came from.

The write side of the NIP is ported for it: CuratedEntryEvent.canonicalTemplate
follows bitcoin.mov's buildCuratedCanonicalTemplate tag for tag, and walks the
same field list as the reader, so what the form proposes is what the queue
reads back. The one deviation is that nothing is clipped on the way through; a
value the schema refuses is named under its input instead, by the queue's own
verifier, before a quorum is spent on it. The d is kept whatever field writes
it, so accepting twice revises one entry rather than making two, and derived
fields are kept as the suggester's client filled them in, since this app
derives nothing.

After the quorum signs, a 31890 has somewhere to land: the signing screen says
which entry and who suggested it, the transcript gets a line naming the list,
and the broadcast screen seeds the schema's relays, which the NIP makes a MUST
and which the entry only names by coordinate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@fcc19f9500
2026-09-13 12:01:41 +02:00
Kgothatso Ngako
39b1c714b8 feat(groups): a broadcast button under each signed event, and the relays it goes to
Until now nothing a group signed reached a relay. `FrostSigningManager.complete`
files the batch as `GroupSignedEvent` rows and applies it locally, and the profile,
the relay lists, the posts and the schemas are all read off those rows -- the docs
on each said so, and the only way out was the copy button on the key-state sheet.
This is the way out: a "Broadcast" button under every signed event in the identity
block, opening `BroadcastGroupSignedEventScreen`, where the member sees and edits
the relays it will go to, presses once, and reads what each relay answered. The raw
JSON is at the bottom for anyone who would rather use another tool.

**The send goes to the relay pool directly, not through the broadcast queue.**
Everything the app publishes as itself goes through `BroadcastNostrEventRequest`,
which is durable and retried, and it is not used here on purpose. The queue hangs
off a `NostrEvent` row, and a row for the group's event is more than a queue entry:
the home feed selects by kind, so the group's post would appear in it as anybody's;
and the sync loop offers every local event id to the relays it reconciles with, so
the event would reach relays the screen never named -- which would make "edit the
relays" a fiction. `GroupSignedEvent` already explains at length why it is not a
`NostrEvent`; this keeps that true. `EventPublishTransport` is the slice of
`RelaysSocketManager` the screen needs, cut the way `LiveSubscriptionTransport` is
and for the same reason: the manager cannot be stood up in a test. The cost is that
a broadcast is only as durable as the button press. Each row says what its relay
said, and a relay that did not answer is sent to again by pressing again.

**Behind no gate.** Every other button in the block is hidden from a member holding
no share of the key, because each proposes a signature by the group. Sending what
the group has already signed takes no share: the signature is the group's whoever
repeats it, and any member could paste the JSON into another client today. So the
button is there for whoever is looking, the way the suggestion queue is.

**Where it goes before anyone says otherwise** is `defaultRelaysFor`: the group's
General list where it has agreed one -- its write relays, and nothing from the app's
set beside them, since a member who edited the relays meant them -- and the app's
publish set where it has not. A relay list also goes to the indexers, because a list
sent only to the relays it names is circular; a curated schema also goes to the
relays it names for its own replies, so a client reading suggestions there finds
what they answer. Nothing goes to a relay the group has blocked. The seed happens
once, so a list the member emptied stays empty.

**Each relay's answer is shown in the relay's words.** The pool answers once per
relay in three shapes -- an OK, a rejection wrapped in `NostrPublishException`, and
any other error -- and the rows keep them apart, because "blocked: not on the allow
list" is something a member can act on and a red icon is not. A relay still
unanswered when the whole send runs out of time reads "No answer", which is not the
same as refused. Answers are matched by host, since the pool names a relay by the
URL it first opened a socket with.

The relay-list rows get their button inside the shared card, under the row and at
the end, because the four rows share one card and "under the event" has to mean
under the row. An agreed empty list gets one too: a withdrawal is a statement, and
relays still holding the old list need to hear it. The key-state event and the
subgroup certificates get none -- `ChronicleManager` keeps the key state off even
the members-only path, and a public relay is a bigger audience than that.

`BroadcastGroupSignedEventViewModelJvmTest` pins the seed rules, the once-only
seeding, the three answer shapes and the host matching, and that what goes on the
wire is the seven fields the group hashed. `BroadcastGroupSignedEventScreenJvmTest`
presses the button against a fake pool and finds each answer under its relay with
the sum above them. `GroupNostrProfileSectionJvmTest` counts five buttons on a group
with a profile, two agreed lists, a post and a schema, each under its own card, and
finds them still there for a member with no share.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@2326839e48
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
9e6aa382af test(identity): the restore round trip, and that no node ran
Phase 7 of docs/nsec-sign-in.md. Phases 2 through 6 each pin one seam; this is
the seams in a row, with the network faked at the repository.

NsecRestoreRoundTripJvmTest unlocks the jvm key store into a temporary
directory, writes an nsec the way the sign-in screen's commit does, lists
identities the way startup does (both stores, merged) and asserts the key is
listed under the id the writer returned, builds the Identity the startup
screen's NostrSecret branch builds -- business = null -- and hands
NavigationViewModel a device holding the placeholder account the sign-in
planted. It must land on UnqueuedProfileSynchronization: fetch, do not
create. Then the device's account gains an indexed kind 0, as the rows would
when the relays answer, and the same observer must move to ProfileLoaded.

And the assertion nothing in the UI would reveal: no Lightning node ran. The
identity has no business behind it, and the jvm BusinessManager's flow of
running businesses is empty afterwards. An nsec identity that quietly
started a node -- Electrum, the LSP peer, the watchers -- would be a bug
with no visible symptom, which is why it is asserted here rather than
noticed later.

The device fake holds its account in a MutableStateFlow rather than a
flowOf, so the second step is the observer seeing a change, as it would in
the app, not a second call.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (902
tests) and :composeApp:m3Audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@647ee815c1
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
e2295b3971 feat(recovery): the key behind an identity that has no phrase, and the way to forget it
Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the
recovery phrase, whose screen reads userWallet.words out of the seed map. For
an identity signed in with a bare key there are no words, and the honest
answer that screen would give is "this device holds no phrase for the
profile". So:

KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity
keeps the phrase option. A NostrSecret identity gets a nostr secret key option
in its place, routing to NostrSecretRoute, with its own status line ("you said
you stored it") and a header that no longer promises coins. The backup flags
underneath are the same two per-identity preferences the phrase screen
writes; they already mean "this identity's secret is not backed up" for
whichever secret it is.

NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the
nsec: hidden until revealed, revealed in monospace, selectable, with a copy
action -- nobody transcribes sixty-three characters by hand -- the same two
checkboxes, hidden again on leaving. The view model reads the key from
nostr-keys.dat at reveal time, by the identity's public key, not off the
active identity, so the screen's contract -- nothing secret held longer than
it is shown -- is the phrase screen's. The disclaimer is a new string: the
old one said "...and the funds in its wallet", and this identity has no
wallet to warn about.

Forget this key. An import needs an inverse, and this is the first real "sign
out" in the app; ActiveProfileScreen's button still routes to a pending
screen, and the general case stays there, because removing a seed is a wallet
question with funds behind it. Behind a dialog that names the npub and says
the profile stays on the relays, four effects in an order that matters: the
key out of nostr-keys.dat and the identity's preference files off the disk
(IdentityWriter.forgetNostrKey, which refuses a key that is not in the file
-- a wallet's nostr key lives in the seed); the metadata entry hidden, since
the metadata store has no delete and isHidden is what the selector filters
on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0
that made it a local account and anything queued that can now never be
signed, with the requests that hang off them cascading; published events
and the profile cache stay, as anyone else's would); and only then the
caller told, which re-lists identities and clears the active one so the
navigation observer sends a null identity to startup. A failure at the first
step leaves the rest untouched -- an identity the selector lists but nothing
can open is worse than one that is still there.

Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key
store and a real directory: a key written once and refused the second time
by public key, with its preferences file created; forgetting one key leaves
the other and takes its preferences with it; a key that is not in the file is
not forgotten. Its keys are fresh per test rather than fixed, because
DataStoreManager caches each id's preferences in a companion object for the
life of the process, and a fixed key was served the UserPrefs an earlier test
had created in an earlier temp directory -- worth knowing about that cache.
NostrSecretViewModelJvmTest records the four effects and asserts their order,
and that a key that could not be removed stops the sequence at one.
ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded
requests go while an unrelated account stays.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901
tests) and :composeApp:m3Audit.

Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e3cc23ae31
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
347cf26d83 feat(sign-in): ask the relays that would know, and say when none of them did
Phase 5 of docs/nsec-sign-in.md. Not nsec-specific: a restored recovery phrase
whose profile was never published, or a device offline at sign-in, sat on the
same spinner. An imported nsec is the first path that hits it routinely --
many nostr keys are made in a client that never wrote a kind 0.

Ask the relays that would know. The sign-in sync fanned its REQ out over
Relays.DefaultDMRelayList, which is listOf(ephemeral): our own relay, alone.
The right answer for a profile this app created; an identity that has lived
on Damus for three years has never heard of it. SignInSync now holds the one
definition three call sites want -- the kinds, the request builder and the
bootstrap set, which is every indexer relay plus ours. Indexer relays exist
to hold everyone's kinds 0, 3 and 10002; that is exactly what the sync asks
for.

Then follow the answer, once. When a level-0 sign-in request brings back the
user's own kind 10002, the sync pump queues the same request at level 1 at
the relays that list says they write to, less the bootstrap set already
asked. The outbox model doing what it is for: the indexers know *where* the
user publishes, and the user's own relays are where the rest of their lists
are authoritative. Write relays, not read relays -- asking a user's inbox for
their own events is the mistake the split exists to name. One hop per
account per session, because every bootstrap relay that holds the list
answers with it; and a level-1 request never re-enters, because past the
user's own relays lies the feed, not the profile.

Say when none of them did. A request went pending -> sent when its REQ was
dispatched and sent -> processed only if an event arrived for it; a relay
that answered with EOSE and nothing else left its row at `sent` for good, so
"still searching" and "searched, found nothing" were the same row and the
screen waiting on the sync had no way to say the second thing. The pump's
finally block -- every exit: EOSE, CLOSED, the bounded timeout -- now records
`complete`, conditionally in SQL on the row still being `sent`, because the
event handler that writes `processed` runs in its own coroutine and can land
after the subscription has closed, and a completion that overwrote it would
turn "found" back into "found nothing".

UnsyncedProfileViewModel reads that. Its one decision, `decide`, is a pure
function of the account: a kind 0 indexed or a Profile row means the route is
about to move, so still searching; any request still pending or sent is a
relay that has not finished; only when every request has finished and
nothing came is the answer not-found. The screen's not-found state offers
two exits. Try again re-queues the bootstrap, and the new in-flight requests
put the screen back to searching on their own. Set up a profile is the
six-event bootstrap a fresh key gets, without a fresh key:
setUpProfileForExistingKey writes the kind 0 *over* the placeholder row,
under its own id with signedAt back to null, rather than beside it --
getLocalAccounts is every kind-0 row, and two for one pubkey would be two
accounts disagreeing about which is this one. The rows change under
observeLocalAccount, NavigationViewModel sees an unsigned kind 0, and the
notary, which already holds this key, signs it. createNewProfile now builds
its events through the same bootstrapUnsignedEvents.

Tests. SignInSyncTest pins the bootstrap set (every indexer, ours, no
duplicates, more than one) and the outbox reading of a relay list: write and
unmarked relays in, read relays and malformed tags out, already-asked
removed. UnsyncedProfileDecisionTest walks pending -> sent -> complete and
asserts the answer flips only on the last, that a request answered with
something other than a profile still counts as finished, that an arrived
kind 0 is never not-found. SynchronizeNostrEventRequestCompletionJvmTest
pins the conditional update against a real Room database: sent becomes
complete, processed and pending are left alone. SetUpProfileForExistingKeyJvmTest
runs signInToProfile then the set-up against the repository and asserts one
kind-0 row, the same id, unsigned, carrying the name, with five events beside
it.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (895
tests) and :composeApp:m3Audit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@d61d3560a0
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
2e31daf117 feat(sign-in): a recovery phrase or an nsec, recognised, confirmed, then written
Phase 4 of docs/nsec-sign-in.md. SignInToProfileScreen stops being a sentence
about alpha testing and becomes the screen: one field, two things it accepts,
and the npub it signs as shown before anything is written.

One field, because a user does not choose an input type -- they paste what they
have -- and the two shapes are unambiguous: words have spaces, keys do not.
CredentialParser is that decision as a pure function. Words go through
MnemonicCode.validate and derive their NIP-06 key; an nsec, a nostr:-prefixed
nsec or sixty-four hex characters decode to a PrivateKey and must pass
isValid(). The two shapes a reasonable person might paste and that cannot
work are refused by name rather than lumped in with garbage: an npub is
PublicKeyOnly (nothing here can sign with it), an ncryptsec is EncryptedKey
(NIP-49; a follow-on). Words are not lower-cased -- the wordlist already is,
and a capital is a paste artefact worth showing rather than silently fixing.

Problems with the input stay on the field, as supporting text, while the
prompt is still showing; problems after confirming -- the key was already
here, the write failed -- are a state of the screen, through ErrorState with
a retry back to the field. Four states, wrapped in ScreenStateTransition.

Confirm shows the npub in full, in monospace, and which kind was recognised,
with the one consequence that differs: a recovery phrase also restores the
wallet, a bare key comes with none. The secret itself is never echoed.

SignInToProfileViewModel.commit is the effect, and its order is the point.
The secret is written to its store first -- IdentityWriter.writeNostrKey for
an nsec, SovereignWalletViewModel.writeSeed (isRestoringWallet = true) for a
phrase, both injected as functions so the commit can be driven without a key
store -- and only on success is the placeholder account planted with
nostrRepository.signInToProfile. The placeholder before the write would send
the user to fetch a profile they can never sign for; the write before the
placeholder is what the nav host then does. Both writers already refuse a
duplicate by nostr public key, so a wallet's own nostr secret pasted as an
nsec is AlreadyOnThisDevice, and so is a seed whose key is already here bare.

The nav host wires the route with the same tail the create flow uses after
writeSeed: re-list identities, select the new one, go to startup with the form
popped, so back does not return to a field holding a secret. Startup finds
the identity, activates it, and NavigationViewModel takes it from the
placeholder to UnqueuedProfileSynchronization -- which Phase 3 made safe from
both entrances.

Strings: nineteen new, sentence case, including the refusals as sentences
that say what to do instead. The six Torch-era sign-in strings nothing
referenced any more are gone, and the landing caption no longer promises a
remote signer this does not deliver.

Tests. CredentialParserJvmTest is every row of the table in and out, all
three spellings of NIP-06's vector -- words, nsec, hex, with and without the
nostr: prefix, in either case -- asserted to land on the same public key,
which is the equality the duplicate check rests on; and each refusal by name.
SignInToProfileViewModelJvmTest records the effects of a commit and asserts
their order and count: write then plant for both kinds, and no planting after
a refusal or a throw.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (885
tests) and :composeApp:m3Audit, which found no spacing literal or title-case
string in the new screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@220616019e
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
24ca50ca48 feat(identity): list and start identities from both stores, and fix the other entrance
Phase 3 of docs/nsec-sign-in.md, and the submodule bump that brings Phase 2's
NostrKeyManager in (lightning-kmp-app 01489b8 -> 59c11ed, branch
claude/nostr-key-store). Nothing can create an nsec identity yet -- that is
Phase 4 -- but from here one that exists is listed, selected, started and
routed like any wallet.

StoredIdentity is what the startup screen now reads: a sealed type over
Mnemonic(userWallet) and NostrSecret(id, privateKey), both carrying the nostr
public key, because that is the one thing the two kinds share and the one
thing a duplicate check has to compare. StoredIdentity.merge folds seed.dat
and nostr-keys.dat into one map keyed by WalletId; a wallet's pubkey is one
more LocalKeyManager over words that SeedManager has already derived once.

SovereignWalletViewModel.listAvailableWallets becomes listIdentities. It
reads both files and surfaces a failure in either rather than skipping it: a
corrupt nostr-keys.dat would otherwise drop every imported identity from the
list without a word, and the seed file has always been handled this way.
Metadata registration is unchanged -- it keys on WalletId and does not care
what is behind one.

SovereignWalletStartupScreen picks a StoredIdentity, and LoadWallet, the
screen-lock gate, takes the identity rather than the wallet: it only ever read
the id, to look up the lock preferences, and it now sits outside the branch on
kind so a future lock is not something to remember to add twice. The Mnemonic
branch is the old path -- startupNode, setActiveWallet. The NostrSecret branch
builds the Identity directly with business = null and sets it; no
platformStartupLogic, no schedulePlatformLogic, no StartupViewState. Setting
it recomposes into the existing `activeIdentity != null` branch, which is what
reports the startup, so the nsec path does not double-report as the mnemonic
path does. WalletsSelector shows the npub on the second line for both kinds;
it used to show the node id, which a bare key does not have.

The other entrance. NavigationViewModel.loadNostrProfile(startupRoute) read

    getLocalAccounts().firstOrNull()?.profile?.publicKey

which is wrong twice for this plan. It takes the first kind-0 account on the
device whichever identity is active -- harmless with one wallet, wrong half
the time with two, and an imported key is how a device gets its second. And
it keys off the Profile row, which only the notary's *signing* writes, so a
placeholder account planted by signInToProfile (or a profile created moments
ago and not yet signed) read as null and was answered with Landing, while
observeProfile, reading the same rows, answered the sync screen: two writers
to one state in whichever order the coroutines ran, and Landing pops the back
stack. It now selects the account by the active identity's pubkey, matched on
the unsigned event's pubKey, and passes it straight to processLocalAccount
rather than fetching it a second time.

The seed writer, hoisted. platformWriteSeed was an expect with three
byte-identical actuals -- android, jvm, ios -- using nothing but commonMain.
It is one function now, IdentityWriter.writeMnemonic, and the second writer,
IdentityWriter.writeNostrKey, sits next to it rather than being triplicated
in turn. Both refuse a duplicate by nostr public key as well as by id: a
wallet and an imported key can be the same npub under different ids, and the
database is keyed by pubkey. The three platform files lose the copy and the
imports that only it used; the jvm file's header, which described the copy,
now describes what is left.

Tests. NavigationRoutingTest gains the placeholder fixture -- kind 0 at
GENESIS_AT, no Profile, no sync request -- and asserts it routes to
UnqueuedProfileSynchronization and never Landing. The jvm routing test gains
a device with two accounts and asserts the startup entrance reads the active
identity's. StoredIdentityJvmTest pins the merge against NIP-06's own vector:
the words "leader monkey parrot ..." derive 17162c92...cd917, the same key an
nsec import of that secret produces, which is the whole basis of the
duplicate check; that a wallet and its own nostr key as a bare secret land
under different ids (why the writers dedupe by pubkey); and that a key filed
under a pubkey it does not derive is dropped.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (870
tests) and :composeApp:m3Audit. The submodule commit is local to the branch
named above and has to be pushed with this one.

Replayed onto Mantra by docs/curated-to-mantra.md: gitlink -> 59c11ed9, the pin this commit compiles against (git had fast-forwarded it to the newer one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@366b017787
2026-09-13 12:00:19 +02:00
Kgothatso Ngako
bfba7e186f refactor(identity): put an Identity in front of the wallet, and read the key off it
Phase 1 of docs/nsec-sign-in.md. No behaviour changes; this is the type the
rest of the plan stands on, landed alone so that its diff is boring.

Every consumer of "the active user" in the app was the same expression,

    activeWallet?.business?.walletManager?.keyManager?.value?.nostrPrivateKey()

ten times in eight files, and none of them wanted anything from the node but
those 32 bytes. The new to.curare.compose.identity.Identity carries the nostr
private key, the x-only public key (computed once), the per-id preferences and
an optional PhoenixBusiness, with an IdentityKind saying whether it came from
twelve words or a bare secret. It replaces fr.acinq.phoenix.data.ActiveWallet
in the app rather than wrapping it: ActiveWallet is declared in the library but
nothing in the library reads it, and flattening its fields into Identity is
what lets the recovery screens, which read internalPrefs off the active
wallet, keep working for an identity that will never have a wallet.

SovereignWalletViewModel.activeWalletInUI becomes activeIdentity;
setActiveWallet(walletId, business) builds the Mnemonic identity from the node
it just started and delegates to a new setActiveIdentity(identity), which is
the entry the nsec kind will use. The twenty-five StateFlow<ActiveWallet?>
parameters across eighteen files become StateFlow<Identity?>, a rename.

The ten sites become a read of the identity's key. Two got simpler:
NotaryViewModel dropped the flatMapLatest from the wallet flow into the node's
key-manager flow, which only existed because the key arrived after the node,
and RelaysSocketManager likewise; both keep their per-key cancellation.
NavigationViewModel.observeProfile lost its `business == null -> Startup`
branch -- the one thing that would have made a nodeless identity loop -- and
the "Couldn't get the nostrKey" dead end, since an identity is whole from the
moment it exists. The unconstructed NostrNotaryRepository now takes the
identity flow instead of a WalletManager, so that it compiles against the
same seam.

WalletId stays as the id for both kinds. Every preference in the library keys
on it, and a second id type would mean a second copy of each; for an nsec
identity XonlyPublicKey.toWalletId() gives hash160 of the x-only key, the same
forty-hex shape as hash160 of a node id.

Tests: NavigationRoutingTest keeps passing with the flow retyped, and a new
jvm test pins the two things this refactor is for. A NostrSecret identity with
business = null and a synced account on the device routes to ProfileLoaded
rather than back to startup; and Identity.nostrPublicKey equals the key quartz
derives for the same secret -- the x-only form, sixty-four hex characters,
which is not what the library's own LocalKeyManager.nostrPublicKey() returns.
That test runs under runBlocking with a real-time timeout because the
observer waits 2.1 seconds on Dispatchers.IO, and runTest's virtual clock
would time out first.

The app's WalletManagerExtension.nostrPublicKey() stays: CreateProfileViewModel
still derives a fresh key's pubkey with it. The plan said it could go; it was
wrong by one caller.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (863
tests) and :composeApp:m3Audit.

Replayed onto Mantra by docs/curated-to-mantra.md: RelaysSocketManager.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 call it served; NavigationViewModel.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 call it served.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:00:07 +02:00
Kgothatso Ngako
26de13b010 feat(groups): an unsigned event, pasted, and the promise that it lands where its kind says
The group's identity block has a typed editor for each thing the group signs
about itself -- the profile form, the post composer, the relay editor, the schema
editor -- and each builds one kind of event. This is the untyped way in: an
unsigned nostr event as JSON, pasted whole from wherever it was made, proposed to
the quorum like anything else, and filed by kind once it is signed. A pasted kind
0 becomes the profile, a kind 1 a post, a kind 31889 a curated schema, a kind
10002 the general relay list. The entry is "Propose event", last in the identity
block, behind the same share-holder gate as every button in it and absent from a
NIP-17 room like the block itself.

The case it exists for is the schema. `npm run schema:dry` in the bitcoin.mov
repo prints exactly the kind 31889 event it would publish, id, pubkey, signature
and all; until now the only way to get that list under a group's key was to
retype it field by field into the editor. Now it is pasted, checked, and signed.

**Where the event lands is decided by nothing in this change, and that is the
design.** `FrostSigningManager.complete` files every signed event as a
`GroupSignedEvent`, and the group's screen reads its profile, posts, relay lists
and schemas off those rows by kind, each reader verifying the room's signature
for itself. A pasted kind 0 becomes the profile the same way a kind 0 from the
profile form does, because by the time either is signed there is no telling them
apart. A second path -- a switch on kind that wrote the pasted event into the
right place -- would have been a second reading of the same rows, and two
readings of one signature drift. So the persistence half of this feature is a
test rather than code: `GroupEventProposalTest` takes a pasted kind 0, kind 1,
kind 31889 and kind 10002 each through a real FROST quorum signature, in the
shape `FrostSigningManager.advance` runs, and asserts that `GroupNostrProfile`,
`GroupPost`, `GroupCuratedSchema` and `GroupRelayList` accept what comes out,
under the group's key and coordinate rather than the pasted one.

**What the paste says about its author and its time is ignored, and the screen
says so.** `id`, `pubkey`, `sig` and `created_at` never leave `GroupEventProposal`:
what goes to `proposeSigning` is the kind, the tags and the content, and the
session resolves the room's key, hashes the id over it and stamps the time it
opens at -- which is what every typed editor does too. `created_at` in particular
had to go. Every kind here is replaceable or addressable, so a pasted timestamp
older than the group's last event would make the new one *lose* to the old on
every reader and the proposal would look as though it had done nothing. The
explanatory line under the byline names both facts, because a paste with a
`pubkey` in it is the one case where a member could reasonably expect otherwise.

**Only kinds the screen has a place for, and only events that place would
accept -- checked before the quorum is asked, not after.** A signature is the most
expensive thing this app does, and the readers refuse rather than repair: a kind 0
whose content is not a profile, a blank kind 1, a kind 31889 with no visibility
would each be signed, filed and shown nowhere, with nothing to tell the member
their quorum was spent on nothing. So `GroupEventProposal.read` makes every check
a reader makes, on the paste, and refuses with the reader's own reason -- the
schema's reasons are the same `CuratedSchemaProblem`s and the same sentences the
schema editor shows, since they are the same checks. `ACCEPTED_KINDS` is the set of
kinds with a section on the group's screen: 0, 1, the four relay lists and 31889.
Not the set `ChatMessage.applyInnerEvent` has an arm for. A subgroup certificate
or a key state event has invariants a paste cannot be trusted to meet and a place
in the room's life that a form is not, and the nip30303 kinds have editors of
their own. A long-form article is a perfectly good event the group could sign; it
is refused because the line is "has somewhere to land", and the refusal names the
kinds that do. An empty kind 0 is accepted, since wiping a profile is a thing
groups are entitled to do, and an empty relay list is accepted, since it is a
withdrawal the relays card shows as "no relays" rather than as nothing -- the same
two lines `GroupNostrProfile.of` and `GroupRelayList` already draw.

**The preview is the signing screen's own words.** Above the paste is what the
group would sign -- "Kind 31889 · Curated schema" over "bitcoin.mov · public · 1
field" -- read live on every change, since a paste is a few kilobytes and one JSON
parse is cheaper than a debounce that would hide the answer for a beat. It is
`ProposedEvent.summarize` on the reading, not a description of this screen's own,
so that what a member reads before proposing is word for word what every other
member reads before signing. Two descriptions of one event would drift.

**And `ProposedEvent.summarize` gains arms for kind 0, kind 1 and the relay
lists.** The three features before this one added none for their kinds, so a
member asked to sign the group's profile has been shown "Event of kind 0" over a
line of JSON, and a relay list as "Event of kind 10002" over nothing at all. A
profile is now said by its name and what it says about itself, with "An empty
profile" and "Unreadable profile" kept apart because a signer should know which; a
post by its words, the same call the translated-passage arm makes; a relay list
by which of the four it is and its hosts, or "No relays" for a withdrawal.
`ProposedEventTest` is new and pins all of them, the schema arm from the last
commit included, and that an unrecognised kind keeps its number -- refusing to
describe an event is better than describing it wrongly.

**The paste stays on every refusal.** A refused reading is drawn under the field
and the button, pressed anyway, is answered rather than ignored; a failed proposal
is a snackbar over the same paste. The JSON is the thing worth keeping, and a
member who pasted two kilobytes of schema should not have to find them again
because a visibility tag was missing. The field is set in a monospace face for
the same reason: it is JSON, and a bracket that does not line up is what a member
is looking for in it. `isActionPending` guards a double press, because a pasted
kind 1 accumulates like any other post and a second session over the same paste
is a second post on the record forever.

**The byline is the group's**, as on the post composer and for its reason: whatever
is pasted goes out in the group's name, and a paste naming some other `pubkey`
goes out in the group's name anyway.

23 new tests. `GroupEventProposalTest` (12) covers the reading -- what is read and
what is dropped, junk named as junk, the accepted set, an unreadable profile
against an empty one, a blank post, a schema refused with the reader's reasons, a
relay list with or without relays -- and the four signed chains that are this
commit's promise. `ProposedEventTest` (5) pins how each kind is said to a signer.
`ProposeGroupEventScreenJvmTest` (4) types into the screen and checks the preview,
the two refusals and the inert button for a member with no share, and two more
cases in `GroupNostrProfileSectionJvmTest` put the entry last in the identity block
and take it away from a member with no share and from a NIP-17 room.

1347 tests pass -- 861 in `:composeApp:jvmTest`, 486 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@392b90b8d5
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
f8e5d3610d feat(groups): the lists a group curates, and the one thing an edit may not change
`334e6dd1` gave a group a way to speak in its own name. This is the way it asks:
kind 31889, a *curated schema event*, signed by the room's own key -- the definition
of a list anyone may suggest entries to and only that key may accept them into. The
protocol is the curated-list NIP written up in the bitcoin.mov repo (`docs/NIP.md`
there, with `curated-schema-events.md` as the guided tour), and this is its first
half in this app: the schema. Suggestions (31888) and canonical entries (31890) reply
to it and are not read or written here yet.

The section sits under Posts and closes the nostr-identity block, above Subgroups.
The profile says who the group is, the relay lists say where to find it, the posts
are what it says there -- and a schema is the other thing it publishes for strangers
to answer. A post is the group speaking; a schema is the group asking, for entries to
a list it will then curate by quorum under the same key. Everything in that block is
signed by that key and read by people who were never in the room, and the divider
that used to close it after the posts now closes it after these.

**The protocol is ported tag for tag from the reference module, and the fixture is
the NIP's own example.** `nostr/curated/CuratedSchemaEvent` reads and writes the
event; `CuratedSchema`, `CuratedField` and `CuratedFieldConfig` are what the tags
mean. The point of publishing a schema is that another client can drive its form
from it, so the one property worth pinning is that what bitcoin.mov published reads
here and what this app signs would read there. `CuratedSchemaEventTest` parses the
bitcoin.mov schema from the NIP verbatim -- nine `field` tags, the `require-any`,
the domain, the relay -- checks every field of it, and round-trips it through
`template` back to an equal schema. The field tag's seventh position is a JSON blob,
and a key this app does not know is kept in `CuratedFieldConfig.others` and written
back, because other clients legitimately add their own and an edit here must not
strip what one of them meant.

**Rejected rather than repaired, with the one repair the NIP requires.** A schema is
what suggestions are checked against, so a reader that patched a broken one would
be accepting entries against a form nobody signed. `CuratedSchema.problems` is the
NIP's rejection list and nothing more -- a missing identifier, title, name or
description, an over-long one, a visibility that is absent or not one of the three
words, no `field` tags at all, a picture that is not https, a domain that is not a
hostname, a relay that is not a relay -- and it is the *same* list whether the schema
was typed into the editor or read off a relay: the editor refuses to propose what a
reader would refuse to show. The exception is `normalized`, which forces a field
writing to `d` and one writing to `title` into place, both required, because that is
the rule the NIP states so that no schema, wherever it came from, can talk a client
into accepting untitled or unaddressable entries. The "no fields" check runs before
that repair, on what the publisher said.

**Visibility is never guessed, so it is nullable.** A list that does not say who may
suggest to it is not one a client should act on, and the reference makes the same
point by reporting a missing visibility as a violation rather than defaulting it. An
enum with no "unknown" arm cannot carry that, so `CuratedSchema.visibility` is null
for a schema that does not say, `VisibilityMissing` is the problem it raises, and
`template` writes no `visibility` tag for a null one -- which every reader, this one
included, then refuses. The editor never produces one; only an event off a relay can
be missing it.

**One per identifier, newest wins.** The third ordering in this family. A profile is
one replaceable event and the newest wins; posts are not replaceable and accumulate;
a schema is addressable, so a group may curate several lists and each is its own
coordinate `31889:<room>:<d>`. `GroupCuratedSchema.newestPerListAmong` groups by
identifier, keeps the newest within each -- an edit is a newer event under the same
`d` -- and orders the lists most recently signed first, with the event id breaking
every tie so two devices reading the same events in different orders agree.

**An edit keeps the identifier, whatever the field says.** The identifier is the
coordinate every suggestion to the list replies to. Changing it would not edit the
list but start a second one with an empty queue and orphan the first's, so the
identifier field is read-only on an edit, says why under itself, and
`proposeSchema` takes it from the existing schema rather than from the field even
so -- a screen not drawing something is not a guard. A new list is "Add schema" on
the group's screen, which is why the section has both a per-card way in and a
button: each schema is edited on its own.

**A new list starts filled in, and with the group's own relays.** The two mandatory
fields are put on the form before anything is typed, rather than left for
`normalized` to add at signing time, because a form that showed an empty list and
then signed two fields would be lying about what it proposed. The relays are seeded
from the group's NIP-65 write relays -- where its canonical entries would be read
from -- falling back to `GroupRelaySet.General.defaults()`, which is this build's own
relay and the same thing a group opening the relay editor for the first time is
shown. A schema naming no relays is one whose suggestions could go anywhere, so the
seed is worth getting right; the NIP says the same, and a client is allowed to fall
back to its own list only when the schema names none.

**What the editor lets a group sign is held to the app's rule, not the NIP's.** A
`relay` tag may be `ws://` per the NIP and a schema read off a relay is accepted with
one; a relay *added here* goes through `GroupRelaySet.relayUrlOrNull` -- `wss://`,
not this machine -- because the group is about to put a quorum's signature on it for
strangers to publish to, which is exactly the argument that rule was written for.
The duplicate check compares normalized forms, since a relay signed into an existing
schema is kept as the group wrote it and the normalizer adds a trailing slash: the
same host with and without one is one relay. The first version compared strings and
would have let the second copy in; `EditGroupCuratedSchemaViewModelJvmTest` pins it.
Suggesters -- the `p` tags a closed or private list admits -- are accepted as an npub
(with or without `nostr:`) or 64 hex characters and nothing else, and only offered
when the visibility gives them something to be.

**A field is edited on a sheet of its own, and a mandatory one cannot stop being
one.** A field has a dozen settings, and thirteen of them inline would be a screen
nobody could find anything on. The sheet offers the positional parts first -- name,
label, placeholder, type, required -- and then only the config keys the chosen type
gives a meaning to: `min` and a numeric `max` for the three numeric types,
`options` for an enum, `https` for a url. A setting the event gives no meaning to
would be written into the schema and mean nothing, so when the type changes away
from one it is not kept. For a field writing to `d` or `title` the tag is pinned and
the requirement is on and disabled, because `normalized` would only put the field
back if the sheet let it be moved or relaxed, and offering a change the event will
not carry is worse than not offering it. A field name is one word and belongs to
one field, and a rename follows through to the `require-any` rules that named it,
since a rule naming a field that no longer exists would silently never be
satisfied. The rules themselves are a chip per field, selected for the ones in the
rule; one with fewer than two names says nothing and is dropped from the draft
rather than refused.

**The transcript arm returns null rather than `unsupported`.** The opposite call to
the kind:1 arm, for the reason that arm gives: a kind:1 is content somebody meant,
and a schema rumor from a member -- or one the room signed that no client could act
on -- is identity plumbing, the position the kind:0 and relay-list arms are in.
Parsed as well as verified, so the transcript and the group's screen agree about
which events are lists: `GroupCuratedSchema.of` refuses the same event both places.
The line names the list rather than describing the schema, because what a reader of
the room needs to know is that the group now curates -- or has changed the form for
-- a list called so-and-so, and the fields are on the group's screen.
`TYPE_GROUP_SCHEMA_SIGNED` joins `GROUP_IDENTITY_TYPES`, so it is drawn as a
`RitualNotice` and previewed like the other three.

**The signing screen says what it is.** `ProposedEvent.summarize` gets an arm for
31889, which the last three group features did not add for their kinds. A member
deciding whether to sign this is agreeing to take suggestions from whoever the
visibility admits and to be the one key that accepts them, and the event's content is
only the description -- so "Event of kind 31889" over a sentence would leave them
signing a list without being told its name, who may suggest to it, or how much an
entry asks for. The summary is those three.

**The sheet's switches carry their label as a description.** A screen reader
otherwise announces "switch, off" beside a sentence it has already read past. It is
also what the layout test reaches the switch by, which is how it was noticed.

**The heading is "Curated schemas"**, not the kind's name. The audit enforces
sentence case, and the sibling headings name the thing -- "Posts", "Relays" --
rather than the event.

**And the caveat that bites hardest here of all: nothing has reached a relay.** The
same limit `GroupPost` states, and a schema is the one statement whose *entire*
purpose is to be replied to by strangers. Until group-signed events are published,
a list defined here has no queue. The relays are signed into the schema ready for
that, which is also why the NIP puts them in the event rather than in a config file.

51 new tests. `CuratedSchemaEventTest` (22) works from the NIP's example event: the
read, the round trip, the tag order, a config key this app does not know, a
malformed blob costing a field its constraints and not its life, every rejection
rule, the description falling back to content, the mandatory-field repair, and
domain normalization. `GroupCuratedSchemaTest` (8) signs with real FROST quorums
through the same shape `FrostSigningManager.advance` runs -- the room's signature
reads, a stranger's does not, a forged author does not, and a signed-but-unusable
schema is refused -- and pins one-per-identifier ordering and the id tie-break.
`EditGroupCuratedSchemaViewModelJvmTest` (14) covers the seed, the identifier lock,
the mandatory fields, renames following through to rules, the pubkey and relay
rules, the normalized duplicate check, and the button proposing nothing for an
untouched schema. `EditGroupCuratedSchemaScreenJvmTest` (4) and three more cases in
`GroupNostrProfileSectionJvmTest` cover both editor states, the sheet offering a
type its own settings, and the section's place on the screen, its empty state, a
member with no share reading schemas they cannot edit, and its absence in a NIP-17
room.

1307 tests pass -- 838 in `:composeApp:jvmTest`, 469 in `:composeApp:testDebugUnitTest`
-- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@930d37c81e
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
e74592a48e feat(groups): what the group says out loud, and the one arm that must not swallow a message
`4c0ed0c1` gave a group a name and an address on nostr and nothing to say from
either. This is the third statement that identity is made of: kind:1s authored by
the room's own key, listed under the relay lists on the group's screen, with a
composer behind "Add post" that proposes the next one to the quorum.

The section sits under Relays and above Library on purpose. The profile says who the
group is, the relay lists say where to find it, and these are what a reader would
find there -- so they finish the identity block, and the library below them is the
group's *work* rather than its voice.

**A post is the group speaking; the transcript is a member speaking.** Those are
different things and the room already had the second. A room's messages are `ChatEvent`
kind:9 sent by whoever sent them; a post is kind:1 authored by the room's id, so any
reader can check the group said it and that no single member could have made it say so.
The composer's byline is the whole of that design: every other composer in this app
puts the writer's name over the draft because the writer is the author, and doing that
here would have a member writing what looks like their own note and finding the group
had said it. The byline is the group's profile picture and name, shown before a word is
typed.

**The kind:1 arm in `applyInnerEvent` is the dangerous one in this change, and it is
dangerous in the opposite direction to the last two.** Every kind the group signs needs
an arm there or a signed post lands in the transcript as raw JSON. But kind:1 is not
like kind:0 or a relay list: those are identity plumbing nobody sends into a room on
purpose, so the arms added in `4c0ed0c1` return null for one that is not the group's and
the event silently vanishes. A kind:1 is *content*. Somebody meant it, and it has
rendered as an `unsupported` event since long before any of this existed. So this arm
verifies, and on failure **falls through to `unsupported` rather than dropping the
event** -- which required naming that branch as a local `fun unsupported()` so one arm
can reach it deliberately instead of only falling off the end of the `when`. Getting
this wrong would have been this change quietly deleting messages it has no business
touching, and it would have looked like nothing at all.

**Verified, not merely attributed** -- the same rule the other two arms ended up at. A
rumor carries an empty signature and any pubkey its sender likes, so an author check
alone would let one member write "the group posted in its own name" into the room's own
transcript. `GroupSignedEvent.verifies` is what makes the line mean what it says.

**Posts accumulate; everything else in this feature replaces.** kind:1 is not
replaceable, so a second post stands beside the first rather than superseding it, and
every "newest wins" the profile and the relay lists are built on is wrong for these.
`newestFirstAmong` sorts rather than reduces, and `GroupPostTest` asserts three posts
survive all three orderings a query could hand them over in -- a reading that kept only
the newest would silently delete a group's entire history the first time it posted twice,
and would look like a feature until somebody noticed.

**Blank posts are refused twice.** The composer will not propose one, because a quorum
signing an empty note puts a post on the record that renders as nothing, cannot be told
from a broken one, and -- kind:1 not being replaceable -- cannot be un-said. And
`newestFirstAmong` drops one that reaches it anyway, since anything blank arriving from
outside this app is in exactly the same position.

**A reply is marked as one.** Nothing here writes replies -- `GroupPost.template` builds
a bare note, no `e` tags, no mentions, no NIP-14 subject, because every tag
`TextNoteEvent.build` can add describes a relationship to something else that a group's
first way of posting does not have and should not guess at. The mark is for an event that
arrived some other way: drawing a reply as an announcement would put half a conversation
on a group's page with nothing saying it was half.

**The card shows the whole note.** A post is short by construction and it is the one
thing on that screen which exists to be *read*; truncating it would mean tapping through
to see a paragraph. The composer grows into the screen for the same reason -- a
three-line box that hides what was written two sentences ago is a box nobody proofreads
in.

**No new `Post` row, for the reason there is no `Profile` row.** `Post` hangs off both
`NostrEvent` and `Profile` by foreign key and a group has neither, so this reads off
`GroupSignedEvent` like the profile and the relay lists. `FrostSigningManager.complete`
already files every signed event before applying it, so no table, no migration, and one
more kind-scoped query.

**Reading a post needs no share of the key; proposing one does.** A post is the one
statement here meant for people who were not in the room, so a member holding no share
reads the section like anybody else and simply gets no "Add post". Both gates are
`canEditNostrProfile`, which is `canSign` read once for what is now four entry points.

**And the caveat that bites hardest here: nothing has reached a relay.**
`FrostSigningManager.complete` puts nothing on the wire, so a group's posts are visible
to its members and to nobody else -- for a profile that is an inconvenience, for a post
it is most of the point. The relay lists directly above the section say where these
should go and nothing yet sends them. Stated at the top of `GroupPost` in those terms
rather than the neutral ones the other two readers use.

`TYPE_GROUP_POST_SIGNED` joins `GROUP_IDENTITY_TYPES`, so the transcript draws it as a
`RitualNotice` like the other two -- nobody said it, the group signed it. The line names
the post rather than quoting it: the post itself is on the group's screen where it can be
read whole, and a quote would be the same words twice with the second copy unable to say
who agreed to them.

10 new cases in `GroupPostTest`, signed with real FROST quorums through the same shape
`FrostSigningManager.advance` runs, and three more in the section layout test -- ordering
on screen, the empty state, and a member with no share reading posts they cannot add to.

1226 tests pass -- 787 in `:composeApp:jvmTest`, 439 in `:composeApp:testDebugUnitTest`,
13 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets
every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@334e6dd1cb
2026-09-13 11:58:00 +02:00
Kgothatso Ngako
590230f215 feat(groups): the group's own nostr identity, and the two ways a member could forge it
A Marmot room's id *is* the pubkey it signs as -- see `docs/shared-key-derivation.md`,
where those are one value -- so every group in this app has had a nostr identity from
the moment it had a key, and has never had a way to say anything about it. This adds
the two statements that identity is made of: a kind:0 saying who the group is, and the
four relay lists saying where it can be found. Both are signed by the group's quorum
like everything else it says.

Two sections on the group's detail screen, between the signing key and the library,
and two editors behind them. Neither editor saves: what leaves them is a
`FrostSigningSession`, and the profile changes on every member's device at once when
enough members sign, or not at all.

**A group's kind:0 cannot be a `Profile` row, and that decides the whole shape.**
`Profile` hangs off `NostrEvent` by foreign key, and a group-signed event is not a
`NostrEvent`: no member sent it, it never travelled the wire as itself, and the
outbound pipeline re-authors rumors as their sender and would strip the group's
signature off -- which is exactly the position `GroupSignedEvent` exists for. So
`GroupNostrProfile` and `GroupRelayList` read off the group's signed events and are
readings rather than rows. Nothing new is stored: `FrostSigningManager.complete`
already files every signed event before it applies one, so both readers had their
data before this change and neither adds a table, a migration or a DAO method beyond
the two existing kind-scoped queries.

**Both readings are gated on `GroupSignedEvent.verifies`, and that is the whole trust
model.** The author has to be the room, the id has to be the hash of the fields beside
it, and the signature has to verify -- checkable from the row alone, with no ceremony
or key state to consult. A kind:0 that merely arrived in the room is not the group's
profile; a relay list filed against the room by another group is not the group's relay
list. `GroupNostrProfileTest` and `GroupRelayListTest` sign their fixtures with real
FROST quorums through the same shape `FrostSigningManager.advance` runs, so a stranger's
signature, a forged author and three kinds of rubbish in the signature field are all
tested against the code that actually verifies rather than against a mock of it.

**The arm in `applyInnerEvent` had to verify, not merely attribute, and the first draft
did not.** Every kind the group signs needs an arm there or it falls through to
`unsupported` and puts raw JSON in the transcript -- so a profile update would have
appeared in chat as a JSON blob. But that dispatch is reached from two places and cannot
tell them apart: a completed signing session, where the author is the room by
construction, and an arriving inner event, where it is whatever the sender wrote. A rumor
carries an empty signature and any pubkey its sender likes, so the relay arm's original
author-only check would have let one member write "the group signed its general relay
list" into the room's transcript with no group involved. It now builds a
`GroupSignedEvent` and calls `verifies`; the metadata arm was already safe because it
goes through `GroupNostrProfile.of`, which does. A member's own kind:0 or relay list
passing through the room fails the author half and gets no line at all, which is right --
it is theirs, not the group's.

**`GROUP_PROFILE_TYPES` became `GROUP_IDENTITY_TYPES`** and holds both new message types.
The profile and the relay lists are the same kind of statement and the transcript does
the same thing with both -- a `RitualNotice`, answered and settled, because by the time
the line exists a quorum has signed and there is nothing left to do about it. A type
missing from that set renders as a chat bubble, silently, looking exactly like a member
having said "The group is now called Translation collective"; that is why it is a set
with two members rather than two checks.

**Relay lists: four sets, and the two that were left out are the interesting part.**
General (NIP-65, 10002), Messages (NIP-17, 10050), Search (NIP-50, 10007) and Blocked
(NIP-51, 10006), matching the reference interface. Key packages (MIP-00, 10051) is not
here even though this app writes one for every person: a key package is a device's offer
to be added to an MLS group, and a group has no device and joins nothing, so a group
advertising where its key packages live would point at an address that is permanently
empty -- worse than saying nothing. Relay feeds (10012) is out for the harder reason
below.

**A group signs and cannot decrypt, so every list is written in public tags.** NIP-51
puts a relay list's entries in the encrypted half by default, and a blocked list
especially: who you refuse to talk to is nobody's business. The encryption is NIP-44 to
the author's own key and there is no ECDH for a FROST threshold key here. That rules out
10012 entirely -- its list is only ever private -- and it means the group's blocked list
is public where a person's client would keep it secret. Said in the code, and said on the
Blocked tab where somebody is about to act on it.

**The editor proposes every list that changed at once, which is the deliberate departure
from the interface it copies.** Wisp publishes the tab in front of you, because a person
signs for themselves and there is nothing to coordinate. Here each signature costs a
quorum's attention, and four sessions for one sitting at one screen would ask for it four
times over what is plainly one decision. `changedSets` is what keeps that honest, and it
is the piece with a quiet failure on both sides: too eager and every visit re-signs four
untouched lists, dating a decision the group did not make; too shy and an edit is dropped
on a screen that said it had been sent. It compares in order, because a relay list is
written in the order it is read back and a member who moved a relay to the front meant to;
it counts a seeded first-time list as a change, so a group whose editor filled itself in
will actually send it; and it counts an empty unagreed set as no change, so opening
Blocked and pressing the button does not put a quorum's signature over silence. Nine
cases in `EditGroupRelaysViewModelJvmTest`.

**A relay that is neither read nor write is a state NIP-65 cannot express**, so it must
not be reachable. `AdvertisedRelayInfo.assemble` writes a bare `r` tag for both, `read`
for read-only and `write` for write-only, and has nothing for neither -- what neither
would mean is that the relay is not in the list, and removing it is the row's own button.
The toggles refuse to turn off the last marker and `template` drops such a relay as a
second line of defence, because a bare tag written for it would advertise it as *both*,
which is the opposite of what was asked. The round trip is tested for all three markers
at once: the ordinary case is the dangerous one, since "both" is encoded by the third
element being *absent*, so a reader that dropped it entirely would look correct for a
both-ways relay and silently promote every read-only one.

**`ephemeral.mantra.press` is what a first-time group is seeded with**, for General,
Messages and Search. It is `Relays.ephemeral`, already in this build's bootstrap, publish,
finder and DM sets. Blocked is seeded empty on purpose: a default there is a refusal to
talk to somebody that nobody in the group chose.

**A profile edit builds on the group's last one; a relay list does not.** `template` for
the profile goes through `MetadataEvent.updateFromPast`, so a field this form does not
offer -- a birthday, a CLINK offer, whatever a future NIP adds -- survives an edit instead
of being dropped by it. Using `createNew` there would compile, pass any test that only
looked at the six fields on the form, and quietly wipe the rest every time somebody fixed
a typo in the group's name. A relay list is the opposite: it is one list, replacing it is
the whole point of signing a new one, and carrying a tag forward would make removal
impossible. Both are tested for the behaviour that would be silent if wrong.

**The name is written to both `name` and `display_name`.** They are the two fields readers
pick a name out of and they disagree at their peril: a group whose `display_name` came
from some other tool and whose `name` was edited here would keep answering to the old one
in every client preferring `display_name` -- an edit that visibly does not take. One field
on the form, both keys in the event.

**Blank clears, and that is the only way to unsay something a group has signed.**
`MetadataEvent`'s rule for these arguments is that an empty string removes the key and
null leaves it alone, so the form sends what its fields hold. Said on the screen, because
a member emptying a field is entitled to know whether it will be ignored.

**Both sections are hidden entirely in a NIP-17 room.** Its id is a conversation rather
than a key it can sign as, so it has no nostr identity and never will; an empty section
there would promise something that is not coming. Every other room shows the sections and
gates only the two edit buttons on holding a share of the key -- the profile and the relay
lists are worth reading whoever is looking, and only a share-holder can open a session
about them. That is `canEditNostrProfile`, which reads `canSign` once for the two entry
points and the subgroup one, since it walks the room's ceremonies looking for a share and
asking three times would do the same walk three times for one answer.

**"Never agreed" and "agreed empty" are different states and the summary says which.**
A set the group has never signed reads "not set yet"; one it signed empty reads "no
relays". Collapsing them would hide a deliberate withdrawal behind an oversight, and
`GroupRelayList.newestAmong` returns an unsigned empty list rather than null precisely so
the screen has a row per set either way.

**The editor's working copy is seeded from the screen, not from the loader**, and that is
a fix rather than a preference. Seeding inside `initiateEditGroupRelays` meant any path
that supplied a loaded state -- the `@ConformancePreviews` body, the layout test -- got an
empty editor while the app worked fine, which is the shape of bug that survives review
because the only thing that exercises it is the thing nobody looks at. `seedWorkingCopy`
is idempotent and fills only missing keys, so the effect that calls it can re-run without
throwing away typing.

**`ProfileAvatar` gained an overload taking a picture URL rather than a `Profile`**, since
a group has no `Profile` row to hand it and the picture and the name were all it ever
wanted from one. The two existing overloads delegate to it, so there is one avatar rather
than a second one for groups.

**What this does not do: nothing here reaches a relay.** `FrostSigningManager.complete`
applies signed events locally and puts nothing on the wire, for the reason its comment
gives. So the profile and the lists are visible to members and to nobody else, and the
relay lists say where the group's work *should* go without anything yet sending it there.
Publishing would mean a `NostrEvent` row for an event no member authored, which is an
architectural decision and belongs in its own change. Said at the top of both readers so
the next reader does not assume otherwise.

**And they are not chroniclable.** `ChronicleEvent.APPLY_ORDER` is an allowlist that
deliberately excludes group-signed statements like `GroupKeyStateEvent`, on the grounds
that a validly signed old one replayed by whoever kept a copy is a statement nobody can
refuse. These five kinds are in the same position, so a member who joins after a profile
is signed will not be handed it in their catch-up. Adding them is a decision with its own
trade and is not made here.

48 strings, all sentence case, all bare-apostrophe -- Compose Resources does not unescape
`\'`, so the aapt spelling would render the backslash.

1203 tests pass -- 774 in `:composeApp:jvmTest`, 429 in `:composeApp:testDebugUnitTest`,
41 of them new -- `:composeApp:compileDebugKotlinAndroid` is clean, and `m3Audit` meets
every budget.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 11:04:35 +02:00
Kgothatso Ngako
39fb64b6c0 refactor: take nostrPublicKey() from the library, and pin it against NIP-06
The app has carried its own `LocalKeyManager.nostrPublicKey()` in
WalletManagerExtension.kt since before the library's returned anything usable:
the library's gave back `publicKey().toHex()`, the 33-byte compressed encoding,
so the app went through quartz -- `KeyPair(privKey).pubKey.toHexKey()` -- to
get the x-only key that relays, events and npubs actually carry. As of
lightning-kmp-app 84cc44c the library's returns the x-only key too, and the
app's copy is a second implementation of the one value that is every profile's
identity. This deletes it and switches its three callers -- RelaysSocketManager,
NavigationViewModel, CreateProfileViewModel -- to
`fr.acinq.phoenix.managers.nostrPublicKey`.

**The two were proved equal before anything was deleted, not read to be.** They
go through different stacks (quartz's `KeyPair` against bitcoin-kmp's
`xOnly()`), and a difference between them would not fail a build or a test: it
would publish every existing profile under a new key on the next launch, with
nothing on screen to say so. So the first form of the test in this commit built
one `LocalKeyManager` from NIP-06's mnemonic exactly as CreateProfileViewModel
does -- `NodeParamsManager.chain`, `NodeParamsManager.remoteSwapInXpub` -- and
called both functions on it, the library's imported under an alias because the
names collide. Equal on mainnet, which is what the app ships on, and equal on
Testnet3, where the library derives from account 1' instead of 0'. Only then
did the app's go.

**What remains is a pin from the consuming side, anchored to NIP-06 rather than
to a captured output.** A value copied out of a passing run pins the code to
itself; NIP-06 publishes two test vectors -- a twelve-word and a twenty-four-word
mnemonic, empty passphrase, path m/44'/1237'/0'/0/0 -- and that path is the
library's mainnet path exactly, so on the chain the app ships on the answer is
not ours to choose. Both vectors are asserted, and the test also asserts that
`NodeParamsManager.chain` is Mainnet, so a chain change surfaces here as the
moment the published answers stop applying rather than as two mysteriously
failing assertions.

**Quartz stays in the test, as an oracle rather than as an implementation.** The
app signs events with quartz from `nostrPrivateKey()`, and the key it publishes
has to be the one quartz derives for the secret it signs with; that agreement
was the property the deleted copy embodied by construction. It is now stated
once, on mainnet and on Testnet3 -- alongside the shape check, sixty-four
lowercase hex characters, which is the line a revert to the compressed form
(sixty-six) would trip. Off mainnet there is no published vector for account
1', so shape and quartz-agreement are all that is asserted there.

**The test lives in `managers`, not `extensions`.** Its first form sat beside
the function it was checking, in `press.mantra.compose.extensions`. With that
function gone, a test in that package would be about an extension the app no
longer has; `press.mantra.compose.managers` mirrors `fr.acinq.phoenix.managers`,
the library package it pins, and is where the app's other key-material tests
sit. The import in each caller is placed inside the file's sorted
`fr.acinq.phoenix` block rather than where the old `press.mantra` line stood.

:composeApp:compileDebugKotlinAndroid is clean; :composeApp:jvmTest is 736
tests, 0 failures -- the 733 before this change plus the three here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:06:50 +02:00
Kgothatso Ngako
f4434ab15d build: advance lightning-kmp-app onto the nostr credentials file
Moves the pin from 01489b8 to 84cc44c, three commits on the submodule's master:
two features and the merge between them. The submodule's own messages call this
the library half of the app's nsec/npub sign-in plan; the app half is not in
this commit, which changes no composeApp source.

The first feature gives a nostr key with no seed above it somewhere to live. A
key pasted in as an nsec cannot go in seed.dat, whose reader runs
MnemonicCode.toSeed over every entry and whose format is inherited from
upstream, so it gets a sibling file, nostr-keys.dat, under the same
KEY_NO_AUTH: one version byte, a 16-byte iv, and the ciphertext of a JSON map
from x-only public key to private key. Every secret is re-derived on read and
must land on its own key, so a corrupt file is refused rather than handed out
under the wrong identity. The temp-file / read-back / atomicMove discipline
SeedManager always had is lifted into AtomicFileWrite so the new file gets it
by calling rather than by copying; SeedManager.writeSeedToDir now goes through
it with its original check and its original exception type, and the three
public entry points the app uses -- loadAndDecrypt, loadAndDecryptOrNull,
writeSeedToDisk -- are unchanged.

The second replaces that file's shape with a typed one before anything has
written it in anger. nostr-credentials.dat holds one entry per x-only public
key, each either {"type":"secret","privateKey":hex} or {"type":"public"}, so a
profile signed in read-only and the same profile with its nsec pasted later are
one entry in one file, and the second is a single write over the first. It is a
new file rather than version 2 of the old one because an older build's identity
listing returns on an unreadable version before it publishes anything, and
would list no wallets at all, seed wallets included; a file it does not look
for cannot do that to it. NostrCredentialManager.migrateFromNostrKeys reads the
old file once, writes the new one and deletes the old, and runs only while the
new file is absent. NostrKeyManager becomes LegacyNostrKeysFile, read-only, for
that one caller.

Also in WalletManager.kt: PrivateKey.nostrPublicKeyHex(), the x-only form, and
LocalKeyManager.nostrPublicKey() now returns it. That function used to return
publicKey().toHex(), the 33-byte compressed encoding, which is not a nostr
public key at all -- nothing in the library called it, and the app has been
carrying an extension of its own with the same name instead. Retiring that copy
is the next commit, not this one.

The nested experimental/lightning-kmp gitlink does not move: it stays at
5103b79, so the lightning-kmp -> bitcoin-kmp -> secp256k1-kmp chain under the
composite is exactly what it was, and nothing native is rebuilt.

Verified in the composite: :composeApp:compileDebugKotlinAndroid is clean,
:composeApp:jvmTest is 733 tests, 0 failures, and
:lightning-kmp-app:library:jvmTest is 159 tests, 0 failures, 3 skipped -- up
from 128 by exactly the 31 tests in the four new classes
(EncryptedNostrKeysTest, EncryptedNostrCredentialsTest,
LegacyNostrKeysFileJvmTest, NostrCredentialManagerJvmTest), the skips still
being the @Ignore'd ElectrumServersTest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 23:06:23 +02:00
Kgothatso Ngako
ba26c0b12a Update version name and gitignore
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
v0.1.2
2026-09-09 17:04:09 +02:00
Kgothatso Ngako
0be31803f2 docs: record phase 10, and the deferred decision it carries out
`docs/subgroups.md` was written as ten phases of reasoning kept in the order they
were argued, and phase 4 spent forty lines on why the child's ceremony was *not*
held in the parent's Marmot room -- explicitly so the decision would not be
re-litigated without its price attached. That section is now a shopping list that
has been carried out, so it keeps its argument and gains a pointer forward, and
the three costs it enumerated are checked off one by one in a new phase 10.

The parts of the note that state the old arrangement as present-tense fact are
updated rather than annotated: the three-ceremonies table now reads one room,
three ceremonies, two quorums, and says the thing that needs saying twice -- an
MLS message reaches the whole tree, so a ceremony in the parent's room has to name
who it is with.

Phase 10 itself is written the way the others are, around what fails silently:

- `DkgSession.chatRoomId` stopped identifying a ceremony, and the place that
  matters is `completedKey`'s last fallback, which every member welcomed after a
  group's own ceremony lands on;
- `signingPath` had to admit a Marmot room, which widens the one function whose
  contract is that a path never comes off a proposal;
- the p-tags had to stay on both transports, which is the opposite of what
  `FrostSigningManager` correctly does.

The two sections that argued the old collision -- "The collision this buys" and
"Why a subgroup cannot be the whole group was withdrawn" -- keep their reasoning
and gain the end of it: `(room, parent)` stopped telling two subgroups of one
parent apart, so the lookup moved to `(room, parent, admins)`, and the permanent
half of the refusal disappeared with the derived room. The limitation and the
appendix entry are struck through rather than deleted, since what they were
weighing is why the phase exists.

`docs/shared-key-ceremony.md` no longer says a ceremony runs over a NIP-17 chat.
The participant set is the proposal's p-tags on both transports, and that
distinction is the whole reason it is stated that way rather than as "the group".

`docs/mls-skipped-keys.md` keeps `proposeRitual` in its table of reliable
triggers and now says what changed about it: it reached that table on gift wraps,
where the bug does not apply, and a subgroup's ceremony now rides group events. It
is the entry with the worst consequence -- a ChillDKG cannot finish until every
participant takes part, so one lost round-1 message stalls it permanently for
everybody rather than costing one member a line of chat. That is the thing the
quartz fix in that note is now load-bearing for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:28:28 +02:00
Kgothatso Ngako
820e83e181 test(subgroups): the ceremony in somebody else's room, and the four ways it lies
Six cases, and the ones worth writing are the ones where reading the room instead
of the ceremony produces a plausible wrong answer rather than a crash.

**The transport, and the p-tags on it.** The proposal is a `MarmotInnerEvent` and
there are no gift wraps at all, and it p-tags the picked admins and nobody else.
Both halves: the transport is the change, and the p-tags are what keeps the change
from being a disaster, because dropping them the way `FrostSigningManager`
correctly does in a Marmot room would enrol the whole parent in the child's
permanent signing quorum.

**The name.** On the proposal, on the session, once -- and the room keeps its own.
There is nowhere else for a child's name to live now, and the parent's is the one
name it must not take.

**A parent member who was not picked drops the proposal**, opens no session, and
publishes nothing. This is the property that makes the parent's room a safe place
to hold a ceremony a subset of it is in: everyone can read the message, only the
p-tagged are in the ceremony, and "can read" and "is in" have to stay different
questions when the second one is permanent.

**A picked admin joins, and not before approving.** The approval gate is unchanged
by the move and has to stay that way -- a relay delivering a group event to a
phone in a pocket must not enrol its owner in anything -- so this asserts nothing
goes out until `approve`, then that what goes out is an inner event carrying the
roster minus its own sender.

**A host key that beat the proposal is replayed**, out of the inner-event store.
The resume machinery is what makes the ritual safe to run anywhere, and it reads
the backlog out of whichever store the room's transport writes to; looking in the
wrong one is a stall with nothing to blame it on.

**`completedKey` refuses to hand the parent its child's key.** The regression test
for `ba8aa1e2`: a parent member with no key state and no share -- the position
every member welcomed after the group's own ceremony is in, and the one that
reaches the last fallback -- with a completed subgroup ceremony sitting in the
room. Before the fix that fallback returned the child's key and the parent would
have authored events as its own subgroup. It also asserts the child's *own* room
still resolves it, by rederiving the id from the key, which is the check that
actually binds a room to a key.

Two databases, and the MLS group in each is real but not joint: messages are
handed to the manager rather than encrypted between two trees. That is the right
seam here -- what is under test is which store a message is queued in and which
set it names, not whether quartz can encrypt it -- and `SignedGroupKeyStateTest`
is where a session runs end to end.

1136 tests pass, `m3Audit` meets every budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:28:28 +02:00
Kgothatso Ngako
5110687413 feat(subgroups): run the child's ceremony in the parent's room, not beside it
The switch. A subgroup's ChillDKG and its key state now happen in the parent's
Marmot room; the sibling NIP-17 room derived from the child's admins is gone, and
so is the rule that one admin set could hold one subgroup forever.

This is what `docs/subgroups.md` deferred in "Why not the parent's Marmot room"
and said to revisit. The reason to wait was `docs/mls-skipped-keys.md` -- a DKG
cannot finish until every participant takes part, so one group event lost to the
skipped-keys bug stalls it for everybody -- and that is being fixed. The two
transports and the two guards this needs landed in the three commits before it.

**`SelectSubgroupAdminsViewModel` stops standing up a room.** It opens the ritual
in `loaded.parentRoom`, p-tagged to the picked admins, and hands back to the
parent's transcript. That transcript is where the other admins were going to
answer from anyway -- `observeCeremoniesAwaitingYou` and
`observeProposalsAwaitingYou` are room-scoped and were already looking there -- so
the coordinator now lands in the same place as everybody else rather than in a
room only they knew was coming.

The name and the admin set ride on the proposal and land on the session, which is
what `1e7ddd84`'s two columns are for. `MarmotGroupName.of` still puts the `#` on
at the two sites that use the name, and is still idempotent, so the certificate
the parent signs and the room `MarmotGroupCreation` creates are called the same
thing.

**`SubgroupManager.ceremonyRoomIdFor` is deleted and `refuseCeremonyRoom` becomes
`refuseSubgroup`.** There is no ceremony room to derive or to name a refusal
after. The refusal it made -- "this group already has a subgroup run by exactly
these members", forever -- narrows to a ceremony over those admins under that
parent that is still *running*, on `getLatestSubgroupSessionFor`.

That refusal is worth keeping for a reason the old one did not have. It is not
that two subgroups over the same people are forbidden; it is that nobody can
answer for two live ceremonies at once. Every admin would be asked twice, on two
ladders, for two keys, one of which nobody will make a room from. A *finished* one
means that subgroup exists, and asking for another is a legitimate ask that the
derived room made impossible.

**Three reads move from the room to the ceremony**, all of them in the ritual
screen, and each would have been silently wrong in the parent's room:

- `proposeBirthCertificate`'s `adminPublicKeys` -- the room's roster would ask the
  parent to certify itself as its own child;
- `createAdminGroup`'s `members` -- it would welcome the whole parent into the
  subgroup;
- the name both of those carry -- it would be the parent's.

`DkgRitualUIState.ritualMembers` follows, so the progress ladder draws the
ceremony's participants rather than a row per parent member, and does not report a
ceremony waiting on people it was never with. Profiles still come off the room,
which for a subgroup is the parent and holds every admin the ceremony can have.

**Nothing new is offered on a Marmot room's detail screen.** The shared-key button
is still gated on `mlsGroupState == null`, and the comment there now says why that
is still right: offering a ceremony to a room is offering it a key of its own, and
a Marmot room's id is derived from a key it already has. Its subgroups' ceremonies
are reached from the transcript line that names the one they are about, which is
the only thing that can say which.

`SubgroupManagerJvmTest` follows the refusal. The two tests built on
`ceremonyRoomIdFor` become tests of what actually distinguishes ceremonies now --
another subgroup's and the room's own do not block one, a live one over the same
admins does, a finished one does not -- and the ordering test moves to
`DkgSession.formatParticipants`, which is where "the same set however it was
assembled" now has to hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:27:57 +02:00
Kgothatso Ngako
ba8aa1e220 fix(subgroups): stop a parent resolving its own child's key, and let a child sign
Two guards in `FrostSigningManager` that were correct only while every ceremony
ran in a room of its own. A parent's Marmot room is about to host its subgroups'
ceremonies, and both of these read a ceremony's room as though that could only
mean one thing. Neither fails loudly.

**`completedKey`'s last fallback is "a ceremony held in this very room", and that
stops being the room's own key.** The fallback is not decoration: a room's
`GroupKeyState` is signed before the room exists and filed as it is created, so
every member welcomed after that -- an invite, a reinstall -- has a room and no
state, and lands here. The parent's room will hold a *completed* ceremony whose
threshold key belongs to the child, so those members would resolve the child's key
for the parent and the group would author events as its own subgroup, with a valid
signature and nothing on screen to say so. The certificate a subgroup is born with
is exactly one of those events.

`getLatestOwnSessionForChatRoom` is the same query with `parentChatRoomId IS
NULL`. A ceremony run to make a subgroup is never the room's own key, and that
column is all that has to be read to know it.

**`signingPath` has one case it cannot self-check, and there are now two rooms in
it.** Everywhere else a candidate path is right exactly when walking it reaches
the room, which makes the function self-checking rather than trusting -- and the
path decides what key the group signs as, so it must never come off a proposal.
The exception is the room a ceremony ran in, signing the statement that lets the
room the ceremony's key derives be created. That room is not derived from the key
at all, so nothing rederives.

It used to mean "a NIP-17 room whose ceremony is its own", gated on
`mlsGroupState == null`. It now also means "the parent's room, where the ceremony
claims that parent" -- without which a subgroup's key state would be signed as the
bare threshold key, an identity no room answers to.

`key.parentChatRoomId` is an unverified claim off a proposal and admitting it here
grants nothing. The signature it enables is by the *child's* key over the
*child's* own id, both derived from a ceremony every signer contributed to and
approved twice. A member who put a false parent on a proposal ends up with a key
state for a room made from a key they helped make, which is what telling the truth
would have got them.

Both inputs are still read from this device's own database, so a proposer chooses
nothing: naming some other ceremony this device holds a share for gets no path,
and a session with no path signs as the threshold key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:27:24 +02:00
Kgothatso Ngako
1c0acd546a feat(subgroups): put the key ceremony on both transports, p-tags and all
`ChillDkgRitualManager` ran on gift wraps only. It now runs on whichever transport
the room it is in has -- a gift-wrapped rumor in a NIP-17 room, an MLS application
message in a Marmot one -- which is the shape `FrostSigningManager` has had since
`113eda9f`, and for the same underlying reason: a group has to say something
before it owns the room the saying is about.

Nothing is wired to the new arm yet. A group's own ceremony still runs in its
NIP-17 room and a subgroup's still runs in the sibling room derived from its
admins; what changes here is that the manager stops assuming which.

**`broadcast` reads the room and writes to the matching store.** MLS state present
is what marks a room Marmot -- the same reading `sendChatMessage` makes when it
chooses between a group event and gift wraps. `replayStoredMessages` has to make
that reading again, because a ceremony's backlog is in whichever store its
messages were queued in and looking in the wrong one is a stall with nothing to
blame it on.

**The p-tags stay on both, unlike `FrostSigningManager`'s, and this is the one
thing here that would be a disaster to get wrong.** There a p-tag is an address
and a group event needs none, because the message is encrypted to the whole tree
and the signer set comes from the ceremony's host keys either way. Here the p-tag
set *is* the participant set: `acceptProposal` builds `n` out of it on every
device and `n` is hashed into the session identity, so a Marmot proposal without
them leaves every receiver unable to say what they were invited to. In a room
whose membership is wider than the ceremony they are also the only thing marking
who is in it, and a member who publishes a host key is in that group's signing
quorum for good. Inside MLS encryption, naming them leaks nothing.

They are also read off the *session* rather than off the room now, via
`participantsOf`, and sorted. A room whose membership is wider than the ceremony
is exactly the case this is for.

**`processRitualPayload` takes an `Event`.** The rumor as its sender wrote it,
which is the shape both transports hand over -- `NostrDao` already rebuilt one
from a decrypted gift wrap for the FROST arm and now does the same here. `sig` is
empty on both and nothing reads it: what vouches for the author is the seal or the
MLS frame, not a signature on the payload. `record` and `isFromCoordinator` follow.

**`proposeRitual` grows two parameters and splits its guard.** `participantPublicKeys`
defaults to the room's members, which is the whole story where the room is the
participant set; `subject` defaults to the room's name, but only for a ceremony
with no parent -- falling back there would name a child after its parent.

The guard now asks two different questions. A ceremony with no parent is scoped by
room as before. One with a parent is matched on `(room, parent, admins)` and folds
only into a ceremony that is still *running*: a completed one means that subgroup
was made, and a group is entitled to a second run by the same people. That was
impossible while the ceremony room was derived from its admins -- asking again
handed back the first ceremony's key, so the "new" subgroup was the old one under
a new name.

A new `require`, because the old invariant stops holding for free: everyone in a
ceremony has to be able to hear it. A gift wrap is sealed per p-tag so this was
true by construction; a group event reaches the MLS tree and nobody else, so a
participant outside it is an `n` that can never be met. Placed behind the
duplicate guard, so a running ceremony is still handed back whatever the room's
rows have since done.

**`NostrDao` dispatches the DKG kinds on the Marmot arm** beside the FROST ones it
already did, and `ChatMessage.applyInnerEvent` returns null for them the way it
does for `FrostSigningEvents.ALL` -- the manager writes the transcript of a
ceremony itself, and an "unsupported" row would be a second, worse account of it.
A member of the room who was not p-tagged never reaches either: their
`acceptProposal` drops the proposal and no session is opened.

**A ceremony with a parent says so in the transcript.** The line lands in a room
that already has a key, and "started a shared key ceremony. It will take 2 of 3
members to sign with the key" reads there as though *this* group were getting a
new one. It is not -- the quorum quoted is the child's, over the child's admins --
and that is the sort of misreading that gets somebody to approve a step they did
not follow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:27:06 +02:00
Kgothatso Ngako
1e7ddd844a feat(subgroups): schema 19 -- the two things a ceremony's own room used to answer
A subgroup's ChillDKG is about to move out of the sibling NIP-17 room derived from
its admins and into the parent's Marmot room. That room answers two questions the
sibling room answered for free, and it answers both of them wrong.

**Who the ceremony is with.** The sibling room's membership *was* the participant
set -- it was derived from it -- so `memberPublicKeys(localChatRoom)` was the
admin list, and three callers read it that way: `broadcast`'s p-tags, the birth
certificate's `adminPublicKeys`, and the members `MarmotGroupCreation` welcomes
into the child. The parent's room is a superset, so each of those would silently
name every person the subgroup is *not*. `participantPublicKeys` is the set the
ceremony was opened on, sorted and comma-joined the way `publicShares` already
stores a list.

**What the room it makes is called.** The sibling room carried the subgroup's
name as its subject, put there by the coordinator and delivered to everyone else
by `getOrCreateNip17ChatRoom` reading the proposal's `subject` tag. A ceremony in
the parent's room has no room of its own to be named, and the parent's name is the
one name a child must not take -- `proposeBirthCertificate` normalises this string
into the certificate the parent's quorum signs, and `MarmotGroupCreation`
normalises it again onto the room. `subject` is where it lands instead.

Both are read off the proposal, which has carried both since `7bccf243` -- the
p-tags and the `subject` tag. Nothing new goes on the wire; what changes is where
it is kept. Both are nullable and both fall back to the room, which is correct for
every row written before this: a ceremony in its own NIP-17 room ran over exactly
that room's members and was named after it.

**Sorted, because two devices assemble the set differently.** The coordinator
builds it from a picker; every other device builds it from the proposal's p-tags
plus the sender. `DkgSession.formatParticipants` sorts so those are the same
string, which is what makes it something a query can match on.

Two queries, both of which exist because `DkgSession.chatRoomId` is about to stop
identifying a ceremony:

`getLatestSubgroupSessionFor(room, parent, admins)` is what "may I open another"
means for a subgroup once the parent's room hosts every one the group ever runs.
`getLatestSessionFor(room, parent)` cannot answer it -- two subgroups of one
parent share both columns -- and scoping on the room alone would refuse a group's
second subgroup on the strength of its first.

`getLatestOwnSessionForChatRoom(room)` is `getLatestSessionForChatRoom` with
`parentChatRoomId IS NULL`. It exists for `FrostSigningManager.completedKey`'s
last fallback, which is the one every parent member welcomed after the group's own
ceremony lands on: the parent's room will hold a *completed* ceremony whose key is
the child's, and a ceremony run to make a subgroup is never the room's own key.
The claim is unverified, but the only direction it can be abused in is a member
excluding a ceremony they themselves proposed, whose key they would simply not
have proposed.

`AutoMigration(18, 19)`: two nullable columns, a shape Room migrates itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 16:26:39 +02:00
Kgothatso Ngako
45cc80b538 feat(marmot): put a # in front of every group's name, and retire (#admins)
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
A device's room list holds two unrelated kinds of room and nothing on a row said
which. A NIP-17 room is a conversation between the people in it. A Marmot room is
a *group* -- an id its key derives, a membership baked into an MLS tree, admins
who can act for it, a signature anyone holding the id can check -- and the two
behave differently enough that guessing is a mistake.

`"Ekklesia (#admins)"` was an attempt at saying so, and it marked the wrong half.
Only the admin room got it; a subgroup got no marker at all, so as soon as a group
had one child, half the Marmot rooms on the device were unmarked. It also sorted
nowhere near the group it belonged to, and a truncated row drops a trailing suffix
first -- so the marker was missing exactly where the list is crowded enough to
need it.

**The rule is `MarmotGroupName.of`, and it runs where a room is minted rather than
where it is drawn.** The name is baked into the epoch-0 `MarmotGroupData` every
member is welcomed with, so a `#` added at display time would be a name this
device alone could see. `#Ekklesia` marks both kinds of group room, and marks them
at the front.

**Three mints, because there are three ways a Marmot room comes into existence.**
`MarmotGroupCreation.create` is the funnel for two of them -- the admin room a
group opens after its ceremony, and a subgroup -- and normalising there means
neither caller has to remember. The third, `SelectChatRoomTypeViewModel`'s
convenient room, has a random id rather than a derived one, so it has no key state
to adopt and no admin set to bake in and does not pass through that funnel; it
applies the rule itself.

**Idempotence is load-bearing, not tidiness.** A subgroup's name is derived twice
from the same bare ceremony-room subject, by two callers that never see each
other: `SubgroupManager.proposeBirthCertificate` normalises the name the parent's
quorum is asked to sign, and `MarmotGroupCreation` normalises the name the room
carries. Those two have to be the same string, or the subgroup is not called what
its parent certified -- and a certificate is a signature over the name, so a
verifier comparing them would see a real mismatch. `of` being idempotent is what
makes them agree by construction rather than by both sites being kept in step.

**The ceremony room keeps the bare name.** It is a NIP-17 room -- where a subgroup
is made, not the subgroup -- and prefixing it too produced two identically-named
rows, which spends the mark to say nothing. `Translators` (the ceremony) now sits
beside `#Translators` (the group it stood up), which is the distinction the `#`
exists to draw. Its subject is trimmed, so the bare name and the two normalised
ones cannot differ by whitespace.

**The `#` is drawn beside the name field, not pushed into its state.** `name` in
`SelectSubgroupAdminsViewModel` stays bare and the M3 `prefix` slot shows the
convention, because normalising on every keystroke moves the caret out from under
somebody halfway through a word. The coordinator still reads the name they are
about to get.

Four strings lose the old name -- "Create the #admins group" becomes "Create the
admin room", and the three about what "the #admins room" will sign with now say
"the admin room". Their keys are renamed with them, since the keys in this
catalogue are derived from the text. Around twenty comments, two screen previews
and seven test fixtures follow.

Docs: the ceremony note states the convention and what it replaces, and the
subgroups note's name-field section is rewritten -- it had been arguing from the
`"${parent.subject} (#admins)"` synthesis that no longer exists.
`docs/mls-skipped-keys.md` keeps its `"Frosty (#admins)"`: that is a captured
debugging log, and rewriting it would falsify a record.

Three tests. `MarmotGroupNameTest` pins the rule, idempotence included.
`MarmotGroupCreationJvmTest` pins the funnel -- a bare name in, `#Ekklesia` on both
the room row this device draws and the group data every other member reads.
`SubgroupManagerJvmTest` pins the pair that has to agree, by reading the proposed
event's tags back out of the signing session: the name the parent is asked to sign
is the name `MarmotGroupCreation` will give the room. That last one needed the
signable-parent fixture to seed host keys, since a ceremony's signer ids are
derived from them rather than stored.

**Rooms that already exist keep their names.** The name lives in the epoch-0 group
context, so renaming one is an MLS commit every member has to process -- a
different change from a naming convention, and not made here.

403 common tests, 726 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-09-09 11:41:30 +02:00
Kgothatso Ngako
5c893ab108 feat(subgroups): put the ceremony that needs you at the bottom of the chat
A NIP-17 room already showed the standing "waiting for your signature" notice
under the newest message -- `ProposalsAwaitingYouNotice` is transport-agnostic and
reads `FrostSigningSession` by room. What it never covered is the other thing a
room can owe somebody, which in a NIP-17 room is the main thing: a ceremony.

That gap matters more here than the signing one does. A ChillDKG cannot finish
until **every** member has taken part, so one member not finding their request
stalls everyone indefinitely -- and the only way to find it was to scroll the
transcript to its request line, past whatever else the room has been used for.
Three subgroups on the connected devices sat at 1 of 3 host keys for exactly that
reason.

`CeremoniesAwaitingYouNotice` sits beside the signing one, first in the reversed
layout so a room owing both puts the ceremony nearest the composer -- until a
ceremony finishes there is no key to sign anything with.

**It covers two different kinds of owing, and the second has no gate behind it.**
A participant is owed an approval, read through the same `pendingApproval` the
ritual screen uses so the two cannot disagree. The member who *opened* it is owed
something the protocol has no gate for: a ceremony reaching COMPLETE finishes
nothing on its own -- the group has a key and somebody still has to get its state
signed and create the room -- and that somebody is whoever opened it. Nothing else
in the app would ever say so, which is what the coordinator was missing.

`roomAwaitingCreation` is how it knows when to stop: the room a finished ceremony's
key derives either exists or does not. Asking that rather than keeping a flag means
the notice cannot become permanent furniture in every room that has ever held a
ceremony.

**It reads every ceremony in the room, not the newest.** A room holds more than one
the moment a subgroup's admins are the whole group, and the one that wants you is
routinely not the one that happened last -- that is what buried 2.0 and 2.1. The
notice opens the ceremony it names, by session id, through the same
`onOpenSharedKey` the transcript's own lines use since `0b65d702`.

Unlike the signing notice it opens the ceremony rather than a queue: there is no
queue of ceremonies, and with several the count is shown and the newest opened.

Eight strings in the catalogue in sentence case, each step worded the way its
approval screen words it so a member is not asked twice in two vocabularies.
`dkgRepository` is threaded to `ChatMessageListViewModel` through the messaging
screen, the home pane and the nav host.

397 common tests, 718 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-09-09 02:14:11 +02:00
Kgothatso Ngako
a6bb811b03 fix(subgroups): land the coordinator in the transcript, not on a screen offering another ceremony
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Pressing "Start the key ceremony" in the picker opened the ceremony and then
navigated to the shared-key screen -- whose whole job is to *offer* a ceremony to
a room that has none. So the coordinator arrived at a "Start key ceremony" button
seconds after starting one, and pressing it opened a second.

The second one is not a duplicate as far as the protocol is concerned, which is
why nothing refused it. `proposeRitual`'s guard is scoped by purpose, deliberately:
a room has to be able to hold the group's own ceremony and a subgroup's, since a
subgroup whose admins are the whole group runs in the room the group's own ceremony
ran in. A room holding a subgroup's ceremony will therefore take one for *no*
subgroup without complaint -- and then "the room's newest ceremony" is the new
empty one, and the subgroup's is buried under it. That is what happened to 2.0 and
2.1.

**The coordinator goes to the transcript.** The ceremony is already open and its
first line is already in that room; what they need is to watch it and answer the
requests as they arrive, through the same ritual notices every other member uses
-- which since `0b65d702` name their own ceremony and so open the right one. The
shared-key screen has nothing to offer a room that already has a ceremony, and
should not have been the destination.

**And the screen stops offering one.** `canStartRitual` asked only about the
ceremony being *shown*; it now also asks whether the room holds any that has not
failed, which is a different question and the one that matters here.
`DkgRitualUIState.roomHasCeremony` is observed separately for exactly that reason.

**The route's `parentChatRoomId` goes.** Nothing passed it any more, and it was the
shape of two of these bugs: only the member who opened a subgroup could ever fill
it in, so a route that claimed to know the purpose was right for one caller and
silently wrong for everybody else. The purpose is read off
`DkgSession.parentChatRoomId` and nowhere else now, which makes that rule
structural rather than conventional.

One test in `RobustRoomKeyCeremonyTest` pinning the thing that is *not* a bug: a
room holding a subgroup's ceremony still accepts one for another purpose, because
two purposes are two ceremonies -- so the refusal has to live in what the UI
offers, not in the guard.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:03:53 +02:00
Kgothatso Ngako
31e6fc6425 fix(subgroups): ask whether the key state is signed by ceremony, not by room
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Subgroups "2.0" and "2.1" on the connected devices had finished everything. A
ChillDKG COMPLETE for each, a birth certificate signed by the parent's quorum for
each, a key state signed by their own quorum for each -- all four events sitting in
the coordinator's database. The coordinator was offered no way to create the room.

`observeSignedGroupKeyState` resolved which key to ask about by looking the
ceremony up **from the room**:

    getLatestSessionFor(chatRoomId, parentChatRoomId)   // parent was null here

The view model calls it with no parent, so that reads "the ceremony in this room
that is *not* for a subgroup". A subgroup's ceremony room holds only the
subgroup's ceremony, so it matched nothing, derived no key, and reported "not
signed" over a state in the same database. The button is gated on that boolean, so
it never appeared.

This is the same mistake as `0b65d702` one layer down. That commit fixed the
*session* lookup by having the transcript name the ceremony; it left the key-state
and certificate lookups still re-deriving a ceremony from the room. A room does not
have one, and every lookup that assumes it does is wrong in a different way: by
room it finds none here, and by "newest in room" it would have found the wrong one
in the rooms from the previous report.

So both observers now take the ceremony they are about.
`observeSignedGroupKeyState(dkgSessionId)` derives the subject room from that
ceremony's own threshold key, and `observeBirthCertificate(dkgSessionId, parent)`
does the same. Neither can disagree with the ceremony the screen is showing,
because it is handed the same one.

In the view model they move into `observeForCeremony`, re-pointed when the
ceremony changes the way the message watcher already is -- previously the key-state
watcher was started once at init against a room, which is what let it drift from
the session on screen. `observeKeyState` keeps only the room-scoped watch of
signing sessions, which is genuinely a room question.

One test in `SignedGroupKeyStateTest`: a key state the group really signed is
reachable from the ceremony that produced it, and a ceremony with no key answers
null rather than throwing -- this flow runs from before a ceremony finishes.

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

The two stuck subgroups will offer "create the subgroup" on the next build: their
key states are already signed and on file, and nothing about them has to be redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:54:01 +02:00
Kgothatso Ngako
0b65d7025b fix(subgroups): open the ceremony a transcript line is about, not the room's newest
Diagnosed off the four connected devices. Three subgroup ceremonies of "one
(#admins)", all opened correctly by the coordinator, all sitting at
COLLECTING_HOST_KEYS with 1 of 3 host keys in. Nothing was dropped: every
participant device held the session, the ceremony room, its members and the
"your approval is needed" line. What none of them could do was reach it.

**A room does not have *a* ceremony, and every version of this screen has assumed
it does.** A subgroup whose admins are the whole group runs in the room the
group's own ceremony ran in -- that is the point of `1930d6aa`, and it is what the
devices did. Ask that room for its ceremony and you get whichever was opened last.
On the devices that was the group's own, already COMPLETE, in five of the six
room/device pairs; the subgroup's request for a host key sat underneath it,
unanswered, with the screen showing a finished ceremony and nothing to do.

Both previous attempts were the same mistake:

- unscoped "newest in room" -- picks the wrong ceremony whenever the other one is
  newer, which is what shipped and what the devices show;
- scoped by purpose (`1930d6aa`) -- invisible to every member who did not open the
  subgroup, since only the coordinator's route carries a parent (`98626a9e`).

Neither ordering can be right, because the question is wrong.

**A line knows which ceremony it is about; the room does not.** So `ChatMessage`
gains `dkgSessionId`, the pair to `frostSigningSessionId` and added for the same
reason one-at-a-time stopped being true -- `docs/frost-batch-signing.md` reached
this conclusion for signing sessions already, and the ceremony half was left on
the clock because "a room runs one at a time". It doesn't any more.

Every DKG line is stamped in `announce`, which all of them already funnel through.
`DkgRitualRoute`, the three approval routes and their screens carry the id, the
transcript's ritual notice passes the tapped line's, and `DkgRitualViewModel`
observes that session when given one and the room's newest otherwise -- which is
still the best a caller holding only a room can do, and is what the group's
details entry passes.

`ChatMessage.isAbout` uses it too, so `answeredRequests` stops crediting an answer
given to one ceremony as an answer to the other. Rows written before the column
read back null and fall back to the clock, which is correct for them: nothing that
predates subgroups ran two ceremonies in one room.

Schema 18, one nullable column, `AutoMigration(17, 18)`.

One test in `RobustRoomKeyCeremonyTest`: with two ceremonies in one room, every
line names one of them and a subgroup line resolves to the subgroup's ceremony. It
deliberately does not assert which the room's "newest" is -- two ceremonies opened
in the same second tie on `createdAt`, and the point is that nothing relies on
that ordering any more.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:25:12 +02:00