Skip to content

feat: multi-account support — Stage B (iOS plumbing + dev menu)#23

Merged
DocNR merged 117 commits into
mainfrom
feat/multi-account
May 4, 2026
Merged

feat: multi-account support — Stage B (iOS plumbing + dev menu)#23
DocNR merged 117 commits into
mainfrom
feat/multi-account

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 1, 2026

Summary

Stage B of the multi-account sprint — full iOS plumbing for one device holding N Nostr identities. End-to-end multi-account works at the data + signing layer; new UI is intentionally deferred to Stage C.

Pairs with proxy-side Stage A: #22.

What's in this PR

8 task commits + a dev menu testing harness:

2b1b621 dev menu — MultiAccountDiagnosticsView (testing harness)
4b633d5 Task 8 — migration polish + reinstall recovery
d9ca809 Task 7 — scope view callers + backfill legacy records
29ef9db Task 6 — NSE pubkey-routing + foreground signing path
34aa5bc Task 5 — AppState refactor: accounts list + per-account session
7a807a4 Task 4 — SharedStorage scoped variants + LightSigner per-signer routing
df7b50a Task 3 — signerPubkeyHex on every persisted record
8e8e3bd Task 2 — per-pubkey Keychain API + main-app-only enumeration
c144bd3 Task 1 — Account model + new SharedConstants keys

Architecture

  • Per-pubkey Keychain entrieskSecAttrAccount = pubkeyHex (was fixed "signer-nsec"). Bootstrap migrates the legacy entry transparently. Same protection class (AfterFirstUnlockThisDeviceOnly).
  • signerPubkeyHex on every persisted recordActivityEntry, PendingRequest, ConnectedClient, ClientPermissions, PairOp. Records backfilled with the migrated pubkey on first launch.
  • SharedStorage scoped variantsgetActivityLog(for:), getClientPermissions(signer:client:), etc. Per-signer bunker secrets, last-contact-sets, register timestamps. Composite-key writers prevent cross-signer clobbering.
  • LightSigner derives signer from privateKey — kind:3 lastContactSet now writes to the per-signer dict, preventing PR feat: enrich activity detail with what was signed + njump link #19's cross-account corruption hazard.
  • AppState.accounts: [Account] + currentAccount: Account? — full lifecycle (add / switch / delete / rename) with audit-driven security (delete ordering, petname sanitization).
  • NSE pubkey-routing — reads signer_pubkey from APNs payload (Stage A), falls back to currentSignerPubkeyHexKey. Defense-in-depth pubkey-derive verification.
  • Bootstrap for build-31 → multi-account: transparent. Existing nsec, paired clients, activity, pending requests, bunker URI all preserved.
  • Reinstall recovery for the iOS Storage-settings-wipe case where UserDefaults is gone but Keychain persists.

Test plan

  • 172 unit tests pass (was 131 pre-sprint; +41 new across AccountModelTests, SharedKeychainMultiAccountTests, MultiAccountRecordCodableTests, SharedStorageMultiAccountTests, AppStateMultiAccountTests, NotificationServicePubkeyRoutingTests)
  • Zero regressions in any prior tests (PR feat: enrich activity detail with what was signed + njump link #19 enrichment, V2 response-relay-url, foreground subscription, parser, dedup, etc.)
  • Build succeeds for both targets (Clave + ClaveNSE)
  • grep -c listAllPubkeys ClaveNSE/ returns 0 (security audit A1)
  • Only 2 production callers of legacy loadNsec() remain — bootstrap + cleanup, both intentional
  • Manual upgrade-from-build-31 smoke test on real device (internal TestFlight): existing nsec preserved, paired clients still functional, activity log retained, bunker URI unchanged
  • Multi-account smoke test via dev menu (Settings → 7-tap → Multi-Account): generate test account, switch, send sign requests from clients on each account against test proxy, verify NSE routes correctly, delete test account, confirm scoped cleanup
  • Cross-version cleanup verification: dev menu's "Migration diagnostics" section shows all legacy keys "absent" post-Task-8

Ship gate

⚠️ DO NOT ship to external TestFlight without #22 (Stage A proxy) also deployed to production. Without Stage A's signer_pubkey payload field, NSE falls back to currentSignerPubkeyHexKey (single-account-correct for build-31 migration but fails silently for the 2nd+ account on a 2-account device).

For internal TestFlight verification:

  • The test proxy (proxy-test.clave.casa) already has Stage A deployed.
  • Shared/SharedConstants.swift defaultProxyURL should be flipped to the test proxy on this branch BEFORE archiving an internal TF build (one-line change; revert before merge to main).

Plans + audit

What's NOT in this PR (Stage C)

New multi-account UI — account picker bottom sheet, AccountDetailView, ApprovalSheet "Signing as" header, OnboardingView .addAccount mode, SettingsView Accounts section, PendingApprovalsView merged grouping, iOS notification body with account label. Single-account UX behaves identically post-merge.

🤖 Generated with Claude Code

DocNR and others added 30 commits April 30, 2026 21:50
…sk 1)

Pure additive change. Reserves the new UserDefaults namespace for
multi-account state and extracts CachedProfile from AppState into
Shared/SharedModels.swift so multi-account code in Shared/ can reference it.

Added:
- Account struct (pubkeyHex, optional petname, addedAt, optional profile)
- CachedProfile struct (extracted from AppState; field shape preserved)
- SharedConstants.accountsKey, currentSignerPubkeyHexKey,
  bunkerSecretsKey, lastContactSetsKey, lastRegisterTimesKey

No callers wired up yet — follow-up tasks (2 through 8) populate the
new keys. Single-account UX continues to work identically.

Tests: AccountModelTests (8 tests, all passing on iPhone 17 / iOS 26.4).
Verifies Codable roundtrip, Optional-field decode, Identifiable
conformance, Equatable, legacy CachedProfile shape compatibility, and
the 22-key SharedConstants namespace uniqueness invariant.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Architecture: ~/.claude/plans/doesnt-each-account-have-dreamy-journal.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion (Task 2)

Pure additive. One Keychain entry per account, keyed by
`kSecAttrAccount = pubkeyHex`. `kSecAttrService` and accessibility class
unchanged from the legacy single-account path. Legacy single-account
methods remain — Task 8 migration reads the legacy entry once, copies
to a pubkey-keyed entry, then deletes the legacy entry.

Added (Shared/SharedKeychain.swift):
- saveNsec(_ nsec: String, for pubkeyHex: String) throws
- loadNsec(for pubkeyHex: String) -> String?
- deleteNsec(for pubkeyHex: String)

Added (Shared/SharedKeychain+Enumeration.swift, NEW):
- listAllPubkeys() -> [String]

SECURITY (audit 2026-04-30 finding A1): listAllPubkeys lives in a
separate file included only in the main app target — NOT ClaveNSE.
Compile-time enforcement prevents NSE compromise from enabling
enumeration → exfiltration of all accounts' nsecs from a single push
wake. NSE only ever needs loadNsec(for:) with the pubkey from the
APNs payload.

Verified A1 enforcement:
- pbxproj: SharedKeychain+Enumeration.swift has ONE build file UUID
  (Clave main-app target only); shared files have TWO (both targets).
- grep -r listAllPubkeys ClaveNSE/ → 0 matches.
- strings ClaveNSE binary → 0 matches for "listAll".
- find DerivedData → SharedKeychain+Enumeration.o exists only under
  Clave.build/, never ClaveNSE.build/.

SECURITY (audit 2026-04-30 finding A4): test fixtures are GENERATED
at setUp via SecRandomCopyBytes, never hardcoded. Avoids realistic-
looking nsec strings that could cause copy-paste confusion in
screenshots, logs, or stack traces.

Tests: SharedKeychainMultiAccountTests (8 tests, all passing on
iPhone 17 / iOS 26.4):
- save/load roundtrip
- two accounts coexist
- isolated delete (one removed, other intact)
- save-twice replaces
- load-when-absent returns nil cleanly
- listAllPubkeys returns both pubkey-keyed entries
- listAllPubkeys excludes the legacy fixed-account entry
- listAllPubkeys empty when no entries

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds non-Optional `let signerPubkeyHex: String` to ActivityEntry,
PendingRequest, ConnectedClient, ClientPermissions, and PairOp.
Wire format tolerates legacy build-31 rows via
`decodeIfPresent(forKey:) ?? ""` in `init(from:)`. Memberwise inits
default the new parameter to `""` for source-compat with existing
call sites — Tasks 4-5 thread the actual signer pubkey through
SharedStorage writers and AppState.

Promoted `PendingRequest`, `PairOp`, and `ClientPermissions` from
synthesized to explicit `Codable` so the missing-key tolerance
works (matches the existing pattern on ActivityEntry and
ConnectedClient).

ClientPermissions.id is now composite: `"signer:client"` so the same
client paired with multiple signers produces distinct SwiftUI
identities. Defensive fallback for the brief decode-before-migration
window: if signerPubkeyHex is empty, id falls back to bare pubkey
(pre-multi-account behavior). After Task 8 migration backfills, every
row uses the composite form.

Tests: MultiAccountRecordCodableTests (13 tests, all passing on
iPhone 17 / iOS 26.4):
- 5× legacy-row decode without signerPubkeyHex (assert empty string)
- 5× new-row roundtrip with signerPubkeyHex
- ActivityEntry preserves PR #19 fields (signedEventId, signedSummary,
  signedReferencedEventId) alongside the new field
- ClientPermissions composite id distinct across signers
- ClientPermissions id falls back to pubkey when signerPubkeyHex empty

Full ClaveTests suite: 131 tests passing. Zero regressions in
ActivitySummaryTests, LightSignerEnrichmentTests,
LightSignerPeekMethodTests, LightSignerProcessRequestTests,
LightSignerResponseRelayUrlTests, NostrConnectParserTests,
ForegroundRelaySubscriptionTests, etc. Existing constructors continue
to compile because the new parameter is defaulted.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…signer routing (Task 4)

Largest Phase 1 task. New SharedStorage surface for per-signer data;
LightSigner caller updates that prevent kind:3 lastContactSet
cross-account corruption (PR #19 hazard); AppState + view caller
updates to use scoped variants.

SharedStorage.swift:
- ADD filtered readers: getActivityLog(for:), getPendingRequests(for:),
  getConnectedClients(for:), getClientPermissions(forSigner:),
  getClientPermissions(signer:client:), getPendingPairOps(for:)
  (the array variant uses `forSigner:` label to disambiguate from the
  legacy single-arg `getClientPermissions(for: pubkey)` — same `String`
  type, different return type, Swift can't pick the right overload from
  `for:` alone).
- ADD per-signer bunker secrets: getBunkerSecret(for:),
  rotateBunkerSecret(for:). Defense-in-depth legacy-seed branch on
  first read if bunkerSecretsKey is empty AND legacy bunkerSecretKey
  exists.
- ADD per-signer last-contact-set: getLastContactSet(for:),
  saveLastContactSet(_:for:). CRITICAL CORRECTNESS FIX from PR #19 in
  multi-account context.
- ADD per-signer register timestamps: getLastRegisterSucceededAt(for:),
  setLastRegisterSucceededAt(_:for:), getLastRegisterFailedAt(for:),
  setLastRegisterFailedAt(_:for:).
- ADD scoped writers: removeClientPermissions(signer:client:),
  touchClient(pubkey:signer:), unpairAllClients(for:). All use
  composite-key matching to avoid clobbering rows across signers.
- MODIFY saveClientPermissions(_) to match on
  (signerPubkeyHex, pubkey) composite. Without this, the same client
  paired with two signers would clobber on save.
- DELETE dead code: getBunkerSecret() (no-arg), rotateBunkerSecret()
  (no-arg), getLastContactSet() (no-arg), saveLastContactSet(_)
  (no-arg), removeClientPermissions(for:), touchClient(pubkey:),
  unpairAllClients() (no-arg), isClientPaired(_), pairClient(_),
  unpairClient(_) (single-arg).
- PRIVATIZE getPairedClients() — only called by internal
  migrateIfNeeded.

LightSigner.swift:
- Compute signerPubkey once at top of handleRequest from privateKey.
- Update 3 SharedStorage call sites to use per-signer variants
  (getBunkerSecret, rotateBunkerSecret, touchClient).
- Update extractSignedEventEnrichment signature with optional
  signerPubkey param (defaults to "" for test source-compat); thread
  to per-signer lastContactSet readers. THIS IS THE PR #19
  CROSS-ACCOUNT CORRUPTION FIX.
- Update logAndTrack signature with required signerPubkey param;
  stamps ActivityEntry.signerPubkeyHex correctly + scopes touchClient
  to the right (signer, client) row. 9 call sites updated.

Clave/AppState.swift:
- Update 5 bunker secret call sites + unpairAllClients call site to
  use per-signer variants (passing the existing signerPubkeyHex
  scalar; Task 5 will replace it with currentAccount.pubkeyHex).
- deleteKey now scopes unpairAllClients(for:) to current signer
  (transitional — Task 5 rewrites the whole flow as
  deleteAccount(pubkey:)).

View caller updates (drive-by since old API was deleted):
- Clave/Views/Home/HomeView.swift:110 — removeClientPermissions(for:)
  → removeClientPermissions(signer: appState.signerPubkeyHex,
  client: client.pubkey).
- Clave/Views/Home/ClientDetailView.swift:401 — same pattern.

Tests: SharedStorageMultiAccountTests (13 tests, all passing on
iPhone 17 / iOS 26.4):
- 4× filtered readers (activityLog, pendingRequests, clientPermissions
  composite, legacy empty-signer not surfaced).
- 4× scoped writers (saveClientPermissions composite match,
  touchClient scoped, removeClientPermissions scoped, unpairAllClients
  scoped — all verify cross-signer rows are NOT touched).
- 2× bunker secrets (independent rotation, legacy-seed inheritance).
- 2× lastContactSet (independent + clear-for-one-signer).
- 1× per-signer register timestamps.

Full ClaveTests suite: 144 tests passing (was 131; +13 new).
Zero regressions in PR #19 enrichment tests, parser tests,
foreground subscription tests, response-relay-url tests.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…session (Task 5)

The big one. AppState becomes a multi-account-aware session manager:
an `accounts: [Account]` published list with `currentAccount: Account?`
as the UI scope. `signerPubkeyHex` and `profile` become derived
properties. New methods for account lifecycle.

State shape changes:
- ADD `accounts: [Account]` (published) — source of truth, persisted
  to `accountsKey` UserDefaults.
- ADD `currentAccount: Account?` (published) — UI scope.
- DERIVE `signerPubkeyHex` from `currentAccount?.pubkeyHex ?? ""`
  (was stored).
- DERIVE `profile` from `currentAccount?.profile` (was stored).
- DERIVE `isKeyImported` from `currentAccount != nil` (was stored).
- REMOVE `bunkerSecret` stored property — `bunkerURI` getter reads
  SharedStorage directly each access; zero view callers depended on
  the cache.

New account-lifecycle methods:
- `switchToAccount(pubkey:)` — UI scope swap, persists
  currentSignerPubkeyHexKey + legacy signerPubkeyHexKey for
  source-compat.
- `addAccount(nsec:petname:)` — parse nsec → derive pubkey →
  saveNsec(_:for:) → append to accounts → switch → fire-and-forget
  registerWithProxy + fetchProfileIfNeeded. Idempotent: re-adding the
  same nsec just switches to the existing account, doesn't duplicate.
- `generateAccount(petname:)` — Keys.generate() then addAccount().
- `deleteAccount(pubkey:)` — audit 2026-04-30 finding A2 ordering:
  unpair-clients FIRST (still has nsec for NIP-98 signing) →
  unregisterWithProxy → SharedKeychain.deleteNsec(for:) → bunker
  rotation → records cleanup → accountsKey write → auto-switch or
  clear current. Scoped per-signer; never touches other accounts.
- `renamePetname(for:to:)` — audit 2026-04-30 finding A3 sanitization:
  trim whitespace, strip newlines, cap 64 chars. Empty after
  sanitization → nil (not empty string).

Bootstrap + cleanup:
- `bootstrapFromLegacyKeychainIfNeeded()` — one-shot. If accountsKey
  is empty AND legacy fixed-account Keychain entry exists: copy nsec
  to new pubkey-keyed slot, delete legacy, seed accountsKey +
  currentSignerPubkeyHexKey. Build-31 testers upgrade transparently.
- `cleanupOrphanLegacyKeychainEntry()` — every-launch defensive. If
  accountsKey is populated AND legacy entry still exists, delete it.
  Handles the rare race where bootstrap saved-but-failed-to-delete.
  Idempotent.

Legacy method wrappers (preserves view caller signatures):
- `importKey(nsec:)` → `addAccount(nsec:)`
- `generateKey()` → `generateAccount()`
- `deleteKey()` → `deleteAccount(pubkey: currentAccount?.pubkeyHex)`

Caller updates throughout AppState — every `SharedKeychain.loadNsec()`
(legacy fixed-account) is now `loadNsec(for: signerPubkeyHex)` or
`loadNsec(for: op.signerPubkeyHex)`. Sites updated:
- approvePendingRequest — uses request.signerPubkeyHex (Task 3 field)
  with currentAccount fallback.
- handleNostrConnect — current account.
- registerWithProxy — current account; per-signer register timestamps.
- unregisterWithProxy — current account.
- pairClientWithProxy / unpairClientWithProxy — current account.
- retryPairOp / retryUnpairOp — op.signerPubkeyHex (Task 3 field) with
  currentAccount fallback.
- ensureRegisteredFresh — per-signer throttle/cooldown via
  SharedStorage.{get,set}LastRegister{Succeeded,Failed}At(for:).

The only remaining `SharedKeychain.loadNsec()` (no-arg legacy)
callers are the bootstrap + the orphan cleanup itself — both require
the legacy reader by definition.

Tests: AppStateMultiAccountTests (15 tests, all passing on iPhone 17
/ iOS 26.4):
- 2× derived state (signerPubkeyHex, profile from currentAccount)
- 2× addAccount (append + duplicate-nsec switches to existing)
- 2× switchToAccount (changes + unknown-pubkey no-op)
- 3× deleteAccount (Keychain + records cleanup, doesn't touch others;
  current → auto-switch; last → clears)
- 3× renamePetname (persists; sanitization audit A3; empty input →
  nil)
- 2× bootstrap (legacy entry → new format; fresh install no-op)
- 1× orphan cleanup idempotency

Full ClaveTests suite: 159 tests passing (was 144; +15 new). Zero
regressions in any prior test (Task 4 storage, Task 3 record codable,
PR #19 enrichment, V2 response-relay-url, foreground subscription,
parser, etc.).

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sk 6)

Closes the multi-account loop: NSE wakes for any account by reading
the APNs payload's `signer_pubkey` (Stage A proxy field) and loading
the matching Keychain entry. Falls back to currentSignerPubkeyHexKey
for the transient case where the proxy hasn't shipped Stage A yet.

After this commit, ZERO production code calls the legacy
`SharedKeychain.loadNsec()` (no-arg) except the bootstrap +
cleanup-orphan paths in AppState — both by design.

Shared/SharedKeychain.swift:
- ADD `static func resolveSignerPubkey(userInfo:) -> String` —
  payload-first, currentSignerPubkeyHexKey fallback. Lives in Shared/
  so both NSE and main-app callers use the same implementation.

ClaveNSE/NotificationService.swift:
- Replace `SharedKeychain.loadNsec()` with
  `SharedKeychain.loadNsec(for: SharedKeychain.resolveSignerPubkey(...))`.
- Defense-in-depth: verify loaded nsec derives back to the routed
  pubkey; mismatch → silent error (catches Keychain integrity issues).

Clave/ClaveApp.swift (handleForegroundSigningRequest):
- Same pubkey-routing pattern. Same defense-in-depth pubkey-derive
  verification.

Shared/ForegroundRelaySubscription.swift:
- Line 92: read currentSignerPubkeyHexKey (was legacy
  signerPubkeyHexKey — still write-through-synchronized, but the new
  key is the canonical source).
- Line 354 (processEvent dispatch): replace
  `SharedKeychain.loadNsec()` with
  `SharedKeychain.loadNsec(for: currentSignerPubkeyHexKey)`. Without
  this fix, L1 dispatch would fail post-bootstrap (Task 5 deletes
  the legacy Keychain entry; legacy loadNsec returns nil).

Clave/Views/Settings/ExportKeySheet.swift:
- ADD `static loadCurrentNsec()` helper that reads the current account
  via currentSignerPubkeyHexKey + `loadNsec(for: pubkey)`. All 3 sites
  (success path, no-auth-available path, biometric-disabled path)
  use the helper. Stage C will refactor the sheet to take a `pubkey:`
  parameter so AccountDetailView can export per-account.

DELETE Shared/SignerService.swift:
- Dead code (zero callers anywhere — verified via grep). Imported
  NostrSDK and paralleled LightSigner but was never invoked. Cleaning
  up rather than maintaining unused code.
- pbxproj surgery: remove 4 entries (build file, file ref, group
  children, sources phase) — single build file UUID since it was
  main-app-target-only.

Tests: NotificationServicePubkeyRoutingTests (5 tests, all passing
on iPhone 17 / iOS 26.4):
- payload field present → returns payload value (overrides current)
- payload field empty → falls back to currentSignerPubkeyHex
- payload field missing → falls back to currentSignerPubkeyHex
- neither set → returns empty string (silent-drop trigger)
- payload field wrong type → falls back rather than throws

Full ClaveTests suite: 164 tests passing (was 159; +5 new). Zero
regressions.

Verified: only 2 production callers of legacy loadNsec() remain —
both in AppState.swift (bootstrap line 203, cleanup line 262), both
intentional.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sk 7)

Two coupled changes that together make multi-account work end-to-end
in the UI for build-31 testers post-upgrade:

1. Bootstrap backfill of legacy records (Task 5 oversight, fixed here)
2. 11 view caller sites migrated to scoped SharedStorage readers

Why coupled: without (1), build-31 testers post-bootstrap have records
with `signerPubkeyHex = ""` (Task 3 default for missing wire-format
key). Filtered view reads (Task 4) skip empty-signer rows → all
existing activity, pending requests, and client permissions become
invisible after upgrade. With (1), every record gets stamped with the
bootstrapped pubkey, and (2)'s filtered reads return them correctly.

Bootstrap backfill (Clave/AppState.swift):
- ADD `backfillSignerPubkeyHex(for: pubkeyHex)` private helper.
  Loops over every persisted record array (ActivityEntry,
  PendingRequest, ConnectedClient, ClientPermissions, PairOp), stamps
  empty signers with the bootstrapped pubkey, writes back only when
  changes occurred (avoids spurious UserDefaults churn).
- Called from `bootstrapFromLegacyKeychainIfNeeded` after the Account
  record is persisted. Idempotent — rows with non-empty
  signerPubkeyHex are skipped.

View caller updates (11 sites across 8 files). Each uses the cleanest
pubkey source for its context:

- HomeView.swift:312 — getActivityLog(for: appState.signerPubkeyHex)
  (has @Environment AppState).
- ApprovalSheet.swift:247 — getConnectedClients(for: currentSigner)
  read from currentSignerPubkeyHexKey UserDefaults. Pairing cap is
  now per-account: each account independently maintains 5 pairings.
- PendingApprovalsView.swift:115 — getClientPermissions(signer:
  request.signerPubkeyHex, client: ...) using request's signer (Task
  3 field), with appState fallback for legacy entries.
- ConnectionInfoSheet.swift:13 — getConnectedClients(for:
  perms.signerPubkeyHex). Sheet receives ClientPermissions which
  carries the signer.
- ActivityView.swift:126 — getActivityLog(for: currentSigner) read
  from UserDefaults. Scope decision: activity log is always
  per-account (no merged toggle).
- ActivityDetailView.swift:141 — entry.signerPubkeyHex with current
  account fallback for legacy entries.
- ActivityDetailView.swift:186 — same pattern, uses composite
  getClientPermissions(signer:client:).
- ClientDetailView.swift:107, 374, 422 — appState.signerPubkeyHex
  (has @Environment AppState).
- ActivityRowView.swift:91 — entry.signerPubkeyHex with current
  account fallback. Row views often render outside an AppState
  context, so reading from entry + UserDefaults is more robust than
  threading appState through.

Pubkey-source selection rationale for views without AppState
binding: prefer the most-specific source (entry.signerPubkeyHex >
perms.signerPubkeyHex > UserDefaults currentSignerPubkeyHexKey). For
legacy entries with empty signer, fall back to current. After Task 7
backfill, no records have empty signers, so the fallback is dormant
in practice.

Tests: AppStateMultiAccountTests gains
testBootstrap_backfillsSignerPubkeyHexOnLegacyRecords (1 test).
Verifies the full bootstrap → backfill → filtered-reader flow:
plant legacy records with empty signers, run bootstrap, assert all
records get stamped with the bootstrapped pubkey, assert filtered
readers return them.

Full ClaveTests suite: 165 tests passing (was 164; +1 new). Zero
regressions in any prior task's tests.

After Task 7, no view in the codebase calls unfiltered
SharedStorage.getX() or single-arg by-client variants — every read
is scoped to a specific signer. Phase-1 single-account user sees
identical behavior; Phase-2 multi-account user (when Stage C UI
ships) sees correctly per-account-scoped data.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final Phase 1 task. Closes the migration story by handling the
remaining legacy UserDefaults keys, adding reinstall-recovery for the
UserDefaults-wipe case, and per-pubkey profile image cache filenames.

Bootstrap extensions (Clave/AppState.swift):
- ADD migrateLegacyUserDefaultsKeys(to: pubkey) — moves legacy
  global-scoped UserDefaults values into per-signer dicts and deletes
  the legacy keys:
    * cachedProfileKey → Account.profile (via legacyCachedProfile())
    * bunkerSecretKey → bunkerSecretsKey[pubkey] (via getBunkerSecret(for:))
    * lastContactSetKey → lastContactSetsKey[pubkey] (preserves kind:3
      follow-diff continuity — losing this would break the next
      kind:3 sign's "Followed @alice" diff summary)
    * lastRegisterSucceededAtKey → lastRegisterTimes[pubkey][succeeded]
    * lastRegisterFailedAtKey → lastRegisterTimes[pubkey][failed]
- ADD migrateLegacyProfileImageFile(to: pubkey) — renames
  profile_image.jpg → cached-profile-<pubkey>.dat. If the per-pubkey
  file already exists (rare race), drops the legacy file rather than
  overwriting.
- bootstrapFromLegacyKeychainIfNeeded calls both migration helpers
  after persisting the Account record.

Reinstall recovery:
- ADD recoverAccountsFromKeychainIfNeeded() — runs in loadState after
  bootstrap and loadAccounts. If accountsKey is still empty AND
  Keychain.listAllPubkeys() returns ≥1 entry, reconstructs minimum
  Account records (no petname, profile = nil — refetches on next
  foreground). Covers the "iOS Storage settings wiped UserDefaults
  but Keychain persisted" case.

Cross-version cleanup:
- ADD migrateRemainingLegacyKeysIfNeeded() — runs every launch when
  accountsKey is populated. Idempotent: re-runs the migration helpers
  for any legacy keys that linger (catches users who upgraded to a
  Task 5/7 build before Task 8 shipped).

Per-pubkey profile image cache:
- cachedImageURL becomes a function taking pubkey, defaulted to a var
  for source-compat. Filename: cached-profile-<pubkey>.dat (was
  global profile_image.jpg). Migration handled by
  migrateLegacyProfileImageFile.

loadState pipeline (final shape):
  1. bootstrapFromLegacyKeychainIfNeeded — build-31 → multi-account
  2. loadAccounts — hydrate from accountsKey
  3. recoverAccountsFromKeychainIfNeeded — UserDefaults-wipe case
  4. cleanupOrphanLegacyKeychainEntry — defensive sweep
  5. migrateRemainingLegacyKeysIfNeeded — cross-version cleanup
  6. loadCachedProfileImage — populate profileImage from disk

Tests: AppStateMultiAccountTests gains 7 new tests (23 total, all
passing on iPhone 17 / iOS 26.4):
- testBootstrap_migratesLegacyCachedProfile_intoAccountProfile
- testBootstrap_migratesLegacyBunkerSecret_intoPerSignerDict
- testBootstrap_migratesLegacyLastContactSet_intoPerSignerDict
- testBootstrap_migratesLegacyRegisterTimestamps
- testReinstallRecovery_seedsAccountsFromKeychain
- testReinstallRecovery_doesNotRunWhenAccountsKeyAlreadyPopulated
- testMigrateRemainingLegacyKeys_idempotentlyCleansUpAfterPriorBootstrap

Full ClaveTests suite: 172 tests passing (was 165; +7 new). Zero
regressions.

Phase 1 (Stage B iOS) is COMPLETE end-to-end:
✅ Per-pubkey Keychain (Task 2)
✅ signerPubkeyHex on every record (Task 3)
✅ SharedStorage scoped variants + LightSigner per-signer routing (Task 4)
✅ AppState multi-account refactor (Task 5)
✅ NSE + foreground signing path pubkey-routing (Task 6)
✅ View callers scoped + record backfill (Task 7)
✅ Migration polish + reinstall recovery (Task 8 — this commit)

Plus Stage A (proxy `signer_pubkey` payload field) is committed on
feat/multi-account-proxy (Dell-side, b045d26) and deployed to
proxy-test.clave.casa.

Phase 1 is ready for end-to-end testing against the test proxy via
internal TestFlight.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md
Security audit: ~/hq/clave/security-audits/2026-04-30-multi-account-pre-implementation.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rness)

End-to-end testing harness for the multi-account sprint, gated behind
the existing 7-tap dev menu unlock. Lets a tester exercise account
lifecycle (add / switch / delete) on internal TestFlight without
waiting for the Stage C UI sprint, AND surfaces migration health at
a glance.

Three sections:

1. Current account inspector — pubkey, petname, profile name, addedAt;
   tap-to-copy pubkey hex.

2. All accounts list — per-row Switch / Delete buttons. Delete uses
   the canonical "If you didn't save your private key..." alert, same
   string Stage C will use. Exercises real `appState.switchToAccount`
   and `appState.deleteAccount` code paths.

3. Test actions:
   - "Generate test account" — creates a random keypair via
     `appState.generateAccount(petname: "Test HH:mm:ss")`. Auto-
     registers with the configured proxy. Same code path the Stage C
     onboarding will use; bypasses the not-yet-built UI.
   - "Refresh profile (current)" — fires fetchProfileIfNeeded.
   - "Rotate bunker secret (current)" — fires rotateBunkerSecret.

4. Migration diagnostics:
   - accountsKey state, currentSignerPubkeyHex, per-signer dict
     counts (bunkerSecrets, lastContactSets, lastRegisterTimes).
   - Legacy-key presence detector (red text if PRESENT) for all the
     keys Task 8 migration should have cleaned: bunkerSecretKey,
     lastContactSetKey, lastRegisterSucceededAtKey,
     lastRegisterFailedAtKey, cachedProfileKey, legacy Keychain
     entry. Lets a tester verify migration ran cleanly without
     parsing logs.
   - Keychain pubkey count via SharedKeychain.listAllPubkeys().

5. L1 binding inspector — shows which pubkey L1 is currently bound
   to + state. Includes a note that L1 in v1 doesn't auto-restart
   on account switch (deferred to Stage C polish).

Wired into Settings → Developer (after the 7-tap unlock) alongside
"L1 Diagnostics". No pbxproj surgery needed — Clave/ is a
PBXFileSystemSynchronizedRootGroup that auto-discovers new files.

Stage C will replace this with a real account picker UX. This view
stays as a dev tool for ongoing diagnostics + cross-version
migration verification.

Tests: full ClaveTests suite (172 tests) still passes; this view is
a UI wrapper around methods already covered by
AppStateMultiAccountTests.

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
For internal-TestFlight build of feat/multi-account. Two changes:

1. pbxproj CURRENT_PROJECT_VERSION 31 → 32 across all 8 build configs
   (Clave + ClaveNSE × Debug/Release/Profile/Test).

2. SharedConstants.defaultProxyURL flipped from proxy.clave.casa →
   proxy-test.clave.casa. The test proxy on Dell already has Stage A
   (`signer_pubkey` payload field) deployed; production proxy does
   NOT yet (gated on Stage A PR #22 merging to main and being
   deployed).

⚠️ This commit is TEMP. The defaultProxyURL flip MUST be reverted
before merging feat/multi-account to main — at that point Stage A
should already be live on production proxy, and external TestFlight
builds need to point at the production URL.

Suggested last-commit-on-branch:
    revert: flip defaultProxyURL back to production
    Reverts the test-proxy redirect. Stage A is now live on the
    production proxy; merging to main means external builds.

Build: 32 (internal TestFlight only — do not promote to external
without first reverting the proxy URL flip + verifying production
proxy has Stage A deployed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ive cleanup

Build 32 had a regression where Task 4/5/7's caller updates didn't
audit every constructor call site. Task 3 added `signerPubkeyHex`
with a default of `""`; subsequent tasks updated readers but missed
several constructors. New rows post-bootstrap got created with
empty signer, then Task 7's filtered view reads (which skip
empty-signer rows) made them invisible.

Symptoms (build 32 internal TF):
- "Client not found" when tapping a newly-paired client in HomeView
- Empty Connected Clients section despite a successful pair + sign
- New PendingRequest rows queued under empty signer (won't surface
  in the merged pending view's per-account grouping)

8 construction sites fixed:

LightSigner.swift:
- ClientPermissions for new bunker pair (line ~157) — now passes
  `signerPubkeyHex: signerPubkey` (derived from privateKey at top
  of handleRequest in Task 6)
- PendingRequest queued for protected kinds (line ~250) — same

SharedStorage.swift:
- updateClient now requires `signer:` parameter; ConnectedClient
  construction inside uses it
- setClientRelayUrls now requires `signer:` parameter; ConnectedClient
  construction inside uses it
- Both functions match-on-replace via composite (signer, client) key
  so the same client paired with multiple accounts produces distinct
  rows

AppState.swift:
- handleNostrConnect's two ActivityEntry constructions (error path +
  connect-published path) — now pass `signerPubkeyHex: signerPubkey`
- pairClientWithProxy / unpairClientWithProxy: PairOp constructions
  capture signer at function entry (`let capturedSigner =
  signerPubkeyHex`) so the URLSession failure closure enqueues under
  the correct account even if the user switched accounts during the
  in-flight request
- setClientRelayUrls caller updated to pass signer

ApprovalSheet.swift:
- ClientPermissions for new nostrconnect pair — now passes
  `signerPubkeyHex: currentSigner` (already read from
  currentSignerPubkeyHexKey for the cap-check; reused here)

Retroactive cleanup for build-32 testers:
- ADD `cleanupEmptySignerRowsIfSafe()` — runs in loadState() every
  launch, stamps empty-signer rows with the current account's pubkey
  ONLY when accounts.count == 1. Multi-account testers (small set
  who saw build 32) need to manually re-pair affected clients.
- Reuses Task 8's `backfillSignerPubkeyHex` (idempotent — already-
  stamped rows are skipped).

Build: 33 (was 32). Internal TestFlight only — `defaultProxyURL`
still flipped to proxy-test.clave.casa.

Tests: full ClaveTests suite (172 tests) still passes. Construction-
site fixes are mostly mechanical parameter additions; existing
record-codable tests + storage-multi-account tests cover the
roundtrip behavior already.

Manual verification on real device (build 33 install over build 32):
- Open dev menu → Multi-Account → confirm any pre-existing empty-
  signer rows now show the current account's pubkey (cleanup ran)
- Re-pair Nostur (or whichever client showed "Client not found") →
  confirm new row has correct signer (ClientDetailView opens cleanly,
  HomeView Connected Clients section populates)

Plan: ~/hq/clave/plans/2026-04-30-multi-account-sprint.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HomeView's `refreshData` was calling the legacy zero-arg
`SharedStorage.getClientPermissions()` which returns clients across ALL
accounts, while `loadPermissions()` in ClientDetailView (line 110) used
the signer-scoped `getClientPermissions(signer:client:)`. Mismatch caused
"Client Not Found" when tapping a client paired with a non-current
account from HomeView's connected-clients list.

Surfaced during build 33 multi-account smoke test on real device. The
adjacent comment already says "scope to the current account" — the code
just wasn't updated to match. Task 7 oversight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 / Stage B previously left `registerWithProxy()` registering only
the current account's (deviceToken, signerPubkey) mapping with the proxy.
Result: APNs push routing worked for the current account but silently
failed for any other account — proxy received their kind:24133 events
and dropped them with "no registered tokens, skipping". Surfaced during
build 33 multi-account smoke test on real device.

Refactor:
- Extract per-signer body to private `registerSignerWithProxy(signer:)`
- Keep `registerWithProxy()` as a thin wrapper for current-account callers
  (Settings manual button, Onboarding, addAccount post-switch)
- Add `registerAllAccountsWithProxy()` that iterates accounts list and
  registers each pubkey independently
- Add `ensureAllRegisteredFresh()` for scene-active throttled fan-out;
  per-signer cooldown means one account's recent failure does not block
  another's retry

Caller updates:
- AppState init device-token observer → registerAll
- AppState.loadState belt-and-suspenders → registerAll
- MainTabView scenePhase .active → ensureAllRegisteredFresh
- addAccount stays single (just registers the new current account)
- SettingsView Register button stays single (manual current-only intent)
- OnboardingView stays single (single onboarding flow)

Tests: existing 172-test suite passes. No new tests added — refactor is
mechanical and the per-account loop is exercised end-to-end on device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 33 (commit 788c495) was archived for internal-TF and burned the
TestFlight build number. Bumping to 34 so we can re-archive with the
HomeView scoping fix (0e2a0de) + registerAllAccounts refactor (14c7489).

Internal-TF only — defaultProxyURL still points at proxy-test.clave.casa
(commit 11ad90f). URL revert + another bump will be required before any
external promotion, after PR #22 ships to production proxy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t current

deleteAccount(B) called while current=A previously hit two hardcoded-current
bugs:

- unregisterWithProxy() signed /unregister with A's nsec → proxy removed
  (token, A) instead of (token, B). The deleted account's mapping stayed
  registered as an orphan; APNs kept pushing for B's pubkey to a device
  that no longer had B's nsec to sign with → silent NSE drops.

- unpairClientWithProxy(clientPubkey: X) signed /unpair-client with A's
  nsec → proxy looked up (signer=A, client=X) which didn't exist → returned
  200 "no pair found" → real (signer=B, client=X) row stayed on proxy as
  an orphan, leaking secondary-relay subscriptions until 410-pruned.

This also explains the unpair-from-HomeView UX bug (compounds with Bug C
view-scoping miss): tapping unpair on a non-current account's client row
silently failed at the proxy because of the same signer mismatch.

Fix: both functions accept an optional `signer:` parameter, defaulting to
current. deleteAccount passes the to-be-deleted pubkey explicitly. The
nsec is still in Keychain at call-time per audit A2 ordering (Keychain
delete happens at step 3, after these step-1/step-2 network calls).

Other callers (HomeView swipe-unpair, ClientDetailView unpair button) keep
the default — they're explicitly current-account UI flows, and with Bug C's
view-scoping fix they only show current's clients, so current is correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onnection)

Adopting clearer terminology going forward:
- account pubkey = user identity (Account.pubkeyHex / signerPubkeyHex)
- connection pubkey = paired client's ephemeral wire keypair (ClientPermissions.pubkey)

Three sites in LightSigner.handleRequest used the legacy unscoped
`getClientPermissions(for: senderPubkey)` which scans rows across all
accounts and returns the first match by connection pubkey alone. Effects:

- Line 134 (bunker connect with valid secret): isExistingClient=true if
  the connection was paired with ANY account, so the create-row branch
  was skipped. Result: bunker-connecting a connection that was previously
  paired with account A to account B succeeded but never wrote a
  ClientPermissions(account=B, connection=K) row. HomeView's
  account-scoped client list missed the new pair, and per-connection
  activity lookups in ClientDetailView returned no matching entries.

- Line 176 (reconnect without secret): same scope leak. A connection
  paired with account A could reconnect to account B without re-providing
  B's bunker secret.

- Line 195 (non-connect method gate): same scope leak. A sign_event RPC
  with `p`-tag pointing at account B was permitted to sign with B's nsec
  even though only account A had approved this connection. This is a real
  authorization leak — discovered during build 34 multi-account testing
  when the user noticed activity entries appearing under accounts that
  hadn't explicitly paired the signing connection.

Fix: all three call sites now use
`getClientPermissions(signer: signerPubkeyHex, client: senderPubkey)` —
strict (account, connection) consent. Side effect for existing testers:
connections previously cross-pairing across accounts will require an
explicit per-account re-pair via fresh bunker URI. That's correct security.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related issues in the profile-image cache path:

F1: cacheImage(from:) wrote to the no-arg `cachedImageURL` computed
property, which resolves to `signerPubkeyHex` at write-time — i.e. the
CURRENT account's path. fetchProfileIfNeeded captured a specific
account pubkey when it started, but if the user switched accounts during
the network fetch, the resulting image overwrote the new current's cache
file with the old account's image. Visible as "test account's PFP suddenly
disappeared / shows wrong avatar" after rapid account switching.

F2: cacheImage call ran outside the "still on same account" guard. The
guard at the MainActor.run block aborted the in-memory profile update
correctly when the user switched mid-fetch, but the cacheImage call sat
after the guard returned, so the file write happened unconditionally.

F3: deleteAccount only removed the legacy global `profile_image.jpg`,
never the per-account `cached-profile-<accountPubkey>.dat` file. Orphan
files accumulated on disk for every deleted account.

Fix: cacheImage takes an explicit `pubkey` parameter, writes to
`cachedImageURL(for: pubkey)`, and only updates the in-memory image if
the captured account is still current. deleteAccount removes the
per-account cache file alongside the legacy file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 34 shipped Bugs A/B/C/D fixes; build 35 adds:
- Bug E: LightSigner permission gate scoped to (account, connection)
- Bug F: PFP cache writes bound to fetch's account pubkey

Internal-TF only (URL flip still active to test proxy).
Bug G: switchToAccount updated currentAccount and the persisted current-
account-pubkey UserDefaults, but never refreshed `profileImage`. The
property is loaded once at cold-launch via loadCachedProfileImage() —
which uses the current account's path at that moment — and only updated
afterward when cacheImage() runs after a successful kind:0 fetch.

Effect: switching accounts updated the displayed npub/petname (those
derive from currentAccount directly) but the PFP stayed bound to whichever
account was current when the app cold-launched. Visible as "the wrong
avatar persists across account switches".

Build 35 actually made this more obvious because Bug F2 stopped the
cross-account cache file clobbering — so each account's cache file is now
correctly per-account on disk, but switchToAccount didn't reload the
active image from the new account's cache.

Fix: switchToAccount now clears profileImage and calls
loadCachedProfileImage() (which reads from the new current's path) +
fetchProfileIfNeeded() (1-hour cache cooldown — short-circuits if recent).
addAccount also clears profileImage to avoid showing the stale previous-
account avatar between the switch and the new account's first profile
fetch landing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quick interim affordance for testers: tapping the avatar/name/npub area
on the Home identity bar opens a SwiftUI Menu listing all accounts. Tap
an account → switchToAccount fires, the menu auto-dismisses. Current
account is marked with a checkmark. A small chevron indicator appears
next to the npub when 2+ accounts exist (hidden in single-account state
to avoid clutter for build-31 users).

Avoids the dev-menu round-trip when switching accounts during testing.
The copy-npub button stays as a separate sibling so it still works
without opening the menu.

Stage C will replace this with the proper bottom-sheet account picker
per the architecture plan
(~/.claude/plans/doesnt-each-account-have-dreamy-journal.md), but the
Menu is the smallest possible interim that keeps testing fluid without
investing in the full Stage C UX upfront.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Bug H)

When the user switches account via the new identity-bar Menu (or any
other path that mutates currentAccount), the npub label updated correctly
because views read it directly from appState.currentAccount. But:

- HomeView's `clients` and `activityLog` @State arrays are populated by
  refreshData(), which only fires on .onAppear, .onChange(scenePhase),
  .signingCompleted notification, and .sheet onDismiss. Account switch
  wasn't a refresh trigger.
- ActivityView's `entries` array, same shape — refreshData reads
  currentSignerPubkeyHexKey but only on the same trigger set.

Effect: switching accounts changed the npub but the Connected Clients
list and Activity entries stayed bound to the previously-selected
account. User had to navigate away and back for the lists to refresh.

Fix: add `.onChange(of: appState.currentAccount?.pubkeyHex) { _, _ in
refreshData() }` to both views. ActivityView also gets an
`@Environment(AppState.self)` declaration since it didn't previously
need it (refreshData reads UserDefaults directly).

persistCurrentAccountPubkey() updates UserDefaults synchronously when
currentAccount mutates, so by the time onChange fires the right
account's pubkey is already in UserDefaults — refreshData sees the new
value on the same tick.

PendingApprovalsView and SettingsView don't need this fix: they read
appState observable properties directly, which already trigger SwiftUI
re-renders on switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stripped-down Stage C scope locked via superpowers:brainstorming. Final
design: C2 picker pattern (top avatar strip + slim text identity bar)
with per-account hash-derived gradient theming. Visual mockups iterated
through 4 versions in the brainstorm session; v4 approved (full-bleed
gradient background, neutral chrome, theming pulled to slim-bar wash +
active strip pill ring + active tab text only).

Spec covers: picker behavior, gradient theming module (AccountTheme.swift),
explicit account names in destructive copy, AddAccountSheet (replaces
deferred OnboardingView .addAccount mode), AccountDetailView, Settings
Accounts section, ApprovalSheet "Signing as" header, data flow refactor
(extract fetchProfile(for: pubkey) helper), error handling, testing
strategy, 8-commit implementation order.

Out of scope (next sprint candidates documented inline): OnboardingView
.addAccount mode parameterization, PendingApprovalsView per-account
grouping, iOS notification body with account label, ConnectedClient row
creation in bunker connect path, pending-approval badge per pill,
user-customizable colors.

Also: add .superpowers/ to .gitignore so brainstorm session scratch
files (visual companion mockups) don't accidentally get committed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Discussed during spec review (2026-05-01). Letter-on-gradient stays the
default for stripped-down Stage C. Robohash captured as a future
enhancement in the deferred follow-ups list with its tradeoffs noted
(network dependency, pubkey leak to robohash.org, cache mitigation,
~30 lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to docs/superpowers/specs/2026-05-01-stage-c-multi-account-ux-design.md.
8 tasks with bite-sized steps, full Swift code blocks, exact file paths,
build + test commands, commit messages.

Task breakdown:
1. AccountTheme palette helper (TDD, 6 unit tests)
2. AccountStripView + SlimIdentityBar + HomeView gradient
3. AddAccountSheet + remove placeholder
4. AccountDetailView skeleton (banner + petname + delete)
5. AccountDetailView actions (profile / rotate / export / refresh) +
   AppState fetchProfile(for:) refactor
6. SettingsView Accounts section
7. ApprovalSheet SigningAsHeader + destructive-copy named accounts
8. pbxproj 37→38 + archive build 38

Self-review verifies: spec coverage (every approved element mapped to a
task), zero placeholders, type consistency across tasks. Ready for
either superpowers:subagent-driven-development (recommended, fresh
context per task) or superpowers:executing-plans (batch with checkpoints).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… gradients

Pure utility module: pubkey hex → SHA-256 → first 2 bytes → palette
index. 12 curated gradient entries (start, end, accent), stable indices
(never reorder). Empty/invalid hex falls back to palette[0] defensively.

6 unit tests cover: palette count, determinism, distribution, empty
input, invalid input, case-insensitivity. All pass on iOS 17 simulator.

Foundation for AccountStripView active pill ring, SlimIdentityBar wash,
HomeView ambient gradient, AccountDetailView banner, ApprovalSheet
SigningAsHeader.
… background

Replaces the interim Menu identity bar (aa194a9) with the C2 picker:
- AccountStripView: horizontal pills, active gets gradient ring,
  trailing + pill triggers AddAccountSheet, long-press pushes detail.
  Auto-hides when accounts.count == 1.
- SlimIdentityBar: text-only @petname • npub• copy with 22% gradient wash.
- HomeView: full-screen ambient gradient (35%→10% top-down) tied to
  active account's AccountTheme. NavigationStack path binding for
  programmatic detail push from long-press. AddAccountSheet sheet
  modifier with placeholder pending Task 3.

Bug G + Bug H wiring already in place from build 36/37 — switching via
strip triggers the existing refresh chain.
…edNpub argument + transition animation

Three Important findings from code-quality review of 76740a8:

I1: SlimIdentityBar.truncatedNpub(for:) accepted an Account argument
    but ignored it, reading appState.npub instead. The function only
    ever runs in the current-account branch of SlimIdentityBar's body,
    so the parameter was dead code that misled future callers about
    what the function does. Removed the parameter; truncatedNpub is now
    a computed property that honestly reflects "current account's
    truncated npub".

I2: simultaneousGesture(TapGesture) + onLongPressGesture race —
    long-press on a non-active pill fired the long-press (set
    pendingDetailPubkey) AND the subsequent tap-up (called
    switchToAccount). Spec explicitly forbids the latter on long-press.
    Extracted accountPill to a child AccountPillView struct holding a
    local @State didLongPress flag; tap handler suppresses its action
    when a long-press just fired. AccountAvatarPlaceholder split out
    as a reusable helper struct.

I4: HomeView's homeBackgroundGradient changed instantly on account
    switch, producing a visible color hard-cut. Added
    .animation(.easeInOut(duration: 0.3), value:
    appState.currentAccount?.pubkeyHex) keyed on the active pubkey so
    the transition only animates when the account actually changes.

Build green; all 6 AccountThemeTests still pass.
…odal

Reuses existing AppState.generateAccount / addAccount (idempotent). Two
modes via segmented Picker: Generate (random keypair) and Paste (nsec).
Optional petname field. Inline error on invalid nsec. Auto-dismiss + new
account becomes active on success.

Triggered from AccountStripView's + pill (Task 2). Will also be triggered
by SettingsView Add Account row in Task 6.
DocNR and others added 27 commits May 3, 2026 02:26
…on C)

Identity-zone banner extends Home's per-account theme gradient
(56pt avatar, 18/22 padding per design-doc §5.5). Body Form sits on
Home's ambient gradient via .scrollContentBackground(.hidden) +
.listRowBackground(Color.clear) per row, and the same four-stop
LinearGradient Home uses (alphas 0.42/0.22/0.10/0.04 top→bottom).

Section headers switch from default ALL-CAPS small grey to
sentence-case .headline + .textCase(nil) (matches Home's
"Connected Clients" header).

Existing behavior preserved verbatim — Petname rename, Profile
display name, Security (rotate + export gated to current), Delete
with named alert + connection-count footer. New profile fields
(about/nip05/lud16/stat/clave.casa link) come in Tasks 5-7.

Adds .refreshable {} on Form for pull-to-refresh — calls
refreshProfileAsync(for:) (added in same commit).

Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ restore dismissal comment

Two small fixes from code-quality review on commit 312653b:

1. refreshProfileAsync(for:) now guards against empty pubkey, matching
   the existing sibling refreshProfile(for:). Defensive — call site in
   AccountDetailView already guards, but the public AppState API should
   not allow an empty-pubkey REQ to fan out to relays.

2. Restored the inline comment in performDelete() explaining that
   dismissal happens via .onChange(of: account == nil) in body. The
   comment was silently dropped during the visual rewrite; restoring it
   helps future readers understand why performDelete doesn't call
   dismiss() directly.

Also kept .foregroundStyle(.primary) on section headers (matches
HomeView's "Connected Clients" header + design-system §3 functional-zone
rule). The spec text said .white but that came from the dark mockup;
.primary correctly adapts to light/dark mode and is the right SwiftUI
behavior for a Form section header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d-clients stat

Three new conditional kv-rows (Display name + NIP-05 + Lightning),
each rendered only when the underlying CachedProfile field is non-empty.
"N paired clients" stat row is always shown (SharedStorage.getConnectedClients
count). Empty-state hint shows only when entire profile is empty
(no displayName / nip05 / lud16) — never shown alongside actual content.

Lightning value uses .system(.body, design: .monospaced) since lud16
addresses are technically formatted strings.

About row + Edit-on-clave.casa row come in Tasks 6 + 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…unt in delete copy

Two Task 5 review-fix refactors before Task 6 layers in another && clause:

1. Extracted profileIsEmpty computed property from the inline empty-state
   condition. The 4-clause OR-of-AND was already reading at the edge of
   precedence-clarity at 3 clauses; Task 6's about clause would push it
   over. Now Task 6 just adds the about clause to profileIsEmpty itself.

2. deleteAlertMessage now reuses the connectionCount property added in
   Task 5 instead of re-calling SharedStorage.getConnectedClients(...)
   independently. DRY + single source of truth + drops the redundant
   `guard let account` (connectionCount already guards internally).

Plan doc updated to reflect the new Task 6 instruction (add about to
profileIsEmpty rather than the inline condition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
About renders as a stacked block (label above, body below) instead of
kv-row treatment so multi-line bios don't break the section's vertical
rhythm. Default lineLimit(2); tap anywhere on the block toggles to full.
"Show more" / "Show less" affordance only renders when the text actually
overflows two lines (heuristic: count > 80 chars; true text-measurement
deferred to a future polish item).

Light haptic on toggle. profileIsEmpty extended to include about so a
profile with only about set still shows actual content rather than the
placeholder. The aboutOverflowsCap heuristic lives near connectionCount
+ profileIsEmpty (same MARK group of profile-section helpers).

Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… change

Caught by code-quality review on 0a1a3e1: if a user expanded a long
bio then the cached profile updates to a shorter bio (via pull-to-refresh
or account switch), isAboutExpanded would stay true while the "Show less"
toggle pill disappears (because aboutOverflowsCap goes false). The text
renders identically since unlimited lineLimit on short text == 2-line
cap on short text, but the state is semantically stale.

Single .onChange modifier on account?.profile?.about resets expansion
on every bio mutation. Co-located with the existing onChange(of: account
== nil) dismissal handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Always-visible row in the Profile section opens
https://clave.casa/edit#bunker=<URL-encoded-bunker-uri> via
UIApplication.shared.open. Fragment never reaches a server;
clave.casa parses client-side and either re-uses an existing
pairing (matches signer pubkey in localStorage) or pairs fresh.

clave.casa apex is now live (Cloudflare Pages, deployed 2026-05-03);
AASA at /.well-known/apple-app-site-association validated. The
/edit#bunker route is queued on the clave.casa BACKLOG for
implementation; until it lands, the link reaches the apex landing
page (or 404 if /edit isn't routed yet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…fragment data

Caught by code-quality review on c189162: .urlQueryAllowed (and also
.urlFragmentAllowed) permit `&`, `=`, `?` per RFC 3986 sub-delims, but
the bunker URI itself contains `?relay=...&secret=...` syntax. If clave.casa
parses the fragment via URLSearchParams (`splitting on &` + `=`), the
bunker value would be truncated at the first inner `&secret=` boundary.

Switched to a JS encodeURIComponent-equivalent charset (alphanumerics +
`-._~` unreserved per RFC 3986). The bunker URI is now treated as
opaque data inside the fragment, recoverable via a single
decodeURIComponent on clave.casa's side regardless of parsing approach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the old icon-circle row (32pt theme.accent Circle + bold
subheadline + caption + chevron — visually identical to a
ConnectedClient row) with a HIG-standard inline action: 22pt tinted
plus circle + accent-color medium-weight label + no chevron + no
subtitle. Native iOS pattern (Mail "Add Mailbox", Settings
"Add Account").

Cap pre-check in handlePairNewConnectionTap() unchanged.
theme.accent threading preserved for per-account identity continuity.

Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…onnect:// scheme squat

Adds applinks:clave.casa entitlement and extends DeeplinkRouter to
handle https://clave.casa/connect/?uri=<encoded-nostrconnect-uri>.

Backstory: nostrconnect:// is a custom URL scheme. Primal also
registers it, so dual-installed iPhones routed deeplinks to Primal
instead of Clave (no user-facing override exists). Universal Links
solve this — iOS verifies the AASA at the domain and routes the
specific URL pattern exclusively to the registered app.

Coordination: clave.casa apex went live 2026-05-03 (Cloudflare Pages).
AASA at /.well-known/apple-app-site-association declares
appIDs=["944AF56S27.dev.nostr.Clave"] with components scoped TIGHTLY
to /connect/?uri=*. /edit, /, and any other path stays routed to
Safari/clave.casa — verified by curl + the new
testUniversalLink_wrongPath_routesToIgnore test.

DeeplinkRouter extracted a routeNostrconnect helper to share the
account-count branching between the nostrconnect:// case and the
new https case. Five new tests cover single/multi-account routing,
missing/malformed uri params, and AASA scoping (edit/landing reject).

Implementation note: URL.path strips trailing slashes in Swift, so
the path guard matches "/connect" (not "/connect/"). The AASA still
declares /connect/ — iOS enforces the trailing slash at the OS level
before delivering the URL to the app. Tests use a custom CharacterSet
excluding ?&=# to correctly percent-encode the nostrconnect URI as a
query parameter value (urlQueryAllowed leaves ? and & unencoded, which
would fragment the outer URL's query string).

Manual on-device verification deferred to user: install build with
new entitlement on a device with both Clave + Primal, run
`swcutil reset` to flush cached AASA, tap a https://clave.casa/connect/?uri=...
link from Messages — should open Clave directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pty-uri test

Two small test improvements from code-quality review on commit 1bac9a9:

1. Renamed testOtherScheme_routesToIgnore → testHTTPS_nonClaveCasaHost_routesToIgnore.
   The original name became misleading once Phase B added the `https`
   scheme branch. The test still passes for the right reason (host guard
   rejects example.com), but the framing was wrong; the new name + comment
   document that we now have an explicit https branch and the host guard
   is what stops external HTTPS URLs.

2. Added testUniversalLink_emptyURIValue_routesToIgnore covering the
   present-but-empty `?uri=` case. Distinct code path from
   testUniversalLink_missingURIParam (queryItems returns "" not nil for
   empty-value); the router's `!uriParam.isEmpty` guard catches this and
   the new test exercises that guard explicitly.

Both new/renamed tests pass; full DeeplinkRouterTests suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal-only build pointing at proxy-test.clave.casa (URL revert
to prod proxy + MARKETING_VERSION 0.1.0→0.2.0 deferred to a
separate commit before the external archive). Includes the
8-task AccountDetailView redesign sprint + Phase B Universal Links
wiring (applinks:clave.casa entitlement + DeeplinkRouter https
case + 6 unit tests).

Test proxy already has Stage A signer_pubkey routing (PR #22 deployed
there only); prod proxy still pending PR #22 merge. Internal smoke
on test proxy validates the full feat/multi-account stack
independent of the prod-proxy switch.

AASA cache caveat from clave.casa coordination: Apple CDN may still
serve the old single-component AASA for ~few hours. Use trailing-slash
form `https://clave.casa/connect/?uri=...` for Universal Links
verification today — matches both old and new AASA. The no-trailing-
slash form will work after CDN refresh (within ~6h).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kind:0 has two distinct identity fields:
  - display_name: long human-readable ("Alice Smith")
  - name: short handle ("alice")

Pre-build-46 they were collapsed into a single `displayName ?? name` for
the displayLabel fallback. Build 46 surfaces both separately —
AccountDetailView's Profile section will gain a Username row showing
the `name` field (Task 2 of this sprint).

Updates the fetchProfile bail-out gate to treat displayName OR name as
"has identity" (was: displayName only after collapse). Resolves the
deferred revisit note from commit d486464.

Backward compat: pre-build-46 on-disk CachedProfile blobs decode
cleanly with `name` as nil. Same Codable-defaults-for-optionals pattern
that PR #19 used for ActivityEntry and that Task 1 of the AccountDetailView
sprint used for about/nip05/lud16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback on build 45: petname (user-editable account nickname) is
confusing — the only editable field in an otherwise read-only screen
where the rest of the profile is sourced from kind:0 and edits go
through clave.casa. The "rename in Clave separately from your kind:0"
model adds friction now that clave.casa is the editor.

Changes:
- Account.displayLabel chain drops petname: now displayName → name →
  prefix(8). The petname field stays nullable on the model for on-disk
  backward compat (existing accounts decode cleanly) but UI never reads
  or writes it as of build 46.
- AccountDetailView Petname section deleted.
- AccountDetailView Profile section gains a Username kv-row (between
  Display name and About) showing @<name> from kind:0's `name` field.
  Conditionally rendered like the other Profile rows.
- profileIsEmpty extended to include the new name clause.
- AddAccountSheet drops the petname input — sheet is now just
  generate/paste-nsec.
- AccountModelTests updated: displayLabel tests assert the new chain
  AND that petname is ignored even when set.

Mirrors clave.casa's existing displayLabel chain (per design-system
parity rules); iOS catches up to web here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User device-test feedback on build 45: the banner had the right size
but the squared-off full-bleed treatment felt out of place against the
rest of the polished surfaces. Solution: keep current sizing (56pt
avatar, .title3 name, 18/22 padding) but wrap in a rounded card with
the same chrome SlimIdentityBar uses on Home (cornerRadius + shadow).

Implementation:
- Banner moves from `Section { EmptyView() } header: { banner }`
  full-bleed treatment to a regular Section with the banner as
  content, listRowInsets adding 14h horizontal margin so it sits as
  a card.
- bannerHeader drops the outer ZStack; uses
  .background(LinearGradient) + .clipShape(RoundedRectangle 14) +
  .shadow(theme.start.opacity(0.25), radius 8, x 0, y 1) — matches
  SlimIdentityBar's chrome at design-doc §5.2 (radius 12 there;
  banner uses 14 since it's a larger surface and 12 felt too tight
  proportionally).

Visual continuity with Home identity-zone language. No content changes,
no behavioral changes — pure visual chrome refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User device-test feedback: tapping Edit on clave.casa with a fresh
browser session (no existing pairing) creates a new bunker pair on the
iOS side. Discussed adding a confirmation alert; landed on a lighter
informational caption instead — keeps the common case (already paired
→ instant edit) frictionless while setting expectations for the
fresh-pair case.

Caption sits as a Section footer under the Profile section, picking
up the existing footer styling pattern used by deleteSection
(.caption + .secondary).

Text: "Opens in Safari · pairs with clave.casa if needed"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Internal-only build pointing at proxy-test.clave.casa. URL revert to
prod proxy + MARKETING_VERSION 0.1.0→0.2.0 still deferred — both flip
in the build 47 external rollout commit, after PR #22 ships to prod.

What's new vs build 45:
- CachedProfile.name extraction (48b4fa6)
- petname dropped from UI; Username row added; displayLabel chain
  becomes displayName → name → prefix(8) (1a130bb)
- AccountDetailView banner gets rounded corners + shadow matching
  SlimIdentityBar's chrome — same size, just no longer squared off
  (46989f3)
- Caption "Opens in Safari · pairs with clave.casa if needed" under
  the Edit on clave.casa row (2db6fa9)

Also surfaced + queued: ConnectedClient identity sprint (3 iOS-side
gaps that prevent Clave.Casa name from showing in Connected Clients
list). Documented in BACKLOG High Priority. Not blocking external —
sprint targets v0.2.1 or v0.3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported on build 46 that AccountDetailView content felt shrunk
compared to Home/Activity. Root cause: Form defaults to insetGrouped
list style (~16pt extra horizontal margin); Home/Activity use
List.plain (no extra margin). The banner Section had explicit
14pt listRowInsets so it sat at 14pt; the Profile/Security/Delete
sections inherited Form's wider insetGrouped margin and looked
narrower than the banner — visually inconsistent.

Fix: swap Form → List + add .listStyle(.plain) to match HomeView's
chrome. Section structure, listRowInsets values, listRowBackground
modifiers, and all row content stay identical. The .textCase(nil)
override on each section header keeps them rendering correctly in
plain List style. Visual continuity with Home/Activity restored.

Spec: docs/superpowers/specs/2026-05-03-account-detail-view-redesign-design.md
Design system: docs/design-system.md §5 (List/Form style choice)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… single-account

User device-test feedback on build 46: when down to one account on
Home, the strip auto-hid (accounts.count > 1 gate) — which made the
"+" pill invisible too. Users frequently want to add a 2nd account
from Home (not Settings → Accounts → Add), so the "+" affordance
value outweighs the original "no UI noise at single-account" rationale
from the Stage C design.

One-line fix: drop the `if appState.accounts.count > 1` wrapper so
the strip always renders. At zero accounts ContentView routes to
OnboardingView before this view mounts (verified at
ContentView.swift:8 — `if appState.isKeyImported { MainTabView }
else { OnboardingView }`), so the empty-strip case never occurs in
practice.

Visual at single-account: 1 active pill + "+" pill. SlimIdentityBar
below still renders for npub display + copy. Slight redundancy with
the 28pt mini avatar in the slim banner, but the "+" affordance is
worth it.

Docstring updated to reflect the new behavior + reason for the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…internal smoke

Internal-only build for smoking the two device-test fixes from build 46:
- ef1e250: AccountStripView always renders (no auto-hide at single-account)
  so the "+" pill stays accessible from Home for adding new accounts
- 98e441c: AccountDetailView swap Form→List + .listStyle(.plain) to match
  Home/Activity (kills the insetGrouped extra horizontal margin)

URL stays at proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0.
External promotion + URL revert + MARKETING_VERSION bump deferred to a
later commit pending Dell→VPS prod proxy migration plan + PR #22 prod
merge timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User device-test feedback on build 47 internal: swipe-delete an account
from Settings → Accounts list, tap Delete in the confirmation alert,
sometimes nothing happens. No error, no haptic mismatch, just no
deletion. Intermittent.

Root cause: the alert's Delete button action did
`if let account = accountToDelete { ... }` — re-reading the @State
optional at tap-time. When `appState.accounts` mutated during the
alert window (very plausible: profile fetch landing, L1 RPC wake,
pull-to-refresh fire, any of the refresh-trigger paths), the Section
re-evaluated. SwiftUI's Binding-backed `.alert(isPresented:)` can
fire its setter `set(false)` during certain re-render scenarios — a
documented SwiftUI quirk — clearing `accountToDelete` to nil
*before* the user actually tapped Delete. Alert remained visible
(dismissal animation hid the state mismatch), user tapped Delete,
`if let` failed, silent skip.

Fix: switch to the iOS 15+ .alert(_:isPresented:presenting:actions:
message:) overload. The `presenting:` parameter captures the
optional's value at present-time and passes the unwrapped Account
into the action and message closures by-value. The captured
`account` survives even if `accountToDelete` is later nil'd by the
binding setter — race eliminated.

Same root-cause pattern as the swipeActions migration in the V2
proxy sprint (`.onDelete` → `.swipeActions` for unpair UI). Both
are state-vs-gesture race fixes; this one's at the alert layer
instead of the gesture layer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 47 was archived to ASC; TestFlight burns build numbers
permanently. Build 48 includes the swipe-delete account race fix
(1729ada) that wasn't in build 47.

URL stays proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0
— still internal-only smoke. External rollout deferred pending
PR #22 prod merge / Dell→VPS prod migration timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…udit-5)

User reported clave.casa staying on a stale edit page for ~45s after a
server-side unpair instead of recovering on the explicit
"Invalid or missing bunker secret" error iOS already sends in
LightSigner.handleConnect:197-211 (and "Client not paired..." at :222-243
for non-connect methods).

Diagnosis: the error responses were malformed per NIP-46 spec —
`{id, error}` instead of `{id, result, error}`. nostr-tools' BunkerSigner
(used by clave.casa) is permissive in places (accepts Clave's spec-
divergent `result: <secret>` connect response) but evidently not for
missing-result-on-error — the response is silently ignored, client falls
through to its 45s timeout, no useful UX.

Fix: both error-response construction sites now include `result: ""`:
- handleRequest main response path (Shared/LightSigner.swift:308) — uses
  `responseResult ?? ""` so success keeps current behavior + error gets
  the empty result the spec requires
- sendErrorResponse helper (Shared/LightSigner.swift:639) — was the
  dedicated unpaired/invalid-secret path; same `result: ""` addition

Closes audit-5 from `~/hq/clave/security-audits/2026-04-17-pre-external-
testflight.md` (BACKLOG line 19). Symptom matches the audit's
"Strict parsers may reject" note exactly.

The clave-casa agent's earlier "iOS stays silent on unknown secrets"
diagnosis was wrong — iOS already sends both error paths. Real bug was
spec-noncompliance on response shape. ~3 lines of behavioral change
(Audit-5 was estimated at "~1 line"; the actual fix needed both sites).

Tests: full suite passes. No assertions on error-response JSON shape
to update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 48 was archived to ASC for the swipe-delete race fix; TestFlight
burns build numbers permanently. Build 49 includes the audit-5 spec-
compliance fix (635bfac) for NIP-46 error responses — should resolve
clave.casa's 45s timeout on stale-session recovery.

URL stays proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0
— still internal-only smoke. External rollout deferred pending
PR #22 prod merge / Dell→VPS prod migration timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User edits kind:0 metadata via clave.casa but Clave iOS keeps showing
the old name/displayName/about even after AccountDetailView pull-to-
refresh. Bypass throttle works correctly (refreshable -> refreshProfileAsync
-> fetchProfile, no 1-hour gate); the bug was in the cross-relay merge.

Root cause: AppState.fetchProfile(for:) violated NIP-01 replaceable-event
semantics. The variable was named "newest" but the loop only updated it
when the current entry had no picture — effectively "first relay
response with a picture wins, regardless of created_at." The TODO
comment at the old call site already flagged this.

Trace with the test account's actual relay state today:
- damus.io / nos.lol / primal.net  -> 2026-05-03 21:52 (newest)
- relay.powr.build                  -> 2026-05-02 04:06 (1 day stale)
- purplepag.es                      -> 2026-04-25 03:16 (8 days stale)
- nostr.wine                        -> no event
relay.powr.build is the user's primary low-latency relay so it almost
always responds first. Its 2026-05-02 kind:0 has a picture, so it won
the race against the 2026-05-03 events on damus/nos.lol/primal.

Fix: extract the merge into a pure static `mergeKind0(_:)` that picks
max(created_at), and have the static fetcher return a `FetchedKind0`
wrapper (profile + event created_at) instead of bare CachedProfile so
the merge can compare timestamps. CachedProfile itself is unchanged
(it's the persisted form; event time is a transient relay-merge
concern). The per-relay hasIdentity || hasPicture gate remains as the
empty-profile filter.

Tests: 5 new unit tests in AppStateProfileMergeTests covering max-
selection, the regression case (both events have a picture), nil
inputs, partial-nil inputs, and order-independence. Full ClaveTests
suite (incl. these 5) passes on iPhone 17 / iOS 26.4 simulator.

Out of scope (deferred to a separate PR if needed): NIP-65 outbox
lookup to derive user's actual write relays. The merge fix is required
regardless of relay-set composition — outbox would help users who
have a NIP-65 published, but the test account in this report has none
so the existing 5-relay hardcoded set is what would be queried either
way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Build 49 was archived to ASC for the audit-5 NIP-46 result-field fix;
TestFlight burns build numbers permanently. Build 50 includes the
kind:0 cross-relay merge fix (ef3821c) for "edit profile on clave.casa
but Clave iOS keeps showing old fields after pull-to-refresh."

URL stays proxy-test.clave.casa, MARKETING_VERSION stays 0.1.0
— still internal-only smoke. External rollout deferred pending
PR #22 prod merge / Dell→VPS prod migration timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Flip defaultProxyURL: proxy-test.clave.casa → proxy.clave.casa now that
  PR #22 (Stage A signer_pubkey payload) is on prod.
- Bump pbxproj 50 → 51.
- Bump MARKETING_VERSION 0.1.0 → 0.2.0 — first external build with
  multi-account + Universal Links.

Build 50 internal-only stays as the pre-flip diagnostic baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR DocNR marked this pull request as ready for review May 4, 2026 02:03
@DocNR DocNR merged commit 1674c34 into main May 4, 2026
@DocNR DocNR deleted the feat/multi-account branch May 4, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant