feat(overlay): adopt blocking progress overlay for DPNS registration (Bucket A)#866
Conversation
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>
…ng-progress-overlay
…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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
RegisterDpnsNameScreenfrom a non-blocking progress banner to a blocking globalProgressOverlay, 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::identitiespublicly 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.
| pub mod dpns; | ||
| pub mod helpers; | ||
| pub(crate) mod identities; | ||
| pub mod identities; | ||
| pub mod network_chooser_screen; | ||
| pub mod state; |
e709851
into
docs/platform-wallet-migration-design
…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>
Why this PR exists
0d301d01). Based ondocs/platform-wallet-migration-design; the overlay component itself is not in this diff (it's in the base).What was done
register_dpns_name_screennow 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.tests/kittest/register_dpns_name_screen.rs).DPN-001user-story acceptance updated to document the registration overlay.ui::identitieswidened frompub(crate)topubso the integration-test crate can reach the screen.The overlay foundation (the
ProgressOverlaycomponent,app.rsintegration, 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 --lib— 932 passing.cargo test --test kittest --all-features— 149 passing (incl. the newregister_dpns_name_screenoverlay 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