Commit Graph

3 Commits

Author SHA1 Message Date
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
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