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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:09:22 +02:00

43 KiB
Raw Blame History

Pulling Curare's several-profiles line back into Mantra

The first pull landed on 2026-09-13 and the upstream moved the same day: curated/curated went from 86cb876b to 29027f2b, ten commits that let a device hold several profiles and move between them — and, with them, the fork's first schema migration, version 20. That plan named them the next pull and said the schema change earned them a decision of their own rather than a ride. This is that decision, which of the ten Mantra wants, in what order, and how they land — with the dry run already done, so the numbers below are measured rather than predicted.

Read multiple-profiles.md for the line's own reasoning once Phase 2 brings it; nothing here repeats it. Read the first pull's three decisions and its Phase 1 record for the method and the lessons; this document assumes both and adds what changed. The one rule everything still rests on is the derivation note's: two strings in this tree are hash inputs and are never renamed, and the normaliser cannot touch them by construction.

Built 2026-09-13, the same day, on the branch that carries this plan: the ten commits landed by cherry-pick in four phases, each with a Pulled-From trailer, at the dry run's numbers exactly; the phases below carry a Built paragraph each with what was measured at the cut. Phase 6's two native items are done — the refusal of a Welcome for a group another profile holds (679d5249), and the note's four line anchors (2620e43d) — and its other three are the user's: pushing the removal, the two reverse-pulls into the fork, and the fork decision. The reverse direction was measured rather than assumed: the refusal commit, rewritten into the fork's names by four inverse rules, applies cleanly to curated/curated at 29027f2b.

Written against mantra at 776455ec — origin/mantra at 0fa807a6 plus the removal commit, which is where this plan starts and which origin does not yet carry — and curated/curated at 29027f2b. Dry run executed 2026-09-13.

The shape of the fork now

Since the first pull, Mantra's tree has stopped being a superset of Curare's non-brand tree. It took thirty of the fork's thirty-nine commits, then took three lines out again — the group's nostr identity, the curated lists, and the row-per-kind group screen — by a product decision recorded in the first plan. So the two trees now differ in 135 files: 83 that Curare has and Mantra does not (the twelve removed screens and their view models, nostr/curated/, the seven group readings, and WalletManagerExtension.kt, which 39fb64b6 deleted on Mantra's side), 4 that Mantra has and Curare does not (the first plan, its two scripts, and NostrPublicKeyJvmTest), and 48 that both have with different content — strings.xml, MantraNavHost.kt, ChatRoomDetailScreen.kt and the rest of the removal's footprint. That residual is the seam every future pull has to cross, and the first plan predicted that anything upstream touching the identity block would conflict in it.

The ten commits, all of 2026-09-13, all on one line with no merges:

* 29027f2b  docs: record what the multiple-profiles plan built           (record)
* 88577bb5  test(identity): several profiles, both ways, and a boot      (Phase 8)
* 9bb34004  feat(ui): leaving one profile among several                  (Phase 7)
* 759199f2  feat(sync): a fetch queue per identity, the inbox reopened   (Phase 6, schema v20)
* 2f420230  feat(identity): adding a profile from inside                 (Phase 5)
* 25464595  feat(ui): the switcher                                       (Phase 4)
* 00fc9969  feat(identity): which profile opens                          (Phase 3)
* 9b3a1927  feat(identity): a switch that tears down what it should      (Phase 2)
* 3008137e  feat(identity): a seed's key is a credential                 (Phase 1)
* a5ff2641  docs: plan several profiles on a device                      (plan)

Three facts about them decide most of what follows.

They touch none of what Mantra removed. The ten change 69 files. Of those, six already differ between the trees, and in only four of the six is the difference the removal's — strings.xml, MantraNavHost.kt, DatabaseNostrRepository.kt, NostrRepository.kt — and in all four the removal's hunks and this line's hunks are far apart. The seam the first plan predicted is real and this line does not cross it: nine of the ten replay without a conflict, and the tenth conflicts on a line that is Mantra's own (the third decision).

No library change. The pin is 84cc44c on both sides, unchanged by the ten; BusinessManager.stopBusiness, GlobalPrefs.saveDefaultWallet and the credentials file's format all existed already. This is the first pull from the fork in which the pin does not have to follow any commit.

One schema migration, 759199f2: version 20, two nullable columns by AutoMigration, the export committed beside its predecessors. Mantra has no version 20 of its own on any branch — measured: curated/curated is the only ref in this repository carrying a 20.json — so the number is free, and after the pull Room regenerates nothing: the built tree's 20.json is byte-identical to the fork's. The second decision is about the number, not the columns.

The inventory

Every commit, what it is, what it does to Mantra as it is today, and the verdict. Sizes are git show --shortstat; 759199f2's is 5,698 lines of generated schema plus 395 of code.

commit phase size what Mantra gets verdict
a5ff2641 plan 2 files, +1,192 docs/multiple-profiles.md and its README row pull
3008137e 1 16 files, +649 −111 a seed's nostr key written to nostr-credentials.dat as a Secret, repaired in for every seed already on the device; StoredIdentity.merge inverted so the credentials file is the list of profiles; the identity's key read from the credential rather than from the running node; forgetNostrCredential refusing a key a seed derives pull — the on-disk change; see Phase 2
9b3a1927 2 9 files, +490 −31 switchToWallet becomes switchToIdentity and stops the node it leaves; RelaysSocketManager.observeActiveUserId restructured under collectLatest so a switch cancels the previous identity's relay observer; stopPlatformBusiness as three one-line actuals pull — a fix Mantra has today, see below
00fc9969 3 7 files, +233 −22 StartupChoice.resolve, the startup precedence lifted out of the screen and reordered; setActiveIdentity saves the default, the forget tails clear it; the startup screen's six literals into the catalogue as profile words pull
25464595 4 13 files, +547 −24 ProfilesRoute/ProfilesScreen, reached from the profile tab's row that today goes to ImplementationPendingRoute; WalletsSelector rows showing the nostr profile instead of "Default name" over a random emoji, and "Wallet" beside a seed pull
2f420230 5 24 files, +624 −201 sign in with a key and create a new profile under both lists; a profile created from inside is a bare key through NewProfileWriter, a seed only from Landing; end this and its wipeDatabase gone from the create screen; a pasted key already on the device offers switch to it; JvmGlobalPrefs closing a DataStore race on desktop pull — two fixes Mantra has today, see below
759199f2 6 17 files, +6,093 −23 ownerPublicKey on the two fetch queues, stamped by the repository, drained by the owner's pump; NostrDao.reopenInbox opening every wrap addressed to the active key that no seal names, once per activation; GiftWrapSeal.giftWrapMessageId written on the path that never wrote it; schema v20 pull — a fix Mantra has today, and the decision
9bb34004 7 3 files, +61 −1 switch profile on the not-found screen when another profile exists pull
88577bb5 8 1 file, +282 MultipleProfilesRoundTripJvmTest: two profiles, both ways, and a cold boot pull
29027f2b record 2 files, +46 −7 the built-record table in the plan; the README's reading order pull

Nothing is dropped. The line is one feature and seven fixes braided together — four of them live on Mantra today — and the first decision is why the braid is not unpicked.

What is a fix for Mantra today, before any second profile exists

Four of the ten close bugs a Mantra install has now, with one profile, and each is verifiable against the current tree:

  • The relay observer leaks across sign-outs (9b3a1927). RelaysSocketManager.kt:65 still reads // TODO: Cancel all pending jobs? above a map of one relay-list job per pubkey, of which only the job for the pubkey being started is ever cancelled. Sign out of a read-only identity, sign in as another, and two observers feed updateRelayPools with the one pool following whichever list emitted last. The sign-out exits that make this reachable arrived in the first pull.
  • A read-only identity's inbox stays closed after its nsec is pasted (759199f2). NostrDao.kt:451 says of a wrap it cannot open that "the key that opens this one may be signed in later", and nothing runs when it is: storeNostrEvent never re-indexes an id it holds, so the wraps an npub preview fetched are never opened by the upgrade the first pull built. And GiftWrapSeal.giftWrapMessageId is written on the NIP-17 DAO's path and not on the one indexNostrEvent takes, so nothing in the database can say which wraps were opened — the question reopenInbox asks.
  • Desktop can throw "multiple DataStores active for the same file" (2f420230). Phoenix.jvm.kt builds a DataStoreManager per call, and the library caches one GlobalPrefs per process behind an unsynchronised check-then-set; the listing on IO and a sign-in's write racing at first use both build one, and the loser throws. Upstream's tests hit it one run in three. JvmGlobalPrefs is the one way the jvm target reaches the prefs, under a lock; Android goes through the Application's single instance and never raced.
  • The startup screen's words (00fc9969). SovereignWalletStartupScreen carries six string literals — "Initializing...", "Preparing wallet...", "Opening wallet", "Starting wallet" — outside the catalogue and in the wallet's vocabulary; the M3 audit counts them among the 40 literals it reports without a budget, and after the pull it reports 30. "Starting wallet" survives, in the one branch where a wallet is what is starting.

The other three fixes are latent until a device holds two profiles — the precedence when that puts "show me the list" above "open this one", the default that is read at every boot and saved by nothing, end this wiping every profile's rooms from the screen that adds one — and the line makes two profiles possible, so they ship with it.

What is a feature, and whether Mantra wants it

The profile tab has had a Change account row since the first pull, and it routes to ImplementationPendingRoute (ActiveProfileScreen.kt:324). The switch behind it — switchToWallet, resetToSelector, the null identity sent to startup with popUpTo(0) — has been in the tree since Phoenix and is reachable from nowhere; Landing shows only when the device holds nothing, so a second profile cannot be added. Mantra took the nsec and npub sign-in lines without qualification; this is the line that answers the question both of them deferred, and it is wanted for the same reason they were: a translator who reviews as one key and signs a chapter as another, or who holds a collective's read-only preview beside their own profile, is the ordinary case, and the row that promises it is already on the screen.

The one feature choice inside the line that is a Mantra question rather than a Curare one is that a profile created from inside is a key, not a second wallet. Mantra's wallet is the Phoenix fork's; nothing in the translation product asks for a second Lightning node, and a user who wants another profile has not asked for a second phrase with funds behind it. The seed stays the first profile's, from Landing. That is the right answer here too.

Four decisions

1. Take the whole line, and what taking less would cost

The alternative is to take the four fixes and leave the feature: 9b3a1927's observer, 759199f2's inbox, 2f420230's JvmGlobalPrefs, 00fc9969's words. Measured against the commits, that is not four cherry-picks; it is editing five commits by hand. 9b3a1927 renames switchToWallet in the same hunk that stops the node; 759199f2's owner column exists so that a switch cannot lose a message, and its reopenInbox runs from the same activation collector; 2f420230 fixes the DataStore race in the jvm actuals it rewrites for the bare-key writer; 00fc9969's strings are the selector's. Every carve-out leaves a Mantra SovereignWalletViewModel, nav host and startup screen that differ from upstream's in the same places, and the first plan's principle stands: every feature left out is a permanent conflict seam, paid for at every later pull. The removal of lines B, D and H was that principle's one exception, made on product grounds and measured at 135 files of residual; this line touches none of it, and adding a second exception for a feature the product wants would be paying the price twice for nothing.

So: all ten, in upstream's order, with the phases cut where upstream's own rollout section says the line can ship in pieces.

2. Schema version 20, and who allocates the number

Two apps, two applicationIds, two databases that never meet on one device: nothing stops Mantra from having a version 20 of its own shape and Curare another. What it would cost is the method. The first plan's pull was mechanical because the nineteen schema files were byte-identical on both sides; a Mantra 20 that is not Curare's 20 makes every later migration a hand-merge of MantraDatabase.kt's autoMigrations list and a renumbering of every schema file after it, in both directions, forever.

So the rule, stated once: a schema version is allocated by whichever tree migrates first, and the other tree pulls that migration before it adds its own. Never two different 20s. Today that is trivially satisfiable — Mantra has none pending; the check that it stays satisfiable is a git ls-tree over every ref for the next number before a migration is written on either side.

Taking upstream's 20 verbatim is safe on the evidence: the columns are nullable additions to two request-queue tables, AutoMigration with no data rewrite, and the built tree regenerates nothing (git status clean under composeApp/schemas/ after a full compile; identity hash 4414c373…). The downgrade story is the one every migration in this database has had — an older build cannot open the file — and PlatformDatabaseBuilder has the same answer it always had. The two-column change carries no risk of its own; what it carries is the number, and the rule above is what makes the number cheap.

3. The one conflict is Mantra's, and it closes a loop

The dry run's single conflict is CreateProfileViewModel.kt at 2f420230, and it is not the removal's seam. Mantra's 39fb64b6 moved the nostrPublicKey() import from the app's WalletManagerExtension to the library's; upstream never took that change and still imported the app's — one line of residual, the last of the first plan's Phase 6 reverse-pulls that was never done. 2f420230 rewrites the import block wholesale, and removes the seed generation the import served: the new view model asks a NewProfileWriter for a key and derives nothing itself.

Resolution: take the file as upstream leaves it. Not a union — the previous run's lesson about joins holds for import blocks too — but theirs, because the base difference is one import this commit deletes the use of. Afterwards the file is identical on both sides, and the residual between the trees goes from 135 files to 134. The replay driver carries the rule as a THEIRS entry with that reason, beside the one 42f3a697 needed for the same import in two other files.

What is left of 39fb64b6 upstream after this: WalletManagerExtension.kt itself, now with zero callers on either side. The reverse-pull the first plan owed shrinks to deleting a dead file; see Phase 6.

4. Two of the device's own profiles in one group

Upstream's out-of-scope list names it and Mantra should name it louder, because groups are Mantra's product. ChatRoom's primary key is the group id and userPublicKey says whose the row is; DkgSession, GroupKeyState and every FROST share hang off the room. Today one device holds one profile and the collision cannot happen. After this line a user can invite their second profile into their own collective, and the second member's MLS state overwrites the first's — silently, in the shape of "the other device never got it". The library, dialects and projects are keyed by chatRoomId and reached through the room, so they scope per profile for free; the room itself does not.

Decision: not part of the pull, and a Mantra-native follow-up before the line ships to users. The cheap fix upstream names — refuse a Marmot welcome for a room the device already holds under another profile, with a notice saying which — is one branch in the inbound manager and a string. The real fix, keying the room by (id, userPublicKey), is a migration of every table that references a room, and it is not this plan's. Write the cheap one natively, under Mantra's names, and offer it upstream by cherry-pick; it is the kind of commit the fork should take.

How it lands

The first plan's pipeline, unchanged in method, with three things that differ.

Rewrite from the fork point, not from the last pull. The rewritten history is rebuilt from ba26c0b1 every time — 49 commits now, 103 seconds — because a filter-branch over only 86cb876b..curated/curated would leave the first new commit's parent in Curare's names, and the cherry-pick of that commit would be a seven-hundred-file rename. The normaliser has no new rules to learn: the rewritten tip holds no curare, Curare or com.it.curated token outside the first plan's own prose, checked by git grep.

git fetch curated
git worktree add -b tmp/pull2 <scratch> mantra              # from 776455ec, the removal included
cd <scratch>
git branch -f tmp/curated-src curated/curated
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch -f -d <tmpdir> \
    --tree-filter 'python3 /abs/path/docs/scripts/curated-unbrand.py tree .' \
    --msg-filter 'cat; echo "Pulled-From: curated/curated@$GIT_COMMIT"' \
    -- ba26c0b1..tmp/curated-src
python3 docs/scripts/curated-replay.py pick <scratch> \
    a5ff2641 3008137e 9b3a1927 00fc9969 25464595 2f420230 759199f2 9bb34004 88577bb5 29027f2b

A clean worktree is required — filter-branch refuses unstaged changes, and a populated lightning-kmp-app at a pin other than 84cc44c counts. A fresh worktree has no submodule and is clean; for the build afterwards, symlink a populated checkout at 84cc44c in (git status then shows T lightning-kmp-app, which the ten never touch) and pass ANDROID_HOME on the command line, as the first plan's appendix did.

The exactness check is restated, because git diff tmp/curated-src HEAD is no longer "the commits you dropped" — it is 135 files of the removal. The property that holds instead: the pull moves Mantra by exactly the ten commits, so the residual between the trees is the same before and after, file for file and hunk for hunk, except where a resolution deliberately changed it.

U0=<rewritten 86cb876b>; U1=<rewritten 29027f2b>; M0=776455ec; M1=HEAD
diff <(git diff --name-only $U0 $M0 | sort) <(git diff --name-only $U1 $M1 | sort)
#   expected: exactly CreateProfileViewModel.kt, gone from the residual
for f in $(comm -12 <(git diff --name-only $U0 $M0 | sort) <(git diff --name-only $U1 $M1 | sort)); do
  cmp -s <(git diff $U0 $M0 -- $f | grep '^[-+]' | grep -v '^[-+][-+]') \
         <(git diff $U1 $M1 -- $f | grep '^[-+]' | grep -v '^[-+][-+]') || echo "residual moved: $f"
done
#   expected: nothing

Anything else in either list is a normaliser rule that is wrong, a resolution that is, or an upstream commit that crossed the seam — and it is found here.

The rules the driver needs: one. 2f420230 × CreateProfileViewModel.kt → theirs, for the reason in the third decision. The UNION rule for strings.xml, MantraNavHost.kt and docs/README.md stays in place and did not fire — git merged all three cleanly at every one of the ten — but a future upstream commit that appends strings where the removal deleted them will need it, and the first plan's lesson about deletions is already encoded in it.

Then the four checks, per phase, as before: :composeApp:compileDebugKotlinAndroid, :composeApp:compileKotlinJvm, :composeApp:jvmTest, :composeApp:testDebugUnitTest, :composeApp:m3Audit, and — new for this pull — git status under composeApp/schemas/ after the compile, which is the check that Room agrees with the committed 20.json.

The phases

Each phase ends the same way: both compilers, both test suites and the audit green on the branch, the exactness check as above against the phase's cut, and one review of the diff against the phase's stated contents. Upstream's rollout section says which pieces can ship apart — Phase 1 alone, 2 with 3, 4 with 5, 6 whenever, 7 after — and the cuts below follow it, so that each phase is also a shippable state.

Phase 0 — the base

Nothing from Curare lands. Two things to settle first:

  1. The removal is on mantra and not on origin/mantra. 776455ec is the local mantra tip and origin is at 0fa807a6. Every measurement in this document is against 776455ec; a pull onto 0fa807a6 would land the ten on a tree that still holds lines B, D and H, and would then have to be removed from under them. Push the removal, or merge it, before anything else.
  2. A worktree with the library at 84cc44c. The pin does not move in this line, so there is no PIN entry to add; but a worktree whose submodule sits at another commit (this session's was at 01489b8, an older master) makes filter-branch refuse and the compile fail on NostrCredentialManager. git submodule update --init fixes it online; offline, fetch the commit from a populated sibling checkout — git -C lightning-kmp-app fetch <checkout>/lightning-kmp-app 84cc44c && git -C lightning-kmp-app checkout --detach 84cc44c — since worktrees keep separate submodule git directories.

Exit: git status clean in the scratch worktree; origin/mantra at 776455ec or later.

Done 2026-09-13, on the branch that carries this plan (5f2414dc, itself on 776455ec): the worktree's library was at 01489b8 and its nested chain already at the pinned four, so one fetch from the main checkout's submodule and a detached checkout of 84cc44c made the tree clean. The rewrite was rebuilt from ba26c0b1 in 104 s and produced the same forty-nine rewritten commits as the dry run, hash for hash — the normaliser and filter-branch are deterministic given the same input, which makes the dry run's numbers the run's numbers. Item 1 is still the user's: origin/mantra was at 0fa807a6 when this landed, and the removal goes up with the pull.

Phase 1 — the dry run

Run on 2026-09-13, against curated/curated at 29027f2b, on a scratch worktree of 776455ec. The record, so the next reader starts from what happened:

measurement value
filter-branch over ba26c0b1..curated/curated 49 commits, 103 s; 834 files rewritten, 828 paths moved at the tip; 0 brand tokens left in code
files the ten touch 69 (+10,184 −387; without 20.json, 68 files +4,486 −387)
of those, already differing between the trees 6: strings.xml (−234), MantraNavHost.kt (−264), DatabaseNostrRepository.kt (−6), NostrRepository.kt (−14), CreateProfileViewModel.kt (±1), docs/README.md (+7)
replay 10 picked: 9 clean, 1 resolved by rule, 0 stuck
the resolution 2f420230 × CreateProfileViewModel.kt: theirs
exactness residual 135 → 134 files; the one gone is CreateProfileViewModel.kt; no other file's residual moved
compileDebugKotlinAndroid + compileKotlinJvm clean
:composeApp:jvmTest 868 tests, 0 failures (Mantra before: 826)
:composeApp:testDebugUnitTest 420 tests, 0 failures (before: 413; the seven are StartupChoiceTest)
:composeApp:m3Audit all budgets met; 12 adaptive uses, 2 navigation components; composable literals 40 → 30
Room after the compile git status clean under composeApp/schemas/; 20.json byte-identical to the fork's
strings.xml 444 → 455 strings; 14 added, 3 removed (change_account, end_this, select_a_wallet)
library pin 84cc44c before and after; the ten do not touch the gitlink or .gitmodules

If the upstream moves before Phases 2–5 land, redo this on the day: a number that drifts is an upstream commit that crossed the seam, and the driver stops on it rather than guessing.

Phase 2 — the credential, alone

a5ff2641, 3008137e. The plan and its first phase, on their own, because this is the one commit in the line that changes what is on disk: the first launch after it writes a Secret credential into nostr-credentials.dat for every seed the device holds, and a build from 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. Nothing is lost, both rows sign as the same key, and the next upgrade lists them as one; but it is the one line of this pull worth a release note, and shipping it first means that a rollback of any later phase rolls back to a tree that already knows the new file.

What to look at in review: writeMnemonic writes the credential before the seed, so a crash between the two leaves a profile the phrase completes rather than a seed with no listing; SeedCredentials.reconcile runs after both files are read and hands the listing what it returns, so a repair that could not write still lists the seed by derivation — a failed write must never hide a wallet; setActiveWallet reads the key from the credential and check()s it against the node's, since a node disagreeing with the credentials file is the one corruption worth refusing to run under; forgetNostrCredential answers WalletAttached for a key a seed derives, because the next repair would write it back.

Exit: a device with a seed lists once, from the credential, with the node's key as a cross-check; SeedCredentialsJvmTest and the rewritten halves of IdentityWriterJvmTest and StoredIdentityJvmTest green; release note drafted.

Built 2026-09-13, 44324c90 and d957ee8d, both clean picks. Exactness: the residual is 136 files before and after (the dry run's 135 plus this document), nothing moved. Both compilers clean; jvmTest 826 → 837 (the eleven are SeedCredentialsJvmTest and the rewritten writer and listing tests); testDebugUnitTest 413; audit all budgets met; nothing under composeApp/schemas/ changed, as expected of a phase with no migration. The release note this phase owes, in one line: the first launch after this build writes a credential for every recovery phrase on the device; going back to an older build afterwards lists each such profile twice until the next upgrade, and loses nothing.

Phase 3 — the switch, the precedence, the switcher, the entrances

9b3a1927, 00fc9969, 25464595, 2f420230. Upstream ships 2 with 3 and 4 with 5; they go together here because a switch nobody can reach and a switcher nobody can add to are each half a feature, and the four are 53 files of which the one resolution above is the whole of the conflict.

What to look at: observeActiveUserId is a child of collectLatest and the per-pubkey map is gone — the test that fails against the old observer is RelaysSocketManagerSwitchJvmTest's re-emission of A's list changing nothing; switchToIdentity clears the identity before stopping the node, so every collector that could reach the business is cancelled first; StartupChoice.resolve puts what a switch or sign-in named above the user's earlier "show me the list"; ProfilesScreen has a back button and one column at every width; CreateProfileRoute(withWallet = true) only from Landing; the create view model writes the secret, then the six bootstrap events, then calls the tail — the order the sign-in screen documents as load-bearing; JvmGlobalPrefs is the only place the jvm target reaches loadGlobalPrefsForWallet.

Exit: on desktop and Android, a second profile can be signed in or created from the switcher and switched to; the previous node is stopped once; a cold boot opens the last-used profile without asking; IdentitySwitchJvmTest, ProfilesScreenJvmTest, AddProfileFromInsideJvmTest, CreateProfileScreenJvmTest green.

Built 2026-09-13, 84ac84cc, 73a0e5b9, 5654e462, ccb9942a: three clean picks and the one the rule was written for — 2f420230 × CreateProfileViewModel.kt, taken theirs, with the reason in its message. Exactness: 136 → 135 residual files, the one gone being that file, as the third decision said; nothing else moved. Both compilers clean; jvmTest 837 → 858 (IdentitySwitchJvmTest, RelaysSocketManagerSwitchJvmTest, ProfilesScreenJvmTest, AddProfileFromInsideJvmTest, CreateProfileScreenJvmTest, and the three added to ReadOnlyEntrancesJvmTest); testDebugUnitTest 413 → 420, the seven being StartupChoiceTest; audit all budgets met, and the composable-literal count the audit reports without a budget went 40 → 30 as the startup screen's words moved to the catalogue; strings.xml 444 → 455. Read in review, as the phase asked: the observer is one collectLatest child with no map; switchToIdentity sets the desired id, sets startWalletImmediately back, clears the identity, and only then stops a node, and only if there was one; StartupChoice.resolve reads force, desired, "show me the list", the only one, the default; CreateProfileRoute(withWallet = true) appears once, under Landing; the jvm target reaches loadGlobalPrefsForWallet through JvmGlobalPrefs alone; ProfilesScreen puts NavigateBackButton in its bar.

Phase 4 — the queue owner and the inbox, with the migration

759199f2, alone, because it is the schema change and upstream's rollout says it can ship early or late independently of everything else. It fixes a case Mantra has today — the closed inbox of an upgraded read-only identity — and the case the switch would create: a wrap fetched under the other profile's key, stored, and never opened again.

What to look at: the AutoMigration(from = 19, to = 20) comment says what the columns mean and that the broadcast queue deliberately has none; DatabaseNostrRepository stamps where the caller stamped nothing and keeps an owner a caller named; the DAO's own requests — placeholder-profile syncs, a Marmot join's participant syncs — stay unowned on purpose, since they fetch public kinds; reopenInbox runs from the sync pumps' collector once per activation, for an identity that can sign, before the live inbox and the broadcast pump are launched; one transaction per wrap, so one that cannot be opened rolls back its own changes and the next is still tried; GiftWrapMessage.toGiftWrapSeal sets giftWrapMessageId. And the schema check: a full compile, then git status — nothing under composeApp/schemas/.

Exit: OwnedRequestQueueJvmTest and ReadOnlyGiftWrapDaoJvmTest green; an npub signed in, sent a DM by someone else, then upgraded with its nsec reads the DM on the next activation; schema files 1–20 committed and regenerated identically.

Built 2026-09-13, 72afe8aa, a clean pick. Exactness unchanged at 135, nothing moved. Both compilers clean; jvmTest 858 → 864 (OwnedRequestQueueJvmTest and the sweep cases added to ReadOnlyGiftWrapDaoJvmTest); testDebugUnitTest 420; audit all budgets met. The schema check, which is what this phase is for: after a full build of both targets git status shows nothing under composeApp/schemas/, and the committed 20.json is byte-identical to the rewritten fork's, identity hash 4414c373… — Room computed the same twenty tables from Mantra's entities as from Curare's, which is the second decision's rule holding on its first use. Read in review: the sweep runs inside identity.canSign, before the broadcast pump and the live subscriptions are launched; it returns 0 without a query for a key pair with no private key; each wrap is re-indexed in its own transaction and a failure is logged and skipped rather than thrown; the DAO's own requests carry no owner; GiftWrapMessage.toGiftWrapSeal sets giftWrapMessageId; the migration's comment says why the broadcast queue has no owner column.

Phase 5 — the exits, the round trip, the record

9bb34004, 88577bb5, 29027f2b. Small, and the last: the not-found screen's third action when another profile exists, the eight-phase round trip through the real view models, and the built-record table at the top of multiple-profiles.md with the README's reading-order sentence.

What to look at: MultipleProfilesRoundTripJvmTest constructs no GlobalPrefs of its own — the library's per-process cache means a test that builds a SovereignWalletViewModel must read the default through the view model's instance, the trap 00fc9969 recorded — and Mantra's own future tests inherit that rule.

Exit: the full suite at the dry run's numbers or above; the audit green; the README's table has the new row between the npub note and the jvm-target note.

Built 2026-09-13, 8edb52ff, 72b9d3d2, ba5ef723, three clean picks — including the two into docs/README.md, where the UNION rule stood ready and was not needed: git placed the fork's row between the npub and jvm-target rows and its reading-order sentence after the npub note's, beside this document's own. Exactness at the tip: 135 residual files, CreateProfileViewModel.kt the only one gone since the base, nothing moved. Both compilers clean; jvmTest 864 → 868 (MultipleProfilesRoundTripJvmTest and ReadOnlyEntrancesJvmTest's three); testDebugUnitTest 420; audit all budgets met — exactly the dry run's numbers, which is what a deterministic rewrite replayed onto an unchanged base ought to give. The round trip reads the saved default through the app's getGlobalPrefs rather than building a GlobalPrefs of its own, with a comment saying why, so the trap the phase warned of is documented at the place a copy-and-paste would start from.

Phase 6 — close the loop

Five things, none of them a merge.

  1. The follow-up in the fourth decision, natively: refuse a welcome for a room the device already holds under another profile. Before the line reaches users, not after the first report.
  2. Prose that is now off by a removal. multiple-profiles.md anchors three links to line numbers — MantraNavHost.kt:418 and :746, DatabaseNostrRepository.kt:111 — that are upstream's, in a file Mantra's removal shortened by 264 lines. The anchors describe the tree before the line was built and were already approximate upstream; either drop the numbers or re-point them, in the same commit as item 1 or alone.
  3. Reverse-pull what Mantra owes the fork, shrunk by the third decision: WalletManagerExtension.kt is dead on both sides and Curare can delete it; the Torch retirement is unchanged from the first plan's list. Two small commits, both hand-cherry-picks in the other repository.
  4. Offer item 1 upstream. It is written under Mantra's names against a file both trees hold identically, so it applies to the fork as an ordinary cherry-pick — the direction this whole exercise exists to make cheap.
  5. The fork decision, restated with a second data point. The first plan estimated half a day per pull; this one cost the machine about ten minutes and the reading about an hour, because the tooling was in place and the line crossed no seam. That is the good case. The bad case is any upstream commit that edits ChatRoomDetailScreen.kt or the nav host's identity block, and the driver will stop on it rather than resolve it. The three options the first plan listed stand in the same order — converge the package name, invert the relationship, or keep pulling — and nothing about this pull changes which is best; it only shows that the third is affordable for as long as the fork's work stays clear of what Mantra took out.

Built 2026-09-13, items 1 and 2; items 3 to 5 are not this repository's to land.

Item 1, 679d5249. The DAO's welcome branch already declined to overwrite an existing room — "Chat Room already exists", at warning level — so the joiner's MLS state never replaced the holder's; what it could not do was tell a redelivered Welcome for our own room from a Welcome for a room another profile holds, and it told nobody about either. The branch now reads the room's userPublicKey: the same profile's redelivery stays a quiet no-op, and another profile's Welcome is refused with a warning naming both keys and a membership line written into the room that exists — the holder's, since it is the only room on the device the group has and the holder is the one who can act. TYPE_WELCOME_REFUSED_OTHER_PROFILE, in MEMBERSHIP_TYPES, error tint, PersonOff; the content names inviter, invitee and holder. The key package bundle is left unconsumed as the branch always left it. The test is two databases with a real MLS group, a key package whose private keys the invitee's side holds — the fixture now returns the bundle beside the public package — and a Welcome sealed as the app seals one; three cases, refused, redelivered, joined. jvmTest 868 → 871.

Item 2, 2620e43d: the four anchors re-pointed at Mantra's lines — :404, :741, :537, :118 — each still landing on the thing its sentence describes.

Item 4, measured. git format-patch -1 679d5249, four inverse rules over the patch text (press.mantra. → to.curare., press/mantra/ → to/curare/, mantra.composeapp.generated.resources → curare.…, MantraDatabase → CurareDatabase), and git apply --check on a scratch worktree of curated/curated at 29027f2b: applies cleanly, 5 files, +360 −6. That is the whole cost of offering a Mantra commit to the fork while both trees hold the touched files identically — and it is also the shape a curated-rebrand.py, the normaliser's inverse, would take if the fork wanted to pull regularly rather than by hand.

Items 3 and 5 are unchanged from the plan: two small commits in the other repository, and a decision.

Risks, and the silent ones in particular

The write on first launch. Phase 2's repair is the first time a pull has changed a file in the node-data directory of an existing install. It is idempotent, one write for however many seeds are missing, nothing at all on a device with none or one already repaired, and a failed write is a result rather than a throw; the listing goes on with what it read. Test it on a real install with a seed from before the first pull, not only on the in-memory harness.

The downgrade double-listing is a release note, not a bug, and Phase 2 shipping alone is what keeps it to one line.

Room 20. An older build cannot open the file; the same story as versions 2–19. Anyone carrying a Mantra branch that adds a table must rebase onto this before numbering it 20 — the second decision's rule, and the ls-tree check that enforces it.

iOS is unverified, again. stopPlatformBusiness has an ios actual that calls BusinessManager.stopBusiness, one line, compiled by nobody on linux; the ios target is gated behind macOS. Build it on a Mac before believing it — and before it, the first plan's Config.xcconfig change, which has still not been.

Every switch passes the social precondition gate. Upstream names it as out of scope: every ProfileLoaded routes through the onboarding gate, on every launch and so on every switch. For Mantra, whose users will switch between a collective's preview and their own profile more often than Curare's, this is the first thing a user will complain about; the fix is one branch in processLocalAccount for an account the machine has seen before, and it is Mantra's to write.

The per-process GlobalPrefs cache. A test that constructs SovereignWalletViewModel must not build a GlobalPrefs of its own, or the second instance over the same file throws — recorded upstream, and true of every Mantra test written after this.

strings.xml loses three keys. change_account, end_this and select_a_wallet go; nothing on Mantra references them outside the files the same commits rewrite, and the compile is the check. The UNION rule applies a deletion correctly since the first plan's fix; it did not need to.

The upstream moves. Between this document being written and the pull landing, curated/curated may gain commits. Redo Phase 1; the driver stops on anything new.

Appendix — the residual, for the record

The 135 files by which Mantra at 776455ec differs from the rewritten 86cb876b, and which this pull leaves alone, so that the next reader can tell a seam from a surprise:

kind count what
Curare has, Mantra does not 83 35 under ui/composable, 28 under ui/view, nostr/curated/ (5), the seven nostr/Group* readings and CuratedSuggestion with their tests, WalletManagerExtension.kt
Mantra has, Curare does not 4 docs/curated-to-mantra.md, its two scripts, NostrPublicKeyJvmTest.kt
both have, differently 48 the logo's thirteen files and one build.gradle.kts comment; the seven places where Phase 0's prose about the protected names is worded for a tree that had no rebrand (Relays.kt, SharedKeyDerivation.kt, ChillDkgRitualManager.kt, PlatformContext.jvm.kt, HomeScreen.kt, shared-key-derivation.md, material-design-conformance.md); the removal's footprint of twenty-six — m3-title-case.py's two sample titles, strings.xml, MantraNavHost.kt, ChatRoomDetailScreen.kt with its view model and state, ChatMessage.kt's applyInnerEvent arms, ProposedEvent and its test, the four repositories, ChatTranscript, ProfileAvatar, Member.kt, NostrEvent.kt, NostrEventDao, LocalChatRoom, the broadcast screen, button, view model, state and two tests, docs/README.md; and, until this pull, CreateProfileViewModel.kt

After the pull: 134, the same list less one.