Commit Graph

790 Commits

Author SHA1 Message Date
Kgothatso Ngako
76055bc0a8 docs: record the third pull, the profile preview and the extended FAB
Some checks failed
Material Design conformance / budgets (push) Has been cancelled
Material Design conformance / tests (push) Has been cancelled
Eleven commits reached the fork after the second pull had landed, and
this note is their inventory, verdicts and record: the profile-preview
line pulled whole, the extended-FAB sweep pulled with its seven removed
screens resolved as still deleted, and d2a4298d -- one commit half in the
curated-entries line Mantra took out and half in the broadcast line it
kept -- pulled in part, by a PARTIAL rule that applies the kept paths and
writes the rest into the note.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@29027f2b92
2026-09-13 15:54:37 +02:00
Kgothatso Ngako
72b9d3d2ed test(identity): several profiles, both ways, and a boot
Phase 8 of docs/multiple-profiles.md: the seams of Phases 1 through 7 in a
row, through the real view models on an in-memory database with a real key
store.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@6ae5a9676e
2026-09-13 12:01:41 +02:00