**Built**, phases 1–8, one commit each, in the order given; Phase 9 is the rollout
and is process rather than code. The phases are kept as written because they are
the reasoning, and the code reads better against the argument it came from than
against a summary of itself. Where the implementation chose differently the table
below says so:
| what the plan said | what it turned out to be |
|---|---|
| the repair runs "before the credentials file is read for the listing" | after both files are read, and the listing is built from what it returns — the repaired map, or the one read if the write failed — so the file is decrypted once |
| `NotACredential` becomes `WalletAttached` for a key a seed derives | and for a key not in the file at all, which since the repair can only be a seed's key the repair could not write; one outcome, two ways in |
| `stopPlatformBusiness` called from `switchToIdentity` | injected into the view model as a function, defaulting to the `expect`, so the branch can be pinned with a recorder — a node cannot be started in a test, but a `PhoenixBusiness` can be built, since everything in it is lazy |
| the relay observer restructured | as planned, plus an injectable scope and a `relayUrls` read for the test that fails against the old observer |
| `setActiveIdentity` saves the default | wrapped: a default that could not be written is a selector on the next boot, not a crash now. The forget tails call `forgetDefaultIdentity()` on the view model rather than `clearDefaultWallet()` inline |
| "Initializing…" among the four that become *opening profile* | it and "Preparing wallet…" both became *preparing profiles*; they are the same wait |
| `ProfilesViewModel` "owns the four states" | takes the four flows it joins rather than the view model that holds them, so a compose test can drive it with `MutableStateFlow`s and a fake repository; the fourth state, empty, is not modelled, as the plan said it could not happen |
| a row's fallback "the npub and the metadata's emoji" | the npub as the title over the emoji avatar, with no second line; a key with a kind 0 gets the name over the picture with the npub under it |
| "`createAccount` already takes its writer as a function" | a `NewProfileWriter` interface returning the key it derived and the id it filed it under, with `seedProfileWriter` and `bareKeyProfileWriter` on the view model and `CreateProfileRoute(withWallet)` choosing; the view model plants the six events *after* the write and calls the tail after that, so the account is in the database before the identity is activated — an ordering the create flow used to get away with by luck |
| `CredentialProblem.AlreadyOnThisDevice` "carries it" | the id rides on `Outcome.Failed` and the `Error` state instead; `CredentialProblem` stays an enum and the parser's tests stay as they were |
| the DAO's own requests "stamped from the `activeKeyPair` it is already handed" | left unowned, on purpose: the placeholder-profile syncs and a Marmot join's participant syncs fetch public kinds that need no key to open, and any profile that is open may as well fetch them. Only what goes through the repository is stamped |
| `openGiftWrap` lifted out of `indexNostrEvent` | not lifted: the branch is a screenful of room and participant handling, and running the stored wrap through `indexNostrEvent` again, in a transaction of its own per wrap, gives the same result with one code path. `reopenGiftWrap` is that |
| `ForgetIdentityJvmTest` gains "forgetting B while A remains" | the round trip covers it, forgetting B and C and listing A alone; the per-outcome tests were done in Phase 1 |
| the round trip "creates A from a phrase" | through the create view model with the seed writer, as Landing does, rather than through a pasted phrase; and A's node is a `PhoenixBusiness` that was never started, which is enough for the switch to have something to stop |
Three things found on the way that are in no phase. `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 — could both build one, and the loser threw at first
use. `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
same cache means a test that constructs `SovereignWalletViewModel` must not build a
`GlobalPrefs` of its own. And `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, which is the question the inbox sweep asks;
`decryptGiftWrapSeal` now sets it, and a seal from before is swept once and comes
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
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:741](../composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/navigation/MantraNavHost.kt), [:537](../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*
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<HexKey, Profile?>` 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.*