diff --git a/docs/README.md b/docs/README.md index dacca806..f5cf622c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ silent, or a decision that looked arbitrary and was not. | [dead-code.md](./dead-code.md) | code in the sync and relay stack that nothing calls, why each piece is still there, and which of it is a bug rather than a leftover | | [nsec-sign-in.md](./nsec-sign-in.md) | signing in with an existing nostr key — why an nsec can never have a wallet behind it, the ten call sites that make it small, and the sign-in machine that was already built and unreachable | | [npub-sign-in.md](./npub-sign-in.md) | signing in with only a public key — what a key that cannot sign can still see here, why that is a preview rather than a browser, and the four decisions the word settles | +| [multiple-profiles.md](./multiple-profiles.md) | several profiles on one device — why a profile is a credential and a seed is a wallet attached to one, why a switch is a restart rather than a swap, the two entrances the app lacks, and the inbox a switch would silently lose | | [jvm-target.md](./jvm-target.md) | what desktop support cost, phased — why the native chain was already done, why an empty source set in our phoenix fork was the real blocker, and why DAO tests need none of it | | [material-design-conformance.md](./material-design-conformance.md) | what the M3 foundations actually require, measured against all 43 screens — the colour pairing that renders the app's own proposals invisible, and eight phases that put the decisions back in the theme | | [curated-to-mantra.md](./curated-to-mantra.md) | pulling the Curated fork's thirty-nine commits back under Mantra's names — which lines of work to take, the three decisions, and a measured way to replay a twice-rebranded history without touching seven hundred files by hand | @@ -57,3 +58,8 @@ The npub sign-in note is a phased plan that has been built, and reads as that plan's out-of-scope note answered: it takes the `Identity` type and the sign-in machine as given and asks what an identity with no secret is for, before it asks how to build one; read its table of where the build chose differently first. +The multiple-profiles note is a phased plan that has not been built, and reads as +the third of the sign-in notes: it asks what happens when the device holds two +identities, and its first phase changes one rule the other two share — a seed's +key becomes a credential like any other — so read it with `StoredIdentity.merge`, +`SovereignWalletViewModel.switchToWallet` and the startup screen open. diff --git a/docs/multiple-profiles.md b/docs/multiple-profiles.md new file mode 100644 index 00000000..49cd6ef9 --- /dev/null +++ b/docs/multiple-profiles.md @@ -0,0 +1,1186 @@ +# More than one profile on a device + +How a user holds several profiles on one device, moves between them, and adds +another by signing in or by creating one — why a profile is a credential and a +wallet is a thing attached to one, and why the switch has to be a restart of the +signed-in app rather than a swap underneath it. + +Read this after [nsec-sign-in.md](./nsec-sign-in.md) and +[npub-sign-in.md](./npub-sign-in.md). It takes the `Identity` type, the two key +stores, the sign-in screen and the forget sequence as given, changes one rule the +two of them share, and asks the question both deferred without naming it: what +happens when the device holds two of them. + +**Not built.** Phases 1–8 are one commit each, in the order given; Phase 9 is the +rollout. The phases are written as reasoning rather than as a checklist, for the +reason the two sign-in plans give: the code reads better against the argument it +came from. + +## The vocabulary + +The user asks for "accounts". The code says *identity* (`Identity`, +`StoredIdentity`, `activeIdentity`), the storage layer says *wallet* +(`WalletId`, `WalletsSelector`, `select_a_wallet`, "Opening wallet"), and every +string a user reads says *profile* ("Create profile", "Sign out of this +profile?", "This profile is read only"). Three words, and they are not three +names for one thing. + +**A profile is a nostr key. A wallet is a different thing that a profile may have +attached to it.** The two live in different files for a reason both sign-in plans +spelled out: `seed.dat` holds twelve words, from which a Lightning node *and* a +nostr key are derived; `nostr-credentials.dat` holds a bare key, or a bare public +key, from which nothing else can be. The relationship runs one way — a seed +always yields a key; a key does not imply a seed — and the code has, until this +plan, drawn the line in the wrong place: a seed's key is *not written to the +credentials file*, so a seed-backed profile exists only as a derivation, and the +app has to start a Lightning node to find out who it is +([What actually blocks it](#what-actually-blocks-it)). + +This plan settles the model and the words together: + +- **The credentials file is the list of profiles.** Every profile the device can + sign as is a `Secret` entry in it, and every one it can only look at is a + `Public` entry. A seed contributes a wallet, and the wallet is *attached* to + the profile whose key it derives — matched by public key, and written as a + credential when the seed is, which is [Phase 1](#phase-1--the-credential-a-seed-always-implied). +- **The code keeps saying identity; the user reads profile; and *wallet* is used + only of a wallet.** *Account* is not used anywhere. The two Phoenix-era strings + the user can still see, `select_a_wallet` and `change_account`, become *choose + a profile* and *switch profile*; the startup screen's loading literals follow, + except the one that is genuinely a wallet starting. +- **No renaming of types.** `WalletId` keys every preference file in the library + and was left alone by both sign-in plans for that reason. A profile's id is its + wallet's when it has one — `hash160(nodeId)`, as it has always been for a + seed — and `hash160(pubkey)` when it has none, which is the rule the npub plan + set and this plan keeps, for the reason in the + [appendix](#one-id-space-for-every-profile). + +What the distinction decides, phase by phase: + +| the wallet is not the profile, so | where | +|---|---| +| a seed's key is a credential like any other, written when the seed is and repaired into the file for every seed already here; the identity's key comes from the credential, not the node | [Phase 1](#phase-1--the-credential-a-seed-always-implied) | +| a switch is between profiles; the node, where there is one, follows the profile it belongs to — stopped when that profile is left, never started for one that has none | [Phase 2](#phase-2--a-switch-that-tears-down-what-it-should) | +| the switcher says which profiles have a wallet attached, before the tap | [Phase 4](#phase-4--the-switcher) | +| a profile added from inside is a *key*, not a second seed: wanting another profile is not wanting another wallet | [Phase 5](#phase-5--adding-a-profile-from-inside) | +| a bare key can be forgotten; a key with a wallet attached has funds behind it, and its sign-out stays a wallet question | [Phase 7](#phase-7--leaving-one-profile-among-several) | +| a wallet attached to a profile that did not derive it, or one wallet every profile on the device pays from, is the model this leads to — and its own plan, which Phase 1 makes small | [Out of scope](#out-of-scope) | + +## The constraint + +One database, one process, one set of pumps, and every route on the stack +addressed by the key it was pushed for. + +Every screen from the home tab inward takes `activeUserPublicKey` as a route +argument — `HomeRoute`, `SearchRoute`, `ActiveProfileRoute`, `ChatRoomDetailRoute`, +all of them. The navigation component reads the signed-in key *off the current +route* rather than holding it +([MantraNavHost.kt:418](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt)), +precisely so that it cannot drift from the screen underneath. That is the right +design for one profile and it decides the shape of a switch: a stack of routes +built for key A cannot be handed key B. The only honest switch is to clear the +stack and rebuild it for B — which is, as it happens, exactly what the app already +does when the active identity changes. + +The second half of the constraint is the database. It is one Room file for the +device, and it is *mostly* keyed by whose it is: `ChatRoom.userPublicKey` — whose +comment reads "should help us have multiple user support" +([ChatRoom.kt:48](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/model/ChatRoom.kt)) +— `UnsignedNostrEvent.pubKey`, `MarmotKeyPackageBundle.publicKey`, +`GiftWrapMessage.receiverPublicKey`, the `Connection` relation by both keys. The +profile cache is shared, and should be: a kind 0 is the same event whoever fetched +it. What is *not* keyed by whose it is are the three request queues, and +[Phase 6](#phase-6--a-queue-per-identity-and-the-inbox-that-arrived-while-another-profile-was-open) +is about the one place that matters. + +## What is already built + +Most of a switch. The Phoenix fork this app grew from is a multi-wallet +application, and its switching machinery came across intact and unused. + +| piece | where | state | +|---|---|---| +| the credentials file, its encrypted writer, and the app writing it from `writeMnemonic` — to *delete* a public entry a seed supersedes | [IdentityWriter.kt:148](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt) | built; writing a `Secret` from the same place is one line more | +| `migrateFromNostrKeys`: a named, idempotent write that runs from `listIdentities` before anything reads either file | [SovereignWalletViewModel.kt:183](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; the shape Phase 1's repair copies | +| `StoredIdentity.merge`, deriving every seed's nostr public key at listing to compare stores by pubkey | [StoredIdentity.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/StoredIdentity.kt) | built; the derivation is what attaches a seed to its credential | +| `switchToWallet(id)`: sets `desiredWalletId`, clears the active identity | [SovereignWalletViewModel.kt:304](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; called by the sign-in and create tails and the startup selector, never from a signed-in screen | +| `resetToSelector()`: clears both and asks startup for the list | [SovereignWalletViewModel.kt:315](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt) | built; the sign-out and forget tails use it | +| `NavigationViewModel.observeProfile`: a null identity is `StartupPhoenix`, which the nav host navigates to with `popUpTo(0)`; a new identity is observed from its account | [NavigationViewModel.kt:184](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NavigationViewModel.kt) | built — this *is* the restart | +| the notary, the sync pumps and the live subscriptions run as children of `collectLatest` over the identity, so a change cancels them — the comments say "a wallet switch" in as many words | [NotaryViewModel.kt:62](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/NotaryViewModel.kt), [SynchronizationViewModel.kt:193](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SynchronizationViewModel.kt) | built | +| the startup screen: lists, picks by `forceWalletId` / `desiredWalletId` / the default, shows `WalletsSelector` otherwise, and wraps every activation in the lock gate | [SovereignWalletStartupScreen.kt:106](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt) | built; the precedence is wrong for a switch, see below | +| `WalletsSelector`: a current section with a divider, the others below, a *read only* label, `topContent` and `bottomContent` slots | [WalletsSelector.kt:59](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt) | built; shows "Default name" and a random emoji for every row | +| `BusinessManager`: a map of running nodes by wallet id; starting one that runs returns it; `stopBusiness(walletId)` on all three platforms | [BusinessManager.kt:208](../lightning-kmp-app/library/src/androidMain/kotlin/fr/acinq/phoenix/android/BusinessManager.kt) | built; nothing in the app calls `stopBusiness` | +| `GlobalPrefs.getDefaultWallet`, `saveDefaultWallet`, `clearDefaultWallet` | [GlobalPrefs.kt:102](../lightning-kmp-app/library/src/commonMain/kotlin/fr/acinq/phoenix/utils/preferences/GlobalPrefs.kt) | read by startup; never written by anything | +| the sign-in and create tails: re-list, select, go to startup | [MantraNavHost.kt:746](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), [:545](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt) | built; a shape that works from inside the app as well as from Landing | +| *Change account* on the profile tab | [ActiveProfileScreen.kt:309](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/ActiveProfileScreen.kt) | `ImplementationPendingRoute` | +| `ForgetIdentity` and `SignOutViewModel` for a read-only identity; *forget this key* for an nsec | `identity/ForgetIdentity.kt`, `NostrSecretScreen` | built; the tail lands on the selector or Landing, which is right for several profiles too | +| `NpubPreviewRoundTripJvmTest`: a real key store, a real in-memory database, real view models | `jvmTest/.../identity/` | the harness every test below reuses | + +So the switch exists as a state transition and is reachable from nowhere, and +the list it would switch between is drawn from two files that disagree about +what a profile is. That is the whole shape of this plan: fix what a profile is, +build the two entrances, fix what the transition gets wrong, and make the one +data problem it exposes go away. + +## What actually blocks it + +Seven things, in the order a user would hit them. + +**A seed's profile is not a credential.** `writeMnemonic` writes `seed.dat` and +nothing else — and if a `Public` entry for the seed's key exists, it *deletes* +it, so the seed becomes the only holder +([IdentityWriter.kt:148](../composeApp/src/commonMain/kotlin/press/mantra/compose/identity/IdentityWriter.kt)). +The key is then derived twice on every launch: at listing, from the words, to +learn the pubkey the selector shows; and at activation, from the *running node's* +key manager ([SovereignWalletViewModel.kt:151](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/SovereignWalletViewModel.kt)), +which is why a seed-backed profile has to start Lightning before it can be +anyone. Both sign-in plans made this an invariant rather than an accident — one +entry per pubkey *across both files*, `writeNostrKey` refusing a key a seed +derives, `writeMnemonic` refusing a seed whose key is here as a secret — and it +is the wrong invariant: it says a wallet is a profile, and the switcher this plan +builds would inherit a list in which one kind of row is a key and another is a +seed pretending to be one. + +**There is no way in.** Landing is reached only when `availableIdentities` is +empty ([SovereignWalletStartupScreen.kt:95](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)), +and the sign-in and create screens are reached only from Landing. A device with +one profile cannot be given a second: the flow that adds one is behind a screen +the device never shows again. + +**There is no way across.** The selector is drawn by the startup screen and +nowhere else, and nothing in the signed-in app calls `resetToSelector()` except +the sign-out tails. The row that promises it on the profile tab routes to the +pending screen. + +**The startup precedence is wrong for a switch.** The `when` that picks what to +open reads `!startWalletImmediately -> null` *before* `desiredWalletId != null` +([:122](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/SovereignWalletStartupScreen.kt)), +and `startWalletImmediately` is set to false by `resetToSelector()` and by the +lock prompt's back button and set back to true by nothing. So after the first +visit to the selector, every sign-in on that device lands on the selector instead +of the profile that was just signed in. And because `saveDefaultWallet` is never +called, `availableIdentities[defaultWallet]` is always null: a cold boot with two +profiles is the selector, every time, with no memory of which one was open. + +**Two things are not torn down.** `RelaysSocketManager.observeActiveUserId` keeps +one relay-list observer *per pubkey* and only ever cancels the one for the pubkey +being started; its own comment says `TODO: Cancel all pending jobs?` +([RelaysSocketManager.kt:65](../composeApp/src/commonMain/kotlin/press/mantra/compose/network/relays/RelaysSocketManager.kt)). +After a switch, A's observer and B's observer both feed `updateRelayPools`, and +the one pool follows whichever relay list emitted last. And the previous +profile's node, where it had one, keeps running in `BusinessManager` — Electrum, +the LSP peer, the swap-in watcher — for a profile nobody is looking at. The nsec +plan established that nothing in this app reads the node but the key; a node +running for a profile that is not active is that finding with the sign flipped. + +**The queues belong to the device, not the identity.** `SynchronizeNostrEventRequest`, +`NegentropySynchronizeRequest` and `BroadcastNostrEventRequest` have no owner +column, and the pumps drain whatever is `pending` with the *active* key pair +([DatabaseNostrRepository.kt:111](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/repository/DatabaseNostrRepository.kt)). +Requests A queued a moment before the switch — its sign-in sync, its room +reconciliation — are answered while B is active, and everything that comes back +is indexed as B: a gift wrap addressed to A is stored, found not to be addressed +to the active key, and left ([NostrDao.kt:439](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt)). +Then `storeNostrEvent` never indexes an event it already holds +([NostrDao.kt:176](../composeApp/src/commonMain/kotlin/press/mantra/compose/database/dao/NostrDao.kt), +the `TODO: Check if event got indexed`), so when A is opened again, its live +subscription re-receives the wrap, the DAO sees a known id, and returns. **A +message that arrives while the other profile is open is lost to the one it was +for.** Silently, and it looks in the logs like nothing happened, because nothing +did. + +**Two destructive controls assume one profile.** `CreateProfileScreen`'s *end +this* calls `wipeDatabase()` ([CreateProfileScreen.kt:381](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/CreateProfileScreen.kt)) +— on a device with a second profile, that profile's rooms and MLS state go with +it, from a screen the user opened to *add* one. And once a default is remembered, +forgetting the identity it names has to clear it, or the next boot opens a +profile the list no longer has. + +## The two decisions to make first + +### What a profile is + +Two models were on the table. + +**A seed is a profile by derivation.** What the code does: the credentials file +holds only bare keys, a seed's key is recomputed from the words at listing and +from the node at activation, and the writers refuse to hold the same key in both +files. One copy of every secret, nothing to migrate, and the two sign-in plans' +tests pass as written. The costs are the ones the blocker above lists: the node +has to run for the profile to know its own key, the list of profiles is a merge +of two files with opposite ideas of what a row is, and there is no place for a +wallet that belongs to a profile it did not derive. + +**A profile is a credential; a seed is a wallet attached to one.** Creating or +restoring a seed writes the seed *and* a `Secret` credential for the key it +derives; every seed already on the device gets its credential written at the +first listing; `merge` lists the credentials file and attaches each seed to the +entry it derives. The identity's key is read from the credential for every kind, +so the node stops being where a profile's key comes from and becomes what a +wallet is — which is the sentence this plan wants to be true, and the one that +makes "attach a wallet to this profile" a later plan instead of a redesign. + +**This plan takes the second.** Its costs are real and Phase 1 pays them: a +repair step at listing, the derived secret held in a second file under the same +keystore key, one of the sign-in plans' invariants inverted, and every `when` +over `IdentityKind` re-read for what the kind now means. What it does *not* do is +make the node lazy, though it makes that possible — the reason is under Phase 1, +and the decision is named under [out of scope](#out-of-scope). + +### What a switch is + +Is a switch a *restart* or a *swap*? + +**A swap** sets the new identity while the stack stands. Every route on that +stack carries the old key as an argument; every view model built from those +routes is observing the old key's rows; the notary and pumps restart under the +new key while the screens above them show the old one. Making that consistent +means every screen re-reads the identity from a flow instead of its route — the +snackbar-host problem again, in forty-three places — or a re-navigation that +amounts to a restart anyway. Rejected, and the [appendix](#a-live-swap-under-the-stack) +says so at more length. + +**A restart** is what `observeProfile` already does when the identity goes null: +`StartupPhoenix`, `popUpTo(0)`, the startup screen activates the next one, the +machine routes it from its account. Every screen is torn down; every collector is +cancelled and rebuilt; nothing built for A survives into B. It costs a visible +pass through the startup screen — "Opening profile", under a second for a bare +key, a little over one for a profile with a wallet attached — and it is honest +about what it is. + +**This plan takes the restart.** It is what the code does, it is the only design +that cannot leak one identity into another's screen, and it settles four things +that would otherwise each be an argument: + +- **The back stack is cleared by a switch**, so the control that switches is a + pushed screen reached by one deliberate tap from the profile tab — never a + global control in an app bar, a long press, or a swipe that could fire from + inside a half-written message. +- **The previous node is stopped.** It belongs to the profile being left, not + to the device; nothing reads it, the restart tears down everything else, and a + library that already handles "start one that exists" makes switching back + cheap enough. +- **The profile that opens on launch is the last one used.** A restart through + startup is also what a cold boot is, and the two should agree: `desiredWalletId` + for a switch, the saved default for a boot, the selector only when the user + asked for it. +- **A profile added from inside is switched to.** The sign-in and create tails + already select what they wrote; the one they left stays on the device. + +Everything below is downstream of those two words: credential, and restart. + +--- + +## Phase 1 — the credential a seed always implied + +**App only. No library change.** The file format, the encrypted writer and the +manager all exist; the app already writes the credentials file from the seed +writer. This phase changes what it writes there, and what the listing believes. + +### The write + +`writeMnemonic` writes two things, in this order: + +1. the credentials file, with a `Secret` for `keyManager.nostrPrivateKey()` under + its x-only public key — replacing a `Public` entry for that key if one is + there, which is the read-only upgrade the npub plan built, now landing on a + secret rather than on nothing; +2. `seed.dat`, as today. + +Credential first, for the reason the npub plan gave for the order it chose: a +crash between the two leaves a *bare-key profile* whose key the phrase derives, +which is a valid thing for the device to hold and which pasting the phrase again +completes. The reverse order would leave a seed with no credential, which the +listing below tolerates and the repair fixes — also recoverable, but the first +order says the model out loud: the profile exists, and then a wallet is attached +to it. + +`writeNostrKey` and `writeNostrPublicKey` are unchanged. The duplicate table +becomes: + +| the pubkey is already here as | pasting its nsec | pasting its npub | pasting its phrase | +|---|---|---|---| +| a secret with a wallet attached | `AlreadyExists` | `AlreadyExists` | `AlreadyExists` | +| a secret, bare | `AlreadyExists` | `AlreadyExists` | **attach**: the seed is written, the entry stays as it is, `Written` with the wallet's id | +| a public entry | upgrade to secret, one write | `AlreadyExists` | upgrade to secret and attach, two writes | +| nothing | `Written` | `Written` | `Written`, two writes | + +The one new cell is the phrase pasted over a bare key, which the nsec plan +refused as `SeedAlreadyExists`. It was a duplicate under the old model because +two files would have held one key; under this one it is a profile acquiring the +wallet that derives it, and it is the first attachment the app performs. The +profile keeps its key and every row keyed by its pubkey. Its id changes to the +wallet's, so its preferences and metadata are fresh, as a new wallet's are — the +backup flags reset, which is right: there is a new secret to back up. + +### The repair + +Every seed already on a device has no credential. `SeedCredentials.reconcile`, +beside `migrateFromNostrKeys` in `listIdentities` and shaped like it — a named, +idempotent write that runs before anything reads the merged list: + +```kotlin +object SeedCredentials { + sealed interface Result { + data class Written(val count: Int) : Result + data object NotNeeded : Result + data class Failed(val cause: Throwable) : Result + } + + /** + * Writes a Secret credential for every seed whose derived key has none, replacing a + * Public entry for that key where there is one. One write, or none. A failure is + * logged and the listing goes on: a seed that cannot be repaired into the file is + * still listed, with its key derived, by [StoredIdentity.merge]. + */ + suspend fun reconcile(phoenixGlobal, wallets: Map, credentials: Map): Result +} +``` + +It runs after the seed file is read and the nostr-keys migration has run, and +before the credentials file is read for the listing — so the listing sees the +repaired file. On a device with no seeds it does nothing; on a device already +repaired it does nothing; on the first launch after this phase it writes once. +`Failed` is logged, not surfaced: this is a repair of something the app can still +work around, and a listing that goes red because a background write failed would +be worse than the derivation it was replacing. + +### The listing + +`merge` inverts. Today the seeds are the list and the credentials are added to +it; now the credentials are the list and the seeds are attached to it. + +```kotlin +fun merge(wallets, credentials): Map { + val walletsByPubkey = wallets.values.associateBy { nostrPublicKeyOf(it.words) } + val merged = LinkedHashMap() + credentials.forEach { (pubkey, credential) -> + when (credential) { + is Secret -> { + val wallet = walletsByPubkey[pubkey] + val stored = if (wallet != null) mnemonic(wallet, credential.privateKey) else nostrSecret(credential.privateKey) + if (stored.nostrPublicKey == pubkey) merged[stored.id] = stored + } + is Public -> if (pubkey in walletsByPubkey) log.w { "public entry for a key a seed derives; the next repair upgrades it" } + else nostrPublic(pubkey)?.let { merged[it.id] = it } + } + } + // A seed the repair could not write a credential for. Listed as it always was, + // with the key derived from the words, so that a failed write never hides a wallet. + walletsByPubkey.forEach { (pubkey, wallet) -> + if (pubkey !in credentials) { log.w { "seed with no credential; deriving" }; mnemonic(wallet).let { merged[it.id] = it } } + } + return merged +} +``` + +`StoredIdentity.Mnemonic` gains the key: `Mnemonic(userWallet, privateKey)`, with +`nostrPublicKey` derived from the key and checked at construction against what +the words derive — a mismatch is corruption, and the entry is dropped with a log +line rather than listed under a key it cannot sign as. The derivation from the +words stays, because it is what matches a seed to its credential; the nsec plan +noted `SeedManager` had already built that key manager once, so this is the same +second derivation it was. + +`IdentityKind` keeps its three values and its doc comment changes: + +```kotlin +/** A key the device also holds a seed for. The key is a credential like any other; the seed is the wallet attached to it. */ +Mnemonic, +``` + +Every `when` over the kind is re-read in this phase for what the kind now means, +and none of them change: `KeyRecoveryScreen` offers the phrase for this kind +because the phrase is the wallet's backup and derives the key; `NostrSecretScreen` +refuses to forget it because a wallet is attached; the startup branch starts the +node because a wallet is attached. The meaning shifted under them and the answers +held, which is what a kind that names the *attachment* rather than the *source* +buys. + +### The activation + +`setActiveWallet(walletId, business)` becomes `setActiveWallet(stored: StoredIdentity.Mnemonic, business)` +and builds the identity from the **credential's** key: + +```kotlin +val derived = business.walletManager.keyManager.value?.nostrPrivateKey() +check(derived == stored.privateKey) { "the node derives a different nostr key than the credential holds" } +setActiveIdentity(Identity.signing(id = stored.id, kind = IdentityKind.Mnemonic, nostrPrivateKey = stored.privateKey, …, business = business)) +``` + +The check cannot fail on a file the repair wrote — it derived the key from the +same seed — and it is kept because a credentials file is now the thing the app +signs with, and a node disagreeing with it is the one corruption worth refusing +to run under. + +**The node still starts.** Not for the key any more: for what `startNewBusiness` +does besides — it registers the wallet's metadata, applies its Electrum and Tor +preferences, records the last-used build, and on Android schedules the channel +watcher that notices a force-close while the app is shut. A phrase pasted from a +real Phoenix wallet can have channels behind it, and a build that stopped +watching them to save a second at startup would be trading a spinner for funds. +With the key in the credential, making the node lazy is one branch in +`doLoadWallet` and a decision about who watches channels while it is not running; +it is named under [out of scope](#out-of-scope) and this phase does not take it. + +### The forget + +`forgetNostrCredential` refuses two things instead of one: a key that is not in +the file, as today, and — new — a key that a seed derives. The seed would +re-derive it and the next repair would write it back, so a forget that succeeded +would undo itself at the next launch. `ForgetNostrCredentialResult.NotACredential` +becomes `WalletAttached`, because that is now the only reason a signing profile +cannot be forgotten, and `ForgetIdentity.Outcome` follows. + +### The two plans that said otherwise + +Each of the sign-in docs carries a table of where the build chose differently +from the plan. Each gains one row at the bottom, pointing here: the +one-entry-per-pubkey rule across both files, superseded — a seed's key is a +credential, and the seed is attached to it. Their tests change in the same +places: `IdentityWriterJvmTest`'s "the npub of a key a seed derives is refused" +stays; its "a seed whose key is here as a secret is refused" becomes "…is +attached to it". + +### Tests + +`SeedCredentialsJvmTest`, through the jvm key store: two seeds and an empty +credentials file — `Written(2)`, two `Secret` entries under the keys the words +derive; run again — `NotNeeded`, and the file's bytes are unchanged; a `Public` +for one seed's key — upgraded to `Secret`, `Written(1)`. + +`StoredIdentityJvmTest`: a `Secret` whose key a seed derives lists once, as +`Mnemonic`, under the wallet's id, carrying the credential's key; a seed with no +credential lists as `Mnemonic` with a derived key; a `Secret` whose key no seed +derives is `NostrSecret`; a `Public` for a seed's key is dropped. + +`IdentityWriterJvmTest`: creating from a phrase writes both files and the +credential is a `Secret` for the derived key; the phrase of a bare key attaches — +`Written` with the wallet's id, the entry unchanged; the nsec of a key with a +wallet attached is `AlreadyExists`; forgetting a key with a wallet attached is +`WalletAttached` and changes nothing. + +And a mnemonic activation in the round-trip harness, which neither sign-in plan +wrote because both were about the other kinds: the identity's key equals the +credential's and the node's. + +--- + +## Phase 2 — a switch that tears down what it should + +**App only. No UI. Needs Phase 1.** The transition, made correct, with nothing +yet calling it from a screen. Its diff is the view model, one manager, and three +one-line actuals. + +### The call + +```kotlin +// SovereignWalletViewModel +/** + * Makes [id] the identity to open next and clears the active one, which is the + * whole of a switch: the navigation observer sends a null identity to startup, + * startup opens [id] through the lock gate, and the machine routes it from its + * account. Everything reading [activeIdentity] is cancelled by the null and + * rebuilt by the activation. + */ +fun switchToIdentity(id: WalletId) { + val previous = _activeIdentity.value + _desiredWalletId.value = id + startWalletImmediately.value = true + _activeIdentity.value = null + if (previous?.business != null) { + viewModelScope.launch(Dispatchers.IO) { stopPlatformBusiness(previous.id) } + } +} +``` + +`switchToWallet` is renamed to this; its three callers — the two tails and the +startup selector's `onWalletClick` — follow. `startWalletImmediately` is set +back to true because 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. + +The order inside is deliberate. The identity is cleared **before** the node is +stopped: clearing cancels every collector that could reach the business, so the +stop finds nothing reading it. The stop is `BusinessManager.stopBusiness` behind + +```kotlin +expect fun stopPlatformBusiness(walletId: WalletId) +``` + +with an actual per platform beside `updateBusinessActiveInUI`, which has the +same shape and the same reason to be an `expect`: the manager is a per-platform +object. + +The test is `business != null`, not `kind == Mnemonic`. The question is whether +the profile being left has a node behind it, and today those are the same fact — +but the first is the fact, and the second is how it currently comes to be true. +`previous.id` is the wallet's id when a wallet is attached, which is why +`stopBusiness` can take it, and why a profile's id is its wallet's. For a bare +key there is nothing to stop and the branch is not taken; for a read-only profile +likewise. + +**The tail keeps its own `navigate`.** The sign-in tail both switches and +navigates to startup with `popUpTo(0)`, and so does the observer on +`StartupPhoenix`; two writers to the stack, one entry popped by the other. It +looks redundant and is not: `_navigationUIState` is a `StateFlow`, and a +`getAndUpdate` to a state equal to the current one emits nothing. The explicit +navigation is what guarantees the stack moves even on the day the state does not. +Leave both, and leave a comment saying so, because the next reader will want to +remove one. + +### The relay observer + +`observeActiveUserId` becomes what the pumps already are — a child of +`collectLatest`, cancelled by the next emission: + +```kotlin +private fun observeActiveUserId() = scope.launch { + activeIdentityStateFlow.collectLatest { identity -> + val pubkey = identity?.nostrPublicKey ?: return@collectLatest + relayRepository.observePublicKeyRelays(pubkey).collect { relays -> + updateRelayPools(relays.filter { it.type == "user" }.map { it.mapToRelayDTO() }) + } + } +} +``` + +The `observeRelayJobs` map goes; it existed to replace an observer per pubkey and +could only ever leak. A null identity closes nothing — the pool is shared by the +pumps that a null identity has already cancelled, and the next identity's list +replaces it through `changeRelays` as it does today. + +### What needs nothing + +Listed so that nobody adds a guard that already exists. +`SynchronizationViewModel`'s pumps and `LiveSubscriptionManager.observe` are +children of `collectLatest`; `hasRequeuedStaleBroadcasts` is once per process +*on purpose*, and its comment already names the wallet switch as the reason. +`NotaryViewModel` keys on the private key and restarts its four observers. +`MlsGroupCache` and `MarmotInboundManager`'s pending commits are keyed by room, +and a room belongs to one identity by its `userPublicKey`. `DataStoreManager` +caches preferences per id for the life of the process, which is right for a +switch and only wrong for a test that reuses a key across directories, as the +nsec plan found. + +### Tests + +`IdentitySwitchJvmTest`, in the round-trip harness beside `NpubPreviewRoundTripJvmTest`: +write an nsec A and an nsec B through the writers, list, activate A with a real +`NotaryViewModel` and `NavigationViewModel` on the same database. Queue an +unsigned kind 1 for A: it is signed. Switch to B, activate B the way startup +does: the machine lands on B's state. Queue an unsigned kind 1 for A: it is +**not** signed while B is active, and is when A is opened again. That is the +assertion a switch is for — the notary signs as whoever is open and nobody else. + +`switchToIdentity` on its own, with `stopPlatformBusiness` injected as a recorder +the way the writers are injected into `SignInToProfileViewModel`: called once +with A's id when A had a node; not called for a bare key; not called when nothing +was active. And a `RelaysSocketManager` test over a fake relay repository with two +pubkeys' lists: after the switch the pool holds B's relays, and a re-emission of +A's list changes nothing. + +--- + +## Phase 3 — which profile opens, on launch and after a switch + +**App. Needs Phase 2.** The startup screen learns to agree with the switch. + +### The precedence + +The `when` that picks what to open, lifted out of the composable's `remember` +into a function that can be read and tested, in `ui/view/state`: + +```kotlin +object StartupChoice { + /** Which stored identity startup opens without asking, or null to show the selector. */ + fun resolve( + force: WalletId?, + desired: WalletId?, + startImmediately: Boolean, + identities: Map, + default: WalletId?, + ): StoredIdentity? = when { + force != null -> identities[force] + desired != null -> identities[desired] // a switch or a sign-in said which + !startImmediately -> null // the user asked for the list + identities.size == 1 -> identities.values.single() + default != null -> identities[default] // the last one used + else -> null + } +} +``` + +Two rows move. `desired` goes above `!startImmediately` — it is the more +specific instruction and the one a switch relies on — and the `size == 1` row +drops below it, because when both are set they name the same thing and the order +only mattered for reading. Everything else is as it was. + +### The default + +`setActiveIdentity` saves what it activates: + +```kotlin +fun setActiveIdentity(identity: Identity) { + _activeIdentity.value = identity + viewModelScope.launch(Dispatchers.IO) { getGlobalPrefs().saveDefaultWallet(identity.id) } +} +``` + +Every activation goes through here — startup for all three kinds, and the +sign-in and create tails through startup — so the default is always the profile +most recently open, and a cold boot with two profiles opens it. The selector is +one tap away in Phase 4, and the lock prompt's back-to-selector button stays for +the case it was built for. + +The inverse: the two forget tails — sign out of a read-only identity, *forget +this key* for a bare key — call `clearDefaultWallet()` before `resetToSelector()`. +`resetToSelector` already nulls `desiredWalletId`; the default is the one memory +of the identity that would otherwise outlive it, and `identities[default]` on a +forgotten id is a selector anyway, but a null read is better than a miss that +happens to be handled. + +### The words + +The five literals the startup screen shows — "Decrypting...", "Initializing...", +"Preparing wallet...", "Opening wallet", "Starting wallet" — move to the catalogue +in this phase because the phase rewrites the `when` they sit in. Four of them say +"wallet" about something that is not one and become *preparing profiles* and +*opening profile*. The fifth is right as it is: "Starting wallet" is shown from +`StartupViewState.StartingBusiness`, which only the branch with a wallet attached +reaches, and what is starting there is a Lightning node. It stays *starting +wallet*, so that a user with a bare-key profile never sees the word and a user +with a wallet sees it at the one moment it is true. The selector's title, +`select_a_wallet`, becomes `choose_a_profile`. The lock prompt's "coming soon" +literal stays; it is a stub and marked as one. + +### Tests + +`StartupChoiceTest` in `commonTest`, one case per row and the two orderings this +phase exists for: `desired` set and `startImmediately` false opens the desired +one; one identity and `startImmediately` false shows the selector, because the +user asked. `IdentitySwitchJvmTest` gains: after activating B, `getDefaultWallet` +is B's id; after forgetting B, it is empty; a fresh `SovereignWalletViewModel` +over the same store and a `StartupChoice.resolve` with its list and default +answers A. + +--- + +## Phase 4 — the switcher + +**App. Needs Phases 2 and 3.** One pushed screen, and the startup selector +brought up to match it. + +### The screen + +`ProfilesRoute`, reached from the profile tab's row — *switch profile*, where it +said *change account* — and drawn by `ProfilesScreen`: a `TopAppBar` titled +*profiles* with `NavigateBackButton` in the leading slot, and `WalletsSelector` +as its whole content, with `activeWalletId` set so the current profile sits above +the divider and the others below. + +Tapping another row is `switchToIdentity(id)` and nothing else — the observer +restarts the app as that identity, through its lock gate. Tapping the current row +does nothing; `canEdit` stays false, the edit dialog behind it is a stub. The +`bottomContent` slot holds the two rows [Phase 5](#phase-5--adding-a-profile-from-inside) +adds. + +Four states, said so. *Loading* while `listWalletState` is `Init`, which in +practice is never seen — the list was read at startup — but the state exists and +the screen says what it is waiting for. *Loaded* is the list. *Error* is +`ListWalletState.Error`, through `ErrorState` with `onRetry = listIdentities`, +because an unreadable credentials file is exactly the failure a retry can answer. +*Empty* cannot happen while something is signed in and the screen does not +pretend it can. `ScreenStateTransition` wraps the `when`; `readableContent()` on +the root; one column at every width, since a list of a handful of rows has no +detail to pair with. + +### What a row shows + +`WalletsSelector` shows `metadata.nameOrDefault()` — "Default name", for every +row, because nothing in this app writes the wallet metadata's name — over a +random emoji ([WalletsSelector.kt:162](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/widgets/wallet/WalletsSelector.kt)). +Two profiles side by side, both called Default name, distinguished by a bech32 +string, is not a switcher. + +The row takes the nostr profile instead: `WalletsSelector` gains a +`profiles: Map` parameter, and the row shows +`humanReadableNameOrPubkey()` over `ProfileAvatar(publicKey, profile)` — the +widgets the profile tab already draws itself with — falling back to the npub and +the metadata's emoji for a profile the device has no kind 0 for yet: one just +created, or a read-only one that was never found. `ProfilesScreen` collects the +map through `observeProfileWithPublicKey` for every listed pubkey, in a +`ProfilesViewModel` that owns the four states above. The *read only* label stays +where it is and means what it meant. + +And a second label of the same kind, *wallet*, on a row with a seed attached. +What the device holds for a profile — nothing, a key, or a key and a wallet — is +the one thing the switcher knows that the profile tab never shows. 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, and it belongs +before the tap for the reason the read-only label does. A bare-key profile +carries no label; it is the plain case. + +The startup screen draws the same widget and gets the same map: `MantraNavHost` +already holds `databaseNostrRepository` where it composes +`SovereignWalletStartupScreen`, and passes it through. Both lists then look the +same, which is what a user who has just seen one and is now looking at the other +expects. + +### Tests + +`ProfilesScreenJvmTest`, with `runDesktopComposeUiTest` and the unmerged tree, as +`ReadOnlyEntrancesJvmTest` does: two identities, A current — A's name is above the +divider and B's below; tapping B invokes `onSwitch(B.id)` exactly once; tapping A +invokes nothing; a read-only B carries *read only*, an A with a seed attached +carries *wallet*, and a bare key carries neither; a B with no profile row shows +its npub. And `ActiveProfileScreen` under `LocalCanSign` true and false both have +*switch profile*: a read-only identity can leave for another profile the same way +a signing one can. + +--- + +## Phase 5 — adding a profile from inside + +**App. Needs Phase 4.** The two entrances, and the two things that were only +safe with one profile. + +### The rows + +Under the list, in `bottomContent`, on the switcher and on the startup selector +alike: + +| row | goes to | why not Landing | +|---|---|---| +| *sign in with a key* | `SignInRoute` | Landing has no back button and clears the stack on its way out: 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. The sign-in screen has `NavigateBackButton` already | +| *create a new profile* | `CreateProfileRoute` | same; and its tail is the one the create flow uses today | + +Landing stays exactly what it is. The caption on each row says what the tails +below make true: *the profile you are in stays on this device.* + +### The sign-in tail + +Unchanged in shape — `listIdentities { switchToIdentity(id); navigate(Startup) { popUpTo(0) } }` +— and now correct from inside: Phase 2 stops the previous node and Phase 3 makes +startup open the id it was handed. A read-only identity upgrading itself through +the Messages tab's *sign in with the nsec* is the same tail with the same id, and +keeps working; a bare key acquiring its wallet through a pasted phrase is the +same tail with the wallet's id. + +One thing changes. `IdentityWriter.WriteNostrCredentialResult.AlreadyExists` is a +`data object` and says only that the key was refused. From Landing that was the +whole message; from inside, "this key is already on this device" is a sentence +with an obvious next step, and the writers know which id they collided with — +the credentials map by pubkey, and the attached seed's wallet id where there is +one. It becomes `AlreadyExists(val id: WalletId)`, +`CredentialProblem.AlreadyOnThisDevice` carries it, and the sign-in screen's +error state gains one action, *switch to it*, which is `switchToIdentity(id)` and +the same tail. The recovery-phrase writer's `SeedAlreadyExists` maps the same way. +Two writers, one distinction, and a duplicate paste becomes the fastest switch in +the app. + +### A profile created from inside is a key + +`CreateProfileScreen` generates twelve words behind its form +([CreateProfileViewModel.kt](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/view/model/CreateProfileViewModel.kt)): +a seed, a node, a wallet, and — at the NIP-06 path — the key that becomes the +profile. That is the right thing for the first profile on a device, which is the +one the wallet will belong to, and the wrong thing for the second: a user who +wants another profile has not asked for another Lightning node, another set of +channels, or a second twelve-word phrase with funds behind it, and would not thank +the app for handing them one. + +From the switcher, *create a new profile* makes a **bare key**: thirty-two random +bytes as a `PrivateKey`, written through `writeNostrKey` — the writer the sign-in +screen uses for a pasted nsec — then `createNewProfile(pubkey, name, bio)`, the +six-event bootstrap a new key gets, then the tail. Same screen, same form: +`createAccount` already takes its writer as a function, so the nav host passes +the seed writer from Landing and the key writer from the switcher, and the +screen does not know which it was given. Under this plan's model the two writers +differ by exactly one thing — whether a wallet is attached to the profile they +make — which is what the model was for. The declaration and confirmation copy say +"profile" and "keys" and are true of both. The backup lives where it does for a +signed-in nsec: the key recovery screen already shows the secret for that kind, +and the backup flags are per id, so a profile made this way is reminded to back +up its key the way a seed's profile is reminded about its phrase. + +The previous identity's notary observes `observeUnsignedNostrEvents(publicKey = +its own)`, so it never sees the new key's events; they wait for the new +identity's notary, which starts when startup activates it — at once, since there +is no node to start. Nothing crosses. The tail gains the `popUpTo(0)` the sign-in +tail has, so that back from the new profile's startup does not return to a form +whose key is already on disk. + +A second *wallet* is not offered from inside. Restoring one is the sign-in row — +a pasted recovery phrase brings its wallet with it, and the confirm step already +says so — and creating a fresh one waits for a wallet feature to want it, which +is named under out of scope. Landing's *create profile* keeps making a seed; if +the product decides the first profile should be a bare key as well, that is the +same writer swapped in one more place. + +**And *end this* goes.** It wipes the database — every profile's rooms, MLS state, +key packages and queues — from a screen whose purpose is to add a profile, and it +was only ever a development exit. Remove the button and the string; +`wipeDatabase` stays on the repository for the tests that use it. If a +device-level reset is ever wanted, it is a settings screen with a confirmation +naming every profile it will take, and not this. + +### Tests + +`SignInToProfileViewModelJvmTest`: the nsec of a key already here fails with the +id it is already here under, for each of the three writers. +`AddProfileFromInsideJvmTest`, in the round-trip harness: A active; B's nsec +committed through the sign-in view model; list — two entries; switch; B active; +switch back — A active, and `getLocalAccounts()` holds two accounts, each with its +own kind 0 and neither with the other's. A profile created from inside through +the key writer lists as `NostrSecret`, its account holds the six bootstrap rows, +and `BusinessManager.businessFlow` is empty afterwards — no node ran, the +assertion the nsec round trip already makes. `CreateProfileScreen` composed has +no node with the text *end this*. + +--- + +## Phase 6 — a queue per identity, and the inbox that arrived while another profile was open + +**App. Needs nothing above it to compile; Phase 2 to matter.** The one data +problem, closed from both ends. + +### Whose request + +The two queues that *fetch* gain an owner, because what a fetch brings back is +opened with the fetcher's key. The one that *sends* does not, because a signed +event is anyone's to carry: the signature is the author's whoever puts it on the +wire, and holding A's outgoing message until A is opened again would be worse +than sending it as B. A read-only identity does not run the broadcast pump at all +and the npub plan's rule stands. + +```kotlin +// SynchronizeNostrEventRequest, NegentropySynchronizeRequest +/** The identity that asked. Null for rows written before there was one to record, which any identity may drain. */ +val ownerPublicKey: HexKey? = null, +``` + +Room database version 20, an `AutoMigration` with a comment in the list the way +every nullable addition before it has one: rows written before it read back null, +meaning "the device's", which is what they were. + +**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 `activeIdentity: StateFlow` at construction, which the nav host +has in hand where it builds the repository, and `queueSynchronizeNostrEvent` and +`queueNegentropySynchronizeRequest` stamp `ownerPublicKey = activeIdentity.value?.nostrPublicKey` +on every row. The DAO's own requests — the placeholder-profile syncs it plants +while indexing — are stamped from the `activeKeyPair` it is already handed. No +call site changes, and no call site can forget. + +The pumps then ask for their own: + +```sql +SELECT * FROM SynchronizeNostrEventRequest +WHERE status = 'pending' AND (ownerPublicKey = :publicKey OR ownerPublicKey IS NULL) +ORDER BY createdAt ASC LIMIT 1 +``` + +`observePendingSynchronizeNostrEventRequests(publicKey)` and its negentropy twin +take the pubkey the pump already builds its key pair from. A's requests wait for +A; B never opens a wrap that was fetched for A, because B never fetches it. + +### The inbox, reopened + +The other end: wraps that *were* fetched under the wrong key — before this +migration, 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. + +```kotlin +// NostrDao +/** + * Opens every gift wrap addressed to [activeKeyPair] that the device stored but + * could not open at the time -- because another identity fetched it, or because + * this one held no key yet. One pass: wraps do not depend on one another, so a + * wrap that cannot be opened now will not be helped by opening its neighbours. + */ +open suspend fun reopenInbox(activeKeyPair: KeyPair): Int +``` + +The query is every `GiftWrapMessage` with `receiverPublicKey = me` that no +`GiftWrapSeal` names as its `giftWrapMessageId`; the body is the unseal branch of +`indexNostrEvent` from the `isAddressedTo` check down, lifted into an +`openGiftWrap(giftWrapMessage, activeKeyPair)` that both call. A Marmot welcome +inside one of them reaches `MarmotInboundManager` the way it would have on the +day; a direct message is filed as one. + +It runs once per activation, from `SynchronizationViewModel`'s collector, inside +`if (identity.canSign)` and **before** the live subscriptions and the broadcast +pump are launched — so that the sweep and the live inbox are not opening the same +wrap at the same moment. Both are idempotent by event id, so the order is about +not doing the work twice rather than about correctness. + +### Tests + +`ReadOnlyGiftWrapDaoJvmTest` has the fixture: a wrap addressed to A, stored +through B's key pair — event and wrap rows, no seal. `reopenInbox(A)` returns one, +and afterwards the seal and payload are stored and the message is filed; +`reopenInbox(A)` again returns zero and changes nothing; `reopenInbox(B)` returns +zero. A DAO test for the filter: A's pending request is not emitted to B's pump, +a null-owner row is emitted to both, and A's is emitted to A's. And the +repository stamp: a request queued while A is active carries A's pubkey, while +nothing is active carries null. + +--- + +## Phase 7 — leaving one profile among several + +**App. Needs Phases 3 and 4.** 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 `listIdentities { resetToSelector() }`, which +shows the selector with what remains or Landing if nothing does. That is the right +behaviour with several profiles as well; Phase 3 added `clearDefaultWallet()` to +it, and this phase adds nothing else to the sequence. A profile with a wallet +attached answers `WalletAttached` from Phase 1 and is not offered either exit. + +Two exits learn about the others: + +- **The not-found screen** offers *try again* and, for a read-only identity, *use + a different key*, which forgets it. With other profiles on the device a third + action makes sense and costs one row: *switch profile*, pushing `ProfilesRoute`. + The identity that was not found stays listed; forgetting it remains a separate + decision. +- **Sign out for a profile with a wallet attached** stays `ImplementationPendingRoute`, + for the reason both sign-in plans gave: removing a seed is a wallet question, + and now — since the credential would come back at the next repair — it is + also the only way that profile *can* leave. What changes is that the user is no + longer stuck behind it: *switch profile* is the row above. + +### Tests + +`ForgetIdentityJvmTest` gains: forgetting B while A remains leaves A listed and +the default cleared; forgetting a profile with a wallet attached is +`WalletAttached`. `UnsyncedProfileScreen` composed with two identities has *switch +profile*; with one, it does not. + +--- + +## Phase 8 — the tests that actually prove it + +One round trip, beside the two that exist. + +**Two profiles, both ways, and a boot.** Create A from a phrase — both files +written, A listed once with a wallet attached; write B's npub; list; activate A; +sign B in from inside through the sign-in view model; the tail switches; B is +active and read-only, A's node was stopped and B's never started; +`NavigationViewModel` lands on B's state; switch back; A's state and A's node +again. Queue a kind 1 for A while B is open — not signed; open A — signed. Create +C from inside: a `NostrSecret`, active, with no node. Forget B and C; one +identity, default cleared. Then a *fresh* `SovereignWalletViewModel` over the +same directory, which is what a cold boot is: the repair finds nothing to do, it +lists A, and `StartupChoice` resolves A without asking. + +And `./gradlew :composeApp:m3Audit`: two new screens, a new route, and eight new +strings are where a `16.dp`, a "Switch Profile" and an untriaged +`contentDescription` arrive. + +--- + +## Phase 9 — rollout + +**No library change.** `BusinessManager.stopBusiness` exists on all three +platforms, `saveDefaultWallet` has been in `GlobalPrefs` since Phoenix, and the +credentials file's format already holds what Phase 1 writes into it. This plan +owes no tag, which is the first of the three sign-in plans that can say so. + +**Phase 1 ships alone, and first.** It changes what is on disk: the first launch +after it writes a credential for every seed on the device. A build *before* it, +reached by a downgrade after that write, lists each seed-backed profile twice — +once as a wallet with a derived key, once as a bare key under a different id — +because the old `merge` kept a secret for a seed's key and trusted the writers to +have prevented it. Nothing is lost: both rows sign as the same key and share +every row keyed by it, and the next upgrade lists them as one. It is the one +line of this rollout worth a release note. + +**Phases 2 and 3 ship together.** A switch without the precedence fix lands on +the selector instead of the profile it was asked for — not broken, but it is the +thing the phase is for. Phase 3 without Phase 2 is a startup screen that +remembers a default nobody can change without it. + +**Phases 4 and 5 ship together.** A switcher with no way to add a profile is half +a feature; the add rows without the switcher leave a newly added profile reachable +only through startup, which is the state the device is in today with two. + +**Phase 6 ships whenever it is ready, and should ship early if it can.** It is a +Room migration and a DAO change, and it fixes a case that exists *today*: a device +with two identities, switched at the startup selector a moment after one of them +signed in. The migration is a nullable column, which Room generates and an older +build cannot open — the same downgrade story every migration in this database has +had, and `PlatformDatabaseBuilder` has the same answer. + +**Phase 7 can follow by a release.** Without it, the not-found screen has two +exits instead of three and a wallet-attached profile's sign-out row is the same +pending route it is now. + +**Old builds.** A build before Phase 3 ignores the saved default and shows the +selector, as it does today. A build before Phase 6 drains every queue as whoever +is open, as it does today. Apart from the double listing above, nothing written +by this plan is misread by a build that predates it; it is only not read. + +## Estimate + +| phase | work | days | blocked by | +|---|---|---|---| +| 1 | the credential written and repaired, `merge` inverted, the activation, the forget guard | 1.5–2 | — | +| 2 | the switch, the node stop, the relay observer | 1 | 1 | +| 3 | the precedence, the default, the words | 0.5–1 | 2 | +| 4 | the switcher screen, the profile on the row | 1 | 3 | +| 5 | the add rows, a key-only create, the duplicate that switches, *end this* | 1–1.5 | 4 | +| 6 | the owner column, the pump filter, the inbox reopened | 1.5 | — | +| 7 | the exits | 0.5 | 3, 4 | +| 8 | the round trip | 0.5 | all | +| 9 | rollout | — | all | + +**Roughly a week and a half**, with Phase 6 in parallel from the start if two +people are on it. Two places carry the uncertainty. Phase 1 is where the two +sign-in plans' tests are re-read against a rule they were written to enforce, and +a test that turns out to have been asserting the old model somewhere unexpected +is a test to rewrite, not to delete. Phase 6's unseal branch of `indexNostrEvent` +has never been called from anywhere but `indexNostrEvent`, and lifting it out is +where a transaction boundary will turn out to have been load-bearing. + +## Out of scope + +- **A lazy node.** Phase 1 puts the key in the credential, so a profile with a + wallet attached could be activated without starting its node and start it when + a wallet feature asks. Not done here, for the reason under Phase 1: the node is + also where the channel watcher is scheduled, and a restored Phoenix phrase can + have channels. Its own decision, one branch in `doLoadWallet`, and the nsec + plan's "start the node lazily" now has nothing in front of it. +- **A wallet attached to a profile that did not derive it**, or one wallet on the + device that every profile pays from. Phase 1 makes attachment a fact about the + listing — a seed matched to a credential by pubkey — and both sign-in plans + deferred what it would mean to attach by *choice*. `Identity.business` is the + seam, the identity would carry the wallet's id beside its own, and Phase 2's + stop reads `business` rather than the kind so that the switch does not have to + be rewritten when it lands. +- **A second fresh wallet from inside.** Reachable today only through a pasted + recovery phrase. Until a wallet feature exists there is nothing to want it + for. +- **Showing the nsec of a profile with a wallet attached.** Trivially possible + after Phase 1 — the key is in the file — and a real want, for a user taking + their profile to another client. A row on `KeyRecoveryScreen` beside the + phrase; its own small change, with the phrase's warnings rewritten for a + secret that has a wallet behind it. +- **Sign out for a profile with a wallet attached.** Unchanged from both sign-in + plans, and now the only way such a profile can leave the device. +- **Two of the device's own profiles in one Marmot group.** `ChatRoom`'s primary + key is the group id and `userPublicKey` says whose the row is; a second local + member of the same group would overwrite the first's MLS state. Today it cannot + happen — one profile per device — and after this plan it can, by inviting + yourself. Refusing the invite at the join is the cheap fix; keying the room by + `(id, userPublicKey)` is the real one and is a migration of every table that + references a room. Named here so that the first report of it is not a mystery. +- **A database per identity.** The only design that isolates the queues, the + cache and the collision above wholesale. Rejected below, but it is the shape + the collision would eventually force. +- **The social precondition gate.** Every `ProfileLoaded` routes through it, on + every launch, and so on every switch. It is an onboarding screen shown to + people who have finished onboarding; a machine that skips it for an account it + has seen before is one branch in `processLocalAccount`, and its own decision. +- **Per-profile lock, per-profile notifications.** The lock prompt is a stub and + the app has no push. Both are per-id already in the preferences and would slot + into the activation this plan routes everything through. +- **Editing a profile's local name and avatar.** The metadata dialog is a stub, + and after Phase 4 the row shows the nostr profile, which is the name. +- **Tightening secrets in memory; NIP-46; NIP-49.** Inherited, unchanged. Phase 1 + adds one copy of a secret the device already held in derivable form, under the + same keystore key, and the decrypt-at-activation both plans deferred applies + to it as to the others. + +## Appendix — what was considered and rejected + +### A seed's profile by derivation only + +The model the code had, and the one this plan's first draft built on: the +credentials file holds only bare keys, a seed is a profile because its key can +be derived, and the writers keep one key out of two files. It needs no repair +and holds every secret once. It was rejected because it puts the wallet where the +profile should be: the list of profiles is a merge of two files with opposite +ideas of what a row is, the node has to run for a profile to know its own key, +and a wallet can never belong to a profile it did not derive. The cost of +changing it — Phase 1 — is a day and a half; the cost of building a switcher on +it and changing later is that switcher, twice. + +### One id space for every profile + +Under the model this plan takes, a profile could be keyed by `hash160(pubkey)` +whether or not a wallet is attached, with the wallet's `hash160(nodeId)` carried +beside it. Cleaner on paper, and rejected: the library keys the node's +preferences and metadata by the wallet's id inside `startNewBusiness`, so a +seed-backed profile would have two preference files — one the app writes, one +the node reads — and every existing device would need its metadata and backup +flags copied from the wallet's id to the profile's. A profile's id being its +wallet's when it has one keeps every file where it is, keeps `stopBusiness` and +`updateBusinessActiveInUI` one argument, and costs only what the npub plan already +accepted for the read-only upgrade: a profile that acquires a wallet gets fresh +preferences. + +### A live swap under the stack + +Set the identity and keep the screens. Every route carries the old key, every +view model was built from a route, and the navigation component reads the key off +the current route by design. Either every screen re-reads the identity from a +flow — the parameter-in-forty-three-lists problem the snackbar host and the +signing capability were both invented to avoid — or the stack is rebuilt, which +is the restart. The restart also gets the lock gate for free: an identity is only +ever activated through `LoadWallet`. + +### A global switcher control + +The avatar in the app bar, as Google's apps do it; a long press on the profile +tab; a swipe on the navigation bar. The home app bar lost its avatar by an +information-architecture decision recorded in `TopLevelDestination`, and putting +one back for a different purpose is two meanings on one control. A long press is +undiscoverable. And every one of them fires a `popUpTo(0)` from wherever the user +is — a switch is one deliberate tap on a screen that says what it does, and the +profile tab's row is where the app has been promising it. + +### A bottom sheet instead of a screen + +The other common shape, and the app has a `ModalBottomSheet`. Rejected for +consistency rather than on merit: the startup selector is a screen, both lists +are the same widget with the same rows, and the add rows push screens anyway. A +sheet that pushes screens from under itself is a screen with a worse back story. + +### Keeping the previous node running + +The cheaper switch-back — `startNewBusiness` returns a running node in +milliseconds. But nothing reads the node except the key, and after Phase 1 not +even that; the restart tears down everything else built for that identity, and a +background node is Electrum, the LSP peer and the swap-in watcher for a profile +that is not open. If a wallet feature ships and a node has something to do while +its profile is closed, this is the decision to revisit, and it is one branch in +`switchToIdentity`. + +### Stamping the owner at every call site + +Eighteen view models each passing `activeUserPublicKey` into a queue call. +Mechanical, wide, and forgettable: the nineteenth would pass nothing. The +repository is built once, has the identity flow one parameter away, and every +request it writes is made as the active identity by construction. + +### Filtering the broadcast queue by owner + +Symmetry with the fetch queues. Rejected because a signed event needs no key to +send and holding A's message until A returns is a delivery failure the user would +never be told about. The asymmetry is the point: fetches are opened with a key, +sends are not. + +### A database per identity + +Isolates everything at once, including the group-membership collision. Costs the +shared profile cache — every kind 0 fetched once per profile — a Room instance +rebuilt on every switch, and a migration that splits the existing file by +`userPublicKey` across every table that has one and guesses for the tables that +do not. Deferred rather than rejected: it is the answer if the collision becomes +common, and nothing in this plan makes it harder to do later.