Skip to content

feat(overlay): adopt blocking progress overlay for DPNS registration (Bucket A)#866

Merged
lklimek merged 22 commits into
docs/platform-wallet-migration-designfrom
feat/overlay-rollout
Jun 22, 2026
Merged

feat(overlay): adopt blocking progress overlay for DPNS registration (Bucket A)#866
lklimek merged 22 commits into
docs/platform-wallet-migration-designfrom
feat/overlay-rollout

Conversation

@lklimek

@lklimek lklimek commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Why this PR exists

  • Problem: DPNS username registration gives no blocking feedback while the state transition runs. The user sees no clear "in progress" state and can re-submit the same name, risking a duplicate registration attempt.
  • What breaks without it: Imagine you are registering a username. You click Register, nothing visibly changes for several seconds, so you click again — now two registration attempts are in flight for the same name. There's no full-window signal that the operation owns the screen until it resolves.
  • Blocking relationship: This is the first adopter ("Bucket A exemplar") of the blocking-progress-overlay foundation that already landed in the base via feat(overlay): blocking progress overlay + SPV-sync hard-block #863 (0d301d01). Based on docs/platform-wallet-migration-design; the overlay component itself is not in this diff (it's in the base).

What was done

  • register_dpns_name_screen now drives the blocking overlay while registration runs: a full-window overlay owns the screen so the same name cannot be submitted twice, and it lowers automatically on success or error.
  • kittest coverage for the screen's overlay behavior (tests/kittest/register_dpns_name_screen.rs).
  • DPN-001 user-story acceptance updated to document the registration overlay.
  • ui::identities widened from pub(crate) to pub so the integration-test crate can reach the screen.

The overlay foundation (the ProgressOverlay component, app.rs integration, SPV-block) is already in the base via #863 — this branch was reconciled against it (its older in-development copy of that foundation collapsed into the base's more-advanced version, which carries the SEC-001/SEC-002 input-claim hardening), so the diff here is scoped to the Bucket A adoption only.

Testing

  • cargo test --all-features --lib932 passing.
  • cargo test --test kittest --all-features149 passing (incl. the new register_dpns_name_screen overlay tests + the overlay suite from the base).
  • cargo build --all-features, cargo clippy --all-features --all-targets -- -D warnings, cargo +nightly fmt --check — all clean.

Breaking changes

None.

Checklist

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 22 commits June 17, 2026 15:47
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
49 TCs covering FR-1..FR-10, NFR-1..NFR-6, and R-7 kittest checklist.
Items depending on the FR-10 concurrent-overlay architecture decision
(stack vs. replace vs. reject) and the stuck-overlay threshold (R-4)
are marked [depends on 1d] for Nagatha to resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Folds in two user-mandated redesigns of the blocking progress overlay
that the prior session did not land:

Redirect 1 — generic button facility (no first-class Cancel). The overlay
knows nothing about cancellation. `OVERLAY_CANCEL_ACTION_ID`, `with_cancel`,
`CANCEL_LABEL`, and the Esc->Cancel routing are gone. A caller attaches a
generic button via `OverlayConfig::with_button(id, label)` /
`OverlayHandle::with_button(id, label)`, choosing its own opaque action id
and label. A click enqueues the id; the owning screen drains it via
`take_actions` and runs whatever logic it wants — including its own
cancellation. Esc/Tab/Enter are swallowed so a hard block is never
keyboard-dismissable.

Redirect 2 — `Component` trait conformance (placement legitimacy for
`src/ui/components/`). `ProgressOverlay` is now a struct holding
`state: Option<OverlayState>`; `Component::show` renders that instance's
card and returns `ProgressOverlayResponse` (`DomainType = String`, the
clicked action id), with `current_value()` reporting the last clicked id.
The global `render_global` path is preserved as the production entry point;
the instance `show()` is additive, mirroring `MessageBanner`.

Also: clamp the card to the window so it never runs off-screen in a narrow
window (FR-6); settle the centered card in the kittest click/focus cases
before interacting (anchored CENTER_CENTER needs a few frames to cache its
size). Docs: dev-plan gains a post-outage note superseding D-5/FR-7;
test-spec reframes the Cancel-specific cases to the generic-button model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrites the D-5 decision body and §8 risk #3 in place to drop the stale
`with_cancel`/`OVERLAY_CANCEL_ACTION_ID` framing and describe the generic
`with_button(id, label)` facility instead — consistent with the post-outage
note added at the top of the plan. Documentation only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ap (QA-001)

TC-OVL-029 only exercises a with-button overlay, where the first button steals
focus on raise, so typing is blocked incidentally rather than by the overlay's
input handling. This probe raises a button-less hard block over an
already-focused field (the J-2 broadcast / J-4 migration case) and asserts
FR-8 AC-8.2: typed input must not reach the field beneath.

The probe currently FAILS — render_global filters Tab/Enter/Esc only after the
beneath widgets have consumed input that frame, and a button-less overlay has
no first button to steal focus, so keystrokes leak into the focused field
beneath. Marked #[ignore] so the suite stays green; un-ignore once the overlay
claims keyboard focus / consumes text while active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n switch (SEC-007)

Implements two QA-wave findings from the design addendum (§1 A-2, §2 A-4):

- QA-001 (HIGH) — button-less keyboard/text leak. `render_global`'s key filter
  runs at end-of-frame, one frame too late: a button-less hard block raised over
  an already-focused field let typed characters reach the field beneath (the
  J-2 broadcast / J-4 migration case). New `ProgressOverlay::claim_input(ctx)`,
  called near the top of `AppState::update` (before the panels) and gated on no
  active secret prompt, releases beneath text-edit focus and strips `Event::Text`
  plus the navigation/confirm keys (Tab, Enter, Escape, Space, arrows). The
  `#[ignore]`d probe `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath`
  is un-ignored and now passes.

- SEC-007 — `clear_all_global` (network switch) now also drains the action queue,
  so a click queued just before the switch cannot survive into the new context
  and be mis-dispatched.

Adds inline unit tests: `claim_input` strips text + nav/confirm keys while a
block is up and is a no-op when idle; `clear_all_global` clears the queue.

Scope note: this is a partial pass on the QA list. The end-of-frame filter in
`render_global` is kept as belt-and-suspenders and is NOT yet gated on a secret
prompt (marked TODO at the call site — blocker #2's full fix removes it and
routes the keyboard tests through `claim_input`). Still outstanding from the
addendum / task: A-1 no-progress watchdog, A-3 keyed `OverlayHandle::take_actions`
+ `sweep_orphan_actions`, instance `Component::show` focus-trap separation,
secondary-button styling, 30s clock seam, Foreground layering, and doc sync.
Also adds Nagatha's `04-design-addendum.md` (the authoritative spec).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… buttons, Foreground, focus separation

Implements the design addendum (§1/§2) plus the rest of the QA fix list and the
three cross-finding reconciliations. All on top of the earlier claim_input/SEC-007 pass.

Addendum §1 (safety-valve / A-1):
- 120 s no-progress watchdog: STUCK_OVERLAY_WATCHDOG_THRESHOLD, OverlayState
  { last_progress_at, watchdog_logged }, watchdog_tripped() clock seam, escalated
  STUCK_WATCHDOG_REASSURANCE (replaces the soft line, never stacks), one-shot
  tracing::error! (no flaky time-based panic). last_progress_at is bumped on a real
  content change, reusing log_overlay_state's change detection, so a progressing
  multi-step flow never trips it.

Addendum §2 (action-dispatch / A-3, SEC-007/A-4):
- Actions are keyed: OverlayAction { key, action_id }. OverlayHandle::take_actions()
  drains only its own ids (FIFO); clear() purges its key's pending ids; the static
  take_actions is demoted to sweep_orphan_actions() (dead-owner ids only). app's
  drain logs orphans. clear_all_global already clears the queue (SEC-007).

Reconciliations (lead brief):
1. SEC-004/F-1 — claim_input is gated on no active secret prompt at the app site,
   and render_global no longer strips keyboard at all (the gated claim_input is the
   sole keyboard block); release-beneath-focus is button-less only (stop_text_input
   clears ANY focus, which would steal a button's focus otherwise).
2. QA-002 — claim_input strips Space (and render_global's removal means the kittest
   keyboard path runs through claim_input). TC-OVL-044 now also presses Space.
3. QA-003 — render_card/render_buttons take trap_focus; the instance Component::show
   passes false so it never seizes the host screen's focus or installs the lock.

Rest of the list:
- SEC-002: overlay dim/sink/card raised to Order::Foreground (above ComboBox /
  autocomplete / SelectionDialog popups); passphrase modal also raised to Foreground
  so it stays above the overlay (R-1, TC-OVL-048).
- F-3/4/7: ButtonStyle { Primary, Secondary }, with_secondary_button on
  OverlayConfig/OverlayHandle/instance, ConfirmationDialog-style right_to_left layout
  (primary right, secondary left).
- SEC-005: corrected the Send+Sync note to the real invariant (UI-thread-only ops).
- F-6: Elapsed uses a named placeholder. SEC-006: log-content doc note on show_global.
- QA-007: instance clear() makes the empty-response path reachable.
- QA-008: TC-OVL-013b asserts elapsed >= 2s; TC-OVL-021 also bounds vertically.

Tests: un-ignored qa_buttonless probe; new inline tests (watchdog threshold/clock-reset/
one-shot, keyed FIFO/isolation/orphan-sweep, QA-007); new kittest reconciliations
(render_global keeps keyboard for the prompt; instance show leaves host focus navigable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… UX user story

- 01-requirements-ux.md: add a supersession callout flagging the Cancel-era
  items now overtaken by the generic-button + watchdog + claim_input redesign
  (FR-7, AC-7.3/7.4, NFR-3 AC-3b, AC-8.4, AC-10.5, J-1/J-2/J-3, §6.3-6.5), pointing
  at the dev-plan post-outage note, the addendum, and the code as source of truth.
- 03-dev-plan.md: drop OVERLAY_CANCEL_ACTION_ID from the §2 re-export row; mark the
  §3 API block superseded (real surface is with_button/with_secondary_button, keyed
  take_actions/sweep_orphan_actions, OptionOverlayExt::raise, the watchdog); fix the
  §4.1 drain comment; update the §9 D-4/D-5 rows.
- user-stories.md: add UX-001 (blocking please-wait overlay; cannot fire a
  conflicting second action), tagged across personas, [Implemented].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RQ-1 (security) — the app.rs secret-prompt gate had no test; deleting
`if self.active_secret_prompt.is_none()` left every test green. Extracted the
gate into `AppState::claim_overlay_input` (called from `update`) and added a
`#[cfg(feature = "testing")]` seam (`AppState::test_set_secret_prompt_active`,
`ActivePrompt::test_stub`). New AppState-level kittest
`rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay` drives the
REAL `update()` loop with a prompt active over a button-less overlay and asserts
the prompt input keeps focus AND accepts typed text (types a passphrase + Enter,
the prompt submits and closes). Deleting the gate makes `claim_input`
(button-less → `stop_text_input`) steal focus and strip the keys, failing both
assertions. Extended `tc_ovl_048` to assert prompt interactivity (submit button
renders + input holds focus), not just visibility.

RQ-2 — added a `#[cfg(feature = "testing")]` clock seam `OverlayHandle::backdate`
(shifts `created_at` + `last_progress_at` into the past). New kittest
`tc_ovl_047b_threshold_reveals_via_clock_seam` renders past 30 s and 120 s and
asserts: the soft "This is taking longer than usual." line + Elapsed
force-reveal, then `STUCK_WATCHDOG_REASSURANCE` REPLACING the soft line (never
both) — the addendum §1 obligation that was previously only flag-checked.

RQ-3 — reframed the `tc_ovl_047` doc comment (the escape-hatch button is a
deliberate v1 non-feature per addendum §1, not a deferred T7 TODO); added a
"(superseded)" note to 01-requirements-ux.md's "what to reuse" list where it
still cited `with_cancel`/`with_action`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s Cancel reconciliation, T7 TODO

Post-gate cleanup on the blocking progress overlay (gate green):

- README: add a ProgressOverlay row to the Feedback Components table,
  covering show_global/render_global, with_button(id, label), the 120s
  watchdog, and companions OverlayConfig/OverlayHandle/OptionOverlayExt/
  ProgressOverlayResponse.
- 01-requirements-ux.md: reconcile the remaining literal-Cancel acceptance
  criteria (intro line, AC-7.3, AC-8.4, the §6.5 "Visible, cancelable" row,
  R-3) to the shipped generic-button model, matching the top supersession
  callout — Esc/Tab/Enter/Space are swallowed and there is no built-in Cancel.
- app.rs: mark drain_overlay_actions with a TODO(T7) recording that an overlay
  button can only stop waiting (not abort) until the BackendTask system gains
  cooperative cancellation; until then the 120s watchdog (see
  progress_overlay.rs) bounds every block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Raises the blocking ProgressOverlay while a startup- or Connect-initiated SPV
sync runs, and lowers it when the chain becomes usable (Synced) or fails (Error).

Honors the overlay's C1/C2 caller contract. SPV sync is UNBOUNDED — it can wait
indefinitely for peers — so a button-less block would trap the user. The block
therefore carries a "Continue in the background" escape
(`SYNC_CONTINUE_BACKGROUND_ACTION`); clicking it lowers the block while sync
proceeds safely in the background (read-only — nothing is stranded). C1: the
block also always lowers on its own at a terminal state.

- `AppState`: `sync_overlay`/`sync_block_active`/`sync_overlay_dismissed` fields;
  armed on boot auto-start and on the manual `StartSpv` (Connect); reset on
  network switch so the handle never goes stale.
- New per-frame `update_sync_overlay` driver (called beside
  `update_connection_banner`) applies a pure, unit-tested policy `sync_block_step`
  (Block / Release / Idle) and drains the escape click.
- Pure decision + descriptions are i18n-clean single sentences.

Tests: 6 inline unit tests of `sync_block_step` (inactive→Idle; active+not-usable
→Block; terminal→Release for both dismissed states; dismissed→Idle; stable action
id; sentence descriptions). New `#[cfg(feature = "testing")]` integration kittest
`task9_sync_overlay_blocks_lowers_on_synced_and_on_escape` drives the real
`update_sync_overlay` against a forced connection state: asserts the block raises
while connecting, lowers on Synced (C1), and lowers on the escape click (C2 — user
never trapped). Adds `ConnectionStatus::set_overall_state` + AppState
`test_activate_sync_block`/`test_drive_sync_overlay` test seams.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks the SPV-sync overlay wiring (introduced in the previous commit) to the
user-approved design. Net behaviour: while the active context is Connecting or
Syncing the overlay hard-blocks the UI, lowering when the chain becomes usable
(Synced), fails (Error), or drops (Disconnected).

Changes vs the first cut:
- Keyed purely to the live connection state + a per-episode dismissal flag — drops
  the separate "armed" flag, so any sync episode (startup, Connect, or reconnect)
  blocks. Pure policy renamed `sync_block_step` -> `spv_block_step`
  (Block/Release/Stand); Disconnected now Releases + re-arms.
- Escape is now an always-visible SECONDARY button "Continue in the background"
  (id renamed `spv:sync:continue_background`); fields renamed to
  `spv_overlay`/`spv_overlay_dismissed`; method renamed `update_spv_overlay` and
  driven BEFORE `update_connection_banner`.
- Live content: description = `spv_phase_summary(progress)` (else a generic
  connecting line), plus a "Step N of 5" counter via new
  `connection_status::spv_phase_step` (Headers=1 … Blocks=5). Raises once per
  episode, then updates in place.
- Suppresses the redundant Connecting/Syncing connection-banner text while the
  overlay is up (don't double-shout); keeps Error/Disconnected banners.

C1/C2 contract preserved: SPV sync is UNBOUNDED, so the escape (lower while sync
continues safely in the background — read-only, nothing stranded) guarantees the
user is never trapped; episode-ending states always release.

Tests updated: 4 inline `spv_block_step` unit tests; the integration kittest
`task9_spv_overlay_blocks_lowers_on_synced_and_on_escape` now also asserts the
secondary escape button, re-raise for a fresh episode, no re-raise within a
dismissed episode, and re-raise after the episode ends. Test seams renamed to
`AppState::test_drive_spv_overlay` (+ `ConnectionStatus::set_overall_state`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ep test (F-SPV-2)

F-SPV-1 — the user-authorized SPV-sync hard-block + always-visible "Continue in
the background" escape contradicted three docs written for the standalone
overlay. Reconcile the docs to the decision (the feature is correct; the docs
were stale) so a future dev does not "correctly" remove the button per old docs:

- docs/user-stories.md: carve out the SPV-sync exception in UX-001's "no
  background/dismiss button" guarantee, and add UX-002 — the blocking SPV-sync
  overlay with the always-on "Continue in the background" escape (tagged across
  personas, [Implemented]).
- 01-requirements-ux.md §5: supersession note — the user chose to block the
  startup/Connect get-connected sync; the power-user concern is mitigated by the
  escape (sync is read-only and safe to background); this is the overlay's first
  adopter.
- 04-design-addendum.md A-1: record that A-1's "ship NO dismiss/background button
  in v1" was scoped to unsafe-to-interrupt ops whose safety rests on boundedness;
  for the unbounded-but-read-only SPV-sync adopter the C2 "never trap the user"
  guarantee is met by the always-on escape, which must NOT be removed.

F-SPV-2 — the granular phase progress (spv_phase_summary description +
"Step N of 5" via spv_phase_step) was already wired in the previous commit; adds
a unit test locking the active-phase → step mapping and the summary text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (F-SPV-A/B/E)

F-SPV-A (sev-2/1 regression, introduced by the prior refactor) — the SPV block
fired on ANY Connecting/Syncing, so an ambient mid-session reconnect, or the SPV
engine flipping Synced→Syncing as it processes each new block (event_bridge
on_progress maps !is_synced() → Syncing), would hard-block a working user.
Re-introduce a startup/Connect-SCOPED arming gate:
- `spv_block_armed` flag, armed only on boot auto-start and the Connect button
  (AppAction::StartSpv); reset on network switch.
- `spv_block_step(armed, dismissed, state)`: !armed → Idle (never block); armed +
  Synced/Error → Disarm (lower + clear armed); armed + Connecting/Syncing/
  Disconnected → Block (or Stand if dismissed). Once disarmed, ambient sync never
  re-blocks until the next user-initiated episode.

F-SPV-B (sev-2) — the block description showed blockchain jargon ("Headers:
12345 / 27000 (45%)") to the Everyday User. Replace with plain complete
sentences ("Connecting to the Dash network." / "Syncing with the Dash network.");
keep the jargon-free "Step N of 5" counter (via spv_phase_step) as the
determinate granularity. spv_phase_summary stays (still used by wallets_screen);
it is just no longer the overlay description. UX-002 acceptance criterion updated
to stop enshrining the jargon.

F-SPV-E (sev-4) — AppAction::StartSpv set an orphaned Info banner whose handle was
dropped (could not be cleared by the overlay's banner suppression). Dropped it;
the block conveys "connecting" and the error path still surfaces via replace_global.

Tests: spv_block_step unit tests rewritten around the arming gate —
`unarmed_never_blocks` is the regression guard (ambient sync never blocks);
`armed_terminal_state_disarms`; jargon-free-description test. The integration
kittest is rewritten to `task9_spv_overlay_armed_scope_disarm_and_escape`: an
un-armed Connecting does NOT block, an armed one does, Synced disarms, ambient
sync afterward does NOT re-block, the escape lowers without re-raising, and only a
fresh armed episode re-blocks. New `AppState::test_arm_spv_block` seam.

is_synced() finding: `EventBridge::on_progress` (event_bridge.rs) does map
`!is_synced()` → `SpvStatus::Syncing`, so overall_state CAN flip Synced→Syncing on
per-block catch-up — the arming gate makes that harmless (disarmed after the
initial episode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…PV phase-count constant, input-claim hardening, doc drift

- Replace the 2.1s wall-clock sleep in tc_ovl_013b with the deterministic
  `backdate` clock seam (gated behind `testing`), mirroring tc_ovl_047b — zero
  wall-clock waiting; asserts the elapsed readout counts up to a concrete 2s.
- Add `SPV_SYNC_PHASE_COUNT` next to `spv_phase_step` as the single source of
  truth for the "Step N of 5" total; reference it at both app.rs call sites and
  guard the max step with a `debug_assert!` so it cannot silently drift.
- Delete the misplaced orphan-sweeper paragraph from `claim_overlay_input`'s doc
  (it belongs to `drain_overlay_actions`, which already carries it).
- Reconcile the `Order::Middle` → `Order::Foreground` doc drift: supersession
  callouts in the dev plan §4.2/§4.3 and the kittest module doc, citing SEC-002.
- Drop the dead `CONNECTING_MSG`/`replace_global` swap in the StartSpv failure
  path (the "Connecting…" banner was removed in F-SPV-E) for a plain
  `set_global(...).with_details(e)`; fix the now-stale comment.
- Extend `claim_input`'s per-frame strip to also drop Backspace, Delete, Home,
  End, PageUp, PageDown and the Copy/Cut/Paste clipboard events; add a kittest
  locking the new classes via event survival + the field-beneath contract.
- Strengthen the SEC-001 lifecycle rustdoc on `show_global` /
  `show_global_spinner_only` (button-less blocks need a frame-driven reconcile
  owner or an escape; the watchdog only logs).
- Nits: UX-001 "developer warning" → "developer error"; "while a armed" →
  "while an armed". Add deferred TODOs (SEC-002-pointer, SEC-001, RUST-006).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… align API to MessageBanner

Three changes to the blocking progress overlay + SPV-sync hard-block:

A — Close the one-frame interactive gap. `update_spv_overlay` now runs at the
top of `AppState::update`, BEFORE `claim_overlay_input`, the visible screen
`ui()`, and `render_global`. A freshly-armed episode therefore raises, claims
input, AND paints on the same frame; previously the block was raised only after
`render_global`, leaving the frame right after Connect/arming fully interactive
(effective at frame N+2). The connection banner still reads the block state
afterwards, so its Connecting/Syncing suppression is unchanged.

B — Stop the 120s no-progress watchdog from falsely escalating on slow phases.
A single SPV phase running >120s (e.g. Headers on a slow link) wrote a constant
(description, step), so `log_overlay_state` never reset `last_progress_at` and
the watchdog tripped — swapping to the STUCK copy and firing the one-shot
dev-error, the exact false signal the SPV escape was meant to avoid. A hidden,
monotonic `progress_token` (step in the high 32 bits, advancing height in the
low 32) is threaded from `ConnectionStatus` into the overlay; an advancing token
resets the watchdog even when the shown (description, step) is unchanged. The
token is NEVER rendered — copy is byte-for-byte unchanged and the jargon-free
test stays green. Distinct from TODO(SEC-001), which is left in place.

C — Align the overlay public API toward MessageBanner so migrating from the
banner is a name-for-name swap. One-way (overlay → banner), no capability loss:
  with_button(id, label)            -> with_action(label, action_id)
  with_secondary_button(id, label)  -> with_secondary_action(label, action_id)
  show_global(...)                  -> set_global(...)  (return type kept)
  show_global_spinner_only(...)     -> set_global_spinner_only(...)
`OptionOverlayExt::raise` keeps its name: renaming to `replace` (the banner
analogue) would be shadowed by the inherent `Option::replace`, so every
`slot.replace(ctx, desc, config)` call would fail with E0061 (verified). A doc
note records why. `render_global`, `claim_input`, the watchdog, `OverlayConfig`,
and all handle progress methods are untouched. Rustdoc, the README catalog row,
and the design-doc API references are updated to the new names; the banner's own
`MessageBanner::show_global(ui)` render path is left alone.

Tests: new real-AppState kittest for the one-frame gap (same-frame paint), new
backdate kittest + unit tests for the token-driven watchdog reset, and a
`spv_progress_token` monotonicity unit test. fmt + clippy clean; kittest 138
passed; lib 926 passed.

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

Resolves the TODO(RUST-006) marker: the SPV-sync hard block's "Continue in
the background" escape was mouse-only, stranding keyboard-only / assistive-tech
users behind the UNBOUNDED block. Hard blocks strip Enter/Space every frame
(the deliberate QA-002 rule, guarded by TC-OVL-044), so the escape could not be
activated by keyboard.

Add a per-block opt-in — `OverlayConfig::with_keyboard_escape(action_id)` and
`OverlayHandle::with_keyboard_escape(action_id)` — that designates ONE action as
the single keyboard-reachable escape. The general rule is unchanged: a block
with no designated escape stays fully keyboard-blocked.

- claim_input: when the active block designates an escape AND that escape button
  is *confirmed* to hold focus (its egui id was recorded by last frame's
  render_buttons and still matches the focused widget), Enter/Space pass through;
  every other key, and the raise frame (focus not yet confirmed), stays stripped.
  So the passthrough can never reach a widget beneath.
- render_buttons: for an opt-in block, pin focus to the designated escape (match
  by action id) — re-requested every frame and locked — and record its id for the
  claim_input gate.
- SPV adopter (update_spv_overlay): mark "Continue in the background" as the
  keyboard escape; it remains unconditionally present whenever the block is up.

Tests (egui_kittest — the reliable check for input/focus):
- TC-OVL-051/052: Enter / Space activate the focus-pinned escape.
- TC-OVL-053: a TextEdit beneath never receives Enter; Tab and a backdrop click
  cannot move focus off the escape.
- task9_spv_escape_is_keyboard_activatable: the REAL SPV block lowers on Enter.
- TC-OVL-044 and the keyboard-block tests stay green (general rule intact).
- Unit tests for the opt-in API + the claim_input safety gate.

Docs: QA-002 design note + NFR-3 accessibility ACs, test-spec, user story UX-002,
and the public rustdoc updated to state the refined rule.

cargo +nightly fmt: clean. clippy --all-features --all-targets -D warnings: 0.
kittest --all-features: 142 passed. lib --all-features: 928 passed.

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

Establish the canonical Bucket A overlay-adoption pattern on the DPNS
username registration screen, the template for the remaining transaction
screens.

The screen used a progress banner as its in-progress indicator and did
not block re-entry while WaitingForResult, leaving a double-submit hole
(duplicate name registration). Replace the banner with a button-less
full-window ProgressOverlay raised at dispatch and torn down on every
terminal result, which both signals progress and closes the double-submit
hole.

- Add `op_overlay: Option<OverlayHandle>`; raise it in `begin_registration`
  only when a real BackendTask is produced, so a no-op click never strands
  a block.
- Tear the overlay down on both terminal paths (SEC-001): the success arm
  of `display_task_result` and the error/warning branch of `display_message`,
  mirroring the prior `refresh_banner` lifecycle.
- Remove the now-redundant progress banner; the full-window block makes a
  WaitingForResult button-disable unnecessary.
- Add a `raise_progress_overlay_for_test` seam and kittests proving the
  raise + guaranteed teardown on success and error.
- Make `ui::identities` `pub` (the lone non-pub sibling) so the screen is
  reachable from the kittest crate, matching `wallets`/`dpns`/`tokens`.
- Note the blocking overlay + double-submit prevention on DPN-001.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#863) into feat/overlay-rollout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…se-merge

Comment-only restore (matches the base #863 app.rs); no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • master
  • v1.0-dev

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 667ace94-f6e7-4e2e-b90e-e51ab5c5b5a2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/overlay-rollout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lklimek lklimek marked this pull request as ready for review June 22, 2026 14:17
@lklimek lklimek requested review from Copilot and thepastaclaw and removed request for thepastaclaw June 22, 2026 14:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adopts the existing global blocking ProgressOverlay foundation for the DPNS username registration flow, so the Register action provides full-window “in progress” feedback and prevents double-submission while the state transition runs.

Changes:

  • Switch RegisterDpnsNameScreen from a non-blocking progress banner to a blocking global ProgressOverlay, raised on dispatch and cleared on success/error terminal paths.
  • Add kittest coverage verifying the overlay is raised on dispatch and cleared on both success and error paths.
  • Document the blocking overlay behavior in the DPN-001 user story and expose ui::identities publicly to support integration tests.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/kittest/register_dpns_name_screen.rs Adds kittest coverage for overlay raise/teardown behavior during DPNS registration.
tests/kittest/main.rs Registers the new kittest module.
src/ui/mod.rs Makes ui::identities public so kittests can reach RegisterDpnsNameScreen.
src/ui/identities/register_dpns_name_screen.rs Replaces the old progress banner flow with a blocking overlay raised on dispatch and cleared on success/error.
docs/user-stories.md Updates DPN-001 acceptance criteria to include the blocking overlay during registration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/ui/mod.rs
Comment on lines 86 to 90
pub mod dpns;
pub mod helpers;
pub(crate) mod identities;
pub mod identities;
pub mod network_chooser_screen;
pub mod state;
@lklimek lklimek merged commit e709851 into docs/platform-wallet-migration-design Jun 22, 2026
7 checks passed
@lklimek lklimek deleted the feat/overlay-rollout branch June 22, 2026 14:52
lklimek added a commit that referenced this pull request Jun 24, 2026
…secret at-rest encryption (#865)

* docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case spec)

UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture.

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

Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (T2,T4)

Crikey, here's the one socket every wallet secret will squeeze through.

T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with
put_secret/get_secret/delete_secret, a no-encryption pass-through to the
upstream vault TODAY. Every put/get body carries the greppable
`TODO(per-secret-encryption):` tag so wiring real per-secret encryption later
is a localized change. Prompt-free — the passphrase requirement lives only in
the retained legacy readers, never here.

No-serialization guard mechanism: compile_fail doctests (no new deps —
static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype
cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string
on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes
these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips
a SecretBytes through the real signatures (compiler is the assertion).

T4 — TaskError variants (no String fields, typed #[source]): SecretSeam,
SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing.

Promote the private assert_no_leak (hex + decimal-array) into a shared
wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases
reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file
holds no raw secret in either form.

Tests: 6 seam unit + 2 compile-fail doctests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd)

ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32
key bytes in the no-password / pre-migration shape — a derived Debug dumped
them as a decimal byte array straight into logs. Hand-write a redacting Debug
mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes.
Parents SingleKeyData / SingleKeyWallet are safe by delegation.

TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array —
the decimal form is the one the pre-fix Debug leaked) at all three levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(model): PrivateKeyData::InVault placeholder + migration probes (T1)

Identity private keys get a non-resident home. New PrivateKeyData::InVault
appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/
AtWalletDerivationPath) are untouched, so blobs written before it still decode
(TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting
Debug/Display arms (carries no bytes — trivially clean).

KeyStorage probes:
- is_in_vault / public_key_for — a vault placeholder reports true yet still
  surfaces its public key for display + signing-key selection.
- take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and
  returns the raw bytes (Zeroizing) the migration must store in the vault FIRST
  (vault-before-blob order). Wallet-derived + encrypted keys untouched — they
  were never plaintext-at-rest.

get/get_resolve_local gain an InVault arm (resolve through the vault, not
locally). key_info_screen gains degraded InVault arms (securely-stored notice;
full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up).

Promote the private assert_no_leak + distinctive_secret to the shared
leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration
KeyStorage has only InVault, and the re-encoded blob leaks neither secret in
hex nor decimal-array form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-gated (T5)

Non-secret metadata moves out of the per-wallet seed envelope into the sidecar.

WalletMeta gains uses_password + password_hint. Because WalletMeta is positional
bincode behind the DetKv envelope, #[serde(default)] alone is NOT
forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2)
framed as [version | bincode] at the WalletMetaView boundary, plus a retained
decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy
and migrates a v1 blob into v2 (defaults uses_password=false), never positionally
misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it
governs every payload, not just WalletMeta). TS-META-01 covers all three shapes.

ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the
locked-render cold-boot path can rebuild a protected key's display wallet
without the secret — moved out of the SingleKeyEntry vault blob ahead of the
raw-seam migration. NON-secret; #[serde(default)] for old entries.

write_wallet_meta now carries uses_password/password_hint from the open Wallet;
the legacy-table drain (finish_unwire) defaults them (the authoritative flag is
read from the envelope at the migrating unlock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch

- leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it).
- encrypted_key_storage: factor take_plaintext_for_vault's return into the
  VaultBoundKey type alias (clippy::type_complexity).
- wallet_hydration bench: carry the new WalletMeta password fields.
- nightly-fmt whitespace.

Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets
-D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 +
3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke
(network-info / tools / core-wallets-list) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (T3)

The chokepoint learns identity keys and goes seam-first for everyone.

- SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque;
  KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label()
  builds identity_key_priv.<m|v|o>.<key_id> — a stable one-char target tag keeps
  the label inside the upstream allowlist.
- SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey.
  Borrowed-only, zeroizing, never resident — same hygiene as the other kinds.
- decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the
  retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the
  migration fallback for HD seeds and single keys. IdentityKey reads raw via the
  seam → loud IdentityKeyMissing if absent (never silent).
- scope_has_passphrase: a migrated raw secret reports false (the password no
  longer gates it); only a not-yet-migrated legacy entry can still be protected;
  IdentityKey is always false → prompt-free fast-path → headless/MCP signing works.
- DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1
  shape, no derivation tree).

Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0,
can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01
(legacy envelope served when raw absent), raw-wins-over-legacy precedence. The
pre-existing protected-HD/single-key tests now exercise the legacy fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (T6)

Secrets start landing raw. No DET envelope for the new write paths.

- New wallet_backend/identity_key_store.rs: IdentityKeyView with
  store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam
  (scope = identity_id, label identity_key_priv.<m|v|o>.<key_id>). NO
  StoredIdentityKey envelope — the InVault marker in the QI blob is the only
  on-disk trace. store_all is the migration's vault-first writer (call before
  the blob rewrite); delete_all backs purge_identity_scope.
- WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under
  seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader).
- write_seed_envelope now branches: a no-password wallet writes the RAW seed
  (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the
  legacy AES-GCM envelope at creation and migrates lazily at unlock (T7).
- import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the
  existing single_key_priv.<addr> label (no SingleKeyEntry framing); protected
  import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The
  locked-render pubkey rides in the ImportedKey sidecar (the T5 field).
  SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a
  raw-written key still rebuilds + opens at cold boot.

Tests: identity_key_store round-trip / scope+target isolation / store_all+
delete_all; seed raw round-trip independent of the legacy label; single-key
unprotected import is exactly 32 raw bytes (no framing) and signs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat: crash-safe dual-format migration + InVault resolver + vault delete (T7)

This is the part that actually moves secrets. Funds-safety ordering throughout.

Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by
is_in_vault/public_key_for, it fetches the raw bytes per-use via
with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed
(WalletLocked); bytes never resident.

EAGER migration on load (dialog-free):
- Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity
  in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView
  store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write
  failure restores the resident plaintext for this session and defers; a
  blob-rewrite failure is re-detected and retried next load. Idempotent.
- No-password HD seeds (hydration::reconstruct_wallet): raw seam wins
  (precedence raw > legacy); a no-password legacy envelope is re-stored raw
  (set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so
  the raw and legacy paths share the xpub-decode + build tail.

LAZY migration on unlock (one prompt, the unlock the user already does):
promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw
(set_raw before delete) inside the borrowed Zeroizing scope and reports
migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false
and shows the one-time disclosure (T8 Copy A/D).

Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy
envelope (a wallet may be in either form) — closes a wipe gap where a migrated
no-password seed would survive removal. identity_db.clear_identity_vault_keys
drains an identity's raw vault keys on single-delete + devnet sweep.

Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing
(was WalletNotFound) on both scope_has_passphrase and decrypt_jit.

Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read
(raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5
wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest
form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10,
identity_db 16, encrypted_key_storage 4 — all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat: key_info_screen JIT identity signing + single-key Copy B disclosure (T8)

Real JIT for vault-backed identity keys, and the per-key migration notice.

Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}):
- DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw
  key JIT, returns only the WIF (Secret).
- SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the
  backend, returns only the public Base64 envelope.
New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity-
flavored — carry identity_id/target/key_id, not a meaningless seed_hash).

key_info_screen: the InVault arms are now real — "View Private Key" queues
DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing
render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The
degraded placeholders are gone. display_task_result handles both new results.

Single-key protected lazy migration + Copy B: verify_passphrase now re-stores
the just-decrypted protected entry raw under the same label (upsert replaces the
AES-GCM framing) and clears the persistent has_passphrase flag, returning a
migrated bool. verify_single_key_passphrase surfaces the one-time per-key
disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup
keeps both) on migration. decrypt_jit's sign path also lazy-migrates
(migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth.
SingleKeyView::clear_passphrase_flag persists the flip to the sidecar.

Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault
holds raw 32 bytes after, and a second resolve under a never-prompt host is
prompt-free with the WIF-plaintext bytes. secret_access 24 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore: fmt + clippy for the T3-T8 integration batch

- secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a
  &Zeroizing<[u8;64]> auto-derefs to &[u8;64].
- nightly-fmt whitespace across the touched files.

Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets
-D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 +
3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail
doctests pass; det-cli standalone smoke (network-info / core-wallets-list /
tools) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars

The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a
positional-bincode DetKv value is format-breaking, and my T5 framing made it
WORSE — WalletMeta writes went through kv.put::<Vec<u8>>(versioned-frame) and
reads through kv.get::<Vec<u8>>, which type-confuses an OLD kv.put::<WalletMeta>
blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost.
ImportedKey appended public_key_bytes with no legacy reader → old keys vanish
from the picker.

Fix (one policy for both sibling sidecars): drop the hand-rolled version byte
(SEC-003: it could collide with a bincode length varint — a 1/2-char alias).
Instead lean on the DetKv schema envelope + try-decode-both:
- write the current shape directly (kv.put::<WalletMeta> / ::<ImportedKey>);
- on read, try the current shape; on a bincode Decode error (an old blob runs
  out of bytes for the appended fields) fall back to the legacy shape
  (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape.
Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field
blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv
schema-version mismatch stays a hard error; only Decode triggers the fallback.

Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION
(PROJ-002 — the unreachable legacy branch + its overclaiming test are gone;
the legacy path is now live via the view and tested end-to-end).

Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003
1/2-char-alias collision case; view legs (old_wallet_meta_blob_*,
old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read
it back through the view preserving every field, and confirm re-store in the new
shape. wallet::meta 3, wallet_meta 13, single_key all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(identity-db): identity-key migration, deletion, write-fault no-loss (QA-002/003/005)

Refactor the eager identity-key migration core out of AppContext into a free
fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a
KeystoreMigration outcome, so the funds-safety logic is unit-testable with a
bare SecretStore + a controllable persist closure (no full AppContext).

QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are
already in the vault and the blob being persisted is InVault-only; the
AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent
(second run = Nothing).

QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with
the vault parent dir chmod'd read-only so store_all fails, the migration
restores the resident plaintext keystore byte-for-byte, does NOT call persist,
and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].)

QA-003 — identity-key deletion is scoped + isolated: delete_all over the
victim's (target,key_id) set removes its vault keys while a second identity's
key under the same (target,key_id) is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(wallet-lifecycle): assert lazy-migration secret post-conditions (QA-004)

The protected-wallet-unlock test asserted only upstream registration. Add the
secret post-conditions the lazy migration is actually for: after
handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed,
the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a
SECOND resolve through a never-prompt chokepoint over the now-raw vault returns
the seed with zero prompts (the migrated wallet is permanently prompt-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (QA-001)

New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing
keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path
migration does), assert residency (zero Clear/AlwaysClear remain), wire the
chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing
runs through the async QualifiedIdentity Signer → resolve_private_key_bytes →
with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast
+ the new key appearing on Platform proves the InVault MASTER key signed live
without ever being resident.

Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in
the header. Compiles + registered in main.rs; left #[ignore] for a manual/live
run during QA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* refactor(wallet-backend): zeroize migration source, flavor identity-key errors, lift signed-message helper

PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident
Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting
the key is the function's whole purpose, so it must wipe the source, not just
the moved-out copy.

PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to
the identity-flavored TaskError::IdentityKeyVault (previously a producerless
variant), so an identity-key vault failure surfaces with identity-specific
banner copy. Wrong-length stays SecretDecryptFailed.

QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from
sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the
wallet-key and identity-key signers now call it instead of two drifting copies.
The recovery-header round-trip tests move alongside the shared helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (SEC-001/002)

SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules —
no Serialize/Encode struct may name a plaintext-key field (SecretBytes,
Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass
the compile_fail doctests can't (they only catch an embedded SecretBytes). The
module list mirrors the blast-radius table; ciphertext fields are deliberately
not flagged. Passes — the invariant holds today and now has a regression guard.

SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar
blobs contain neither secret (hex AND decimal-array via the shared
assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked
render needs it). Canary coverage — the sidecars structurally hold no secret.
Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of
an encryption TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(kittest): disclosure-banner copy coverage (QA-007/Diziet)

Extract the interim at-rest disclosure copy into pure pub fns
(wallet_migration_notice / single_key_migration_notice) + pub
INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is
testable without an AppState and i18n-extractable. Both callsites now use them.

New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render
as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only),
the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet
and a key migrate in one session), and all copy (A/B/D) is jargon-free
(no AES/vault/seam/encryption/0600). 4 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (QA-DOC/DOC)

QA-DOC-01: strip ephemeral review IDs from comments I authored in the
secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)",
"6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63
in code outside this PR's diff are left untouched to avoid scope creep.)

QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs
(belongs in git, not the module header).

QA-DOC-03: secret_access module-header resolution order now lists the
unprotected fast-path as an explicit step 2 (cache → unprotected → prompt),
matching the three-branch body.

DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the
single secret chokepoint + the TODO(per-secret-encryption): grep convention +
the design dir.

DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note;
WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts
encryption that is presently absent — the accepted interim regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore: nightly fmt for the QA-findings batch

Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while
closing the QA findings. No behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault JIT path

The shared_identity() fixture registers a wallet-derived identity, so its keys
are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which
migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in
setup before reaching the path under test.

Add materialize_master_key_as_clear(): derive the master key's raw bytes from the
HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity
index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries
a genuine plaintext key into the vault as InVault and the JIT signing path produces
a signature whose bytes match the on-chain master key. The !taken.is_empty()
assertion is unweakened; no signer stub, no mocked broadcast.

Stays #[ignore]: the live broadcast additionally needs a funding wallet that
derives within its rehydrated window (the e2e funding step hit the known
core-wallet gap-window/rehydration limitation, unrelated to the InVault path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore(deps): repin platform deps to feat/platform-wallet-secret-protection (fb7953ea)

Moves the 4 dashpay/platform branch deps (dash-sdk,
rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage)
— and their 23 transitive platform crates, 27 total — from
fix/wallet-core-derived-rehydration@ea0082e6 to
feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing
the green baseline for the secret-handling-hardening work.

Done on top of the merge of origin/docs/platform-wallet-migration-design
(ac0c3d9), which brought in #864 (headless masternode/evonode
withdrawals) and #866 (DPNS blocking overlay). The merged DET tree
compiles cleanly against the secret-protection branch — no API breakage.

Verified green:
  cargo build --all-features
  cargo clippy --all-features --all-targets -- -D warnings
  cargo +nightly fmt --all -- --check

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(secret): open the vault keyless (file_unprotected) for the Tier-1 baseline

PR #3953 ("platform-wallet-secret-protection") hardened upstream
`SecretStore::file(path, passphrase)` to reject a blank passphrase
(`SecretStoreError::BlankPassphrase`). DET's `open_secret_store` opened the
vault with `SecretString::new("")`, so after the repin every AppContext init
failed at the secret-store open and 7 secret_seam/secret_access tests broke.

Switch to the explicit keyless door `SecretStore::file_unprotected(path)`,
which upstream documents for exactly this model: the vault file itself is
keyless (at-rest floor = owner-only perms) and per-secret confidentiality
comes from Tier-2 object passwords on the individual secrets. Behavior for
the Tier-1 baseline is unchanged from the old empty-passphrase open.

Restores the green baseline at the fb7953ea pin: build/clippy/fmt clean,
the 8 secret_seam/secret_access vault tests pass again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(secret): add Tier-2 seam capability (protected set/get + scheme probe)

Adds the upstream Tier-2 object-password path to the secret seam, the single
coherent encrypt/decrypt chokepoint:

- `put_secret_protected` / `get_secret_protected` seal/unseal a secret under
  its OWN object password via upstream `SecretStore::set_secret/get_secret`
  (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw.
- `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a
  stored secret WITHOUT the password, via a `get(None)` probe that reads the
  upstream `NeedsPassword` signal.
- The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as
  such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per-
  secret encryption IS the upstream envelope selected by the password arg.

Additive and behavior-preserving: existing Tier-1 callers are unchanged; the
read/migration wiring in SecretAccess lands next. Build/check + the 8
secret_seam/secret_access tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(secret): adopt Tier-2 per-secret passwords for HD seeds

Routes HD-seed at-rest crypto through the upstream Tier-2 object-password
envelope instead of DET AES-GCM, KEEPING protection rather than downgrading
a password-protected seed to a raw, password-free secret on first unlock.

- `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`:
  a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope
  (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object
  password; an unprotected seed stays Tier-1 raw.
- `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam
  `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected
  → unseal with the JIT-prompted per-seed password; Absent → decode the legacy
  AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected)
  or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store
  upserts before the legacy delete; the scheme probe prefers the new label.
- `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no
  downgrade" so the unlock callsite's `uses_password=false` finalizer never
  fires — protection is kept and the metadata stays accurate, with no change
  to `wallet_lifecycle.rs`.
- `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a
  Tier-2 unseal with a bad object password re-prompts instead of aborting.

Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so
remembering seed A never satisfies seed B — each prompts and decrypts only
with its own password. Tests: lazy re-wrap keeps protection (legacy gone,
raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the
A/B different-password isolation. 72 secret tests pass; clippy/fmt green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(secret): clean keep-protection replacement of the downgrade subsystem (HD seed)

Supersedes the transitional "inert return" approach with a clean excision of
#865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable
(user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password);
nothing downgrades them to a raw, password-free secret.

- `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the
  `uses_password=false` downgrade flip + the "protection removed" notice) and
  collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase`
  (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock
  callsite no longer finalizes a downgrade.
- `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` /
  `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted
  flag is now accurate from cold-start (`true` for a protected wallet) and always
  agrees with the at-rest scheme — no stale/drift-prone metadata.
- `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the
  keep-protection end-state: after the migrating unlock the seed is Tier-2
  (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true,
  and a second resolve prompts for the object password.

1009 lib tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(secret): adopt Tier-2 keep-protection for imported single keys

Extends the Tier-2 keep-protection model from HD seeds to imported single keys,
replacing their downgrade-to-raw migration. A protected imported key STAYS
protected under its own object password instead of being re-stored raw.

- `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam
  `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted
  per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else
  the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides;
  the in-band length-32 check disambiguates raw vs legacy-framed.
- `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the
  just-decrypted protected key to a Tier-2 envelope under the same password
  (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped —
  protection is kept and the index/persisted flag stay accurate.
- `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2
  instead of downgrading to raw; returns `()` (no migration bool). The
  `clear_passphrase_flag` finalizer is removed.

Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to
disclose): removed `show_single_key_migration_notice` + the
`wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS`
copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`.

Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a
Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib
tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005)

Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior
HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward
findings (SEC-003 — excise the inert downgrade — already landed in 6dafbda):

- SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read
  branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an
  `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which
  still decrypts under the seed's OLD password) cannot survive forever — the
  Absent branch, the only other deleter, is never re-entered once Protected. The
  single-key path migrates in-band (same-label upsert) and has no such orphan.
- SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the
  new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by
  B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding —
  not merely that the DET cache is scope-keyed.
- SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault
  is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds,
  raw single keys, identity keys rest on file perms ALONE; only Tier-2 object
  passwords give real at-rest confidentiality). Documented at `open_secret_store`,
  reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and
  in the design note's threat-model residual.
- SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is
  decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141
  bincode 1.3.3 is a transitive dep. No code change.

1010 lib tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(migration): note the wallet.uses_password/password_hint schema invariant

Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads
`uses_password`/`password_hint` unprobed, unlike the probed optional
`core_wallet_name`). Verified + documented the invariant rather than adding a
needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`)
already SELECTs both columns unconditionally and runs FIRST over the same `wallet`
table at the same cold-start, so any schema lacking them fails there before the
meta pass. The unprobed read here is therefore exactly as robust as the shipped
seed migration; `core_wallet_name` stays probed because it is the one droppable
column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(test): eliminate register_wallet_from_seed race in cold-boot test

The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet`
test failed in CI (1000+ parallel tests) with:

  WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") }

Root cause: the test wired the backend BEFORE calling register_wallet, which
caused register_wallet_upstream to spawn a background subtask that called
create_wallet_from_seed_bytes concurrently with the test's own explicit
register_wallet_from_seed call.

The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts
into wallet_manager (step A) and into self.wallets (step B) with async work
in between (persister.store + load_persisted + initialize). A concurrent
caller that lands between A and B sees WalletAlreadyExists from step A,
then get_wallet returns None (step B not yet complete) →
resolve_registered_wallet returns WalletNotFound. Under CI load this window
is reliably hit.

Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream
finds no backend and returns early without spawning the subtask. The backend
is then wired, and the explicit register_wallet_from_seed call runs
race-free (no concurrent subtask competing for the same wallet slot).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(wallet-backend): keep Tier-2 protected wallets visible at cold boot and stop plaintext key writes

Addresses PR #865 review findings on the secret-storage seam.

A (BLOCKER): identity write paths no longer serialize plaintext keys.
insert/update_local_qualified_identity (and the alias re-encode) now route
through encode_identity_blob_vault_first — the write-path twin of the load
migration: plaintext keys go into the vault FIRST, the persisted blob carries
only InVault placeholders, and a vault-write failure aborts the write (never
lands Clear/AlwaysClear bytes in det-app.sqlite).

B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected
wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key)
branch on the at-rest SecretScheme before reading the secret. A Protected
secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes)
instead of propagating NeedsPassword as fatal, so a keep-protection-migrated
wallet stays in the picker across launches.

D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't
propagate), matching the Protected branch — a transient delete failure no
longer fails an otherwise-successful unlock.

E: the eager no-password seed migration wraps the extracted 64-byte seed in
Zeroizing so the stack copy wipes on drop.

F: resolve_registered_wallet tolerates the registration TOCTOU window with a
bounded re-poll before declaring a wallet missing; the fund-routing xpub gate
is unchanged.

G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a
warn) in both the display and sign tasks, distinct from genuinely-absent
IdentityKeyMissing.

I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta
uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection
shape, and stripped ephemeral review-finding IDs from secret-path comments.

Regression tests cover A, B, and C.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(wallet-backend): seal fresh protected single-key imports Tier-2, typed malformed-identity-key error, skip needless keystore clone

Follow-up to PR #865 review on the secret-storage seam.

Fresh protected single-key imports now seal Tier-2 at import time instead of
writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on
first unlock. import_wif_with_passphrase routes the protected branch through the
seam's put_secret_protected, so the storage chokepoint is a single shape from
import onward. raw_key_bytes and verify_passphrase branch on the at-rest
SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct
read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect,
no oracle), while the legacy decode + lazy re-wrap path is retained for
pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only
reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert
SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written
legacy entry so the legacy->Tier-2 migration stays covered.

Present-but-malformed identity-key bytes map to a new typed
TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import
to refresh") in both the display and sign tasks, replacing the off-domain
SecretDecryptFailed ("recovery phrase") message and staying distinct from the
genuinely-absent IdentityKeyMissing.

migrate_keystore_to_vault and encode_identity_blob_vault_first skip the
KeyStorage clone in the steady-state (already-InVault) case via a new
KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity
re-saves no longer clone per identity for no benefit.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* docs(secret-seam): correct drifted docs to Tier-2 keep-protection reality

- 01-ux-disclosure.md: full rewrite — the previous doc described the
  retired drop-protection design (password downgraded to file-permission
  only, one-time disclosure notices). Replaced with the Tier-2
  keep-protection reality: protected secrets re-wrap under the same
  password, uses_password/has_passphrase stay true, migration is silent,
  no disclosure notices. Removed candy tally and agent byline.

- 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to
  Tier-2: scheme stays Protected, uses_password/has_passphrase stay true,
  second unlock still prompts (ask_count == 1). Added source-test names
  (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's
  note, and future-tense TDD framing. Added section-5 note that raw seam
  applies only to unprotected secrets.

- user-stories.md WAL-006: replace false bullet ("no longer prompts,
  one-time notice") with the truth: Tier-2 re-seal, wallet keeps
  prompting, migration is silent.

- CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption)
  grep pointer (zero hits); describe present state — put_secret_protected/
  get_secret_protected implemented; keyless-vault residual is deferred tier.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* feat(wallet-backend): optional per-identity at-rest encryption for identity keys (SEC-001)

Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto.

The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load.

UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(wallet-backend): seal new keys on a protected identity Tier-2, never keyless (SEC-001)

Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected.

Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition.

SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused).

Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(identity): fail closed before broadcast when adding a key to a protected identity (SEC-001 O-2)

Adding a key to a password-protected identity used to seal the new key
Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the
on-chain AddKeys broadcast. A headless add therefore broadcast the state
transition on-chain and only then failed closed locally (no password) —
leaving the key on-chain but never persisted by DET: an on-chain/local
divergence.

Move the protected-identity precondition BEFORE any on-chain side effect.
`add_key_to_identity` now determines up front whether the identity is
protected (`protected_identity_verify_scope`) and, if so, prompts for and
VERIFIES its object password before building or broadcasting the state
transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a
wrong password returns the typed error before the broadcast, so no state
transition is ever sent. The seal then runs after the broadcast with the
already-verified password — a single prompt, split across the broadcast.

`SecretAccess::seal_new_identity_key` is split into
`verify_identity_object_password` (prompt + verify, returns an opaque
`VerifiedIdentityPassword` that zeroizes on drop) and
`seal_new_identity_key_with_password` (no prompt); the original composes
the two and keeps its tests. The d965ca5 encode fail-closed guard
(`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop.

Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an
unexpected miss (the encode guard still backstops it). O-3 — document that
a Mixed identity fails closed on a plain re-save until "Finish protecting"
reseals the remaining keys (intended secure behavior).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(identity): harden SEC-001 identity-key paths (r2 review)

Address four thepastaclaw findings on the SEC-001 identity-key code at
fcf6da1:

- BLOCKING: `seal_identity_keys` now verifies the supplied password opens
  every already-`Protected` key BEFORE sealing any keyless one. A
  Mixed-state "Finish protecting" re-run with a different password is
  rejected up front with `IdentityKeyPassphraseIncorrect` and zero state
  changes, so an identity can never be split across two passwords.
- `get_identity_by_id` now mirrors the bulk-load vault migration, so the
  single-get read path (and the SEC-001 protect/unprotect tasks that use
  it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on
  read instead of returning and re-persisting plaintext.
- A post-broadcast seal failure in `add_key_to_identity` now surfaces the
  typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry
  after freeing disk space), preserving the upstream cause in the source
  chain — never a silent loss and never a keyless-write fallback.
- The three prompt-meta setters recover a poisoned lock
  (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`,
  so prompt-copy metadata can self-heal after a panicked reader instead of
  silently freezing.

Adds regression tests for each (the blocker's split-prevention, read-path
migration via an offline AppContext, and the typed orphan-error mapping).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-direct

The has_passphrase field doc claimed fresh protected imports use a legacy
AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at
import time. Align the field doc with the function docstring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

2 participants