Commit Graph

721 Commits

Author SHA1 Message Date
Kgothatso Ngako
72b9d3d2ed test(identity): several profiles, both ways, and a boot
Phase 8 of docs/multiple-profiles.md: the seams of Phases 1 through 7 in a
row, through the real view models on an in-memory database with a real key
store.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@9bb340042d
2026-09-13 15:54:37 +02:00
Kgothatso Ngako
72afe8aa40 feat(sync): a fetch queue per identity, and the inbox reopened on activation
Phase 6 of docs/multiple-profiles.md. The one data problem, closed from both
ends.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:45:15 +02:00
Kgothatso Ngako
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
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
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
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
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
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
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
2026-09-09 17:04:09 +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
Kgothatso Ngako
98626a9ef9 fix(subgroups): find a ceremony by the room, and read its purpose off the session
A subgroup's ceremony started and nobody else could see it. The session row was
written, the transcript said so, and the shared-key screen showed "start a
ceremony" as though nothing had happened.

**Only the coordinator arrives knowing it is a subgroup.** They come from the
picker, which puts `parentChatRoomId` on the route. Everybody else reaches that
screen from the room -- the transcript's ritual notice, or the group's details --
and neither has a purpose to hand it, so both construct `DkgRitualRoute(chatRoomId)`
with nothing. When the previous commit scoped the room's session lookup by purpose,
`observeLatestSessionForChatRoom(room, null)` started meaning "the ceremony that is
*not* for a subgroup", and a subgroup's session stopped being visible to every
member but the one who opened it.

**Scoping belongs where scoping is the question being asked**, which is "may I
open another" -- `proposeRitual`'s guard and `refuseCeremonyRoom`. Those keep
`getLatestSessionFor(room, parent)`. The screen asks a different question, "what is
happening in this room", and there is only one honest answer to it: whatever
ceremony is there. So the room's lookup goes back to being room-scoped and the
purpose is read *off the session that turns up* rather than required in order to
find it.

That is also the better shape. A member who did not open the subgroup has no idea
it is one until the proposal they were sent arrives, so the purpose could never
have been an input on their side.

**The certificate watchers move to follow the session.** They cannot start at init
any more -- there is no parent to watch until one is known -- so they are
cancelled and restarted from the session collector when the purpose changes, the
way the message watcher already is. An ordinary ceremony passes null and gets
nothing watched.

**Three actions were reading the route as well**, which is the same bug one step
on. `docs/subgroups.md` says only step 1 belongs to the coordinator: the key state
and the room are open to any of the subgroup's admins, and they arrive by room. So
`proposeBirthCertificate`, `proposeKeyState` and `createAdminGroup` now take the
parent from the resolved state rather than the constructor -- otherwise a second
admin finishing the flow would have created an ordinary `#admins` room with no
parentage on it.

Two tests in `RobustRoomKeyCeremonyTest`, both of which fail against the previous
commit: a subgroup's ceremony is found by the room alone and carries the purpose
with it, and a room holds its own ceremony and a subgroup's at once without either
being mistaken for the other or a third being opened.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 01:04:24 +02:00
Kgothatso Ngako
1930d6aaef fix(subgroups): a subgroup may be the whole group
"A subgroup cannot be the whole group. Leave at least one member out." That rule
shipped in Phase 8 and it was wrong twice.

**It refused something legitimate.** A subgroup is a logical division -- a group
deciding that some of its work belongs to a differently-keyed room -- not a group
carving out a smaller membership. Every member being in it is an ordinary case,
and no guard here had any business deciding otherwise.

**And it was a proxy, not a check.** The thing it stood in for is real: a ceremony
room is derived from its admins, so a subgroup over everybody lands in the room
the group's *own* ceremony was held in, and `proposeRitual` handing back that
ceremony would quietly make the child the parent. But set size does not detect
that. A parent whose membership has changed since its own ceremony derives a
different room -- so the sizes can match with no collision, and differ with one.

**Sharing the room was never the problem; sharing a ceremony was.**
`DkgSession.parentChatRoomId` already told two ceremonies apart, so the fix is to
scope the lookup by it rather than to forbid the selection.
`DkgSessionDao.getLatestSessionFor(room, parent)` replaces `...ForChatRoom` at the
three places that decide whether a ceremony already exists: `proposeRitual`'s
one-at-a-time guard, `refuseCeremonyRoom`, and the two repository observers the
ritual screen follows. A room may now hold the group's own ceremony and a
subgroup's at once.

Nothing below that lookup had to learn about the second one. A ceremony's
messages, approvals and transcript are already keyed by session id; only the
question "what is this room's current ceremony" was ever room-scoped, and that
question was always really "for what purpose".

One collision survives and it is degenerate: the same parent, over the same
admins, twice. Those two have nothing left to distinguish them -- which is another
way of saying they are one subgroup asked for twice, and that is what the message
now says.

`a subgroup that is the whole group is refused` becomes `a subgroup may be the
whole group`, and two new cases pin the scoping: the group's own ceremony sitting
in the derived room does not block a subgroup there, and the same parent asking
twice over the same admins still does while a different admin set is untouched.

`docs/subgroups.md` keeps the withdrawn rule struck through in the refusals table
with a section saying why, rather than quietly deleting it -- the reasoning that
led to it is the reasoning somebody would repeat.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:38:49 +02:00
Kgothatso Ngako
ccaef5d36a fix(subgroups): read the admin list off the group's data, not off a flag no creator ever sets
"Only an admin of this group can make a subgroup", said to the admin who had just
made the group. The guard was reading the wrong thing.

**`Participant.adminAt` is written in exactly one place**:
`MarmotInboundManager.processGroupMembershipChanges`, reached only from `NostrDao`
on a `GroupEventResult.CommitProcessed` -- an *arriving* commit. The rows a
creator makes come from `getOrCreateChatRoom` and `addMembers`, neither of which
sets it. So on the device that created a room, every participant reads as a
non-admin, including the creator, until some other member sends a commit it
processes. A freshly made `#admins` room has had no commits at all, so a group
whose epoch-0 context names three admins has none of them flagged on the one
device certain to be one.

**The guard now reads `MarmotGroupData.adminPubkeys`** off the room's own MLS
state. That is the thing the column is a cache of: MIP-01 stamps it into the
epoch-0 group context, every member gets it in their Welcome, and
`processGroupMembershipChanges` reads exactly this when it writes the flag.
Reading it directly cannot drift from what the group agreed and is right on both
sides from the first moment. A room whose group data cannot be read refuses rather
than falling back to the column.

The compiler caught a second bug while this was being written: the parent's admin
list was named `adminPublicKeys`, shadowing the parameter of the same name that
holds the *subgroup's* picked admins -- and the ceremony room is derived from that
parameter. It is now `parentAdminPublicKeys`, with a comment saying why the two
must never be confused.

**The column is caught up as well, because the UI still labels members with it.**
`MarmotGroupCreation` stamps `adminAt` on the rows of the members the room was
created with as admins -- the same list baked into `MarmotGroupData` a few lines
above, so nothing new is asserted. Flags only: `processGroupMembershipChanges`
also removes participants missing from the MLS tree, and running the whole
reconciliation would soft-delete the rows of anyone `addMembers` could not reach,
turning a partial invite into a partial membership. Widening a flag is safe;
narrowing membership on a best-effort step is not. Swallowed on failure like
`adopt` beside it: a missing flag costs a button not being offered, not
correctness.

**The old fixture was testing nothing.** It built the parent as a NIP-17 room with
MLS state bolted on, and `ChatRoom.deriveChatRoomId` returns a 33-byte compressed
key (66 hex) while `MarmotGroupData.nostrGroupId` takes 32 -- so `toExtension()`
produced an extension `currentMarmotData()` read back as **null**, silently,
taking the admin list with it. Every guard test was passing on that null. The
parent is now built as what a real one is: the `#admins` room derived from the
group's key, with `adminAt` deliberately left null on every row, which is the
state the guard has to work in.

Four new tests. Two in `SubgroupManagerJvmTest`: an admin is admitted with every
`adminAt` still null, and a room with no MLS state has no admin list to consult.
Two in a new `MarmotGroupCreationJvmTest`: the creator is flagged an admin of the
room they just made, and the room can read its own group data back -- the second
asserting `nostrGroupId` is the 64-hex derived id, since the wrong length there
fails by returning null rather than by throwing.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:30:53 +02:00
Kgothatso Ngako
3ae49bd338 test(subgroups): the join between the pure rules and the columns they write
Phase 9 of docs/subgroups.md. Most of the plan's test work landed with the phase
it guarded -- 13 pure cases in Phase 1, 10 more in Phase 3, 21 over a real
database in Phases 2, 5 and 8. What was left is the one thing none of them can
reach.

`GroupKeyStateTest` settles Phase 3's rules purely and exhaustively, and
`SubgroupDaoJvmTest` settles that schema 17 holds four columns. Neither can settle
the **join**: that a parentage put on a proposal survives a real signing session,
a real FROST aggregate and a real `record`, and lands on the *row* on every device
rather than only in the event. That is a schema question wearing a protocol
question's clothes, and it is exactly the sort of thing that breaks without
failing -- `record` could drop both fields and every existing test would still
pass.

Three cases on the existing two-device harness in `SignedGroupKeyStateTest`, which
already runs a whole session across two databases with nothing shared but what is
ferried:

- a quorum signing a subgroup's state puts the parent and the whole certificate on
  both devices, neither of which was sent a row -- each derived the event from its
  own items, re-ran `certifies` against the parent's id, and wrote the same two
  columns;
- a real certificate really signed by the parent but naming another room is
  refused by `propose` before anything is published, and neither device ends up
  with a state;
- a state signed with no parentage keeps both columns null, which is how every
  group made before subgroups reads and every top-level group made after.

The parent is a second `KeyMaterial` and its signature is a real FROST aggregate
assembled by hand. Standing up three more databases to get one would have tested
the harness rather than the join.

397 common tests, 708 jvm tests, `m3Audit` meets every budget. All nine phases of
docs/subgroups.md are built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:51:42 +02:00