Skip to content

fix(identity): make Key Info reachable everywhere and keep key labels/holdings consistent (#889) - #945

Merged
lklimek merged 34 commits into
v1.0-devfrom
fix/889-key-info-navigation
Jul 30, 2026
Merged

fix(identity): make Key Info reachable everywhere and keep key labels/holdings consistent (#889)#945
lklimek merged 34 commits into
v1.0-devfrom
fix/889-key-info-navigation

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: Makes the Key Info screen reachable from every place a key can be seen (not only Developer view), and fixes several places where the keys list and the masternode/Key Info screens could disagree about which key is held, what to call it, or which key's data to show.

User story

As an Everyday User, I want to open a key's details from wherever I can see that key — the identity's keys list or a masternode's page — and see the same name and the same "is this saved on my device" answer every time, to achieve confidence that what the app tells me about my keys is accurate and that I can actually reach the screen that lets me manage them.

Scenario

Base flow

An identity or masternode can hold keys of several kinds (authentication, voting, owner, payout, etc.), some belonging to the identity itself and some to a linked voter or operator identity. Two screens show information about these keys: the identity's own keys list, and — for masternodes — the node's detail page, which links out to the same Key Info screen.

Actual behavior

  • The Key Info screen was only reachable through Developer view; a standard user had no path to it at all.
  • Returning from Key Info didn't always refresh the screen you came from, so a just-finished key restore could appear not to have happened.
  • The keys list and the masternode detail page could each derive "where is this key filed" differently, so the same key could show a different role name on each screen, or be reported as held on one screen and missing on the other.
  • In one specific shape — a key sharing its numeric id with a different key on a linked voter identity — the wrong key's information could be shown, and a disabled (rotated-away) key could be reported as not held even though its private half was still on the device.
  • Recovering stranded keys via Key Info's own "restore" offer could show masternode-style wording ("Payout address key") to a plain user, disagreeing with the identical offer shown on the keys list ("Transfer key").

Expected behavior

  • Key Info is reachable from the identity's keys list in every view level, and from a masternode's detail page.
  • Returning from Key Info always refreshes the screen you came from.
  • Both surfaces resolve "which key is held, and what do we call it" through the same rule, so they never disagree.
  • A same-numbered key on a different identity is never confused with the key you're looking at; a disabled key that the device still holds is correctly reported as held.
  • The restore offer uses wording that matches the kind of identity you're looking at, everywhere it appears.

Detailed discussion

What was done

Continues #889's legacy-key-recovery work (#941) with a dedicated, standard-view "Manage keys" screen and a set of consistency fixes surfaced during review of that work:

  • New KeysScreen (ui/identities/keys/keys_screen.rs) hosts the identity's key list with standard app chrome (top/left panels), reachable without Developer view; rows and Back use proper stacked-screen refresh so returning from Key Info always re-reads the identity and re-evaluates the recovery offer.
  • A single shared key-resolution rule, key_filed_at/same_key (ui/masternodes/mod.rs), used by both the identity keys list and the masternode detail screen: probes both historical filing conventions for a key and accepts a candidate only when its stored public-key material actually matches — comparing every field except disabled_at (which legitimately changes when a key is disabled on-chain, so excluding it stops a rotated-but-held key from being misreported as not held), while still treating differing purpose as a genuine mismatch (the one case where two keys can otherwise share both slot id and public-key data).
  • Key-role vocabulary (key_role_label/manage_keys_labels) scoped by identity type so a masternode's key is described in masternode terms and a plain identity's key in generic terms, applied consistently across the keys list, the masternode detail page, the Key Info page itself, and both hosts of the "restore stranded keys" offer. LegacyRecoverySection's vocabulary is now a required constructor argument (not an optional default), so a future call site can't silently regress to the wrong wording.
  • KeyInfoScreen reads/signs through the resolved target consistently across all its internal call sites, closing a narrow but real divergence where the screen's display path and its sign/save path could resolve to different stored copies of a key.
  • Vault-affecting write/delete decisions are deliberately left on the pre-existing purpose-derived convention in this PR (documented inline) — the follow-up PR stacked on this branch replaces that convention with a proper resolver instead of patching around it.

Testing

Unit and UI (kittest) coverage added for: navigation/refresh correctness (including the exact stale-offer regression this fixes), the shared resolution rule (both filing conventions, a same-numbered-key collision case, and a disabled-but-held key), vocabulary consistency across all offer/list/detail surfaces, and the empty-keys state. cargo test --all-features --lib --test kittest and cargo clippy --all-features --all-targets -- -D warnings clean; cargo fmt --all applied.

Review status

An automated review posted 10 findings after this branch merged #941's just-landed hardening. Verified independently against current code and cross-referenced against the stacked follow-up PR #946 to avoid duplicate work: 4 were already fixed by #946 (the unsafe fallback in the key-resolution helpers, the vanishing Add-protection CTA, the read/write target divergence in Key Info, and a now-dangling TODO left over from that same divergence) — nothing to do here, they land automatically when this PR merges #946. 1 was superseded by this branch's own merge of #941's error-attribution fix. Of the 5 genuinely open findings, 4 are fixed in this PR: KeyInfoScreen's refresh-on-return now actually runs (it was wired to a hook the app never dispatches for how this screen is actually shown, so a write that landed off-screen could be silently erased by the next edit); the three recovery-offer hosts' near-duplicated render/dispatch/error-handling code is now a shared helper instead of three hand-copies; key-role captions are now whole, complete phrases instead of assembled fragments (closing an i18n gap and two places a raw internal enum name could reach the screen); and docs/user-stories.md's IDN-008 now describes what this PR actually ships instead of stale pre-PR criteria. The 5th (relocating a pure key-enumeration helper out of the Expert-gated masternodes module) remains open, deferred as a minor structural cleanup.

Breaking changes

None for end users. LegacyRecoverySection::new gained a required parameter (internal component, not part of any public API) — all call sites updated. role_label_and_tip (internal helper) now returns &'static str instead of String and takes an added disabled parameter.

Checklist

  • Tests added/updated
  • cargo fmt --all
  • cargo clippy --all-features --all-targets -- -D warnings
  • docs/user-stories.md updated (IDN-020)
  • CHANGELOG.md updated
  • Design record updated (docs/ai-design/2026-07-28-legacy-identity-recovery/)

Prior work

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • New Features

    • Manage keys from Settings → Advanced with a navigable list and dedicated key details pages.
    • Inspect and restore missing keys locally without changing interface mode or starting a payment.
    • Access key management directly from the transfer screen when no transfer key is available.
    • Restore offers now appear only when applicable and remain updated after navigating between key pages.
  • Bug Fixes

    • Improved key status, labels, targeting, and private-key detection across identity and masternode screens.
    • Standardized terminology for user and masternode key roles.

lklimek and others added 30 commits July 28, 2026 19:03
Issue #889: a v0.9.3 identity that was already partially loaded before the
upgrade keeps its remaining keys stranded in `data.db`, because the migration
skips a present identity wholesale. That rule is correct — three earlier
reconcile heuristics each failed by inferring user intent from record shape.

`compute_recovery_plan` only lists what the legacy record holds and the modern
one does not, keyed on the `(target, key_id)` map slot and on `None`
associations; it never decides whether an item should come back. That decision
travels back in as an item-level allowlist, so the absence-versus-removal
ambiguity lands with the only party holding the provenance.

`apply_recovery_plan` is additive by construction, not by discipline: it starts
from the fresh modern record, inserts with `or_insert` so the modern copy wins
every collision, recomputes candidacy rather than trusting the allowlist, and
has no write path at all for alias, status, the dpp identity, DPNS names, or
the wallet link. A voting key drags its voter-identity link along, since one
without the other cannot vote.

M1-M10 from the design doc's test matrix. M2/M5/M6/M9/M10 were confirmed RED
against a naive legacy-wins merge before this implementation landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ecoder

Issue #889: the recovery flow needs one identity out of the preserved legacy
`data.db`, not the whole table. A second hand-written reader would be free to
drift from the importer's idea of which rows are readable — and then recovery
could merge from a record the migration never considered importable, or miss
one it did.

So the per-row decode moves into `decode_identity_row` and both readers call
it, along with one shared `SELECT` that binds the same pair of accepted network
spellings. `read_identity_row` adds only the `id` predicate on top.

`LegacyIdentityLookup` keeps "nothing to recover" and "the row will not decode"
apart. An absent table, an absent row, an observed-identity cache row and a
NULL blob are all `Absent` — the ordinary answer. Only a genuinely corrupt row
is `Unreadable`, so no caller can mistake corruption for an empty record and
merge against it.

`open_legacy_read_only` is hoisted next to `database/mod.rs`'s existing one, so
`SQLITE_OPEN_READ_ONLY` is a property of the one function that opens the file
rather than of each caller remembering the flag.

The importer's 12 existing row-decode tests are the regression net for the
extraction; 8 new tests cover the lookup, including one asserting both readers
decode the same row identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… data

Issue #889: an identity that was only partially loaded before the v1.0 upgrade
keeps its remaining keys in `data.db`, reachable by nothing — the load form
rejects the duplicate ProTxHash, and the per-key screen wants a WIF the user may
no longer hold. This is the way in, and it is entirely user-driven.

`CheckLegacyRecovery` lists; `RecoverLegacyIdentityData` writes. Detection is
read-only, offline and side-effect-free, so a screen can dispatch it on arrival
and hide the affordance when the plan comes back empty. Candidacy is recomputed
inside the executing task rather than trusted from the caller: only
`recomputed-candidates ∩ approved` is merged, so an approval that went stale
since the preview is reported rather than acted on, and an item the user never
approved is never restored even when it is missing. An empty allowlist is
refused, not widened into "restore everything".

The protection-downgrade trip that sank an earlier reconcile attempt is
unreachable here by branch condition, not by care: the flow branches on the same
predicate the at-rest guard evaluates. On a protected identity the password is
verified up front through the shipped prompt, the resident-plaintext preflight
runs before any vault write, and every merged key is sealed under that password
before the record is persisted — so the encoder never sees plaintext on a
protected identity. On a keyless identity there is no protected key for the
guard to protect. Cancel, wrong password and headless all fail closed with the
stored record byte-identical.

Exactly one record write, so the merge either lands whole or changes nothing.
No new persisted state: eligibility recomputes from the two existing stores, so
a re-run is a no-op and `data.db` — opened read-only — stays a repeatable
recovery source.

`reject_resident_identity_plaintext` widens to `pub(super)`; it now guards both
seal boundaries. Two test-only readers expose the at-rest blob and the wallet
link, which the hydrated record cannot show.

B1-B12 from the design doc's test matrix, on the offline wired `AppContext`
harness, plus the excluded-key and empty-allowlist cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
…node page

Issue #889: the restore path exists but nothing reaches it. The node detail
page is the canonical surface — its operator is the one holding a node that
cannot vote because its voting key stayed behind in the previous version's
saved data.

The offer is passive and contextual, never a launch-time nag. On opening a
node the view dispatches the read-only detection task once, and only on an
install that actually has a previous-version database; the section renders in
the Keys area only when detection found something, and retires itself once a
restore lands, because eligibility recomputes from the two stores every time.

For a node with no voting key that becomes the primary remedy: the
missing-voter message points at restoring, which asks nothing of an operator
who no longer has the key, and the existing WIF prompt keeps its place as the
fallback for one who does.

`LegacyRecoveryState` (`ui/state/`) owns the fetch state — dispatch-once,
in-flight, offered, retryable — and `LegacyRecoverySection` (`ui/components/`)
renders it, per the placement rule's render/no-render split. A failed restore
returns to its offer so a mistyped identity password can be corrected and
retried; a failed detection does not re-ask.

A preview result is routed into the open detail view rather than triggering the
list screen's reload-and-reopen, which would rebuild the view, re-dispatch its
check and never settle. A completed restore does reload, since the store
changed, and the rebuilt view's fresh check finds nothing left.

`RecoveryItemDescriptor::is_voting_key` factors out the rule `label` already
applied, so "is this the voting key" has one definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
Issue #889: the node page covers the masternode case, but a `User` identity
that was partially loaded before the upgrade has no node page. The Key Info
screen is the issue's own suggested location and the one surface every identity
type reaches — from the Identities list for a user identity, and through Manage
keys for a node.

The offer is identity-scoped rather than key-scoped, so it renders whatever the
opened key's own state is: what it lists are precisely the keys the identity
does *not* hold, which no per-key view could show. Both surfaces share
`LegacyRecoveryState` and `LegacyRecoverySection`, so detection, the allowlist
and the copy have one definition each.

Dispatch follows this screen's established queue-then-drain pattern, alongside
the protect/unprotect ones. The check and a restore can never contend for the
frame's single `AppAction`: the check goes out once on arrival, and a restore
only after a press. A completed restore re-probes the protection status, since
the restored keys have just landed in the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
…ration gap

Issue #889: the migration design's §7 named this limitation and deferred it to
"a dedicated, provenance-aware flow". That flow now exists, so the limitation
gets its resolution rather than staying an open pointer to an issue — with the
one clarification a reader of that section needs: the importer itself did not
change, skip-if-present still stands, and nothing about recovery runs at
migration or launch time.

The design record moves into `docs/ai-design/` under this repo's dated-directory
convention, so the cross-reference resolves inside the repo.

U1 covers the widget from the outside: an empty plan renders nothing, items are
named by role with the voter link folded into its voting key, pressing Restore
approves exactly the previewed set, an in-flight restore has no button to press
twice, unrestorable keys are listed with no restore offered, and an install with
no previous-version data never even asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
The plan admitted any legacy key the modern record happened not to hold,
keyed on the map slot alone. Nothing checked that the saved private half
derived the public half it was stored with, that the public half was still
a live key of the identity, or that a wallet-derivation reference named a
wallet this install holds — the exact checks the manual "type the WIF"
path this flow replaces already enforces.

That matters because `masternode_key_presence` reports a role as held from
the record alone. Restoring a key the node rotated away from flipped the
role to "present", retiring both the recovery offer and the missing-voter
remedy, and the operator found out from a rejected transaction. The voter
link alone did the same thing with no key behind it at all.

Failing candidates now go to `excluded` with their own reason and are
never restorable. `AppliedRecovery` also reports that `excluded` list, so
an approval naming something that was never restorable is no longer also
reported as a stale approval ("already back in place" — the opposite
answer), and the caller stops recomputing the same plan to read one field
off it. Whatever plaintext the legacy record still holds after a merge is
zeroized instead of being dropped intact.

The item labels and exclusion copy move out of `model/` into the UI, where
they now delegate to the shipped `role_label_and_tip` vocabulary rather
than a second mapping that disagreed with it on the same screen ("Payout
key" next to "Payout address key"), and two rows in one role are told
apart by key id instead of rendering identically.

Test fixtures move onto genuinely derived key pairs with the chain's
public keys published on the record — a correspondence check that passes
against placeholder key material verifies nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
`recover_legacy_identity_data` took `migration_run` for the whole async
span, prompt included. The delete path it copied that pattern from is
fully synchronous and holds the mutex for microseconds; this held it for
as long as a modal sat in front of the user. Two independent reviewers
reproduced the fallout: removing an *unrelated* identity failed with
"storage is still being updated" when no migration was running, and
`finish_unwire`'s awaiting acquire could park indefinitely.

The flow is now a preflight and one fully synchronous critical section.
The preflight reads, dry-runs the merge to see whether anything would be
restored at all, decides whether a password is needed, and prompts —
holding nothing. The write section then re-acquires the guard, re-checks
the migration state, re-reads the record, merges again and writes, all
without an await in between.

Re-reading is not just tidiness. The prompt window let any other writer
land in between — refresh, DPNS registration, transfer, none of which take
the load claim — and the write of a pre-prompt snapshot silently reverted
it. It also makes the no-resurrection guarantee explicit: a delete during
the prompt now surfaces as `IdentityNotFoundLocally` instead of being
undone by the upsert, which until now held only as a side effect of how
long the lock was kept.

The at-rest protection predicate is evaluated over the merged record, the
one the encoder's downgrade guard actually sees, rather than over the
pre-merge record. If the identity gained password protection during the
prompt the verified password no longer covers what is about to be written,
so the restore stops with a typed `LegacyRecoveryIdentityChanged` rather
than sealing under a password for a different state.

Also: the nothing-to-recover success no longer records a `Failed` load, the
preview zeroizes the stranded plaintext it decodes on every screen open
instead of releasing it intact, and the "nothing restored" banner is worded
to be true of both outcomes that produce it.

Both concurrency tests were confirmed RED against the old lock scope,
reproducing the reviewers' exact failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
Three ways the two shipped surfaces disagreed with the store after a
restore, and the state machine behind them had no tests at all.

The Key Info screen keeps the identity clone it opened with, and its own
add-key and remove-key paths persist that whole clone. This feature added
a second writer to the same record reachable from the same screen: restore
keys, then add or remove any key, and the pre-restore copy was written
back — the restored keys gone, right after a banner said they were back.
The completion now re-reads the record and the key on screen from it.

The node page never hears about a restore run from the Key Info screen it
pushed: the pushed screen is on top, so it takes the result. Coming back
left the page offering keys already restored and warning about a voting
key it now held. Arrival re-reads the node and re-arms its check; vote
selections and any open prompt survive, which a blanket rebuild would have
discarded. The `recovery_completed()` call in the result handler goes with
it — the reload that follows re-opens the view and re-arms the check
anyway, so it was mutating a view discarded three statements later.

Writing the state machine's tests turned up a third one: `completed()`
re-armed unconditionally, so calling it on an install with no
previous-version database armed a check that must never run. Harmless
until arrival started calling it; now `Unavailable` stays unavailable.

The Restore button loses its ellipsis: on a keyless identity — the common
case this feature exists for — it commits immediately rather than opening
anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
The committed design record was landed marked "implemented" with exactly
one edit — the status line — so the repo's permanent account of this
feature described a dispatch model, a progress mechanism, a module set and
a gating rule that were all deliberately changed, plus the pre-review
merge rules that the fix round replaced.

A §10 now records all nine departures with the reasoning that produced
them, rather than rewriting §1–§9 into a story nobody actually followed;
the status line says which of the two is authoritative. §7's claim that
`finish_unwire.rs` is untouched is reconciled with §4.2's hoist in the
same document: the importer's behaviour is unchanged, the file is not.

`reject_resident_identity_plaintext`'s header claimed to guard "every
boundary that seals an identity's keys". It does not — the merge-load path
seals without it. The header now says which boundaries it covers, and that
path carries a TODO describing the gap. Fixing it is a separate issue:
it is pre-existing code, unrelated to this feature.

Also: user story and CHANGELOG pick up the correspondence check and the
non-blocking prompt, the components catalog names the copy this feature's
component now owns, and three private rustdoc blocks come back inside the
internal-commentary budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
The correspondence check that keeps a rotated-away key out of the offer
resolved a voter/operator-target key's identity as "the modern link, or
the legacy one if there is no modern link". In the case the check exists
for -- a masternode whose record carries no voter link -- that fallback
read the identity snapshot out of the same unauthenticated legacy blob
as the key under test, so any self-consistent pair passed. A voting key
the chain retired months before the upgrade still reads as live in that
snapshot, and restoring it flips the node's voting role to "present",
retiring the manual remedy that would actually fix the node.

reference_identity now reads the modern record only. A key on a voter or
operator identity the modern record does not link to has no admissible
witness and is excluded as LinkedIdentityUnverified, carrying the same
"load this identity again and enter the key" remedy as its neighbours.

Fetching the linked identity from Platform was rejected twice over. It
would put the network in the middle of an offline, read-only preview
that every screen arrival dispatches, and model/ is pure by contract.
More decisively, it would not establish the property: a voter identity's
id is derived from its voting key (Identifier::create_voter_identifier),
so rotating the key on-chain creates a *different* voter identity rather
than retiring a key on the existing one. Fetching the legacy-named voter
identity would confirm the stale key against its own orphaned identity.

Behaviour change worth noticing on its own: a masternode loaded from its
ProTxHash alone no longer gets its voter-identity-target voting key back
in one click. The key is listed as unverifiable, the voter link goes with
it, the node keeps reporting the role as missing, and render_missing_voter
keeps offering manual entry -- the honest outcome, since nothing available
offline can tell that key from one the chain replaced. A record that does
carry the voter link (a re-load keeps it) restores the key as before.

Grouping follows the same rule: voter_association_is_grouped now keys on
the voting role, the predicate that already decides whether the link is
worth offering, so the two cannot disagree about which key the link
belongs to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
… existing

FetchState::Unavailable was documented as the state a fresh install never
leaves, but no shipped install could reach it. AppState::boot_inputs
opens an existing data.db read-only *or* creates one and runs the
fresh-install schema ladder, so db_file_path().exists() is true for every
user, and every identity or node screen dispatched a detection task
against a table the ladder had just created empty. The kittest that
claimed otherwise passed only because the `testing` feature substitutes
an in-memory database whose path is None -- a test of the substitution,
not of the gate.

AppContext::has_legacy_identities now asks whether the legacy `identity`
table holds a local row for this network: one SELECT EXISTS over the same
filter read_identities uses (so "there is something here" and "here it
is" cannot drift), false when the table does not exist at all, answered
once per context and cached. A probe that errors arms the offer instead
of retiring it -- the detection task reports its own typed error, while a
silent false would withdraw a recovery the user's data still supports.

The kittest is replaced by lib tests over a real file-backed data.db,
which assert the premise the old gate missed (the file is there and the
gate still says no) and cover the network scoping a file check could
never have. Confirmed red against the old gate before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
…estore

Two ways a restore's result reached the wrong screen state.

KeyInfoScreen had an empty refresh(), which refresh_on_arrival defaults
to, so returning to a screen that missed its result never re-read the
record. That screen persists the whole identity clone it opened with on
every key add or remove, so the clone -- predating the restore -- was
written back over the restored keys on the next edit, silently and with a
successful save to show for it. It now re-reads on arrival, re-probes the
protection line and re-arms its check, exactly as the masternode detail
view already does.

The LegacyRecoveryCompleted arm discarded identity_id, unlike the
LegacyRecoveryCandidates arm directly above it, which routes through
LegacyRecoveryState::offered's identity check. Results reach whichever
screen is visible when they arrive, not the screen that dispatched them,
so a restore dispatched from identity A's Key Info screen could land on
identity B's: B showed "your keys have been restored to this identity"
for a restore that was never B's, and B's own in-flight recovery state
was reset under it.

The attribution rule now lives once, in LegacyRecoveryState as
completed_for -- the twin of offered -- so both call sites share it
rather than each remembering to check. The masternodes list applies the
same rule through shows_node: a completion for an identity no card and no
open detail names is dropped, banner, reload and detail rebuild included.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
TaskError::LegacyRecoveryIdentityChanged is listed in
persist_legacy_recovery's rustdoc and spelled out in design.md 10.6, but
was the only documented error branch of that function with no test --
every sibling (IdentityLoadInProgress, WalletStorageNotReady,
IdentityNotFoundLocally, LegacyIdentityUnreadable, the prompt's cancel
and unavailable errors, IdentityKeyProtectionIncomplete, the deleted
identity, the undecodable row) has one.

B13 drives the 10.6 scenario: the dry run on a keyless identity decides
no password is needed, a ProtectIdentityKeys lands before the write
section re-reads the record, and the write refuses with the typed error
and a byte-identical stored blob.

It calls persist_legacy_recovery directly with the None the dry run
produced, as B5 calls verify_recovery_password directly: a Tier-1 restore
never prompts, so there is no await point between the two where a
concurrent task could be made to land deterministically. The dry run is
exercised first so the None under test is the one production carries.
Verified the test fails when the fail-closed branch is removed, rather
than merely restating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
The voter-target fix landed one half of the rule: a key on a voter or
operator identity the modern record does not link to has no witness
outside the legacy file, so it is excluded. The operator *link* itself
was offered with no gate at all, which left the same hole open one step
further out.

Restoring it writes the legacy file's own claim into the modern record.
The next preview then reads that claim back as the outside witness for
the same file's operator keys, and every one of them becomes a
candidate — self-vouching in two moves instead of one. Nothing in
production ever writes `associated_operator_identity` fresh, so an
offered operator link was always in that unverifiable state.

An operator link the modern record does not carry is now excluded as
`LinkedIdentityUnverified`, like the keys held on that identity.

Also renames M8, whose name and opening sentence claimed it proved the
whole legacy key set is offered while its own assertions exclude two of
five items.
The node page carried a second missing-voter message offering restore as
the primary remedy, chosen whenever the recovery plan held a voting-role
candidate. Since the voter-key check began reading the modern record
only, that candidate exists only when the record already carries the
voter link — and a record carrying the voter link is written in the same
branch that writes the voting key, so it is never in the missing-voter
state to begin with. The two conditions are mutually exclusive: no user,
and no test, has ever seen that message or its companion tooltip.

Gone, along with the predicate behind them. The remaining message is the
honest one and still matches what the screen offers: the in-place "Add
voting key" prompt right underneath it, which needs no re-load.

Restoring a voting key held on a separate voter identity needs an
on-chain check this flow deliberately does not make; that is issue #942,
not something this branch pretends to do.
… nothing

A plan holding only unrestorable items — now the ordinary shape for a
masternode loaded from its ProTxHash — opened with "Some keys ... haven't
been brought across", followed by no list, no button, and then a second
heading for the items it cannot bring back. A lead-in that promises an
action the section never offers reads as a broken screen.

That case gets its own lead-in, which introduces the list itself, so the
second heading appears only when there really are two lists to keep
apart.

The reason an item cannot be restored also stops being hover-only. It is
the single sentence that tells the user what to do instead, and a tooltip
reaches neither touch nor keyboard; it is now an inline line under each
item. The reason for an unverifiable linked identity is reworded to read
correctly for the operator link as well as for a key held on one.
The user story and the changelog both said restoring is offered as the
first remedy for a node with no voting key, with typing the key in as the
fallback. For the flagship case — a masternode loaded from its ProTxHash
alone — that is never true: the voting key sits on a separate voting
identity that only the previous version's data names, so it is listed as
one that cannot be brought back and entering it by hand is the only
remedy. Naming owner, voting and payout together in the same breath
carried the same implication and is dropped.

Both now describe what happens, and point at issue #942 for the on-chain
check that would let the voting-key case be closed safely later.

The design record gains the two shipped deviations behind this: §2.1's
"restore first, WIF as fallback" branch removed as unreachable, and the
operator link excluded as unverifiable for the same reason its keys are.
The identity hub replaced the legacy Identities screen's per-key popup —
the only route into KeyInfoScreen that did not depend on the identity
already holding a usable key — with Settings > Advanced > "Manage keys".
That destination was a read-only table with no route onward, and
RootScreenIdentities is deliberately out of the nav, so the popup it
still hosts is unreachable. Every remaining route (transfer, withdraw,
the token screens) is gated on holding a key of the kind that action
needs, which is false for precisely the identities issue #889's restore
offer exists to help: a user identity with stranded keys could reach its
own keys only in Developer view, through a send-money screen.

KeysScreen now carries the QualifiedIdentity, so it can say which keys
this device holds and open KeyInfoScreen with that key's private
material. One row per key, named in the shared role vocabulary, held
state stated in words rather than by colour alone, ungated.

The restore offer renders on the list itself, above the rows: it is
scoped to the identity, and making a user pick an arbitrary key to
discover an identity-level offer repeats the defect one level up. It
stays on KeyInfoScreen too, which self-extinguishes and is where the
masternode path lands.

The screen also moves inside the app chrome (top panel, left panel,
island_central_panel). That is not cosmetic: island_central_panel is the
only caller of MessageBanner::show_global, so on a raw CentralPanel the
restore reports nothing at all — a failed restore would be
indistinguishable from a successful one.

key_role_label, manage_keys_labels and the key enumeration move from
detail_screen into ui::masternodes beside the vocabulary they belong to,
so the two "Manage keys" surfaces cannot drift apart; the masternode
kittest covering that list is the regression guard for the move.

The transfer screen's no-key branch gains a "Manage keys" signpost. Its
available_transfer_keys() gate is untouched — it still correctly says
the identity cannot send; the branch simply stops being a dead end.
…s list

The offer self-extinguishes on the next check whether the restore landed
or failed, so its disappearance says nothing about the outcome. Only the
banner distinguishes the two, and the banner only reaches the user
because the screen is built on island_central_panel — the sole caller of
MessageBanner::show_global. Cover both outcomes explicitly, and pin the
render channel itself: reverting the screen to a bare CentralPanel fails
the new guard, which is what makes it worth having.

Also pin AC-6 across the two surfaces that name keys. The restore offer
and the keys list render from different modules over different types,
and nothing but their shared derivation from role_label_and_tip keeps
them saying the same words; a private re-implementation on either side
would read plausibly and drift in silence.

Key Info remains outside that assertion: it does not retain the
PrivateKeyTarget its key was opened with, so it cannot tell a
voter-identity key from a main-identity one, and naming it in role words
today would risk labelling a voting key as an authentication key.
Threading the target through its nine call sites is the fix, and it is
wider than this change.
…running app

The component tests prove two things separately: the screen sets a banner
on a finished restore, and it renders whatever banner its context carries.
Neither closes the seam between them — that the context the screen writes
its outcome into is the context it renders from. A restore reporting into
one context while the screen renders another would pass both and still
tell the user nothing.

This drives the real app to the keys list, delivers a completed restore
through visible_screen_mut the way AppState does, and asserts the success
text appears on the screen; then banners a task error on the app context,
as AppState does for a failed restore, and asserts that appears too. The
offer retires itself either way, so the banner is the only thing that
distinguishes them.

Reverting the screen to a bare CentralPanel fails this test, which is what
makes it a guard rather than a decoration.
…s filed

SEC-001. The keys list resolves each key's PrivateKeyTarget from the identity
it walked, then discarded it; KeyInfoScreen re-derived one from the key's
purpose. impl From<Purpose> for PrivateKeyTarget sends every voting key to the
voter identity, so for a Purpose::VOTING key filed on the main identity — a
supported shape, which masternode_key_presence reads as voting readiness on its
own — the two disagree. The list reports the key as held, Key Info re-reads the
record on arrival, looks in a store the key was never filed under, finds
nothing, and reports it missing. On the screen built to answer that question,
about a key whose private half is listed one screen earlier.

KeyInfoScreen now accepts the resolved target via with_target and uses it for
every lookup, falling back to the old derivation when no caller supplied one, so
the call sites this change does not touch keep their current behaviour. Only the
keys list passes it. The same derivation still governs the pre-existing callers'
reads, writes and deletes — including a delete that can land on a different key
when voter and main key-id spaces overlap — which is filed separately.

Also from QA:

- SEC-002: the held/not-held column called get_cloned_private_key_data_and_
  wallet_info, copying raw key bytes out of the vault unscrubbed, every frame
  for every key, to read is_some(). KeyStorage::has answers it without a copy.
- QA-005: display_message ended a restore on any MessageType::Error. Results
  are not screen-affine, so an unrelated task's failure re-armed Restore
  mid-flight and it could be dispatched twice. Attribution moves to
  display_task_error, which receives the typed error; it returns false so the
  user still gets AppState's banner. Residual documented on is_recovery_error:
  the same variant from another task during a restore still ends it, which needs
  the task's identity to reach the screen.
- QA-003: the completion-banner test asserted only that some banner existed.
  It now pins attribution instead — another identity's restore reports nothing
  here — since fresh_app_context drops the harness its context belongs to and
  the text cannot be read back at that level. The running-app test already
  pins the text.
- QA-006/007: coverage for the transfer-screen signpost and for the Expert-only
  detail row in both directions.
- Empty-state copy said "no keys saved on this device", which in this screen's
  own per-key vocabulary means held=false. An empty record is a different
  statement; the test that codified the wrong string is fixed with it.
- QA-002: "island_central_panel is the only caller of show_global" is false —
  contracts_documents_screen calls it directly. Corrected where it was written
  down as justification.
- key_role_label back to pub(crate); ui/masternodes' module header no longer
  implies its key vocabulary is Expert-Mode-scoped, since an Everyday identity
  screen depends on it.
- Docs: §2.1 and §7 row 1 restored as written, per the document's own
  proposal-plus-deviations model, with §10.13 carrying the correction. CHANGELOG
  no longer claims transfer was the only route to a key's page, and no longer
  promises signing or password protection to an identity that holds no keys yet.
… own words

Track A of round 3.

Leaving a key now pops with a refresh, and the keys list re-reads both halves of
its state on the way back. Only re-reading the record would show a key restored
from the pushed Key Info screen as held while still offering to restore it —
and pressing Restore then reports there was nothing left to do. Consolidating
into refresh() also retires the refresh_on_arrival override, which never ran:
the framework dispatches that hook for root screens, and this screen is pushed.

KeyInfoScreen gains a with_parent breadcrumb crumb so the trail leads back to
the screen the key was opened from. Parameterised, not hardcoded: it has nine
parents and naming one of them for all of them would mislabel the other eight,
which keep their two-level trail unchanged.

Key role words now follow the identity that owns the key. The DIP-3 wording
describes masternode registration duties — updating a registration, receiving
node rewards — and on a user identity that does not read as jargon, it asserts
the user owns a masternode. A user's TRANSFER key is a Transfer key, not a
payout address. Both the keys list and the restore offer take the same
vocabulary from the same identity: scoping only one would have a user's offer
say "Payout address key" six lines above a list saying "Transfer key", which is
the two-names-for-one-key defect the shared vocabulary exists to prevent.

Empty-state copy no longer says "no keys saved on this device" — these rows are
the identity's on-chain public keys, and that phrase means held=false per-row on
this same screen.

Track B, reads only, after verifying the read/write split against the source
rather than the report: :746 (feeds the vault chokepoint, whose consequence is
signing with and displaying the wrong key's material) and :887 (reload) are
reads and now resolve the target the caller established. The keys list also
resolves held-ness against both conventions, since real installs hold material
written under each; looking in a second place can only find material that is
already there.

The writes are deliberately reverted to the purpose-derived target, which the
previous commit had changed. QualifiedIdentity::sign and can_sign_with resolve
that way, so material stored anywhere else is material that can never sign: for
a voting key filed on the main identity, the previous commit would have taken a
hand-entered private key and stored it where the signer never looks. Removal
keeps the derived target for the same reason, which leaves wrong-key removal as
a documented residual. Both sites carry the reasoning, and reconciling the two
conventions is a migration this PR is not the place for.
The keys list gained typed error attribution while the Key Info screen kept
ending a restore on any MessageType::Error, so the two screens hosting the same
identity-scoped offer disagreed about whether that restore had ended. An
unrelated task's failure still re-armed Restore on one of them.

Attribution moves onto LegacyRecoveryState::owns_error, which already knows the
identity it belongs to, and both screens call it from display_task_error. One
implementation, so the two hosts cannot drift; returning false keeps AppState's
banner, because the user still has to see the error.

Key Info keeps its own blanket handling for the separate protection-migration
gate, which is a different state machine with a different in-flight guard.
…e everywhere

Two reviewers found the same hole in the two-candidate held-state lookup. It
derived its fallback target by looking key_id up in the main identity's key set
even while checking a voter-identity row, and KeyStorage::has only proves a slot
is filled, not whose material fills it. A main identity holding a voting key at
id N alongside a linked voting identity holding its own key at id N would report
the main key as held on the strength of the other key's private half — and hand
that material to Key Info. The loop already has the key, so the fallback comes
from key.purpose(), and each candidate now has to hold this exact public key.
Reverting to slot presence fails the new test.

AC-6's third surface: the Key Info page still named its key by raw Debug
purpose, so the list and the restore offer agreed with each other and not with
the page they open. It now takes the same identity-scoped vocabulary, with the
raw purpose kept as Expert detail rather than dropped.

The CHANGELOG's known-limitation note said what the app does internally and left
the reader nothing to do about it. It now names the situation, what can go wrong,
and the check that resolves it — and drops the claim that saving uses "the older
of the two places", which is not what makes saving risky. The collision with a
same-numbered key is.
The restore offer is hosted by three screens — the keys list, Key Info, and
masternode detail — and each names the keys it offers to bring back. Two of
them were scoped to the identity's vocabulary; Key Info was not. A plain
user's transfer key was therefore offered as a "Payout address key" there,
asserting the user owns a masternode, and contradicting the keys list one
screen away, which calls the same key a "Transfer key". Someone deciding
whether to restore a key should not have to work out that the two names are
the same key.

The literal fix is one argument, but a third site forgetting the same call is
a design problem, so the footgun goes with it: `vocabulary` moves from a
builder option defaulting to the masternode wording to a required constructor
argument. Omitting it is now a compile error rather than a plausible-looking
wrong label, and a fourth host cannot inherit masternode words by accident.

A required argument catches a *missing* vocabulary, not a *wrong* one, which
is exactly what happened here — so the guard is at the host: the new test
drives Key Info through the route the user takes, arms the offer, and reads
the words off the rendered offer. Reinstating the wrong vocabulary fails it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stored copy of a public key is a snapshot taken when its private half
was saved. A Platform identity public key is immutable once added with one
exception — disabling rewrites `disabled_at` — so full-struct equality
between the snapshot and the live key stopped matching the moment a key was
disabled or rotated, and the row reported a key this device demonstrably
holds as missing. Same symptom as the voter-identity lookup bug, different
trigger. It fails safe, understating possession rather than over-, which is
why it survived the earlier fix.

`same_key` therefore excludes the one field that legitimately moves, and
nothing else. Comparing just the id and the key material would also fix this
and reopen a worse hole in the other direction: `id` is already the lookup
key, and a main identity's voting key and a linked voter identity's key can
carry identical `data`, leaving `purpose` as the only thing telling them
apart. Conflating those reports the main identity's key as held on the
strength of a different key's private half and hands that material to Key
Info — the exact defect this lookup exists to prevent. Verified both ways:
the narrow id+data comparison passes the new disabled-key test and fails
`a_same_numbered_key_on_the_voter_identity_is_not_mistaken_for_this_one`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…list gave it

The detail view's key rows already knew where each key's private half was —
they looked there to decide whether it was held — but dropped that on the way
to `KeyInfoScreen`, which then re-derived the location from the key's purpose.
The two disagree for a key filed on the voter identity whose purpose is not
`VOTING`, and `role_label_and_tip` exists precisely because that shape occurs:
a key on a voter identity is the node's voting key whatever its purpose field
says.

So the list said "Voting key" and the page one click later said
"Authentication key", about one key. The same wrong target drives the page's
own re-read, so it also reported a key the device demonstrably holds as
missing — the identity-path defect, on the masternode path.

Forwarding the row's target closes both. The new test drives the real route
(card → detail → key row → Key Info) rather than the pieces, since this
drifted where nothing crossed the two surfaces; reverting the fix fails it on
the name assertion.

Also corrects a comment falsified by this branch: the identity keys list is no
longer "the static read-only KeysScreen table".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s surface

`identity_keys()`'s doc comment claimed that sharing key enumeration is "what
keeps the two surfaces agreeing on which keys are held". It wasn't true.
Enumeration was shared; resolution was not. The identity keys list tried both
filing conventions and verified the match, while the masternode detail view
passed the structural target straight through — so a voting key held on the
main identity but filed by purpose derivation, which is what entering one by
hand produces, read as saved on the keys list and missing on the node's page.

So `filed_at` and `same_key` move out of `keys_screen` to sit beside
`identity_keys` as `key_filed_at`, and both surfaces call it. Enumerate alike,
resolve alike. The doc comments on both now describe what the code does: the
structural target is half of pairing a public key with its private material,
and this is the other half.

Only reads change. The two conventions still disagree on the write path;
reconciling that is a migration, tracked separately.

Coverage at both levels: a unit test next to the function pins all four
properties of the rule (either convention found, wrong key at the same id
rejected, `disabled_at` tolerated), and a kittest drives the node's real route
to prove the surfaces now agree. Reverting `detail_screen` to the raw
structural target fails the kittest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… silent gap

`same_key` compares an identity public key field by field, deliberately
skipping `disabled_at`. Written as a chain of accessor calls, a field added
upstream would simply not be compared: two keys differing only in that field
would compare equal, and this function decides which stored private material
belongs to which key.

Destructuring `IdentityPublicKeyV0` exhaustively, without `..`, moves that
decision to whoever adds the field — the build stops until they say whether it
identifies a key or, like `disabled_at`, only describes its state. Verified the
guard bites: dropping a field from the pattern fails with E0027 "pattern does
not mention field", which is exactly what an upstream addition would produce.
Behaviour is unchanged, so the existing tests hold as written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lklimek
lklimek marked this pull request as ready for review July 30, 2026 08:03
@thepastaclaw

thepastaclaw commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

🔍 Review in progress — actively reviewing now (commit af84ea6)
Stage: Codex precheck starting
ETA: complete ~17:06 UTC (median 26m across 30 recent reviews)
Running 9m · Last checked: 2026-07-30 16:50 UTC

@lklimek lklimek added the claudius-review Triggers automated code review using claudius plugin, runs as a CI job label Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Identity key management now uses qualified identities, per-key navigation, vocabulary-aware labels, and resolved private-key targets. KeysScreen coordinates legacy recovery offers, backend results, identity reloads, and refreshes. Settings, transfer, and masternode screens provide entry points with expanded kittest coverage and documentation.

Changes

Identity key management

Layer / File(s) Summary
Shared vocabulary and key resolution
src/ui/masternodes/mod.rs
Centralizes user/masternode role labels, key enumeration, duplicate naming, and held private-key location resolution.
Keys list and recovery flow
src/ui/identities/keys/keys_screen.rs, src/ui/components/legacy_recovery_section.rs, src/ui/state/legacy_recovery.rs, tests/kittest/keys_screen.rs, tests/kittest/legacy_recovery_section.rs, src/ui/mod.rs
KeysScreen accepts qualified identities, renders navigable key rows and conditional recovery offers, processes restore results, reloads identity state, and refreshes when returning from key details.
Key details and masternode integration
src/ui/identities/keys/key_info_screen.rs, src/ui/masternodes/detail_screen.rs, tests/kittest/masternode_tab.rs
KeyInfoScreen preserves breadcrumb and target context, while masternode key navigation resolves stored material and maintains consistent labels.
Keys screen entry points
src/ui/identity/settings.rs, src/ui/identities/transfer_screen.rs, tests/kittest/transfer_screen.rs, tests/kittest/main.rs
Settings passes qualified identities to KeysScreen, and the transfer screen adds a Manage keys route when no transfer key is available.
Recovery behavior documentation
CHANGELOG.md, docs/ai-design/2026-07-28-legacy-identity-recovery/design.md, docs/user-stories.md
Documents recovery-offer placement, per-key navigation, naming, refresh behavior, and explicit restore requirements.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Settings
  participant KeysScreen
  participant RecoveryBackend
  participant KeyInfoScreen
  Settings->>KeysScreen: open qualified identity
  KeysScreen->>RecoveryBackend: check or restore missing keys
  RecoveryBackend-->>KeysScreen: result or error
  KeysScreen->>KeyInfoScreen: open selected key with resolved target
  KeyInfoScreen-->>KeysScreen: return and refresh
Loading

Possibly related PRs

Suggested labels: claudius-review

Suggested reviewers: lklimek, claude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: broader Key Info reachability plus consistent key labels and held-key resolution.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/889-key-info-navigation

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.

@github-actions github-actions Bot 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.

Review — 20 findings (0 critical, 0 high, 10 medium, 7 low, 3 info)

Let me open with something I do not say often: this is good work. The core resolver is genuinely well built. same_key's exhaustive destructure without .. — so that a field added upstream breaks the build rather than silently widening a key match — is exactly the paranoia a wallet deserves, and the reasoning is argued in rustdoc rather than asserted. Excluding disabled_at cannot produce a false positive, because data is still compared and that is what actually identifies a key. key_filed_at reads only the public half, so a per-frame resolution never drags private bytes out of the vault. No plaintext keys, seeds or mnemonics anywhere in the diff. Typed TaskError matching throughout, no error-string parsing, no tokio::spawn smuggled past BackendTask, no secret-seam bypass. The kittest coverage asserts rendered content rather than merely driving code paths.

Now the part where I earn my keep.

All three reviewers independently converged on the same theme: the PR builds the right rule and then does not apply it everywhere. That is not a stylistic quibble — every finding below is a place where this PR's own newly-declared invariant is contradicted by this PR's own code.

The four I would fix before merge:

Finding Why
🟠 SEC-001filed_at.unwrap_or(target) in keys_screen.rs:222 and detail_screen.rs:737 key_filed_at returning None is a positive statement that no material for this key is held. The fallback then does an unchecked map lookup on that same slot — so a row reading "not saved on this device" can hand KeyInfoScreen another key's private key to display, sign with, and delete.
🟠 CALL-001owns_error applied to 2 of 3 hosts The rustdoc you just wrote says "every screen hosting an offer has to answer it the same way". list_screen.rs:632 binds _error and discards it. The invariant ships already violated.
🟠 QA-001first_protectable_key never got the resolver Combined with && short-circuiting, the "Add password protection…" button is not merely misrouted — it is never rendered, for exactly the key shape your own comments cite as the motivation for key_filed_at. Status line says "unprotected"; there is no button.
🟠 SEC-003KeyInfoScreen::refresh() is empty You added new state to refresh_on_arrival, a hook AppState dispatches only for root screens. KeyInfoScreen is never one. Your sibling screen in this same PR documents that exact rule and applies it correctly. The two regression tests call the hook by hand, so green CI proves nothing here.

Worth resolving shortly after: SEC-002 (reads go through the resolved target, writes and deletes do not — the silent no-op on "Remove private key" is the bit the documented residual doesn't cover), DOC-001 ("the reconciliation TODO" exists nowhere in the repo — please file the issue and cite it), DOC-002 (IDN-008 is the story this PR actually implements and now misdescribes it in both directions), CODE-001 (three hand-maintained copies of the recovery-host boilerplate — CALL-001 is the receipt).

Longer horizon: PROJ-001 (a pure, cross-domain key-resolution rule living inside the Expert-gated masternodes screen module — the four-line doc header apologising for it is the placement policy pushing back), PROJ-003 (i18n fragment assembly, blast radius newly quadrupled), plus PROJ-002, CODE-002, QA-002 and RUST-001 in the full report.

Ten MEDIUM findings are posted inline. The seven LOW and three INFO items — including a stale src/ui/components/README.md signature, ephemeral review IDs baked into test doc comments, a PARENT_CRUMB constant not used for its own crumb, and an untested tie-break in key_filed_at — live in the full report rather than cluttering the diff.

Method note: every finding was verified by reading head-vs-base sources. Nothing was compiled or executed — this review runs in an ephemeral sandbox with no warm Rust cache, and this repository's CI already runs the full suite on the PR as the verification backstop. Where a claim could not be settled without execution (CODE-002), the finding says so and is rated conservatively.

Fix the four, and this is a genuinely strong piece of work. The rule you built is right; it just needs to be obeyed in all the places you declared it applies.

🤖 Reviewed by Claudius the Magnificent — three parallel specialists (security, project consistency, adversarial QA), consolidated and severity-adjudicated. Full report: review-report/report.html.

None => button,
};
if button.clicked() {
let opened_at = filed_at.clone().unwrap_or_else(|| target.clone());

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.

🟠 MEDIUM — SEC-001: the unwrap_or fallback quietly undoes everything key_filed_at just did for you

You built a resolver whose entire virtue is that it accepts a slot only when same_key confirms the stored public half actually belongs to this key. Then, two lines later, you throw that away:

let filed_at = key_filed_at(&self.identity, &target, &key);
let held = if filed_at.is_some() { HELD } else { NOT_HELD };
// ...
let opened_at = filed_at.clone().unwrap_or_else(|| target.clone());
let holding = self.identity.private_keys
    .get_cloned_private_key_data_and_wallet_info(&(opened_at.clone(), key.id()));

get_cloned_private_key_data_and_wallet_info is a bare map lookup. It performs no same_key check. So filed_at == None has two causes and this code cannot tell them apart:

  • (a) the slot is empty — the fetch returns None. Harmless.
  • (b) the slot is occupied by a different keysame_key rejected it, yet the fetch happily returns Some(that other key's PrivateKeyData).

Case (b) is precisely the state the shared resolver exists to detect. Reachable shape on a masternode: the main identity carries a Purpose::VOTING key at id N whose private half was filed under the purpose-derived convention at (PrivateKeyOnVoterIdentity, N), while the linked voter identity has its own, different key at the same id N. Both probes land on the same slot, same_key correctly returns false, the row correctly renders "not saved on this device" — and then opened_at falls back and hands KeyInfoScreen the main identity's voting key material.

detail_screen.rs:737 has the identical shape (key_filed_at(...).unwrap_or(target)).

Why this matters more than a UI glitch: the user reads "this key is not saved on this device", clicks through to find out what to do about it, and lands on a screen showing a copyable WIF — for a different key of the same node. "Sign Message" signs with it. "Remove private key from DET" deletes it. A user tidying up a key they were told they don't hold destroys one they do.

Suggested fixNone is a positive statement, so carry it through:

let holding = filed_at.as_ref().and_then(|at| {
    self.identity.private_keys
        .get_cloned_private_key_data_and_wallet_info(&(at.clone(), key.id()))
});

…and only call .with_target(...) when filed_at is Some. Then add a kittest whose fixture puts key A's material in the slot key B structurally occupies and asserts the pushed KeyInfoScreen has private_key_data == None. It must go RED against current master-of-this-branch first, or it isn't pinning anything.

(Verified by reading head sources; nothing was compiled in this environment — CI owns that.)

Comment thread src/ui/state/legacy_recovery.rs Outdated
.get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id()))
.is_some()
})
identity_keys(&self.identity)

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.

🟠 MEDIUM — QA-001: first_protectable_key never got the resolver, and && short-circuiting turns that into a vanishing button

You updated open_key_info_with_mode (line 737) to go through key_filed_at. first_protectable_key got only the cosmetic move from self.identity_keys() to the shared identity_keys(&self.identity) — its lookup is still raw and structural-only:

identity_keys(&self.identity)
    .into_iter()
    .find(|(target, key)| {
        self.identity.private_keys
            .get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id()))
            .is_some()
    })

On its own that would just mean a wrong pick. The call site at lines 659-664 makes it worse:

if tier.offers_add_protection()
    && let Some((target, key)) = self.first_protectable_key()
    && ui.button("Add password protection…").clicked()

&& short-circuits, so when first_protectable_key() returns None the ui.button(...) call never executes — the button is not merely unclickable, it is never rendered. Meanwhile tier.offers_add_protection() is computed independently by protection_tier(), which walks private_keys.keys_set() directly and is therefore correct.

Net effect for a node whose only held key is filed exclusively under the derived convention — which is to say, "a main-identity voting key entered by hand", the exact scenario your own comment at line 734 cites as the motivation for key_filed_at: the status line reads "Keys: unprotected" and there is no button to do anything about it. No error, no explanation, no CTA. A security-hardening feature made silently unreachable for the very configuration this PR was built around.

Suggested fix: resolve held-ness through key_filed_at inside first_protectable_key, mirroring open_key_info_with_mode. While you're there, consider lifting ui.button(...) out of the && chain — a rendering call hiding inside a condition chain is a foot-gun that will bite again the next time one of those predicates gets stricter.

Comment thread src/ui/identities/keys/key_info_screen.rs
// app would have used. That it can therefore remove a
// different key's material, when a voter identity carries
// the same key id, is the known residual of the split
// conventions — the reconciliation TODO owns it.

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.

🟠 MEDIUM — SEC-002: reads now go through self.target(), writes and deletes still go through the derived one. "Remove private key" can silently no-op.

I want to be fair to the deliberate part first: the PR body says vault write/delete stays on the purpose-derived convention and the stacked follow-up replaces it wholesale. That's a defensible call, and this comment documents it. What that decision does not cover is the failure mode it creates in the meantime.

At the base revision, with_target/fn target don't exist and purpose().into() is used everywhere — so display and delete pointed at the same slot and agreed with each other. This PR routes the read paths through self.target() and leaves the write paths on purpose().into(). The divergence between them is new.

The two conventions differ for exactly one shape: a Purpose::VOTING key on the main identity. Not hypothetical — this PR's own kittest key_info_agrees_with_the_list_about_a_voting_key_held_on_the_main_identity builds precisely it. On that fixture the screen opens with target = Main, displays the material from (Main, 0), renders "Remove private key from DET" — and removes (Voter, 0). Two outcomes:

  1. (Voter, 0) is empty → silent no-op. self.private_key_data = None makes the screen look like the key is gone, the clone is persisted, and the material stays on disk at (Main, 0). The keys list one screen away will cheerfully report it as saved on this device on the next visit.
  2. (Voter, 0) holds the voter identity's own keythat key's private half is destroyed instead, while the displayed one survives.

This comment acknowledges (2). Outcome (1) is the one that worries me more, and it isn't mentioned: "I removed my private key from this device" is a security action a user takes before handing over a machine or reducing exposure. Reporting success when nothing happened is the single worst way for it to fail. The add path has the same shape in reverse — a key entered here lands in a slot this screen doesn't read back, so it reverts to "Enter Private Key" for a key it just saved.

Suggested fix — either:

  • (a) move write/delete onto self.target() and migrate QualifiedIdentity::sign/can_sign_with in the same change; or
  • (b) if that must wait for the stacked PR, keep this PR internally consistent: gate the Remove control off, or fail closed with an explicit message, whenever self.target() != self.key.purpose().into(). Offering a destructive action on a key whose read and write slots disagree is the bit that shouldn't ship.

Either way, worth a test that opens a VOTING-on-main key, presses Remove, and asserts the stored record no longer holds the material the screen displayed.

// `QualifiedIdentity::sign` and `can_sign_with` look the key up that
// way, so material stored anywhere else is material that can never
// sign. Writing the resolved target instead needs every reader
// migrated with it — see the reconciliation TODO.

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.

🟠 MEDIUM — DOC-001: "the reconciliation TODO" does not exist

Two comments defer a vault write/delete correctness residual to a tracker that isn't in the repository:

  • here, line 1090 — "see the reconciliation TODO"
  • line 1298 — "the reconciliation TODO owns it"

Grepping the whole repo for TODO finds no such marker. The nearest things are prose in src/ui/masternodes/mod.rs:261 ("tracked separately") and unrelated wallet/banner reconciliation. git blame won't help the next reader either — the referent was never committed.

CHANGELOG.md:119-120 does the same thing in user-facing prose: "This will be closed by the in-progress key-placement resolution fix" — no issue number, in a changelog that cites #942 and #889 by number four lines away.

This is a deliberate, documented residual on a path that deletes private key material. Documenting it was the right instinct; the problem is that a residual naming no tracker is a residual nobody is assigned to close. After merge it survives as prose only, and the known limitation the CHANGELOG warns users about has no issue a maintainer can triage or schedule.

Suggested fix: file the follow-up issue, replace both comments with TODO(#NNN):, and cite the same number at CHANGELOG.md:119 where #942 already sets the precedent. (Please use the issue number, not a review-finding ID — those are reassigned every run and go dead on merge.)

Comment thread src/ui/identities/keys/keys_screen.rs Outdated
Comment thread src/ui/masternodes/mod.rs
/// Reads the stored public half only, never the private one: fetching the entry
/// would clone the raw private key out of the vault unscrubbed, and this runs
/// every frame for every key.
pub fn key_filed_at(

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.

🟠 MEDIUM — PROJ-001: a cross-domain key-resolution rule parked inside the Expert-gated masternodes screen module

identity_keys, same_key and key_filed_at are pure, stateless derivations over QualifiedIdentity and its KeyStorage. No egui. No AppContext. No Sdk. No DB. And they now live in src/ui/masternodes/mod.rs — an Expert-Mode-gated screen domain module — while being consumed from ui/identities/keys/keys_screen.rs, key_info_screen.rs and ui/components/legacy_recovery_section.rs.

CLAUDE.md's DET Module Placement Policy puts stateless data derivation in model/, and names "business logic (signing, filtering, state derivation) in UI or database layers" as an anti-pattern not to add new instances of. Deciding which key store holds a key's private half is state derivation by any reading. And the precedent is one directory over: QualifiedIdentity::masternode_key_presence() (src/model/qualified_identity/mod.rs:579) derives exactly this class of fact and lives in model/.

The module's own doc header is the tell. It now needs four lines to explain why an Expert-gated module exports helpers that ungated screens depend on:

The gate covers the screens, not this module's shared key helpers […] those name, enumerate and resolve the keys of any identity and are used from ungated surfaces…

That's not a documentation gap; that's the placement policy pushing back. The visibility spread reinforces it — identity_keys/key_filed_at/manage_keys_labels/role_label_and_tip/disambiguate_role_labels are pub, key_role_label is pub(crate), same_key is private. Six functions, three visibilities, one conceptual rule.

Why it's worth moving now rather than later: the next developer asking "how does this app decide a key is held on this device?" has no reason to open the masternodes screen module. A second, divergent implementation is precisely how the bug this PR fixes came to exist — your own commit message says so. Also, the rule's unit tests currently hand-build a full QualifiedIdentity inside a UI module's #[cfg(test)] block to exercise a pure function; in model/ that fixture would be shared with the existing masternode_key_presence tests.

Suggested fix: move identity_keys, same_key and key_filed_at (with their tests) into src/model/qualified_identity/, ideally as QualifiedIdentity methods next to masternode_key_presence. Keep the presentational vocabulary in the UI layer, but out of ui/masternodes/ — it names keys for every identity type now, so something like src/ui/identities/keys/vocabulary.rs matches what it actually does. Settle the family on one visibility while you're in there.

Comment thread src/ui/masternodes/mod.rs Outdated
Comment thread docs/user-stories.md
@github-actions

Copy link
Copy Markdown
Contributor

📊 View full HTML review report

@github-actions github-actions Bot removed the claudius-review Triggers automated code review using claudius plugin, runs as a CI job label Jul 30, 2026
Base automatically changed from feat/issue-889-legacy-key-recovery to v1.0-dev July 30, 2026 12:26
…navigation

# Conflicts:
#	CHANGELOG.md
#	docs/ai-design/2026-07-28-legacy-identity-recovery/design.md
#	docs/user-stories.md
#	src/backend_task/identity/recover_legacy_keys.rs
#	src/model/legacy_recovery.rs
#	src/ui/components/legacy_recovery_section.rs
#	src/ui/identities/keys/key_info_screen.rs
#	src/ui/masternodes/detail_screen.rs
#	src/ui/masternodes/list_screen.rs
#	src/ui/masternodes/mod.rs
#	src/ui/state/legacy_recovery.rs
#	tests/kittest/legacy_recovery_section.rs

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/ui/masternodes/detail_screen.rs`:
- Around line 707-717: Update first_protectable_key to use the same
key-file-location resolver as open_key_info_with_mode, including key_filed_at
when determining whether a held key is protectable. Preserve returning the first
matching PrivateKeyTarget/key pair, and ensure the Add password protection
button in the surrounding UI is not conditionally skipped solely because this
predicate returns None.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ef58bfbf-9e58-418f-a4a2-50b86626af5b

📥 Commits

Reviewing files that changed from the base of the PR and between 7607622 and 1a944eb.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • docs/ai-design/2026-07-28-legacy-identity-recovery/design.md
  • docs/user-stories.md
  • src/ui/components/legacy_recovery_section.rs
  • src/ui/identities/keys/key_info_screen.rs
  • src/ui/identities/keys/keys_screen.rs
  • src/ui/identities/transfer_screen.rs
  • src/ui/identity/settings.rs
  • src/ui/masternodes/detail_screen.rs
  • src/ui/masternodes/mod.rs
  • src/ui/mod.rs
  • src/ui/state/legacy_recovery.rs
  • tests/kittest/keys_screen.rs
  • tests/kittest/legacy_recovery_section.rs
  • tests/kittest/main.rs
  • tests/kittest/masternode_tab.rs
  • tests/kittest/transfer_screen.rs

Comment on lines 707 to +717
fn first_protectable_key(
&self,
) -> Option<(PrivateKeyTarget, dash_sdk::platform::IdentityPublicKey)> {
self.identity_keys().into_iter().find(|(target, key)| {
self.identity
.private_keys
.get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id()))
.is_some()
})
identity_keys(&self.identity)
.into_iter()
.find(|(target, key)| {
self.identity
.private_keys
.get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id()))
.is_some()
})

@coderabbitai coderabbitai Bot Jul 30, 2026

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

first_protectable_key still bypasses key_filed_at.

open_key_info_with_mode (Line 751) now resolves the filing location, but this lookup remains structural-only. Combined with the && chain at Lines 673-675 — where ui.button(...) only executes if this returns Some — a node whose only held key is filed under the purpose-derived convention shows "Keys: unprotected" with no Add-protection CTA at all.

♻️ Align with the shared resolver
         identity_keys(&self.identity)
             .into_iter()
             .find(|(target, key)| {
-                self.identity
-                    .private_keys
-                    .get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id()))
-                    .is_some()
+                key_filed_at(&self.identity, target, key).is_some_and(|filed_at| {
+                    self.identity
+                        .private_keys
+                        .get_cloned_private_key_data_and_wallet_info(&(filed_at, key.id()))
+                        .is_some()
+                })
             })

Also consider lifting ui.button("Add password protection…") out of the && chain so a stricter predicate cannot silently remove the control.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ui/masternodes/detail_screen.rs` around lines 707 - 717, Update
first_protectable_key to use the same key-file-location resolver as
open_key_info_with_mode, including key_filed_at when determining whether a held
key is protectable. Preserve returning the first matching PrivateKeyTarget/key
pair, and ensure the Add password protection button in the surrounding UI is not
conditionally skipped solely because this predicate returns None.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified against the merged PR head af84ea6b7a3ef276ef8f416c93eee6ea2dbafa3e (merge commit 06e64e774b5c1ae51a51413bc42857e689863821): this finding remains valid. first_protectable_key still does the raw (target, key_id) lookup, while the Add-protection button is still inside the short-circuiting && let Some(...) chain. The three commits added after the review did not touch this path, so a held key found only through key_filed_at can still show “Keys: unprotected” without the CTA. Since #945 is now merged, this needs a follow-up fix.

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.

@thepastaclaw, agreed — the follow-up is warranted. first_protectable_key should use key_filed_at before checking private-key material, matching open_key_info_with_mode; the Add-protection control should also remain visible independently of whether a protectable key is found.

Would you like me to open a GitHub issue for the follow-up fix, assigned to you?

🐇🔍


✏️ Learnings added
Learnt from: thepastaclaw
Repo: dashpay/dash-evo-tool PR: 945
File: src/ui/masternodes/detail_screen.rs:716-726
Timestamp: 2026-07-30T18:31:49.789Z
Learning: In `src/ui/masternodes/detail_screen.rs`, any check for whether a masternode key’s private material is held must resolve its storage location with `key_filed_at(&QualifiedIdentity, &PrivateKeyTarget, &IdentityPublicKey)`, rather than directly looking up the structural `(PrivateKeyTarget, key_id)` pair. Private material can be stored under the purpose-derived location, and UI entry points such as the Add password protection flow must use the same resolution rule as `open_key_info_with_mode`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

lklimek and others added 3 commits July 30, 2026 15:21
…e keys whole

Three defects a review found on the Key Info navigation work.

Key Info's arrival re-read hung off `refresh_on_arrival`, which `AppState`
dispatches only to root screens. This screen is always pushed onto the screen
stack, so the reload, the protection-status reset and the offer re-arm were
unreachable: a write landing off-screen was erased by the next ordinary key
edit, which persists the whole identity clone the screen opened with. The body
moves onto `refresh`, which does reach a pushed screen; `refresh_on_arrival`
delegates there by default, so both hooks still run it. The regression test
moves to kittest and drives the app's own `TaskResult::Refresh` dispatch, since
hand-calling the hook passed against the bug.

The offer to restore stranded keys was hand-copied across its three hosts:
render, result routing, error routing, dispatch. The render chain becomes
`legacy_recovery_section::host_offer` and the attribution rules become
`LegacyRecoveryState::absorb_result` / `absorb_error`, leaving each screen only
its own side-effects. A shared rule applied to two hosts of three is what caused
an earlier bug here; a fourth host now cannot repeat it.

Key role labels were assembled from fragments — a bare role word, an English
noun, a state, a positional key id — and two surfaces interpolated a raw
`Purpose` through `Debug`. `role_label_and_tip` now returns whole `&'static str`
captions keyed on vocabulary, purpose and retired state, so composing one at a
callsite is impossible rather than discouraged, and matches every `Purpose` by
name so a variant added upstream breaks this build instead of reaching a user as
`{:?}`. The raw purpose keeps its place on Key Info as its own Expert field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The catalog entry for viewing identity keys still described criteria that
predate a reachable route into a key's own page — this branch is what first
gives `KeysScreen` a way into `KeyInfoScreen` at all. The real criteria had
landed under IDN-020, a legacy-migration story, and IDN-008 also overstated the
Everyday view: the new list gates key id, purpose, security level, type and
read-only behind the Expert view.

Restates the criteria as what ships — reachable from the identity's Settings →
Advanced without changing interface mode, every key openable whether or not the
device holds it, role and held state in words, on-chain specifics as Expert
detail — and cross-references IDN-020 for the restore offer rather than
duplicating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…fixes

Covers the two user-facing fixes from this round's review-comment pass on
PR #945: the stale-clone refresh wiring and the whole-caption key-role
labels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd
@lklimek
lklimek enabled auto-merge (squash) July 30, 2026 16:34
@lklimek
lklimek disabled auto-merge July 30, 2026 16:35
@lklimek
lklimek merged commit 06e64e7 into v1.0-dev Jul 30, 2026
5 checks passed
@lklimek
lklimek deleted the fix/889-key-info-navigation branch July 30, 2026 16:51
orchardpaytl pushed a commit to orchardpaytl/orchardpay that referenced this pull request Jul 30, 2026
…/holdings consistent (dashpay#889) (dashpay#945)

* feat(model): add the legacy identity recovery plan and additive merge

Issue dashpay#889: a v0.9.3 identity that was already partially loaded before the
upgrade keeps its remaining keys stranded in `data.db`, because the migration
skips a present identity wholesale. That rule is correct — three earlier
reconcile heuristics each failed by inferring user intent from record shape.

`compute_recovery_plan` only lists what the legacy record holds and the modern
one does not, keyed on the `(target, key_id)` map slot and on `None`
associations; it never decides whether an item should come back. That decision
travels back in as an item-level allowlist, so the absence-versus-removal
ambiguity lands with the only party holding the provenance.

`apply_recovery_plan` is additive by construction, not by discipline: it starts
from the fresh modern record, inserts with `or_insert` so the modern copy wins
every collision, recomputes candidacy rather than trusting the allowlist, and
has no write path at all for alias, status, the dpp identity, DPNS names, or
the wallet link. A voting key drags its voter-identity link along, since one
without the other cannot vote.

M1-M10 from the design doc's test matrix. M2/M5/M6/M9/M10 were confirmed RED
against a naive legacy-wins merge before this implementation landed.

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

* feat(database): read one legacy identity row through the shared row decoder

Issue dashpay#889: the recovery flow needs one identity out of the preserved legacy
`data.db`, not the whole table. A second hand-written reader would be free to
drift from the importer's idea of which rows are readable — and then recovery
could merge from a record the migration never considered importable, or miss
one it did.

So the per-row decode moves into `decode_identity_row` and both readers call
it, along with one shared `SELECT` that binds the same pair of accepted network
spellings. `read_identity_row` adds only the `id` predicate on top.

`LegacyIdentityLookup` keeps "nothing to recover" and "the row will not decode"
apart. An absent table, an absent row, an observed-identity cache row and a
NULL blob are all `Absent` — the ordinary answer. Only a genuinely corrupt row
is `Unreadable`, so no caller can mistake corruption for an empty record and
merge against it.

`open_legacy_read_only` is hoisted next to `database/mod.rs`'s existing one, so
`SQLITE_OPEN_READ_ONLY` is a property of the one function that opens the file
rather than of each caller remembering the flag.

The importer's 12 existing row-decode tests are the regression net for the
extraction; 8 new tests cover the lookup, including one asserting both readers
decode the same row identically.

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

* feat(identity): restore keys stranded in the previous version's saved data

Issue dashpay#889: an identity that was only partially loaded before the v1.0 upgrade
keeps its remaining keys in `data.db`, reachable by nothing — the load form
rejects the duplicate ProTxHash, and the per-key screen wants a WIF the user may
no longer hold. This is the way in, and it is entirely user-driven.

`CheckLegacyRecovery` lists; `RecoverLegacyIdentityData` writes. Detection is
read-only, offline and side-effect-free, so a screen can dispatch it on arrival
and hide the affordance when the plan comes back empty. Candidacy is recomputed
inside the executing task rather than trusted from the caller: only
`recomputed-candidates ∩ approved` is merged, so an approval that went stale
since the preview is reported rather than acted on, and an item the user never
approved is never restored even when it is missing. An empty allowlist is
refused, not widened into "restore everything".

The protection-downgrade trip that sank an earlier reconcile attempt is
unreachable here by branch condition, not by care: the flow branches on the same
predicate the at-rest guard evaluates. On a protected identity the password is
verified up front through the shipped prompt, the resident-plaintext preflight
runs before any vault write, and every merged key is sealed under that password
before the record is persisted — so the encoder never sees plaintext on a
protected identity. On a keyless identity there is no protected key for the
guard to protect. Cancel, wrong password and headless all fail closed with the
stored record byte-identical.

Exactly one record write, so the merge either lands whole or changes nothing.
No new persisted state: eligibility recomputes from the two existing stores, so
a re-run is a no-op and `data.db` — opened read-only — stays a repeatable
recovery source.

`reject_resident_identity_plaintext` widens to `pub(super)`; it now guards both
seal boundaries. Two test-only readers expose the at-rest blob and the wallet
link, which the hydrated record cannot show.

B1-B12 from the design doc's test matrix, on the offline wired `AppContext`
harness, plus the excluded-key and empty-allowlist cases.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* feat(masternodes): offer the previous version's stranded keys on the node page

Issue dashpay#889: the restore path exists but nothing reaches it. The node detail
page is the canonical surface — its operator is the one holding a node that
cannot vote because its voting key stayed behind in the previous version's
saved data.

The offer is passive and contextual, never a launch-time nag. On opening a
node the view dispatches the read-only detection task once, and only on an
install that actually has a previous-version database; the section renders in
the Keys area only when detection found something, and retires itself once a
restore lands, because eligibility recomputes from the two stores every time.

For a node with no voting key that becomes the primary remedy: the
missing-voter message points at restoring, which asks nothing of an operator
who no longer has the key, and the existing WIF prompt keeps its place as the
fallback for one who does.

`LegacyRecoveryState` (`ui/state/`) owns the fetch state — dispatch-once,
in-flight, offered, retryable — and `LegacyRecoverySection` (`ui/components/`)
renders it, per the placement rule's render/no-render split. A failed restore
returns to its offer so a mistyped identity password can be corrected and
retried; a failed detection does not re-ask.

A preview result is routed into the open detail view rather than triggering the
list screen's reload-and-reopen, which would rebuild the view, re-dispatch its
check and never settle. A completed restore does reload, since the store
changed, and the rebuilt view's fresh check finds nothing left.

`RecoveryItemDescriptor::is_voting_key` factors out the rule `label` already
applied, so "is this the voting key" has one definition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* feat(identities): offer stranded legacy keys on the Key Info screen

Issue dashpay#889: the node page covers the masternode case, but a `User` identity
that was partially loaded before the upgrade has no node page. The Key Info
screen is the issue's own suggested location and the one surface every identity
type reaches — from the Identities list for a user identity, and through Manage
keys for a node.

The offer is identity-scoped rather than key-scoped, so it renders whatever the
opened key's own state is: what it lists are precisely the keys the identity
does *not* hold, which no per-key view could show. Both surfaces share
`LegacyRecoveryState` and `LegacyRecoverySection`, so detection, the allowlist
and the copy have one definition each.

Dispatch follows this screen's established queue-then-drain pattern, alongside
the protect/unprotect ones. The check and a restore can never contend for the
frame's single `AppAction`: the check goes out once on arrival, and a restore
only after a press. A completed restore re-probes the protection status, since
the restored keys have just landed in the vault.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* docs(identity): record the legacy-key recovery flow and close the migration gap

Issue dashpay#889: the migration design's §7 named this limitation and deferred it to
"a dedicated, provenance-aware flow". That flow now exists, so the limitation
gets its resolution rather than staying an open pointer to an issue — with the
one clarification a reader of that section needs: the importer itself did not
change, skip-if-present still stands, and nothing about recovery runs at
migration or launch time.

The design record moves into `docs/ai-design/` under this repo's dated-directory
convention, so the cross-reference resolves inside the repo.

U1 covers the widget from the outside: an empty plan renders nothing, items are
named by role with the voter link folded into its voting key, pressing Restore
approves exactly the previewed set, an in-flight restore has no button to press
twice, unrestorable keys are listed with no restore offered, and an install with
no previous-version data never even asks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(model): only offer legacy keys that still correspond to the identity

The plan admitted any legacy key the modern record happened not to hold,
keyed on the map slot alone. Nothing checked that the saved private half
derived the public half it was stored with, that the public half was still
a live key of the identity, or that a wallet-derivation reference named a
wallet this install holds — the exact checks the manual "type the WIF"
path this flow replaces already enforces.

That matters because `masternode_key_presence` reports a role as held from
the record alone. Restoring a key the node rotated away from flipped the
role to "present", retiring both the recovery offer and the missing-voter
remedy, and the operator found out from a rejected transaction. The voter
link alone did the same thing with no key behind it at all.

Failing candidates now go to `excluded` with their own reason and are
never restorable. `AppliedRecovery` also reports that `excluded` list, so
an approval naming something that was never restorable is no longer also
reported as a stale approval ("already back in place" — the opposite
answer), and the caller stops recomputing the same plan to read one field
off it. Whatever plaintext the legacy record still holds after a merge is
zeroized instead of being dropped intact.

The item labels and exclusion copy move out of `model/` into the UI, where
they now delegate to the shipped `role_label_and_tip` vocabulary rather
than a second mapping that disagreed with it on the same screen ("Payout
key" next to "Payout address key"), and two rows in one role are told
apart by key id instead of rendering identically.

Test fixtures move onto genuinely derived key pairs with the chain's
public keys published on the record — a correspondence check that passes
against placeholder key material verifies nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(identity): stop holding the storage guard across the password prompt

`recover_legacy_identity_data` took `migration_run` for the whole async
span, prompt included. The delete path it copied that pattern from is
fully synchronous and holds the mutex for microseconds; this held it for
as long as a modal sat in front of the user. Two independent reviewers
reproduced the fallout: removing an *unrelated* identity failed with
"storage is still being updated" when no migration was running, and
`finish_unwire`'s awaiting acquire could park indefinitely.

The flow is now a preflight and one fully synchronous critical section.
The preflight reads, dry-runs the merge to see whether anything would be
restored at all, decides whether a password is needed, and prompts —
holding nothing. The write section then re-acquires the guard, re-checks
the migration state, re-reads the record, merges again and writes, all
without an await in between.

Re-reading is not just tidiness. The prompt window let any other writer
land in between — refresh, DPNS registration, transfer, none of which take
the load claim — and the write of a pre-prompt snapshot silently reverted
it. It also makes the no-resurrection guarantee explicit: a delete during
the prompt now surfaces as `IdentityNotFoundLocally` instead of being
undone by the upsert, which until now held only as a side effect of how
long the lock was kept.

The at-rest protection predicate is evaluated over the merged record, the
one the encoder's downgrade guard actually sees, rather than over the
pre-merge record. If the identity gained password protection during the
prompt the verified password no longer covers what is about to be written,
so the restore stops with a typed `LegacyRecoveryIdentityChanged` rather
than sealing under a password for a different state.

Also: the nothing-to-recover success no longer records a `Failed` load, the
preview zeroizes the stranded plaintext it decodes on every screen open
instead of releasing it intact, and the "nothing restored" banner is worded
to be true of both outcomes that produce it.

Both concurrency tests were confirmed RED against the old lock scope,
reproducing the reviewers' exact failures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(ui): keep the restore offer and the record it wrote in step

Three ways the two shipped surfaces disagreed with the store after a
restore, and the state machine behind them had no tests at all.

The Key Info screen keeps the identity clone it opened with, and its own
add-key and remove-key paths persist that whole clone. This feature added
a second writer to the same record reachable from the same screen: restore
keys, then add or remove any key, and the pre-restore copy was written
back — the restored keys gone, right after a banner said they were back.
The completion now re-reads the record and the key on screen from it.

The node page never hears about a restore run from the Key Info screen it
pushed: the pushed screen is on top, so it takes the result. Coming back
left the page offering keys already restored and warning about a voting
key it now held. Arrival re-reads the node and re-arms its check; vote
selections and any open prompt survive, which a blanket rebuild would have
discarded. The `recovery_completed()` call in the result handler goes with
it — the reload that follows re-opens the view and re-arms the check
anyway, so it was mutating a view discarded three statements later.

Writing the state machine's tests turned up a third one: `completed()`
re-armed unconditionally, so calling it on an install with no
previous-version database armed a check that must never run. Harmless
until arrival started calling it; now `Unavailable` stays unavailable.

The Restore button loses its ellipsis: on a keyless identity — the common
case this feature exists for — it commits immediately rather than opening
anything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* docs(identity): record what the recovery flow actually shipped

The committed design record was landed marked "implemented" with exactly
one edit — the status line — so the repo's permanent account of this
feature described a dispatch model, a progress mechanism, a module set and
a gating rule that were all deliberately changed, plus the pre-review
merge rules that the fix round replaced.

A §10 now records all nine departures with the reasoning that produced
them, rather than rewriting §1–§9 into a story nobody actually followed;
the status line says which of the two is authoritative. §7's claim that
`finish_unwire.rs` is untouched is reconciled with §4.2's hoist in the
same document: the importer's behaviour is unchanged, the file is not.

`reject_resident_identity_plaintext`'s header claimed to guard "every
boundary that seals an identity's keys". It does not — the merge-load path
seals without it. The header now says which boundaries it covers, and that
path carries a TODO describing the gap. Fixing it is a separate issue:
it is pre-existing code, unrelated to this feature.

Also: user story and CHANGELOG pick up the correspondence check and the
non-blocking prompt, the components catalog names the copy this feature's
component now owns, and three private rustdoc blocks come back inside the
internal-commentary budget.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(model): stop the legacy blob from vouching for its own voter key

The correspondence check that keeps a rotated-away key out of the offer
resolved a voter/operator-target key's identity as "the modern link, or
the legacy one if there is no modern link". In the case the check exists
for -- a masternode whose record carries no voter link -- that fallback
read the identity snapshot out of the same unauthenticated legacy blob
as the key under test, so any self-consistent pair passed. A voting key
the chain retired months before the upgrade still reads as live in that
snapshot, and restoring it flips the node's voting role to "present",
retiring the manual remedy that would actually fix the node.

reference_identity now reads the modern record only. A key on a voter or
operator identity the modern record does not link to has no admissible
witness and is excluded as LinkedIdentityUnverified, carrying the same
"load this identity again and enter the key" remedy as its neighbours.

Fetching the linked identity from Platform was rejected twice over. It
would put the network in the middle of an offline, read-only preview
that every screen arrival dispatches, and model/ is pure by contract.
More decisively, it would not establish the property: a voter identity's
id is derived from its voting key (Identifier::create_voter_identifier),
so rotating the key on-chain creates a *different* voter identity rather
than retiring a key on the existing one. Fetching the legacy-named voter
identity would confirm the stale key against its own orphaned identity.

Behaviour change worth noticing on its own: a masternode loaded from its
ProTxHash alone no longer gets its voter-identity-target voting key back
in one click. The key is listed as unverifiable, the voter link goes with
it, the node keeps reporting the role as missing, and render_missing_voter
keeps offering manual entry -- the honest outcome, since nothing available
offline can tell that key from one the chain replaced. A record that does
carry the voter link (a re-load keeps it) restores the key as before.

Grouping follows the same rule: voter_association_is_grouped now keys on
the voting role, the predicate that already decides whether the link is
worth offering, so the two cannot disagree about which key the link
belongs to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(identity): gate the recovery offer on legacy rows, not on data.db existing

FetchState::Unavailable was documented as the state a fresh install never
leaves, but no shipped install could reach it. AppState::boot_inputs
opens an existing data.db read-only *or* creates one and runs the
fresh-install schema ladder, so db_file_path().exists() is true for every
user, and every identity or node screen dispatched a detection task
against a table the ladder had just created empty. The kittest that
claimed otherwise passed only because the `testing` feature substitutes
an in-memory database whose path is None -- a test of the substitution,
not of the gate.

AppContext::has_legacy_identities now asks whether the legacy `identity`
table holds a local row for this network: one SELECT EXISTS over the same
filter read_identities uses (so "there is something here" and "here it
is" cannot drift), false when the table does not exist at all, answered
once per context and cached. A probe that errors arms the offer instead
of retiring it -- the detection task reports its own typed error, while a
silent false would withdraw a recovery the user's data still supports.

The kittest is replaced by lib tests over a real file-backed data.db,
which assert the premise the old gate missed (the file is there and the
gate still says no) and cover the network scoping a file check could
never have. Confirmed red against the old gate before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(ui): re-read the key screen on arrival and attribute a finished restore

Two ways a restore's result reached the wrong screen state.

KeyInfoScreen had an empty refresh(), which refresh_on_arrival defaults
to, so returning to a screen that missed its result never re-read the
record. That screen persists the whole identity clone it opened with on
every key add or remove, so the clone -- predating the restore -- was
written back over the restored keys on the next edit, silently and with a
successful save to show for it. It now re-reads on arrival, re-probes the
protection line and re-arms its check, exactly as the masternode detail
view already does.

The LegacyRecoveryCompleted arm discarded identity_id, unlike the
LegacyRecoveryCandidates arm directly above it, which routes through
LegacyRecoveryState::offered's identity check. Results reach whichever
screen is visible when they arrive, not the screen that dispatched them,
so a restore dispatched from identity A's Key Info screen could land on
identity B's: B showed "your keys have been restored to this identity"
for a restore that was never B's, and B's own in-flight recovery state
was reset under it.

The attribution rule now lives once, in LegacyRecoveryState as
completed_for -- the twin of offered -- so both call sites share it
rather than each remembering to check. The masternodes list applies the
same rule through shows_node: a completion for an identity no card and no
open detail names is dropped, banner, reload and detail rebuild included.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* test(identity): pin the mid-flight protection guard against regression

TaskError::LegacyRecoveryIdentityChanged is listed in
persist_legacy_recovery's rustdoc and spelled out in design.md 10.6, but
was the only documented error branch of that function with no test --
every sibling (IdentityLoadInProgress, WalletStorageNotReady,
IdentityNotFoundLocally, LegacyIdentityUnreadable, the prompt's cancel
and unavailable errors, IdentityKeyProtectionIncomplete, the deleted
identity, the undecodable row) has one.

B13 drives the 10.6 scenario: the dry run on a keyless identity decides
no password is needed, a ProtectIdentityKeys lands before the write
section re-reads the record, and the write refuses with the typed error
and a byte-identical stored blob.

It calls persist_legacy_recovery directly with the None the dry run
produced, as B5 calls verify_recovery_password directly: a Tier-1 restore
never prompts, so there is no await point between the two where a
concurrent task could be made to land deterministically. The dry run is
exercised first so the None under test is the one production carries.
Verified the test fails when the fail-closed branch is removed, rather
than merely restating it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

* fix(model): stop the legacy blob from vouching for its own operator key

The voter-target fix landed one half of the rule: a key on a voter or
operator identity the modern record does not link to has no witness
outside the legacy file, so it is excluded. The operator *link* itself
was offered with no gate at all, which left the same hole open one step
further out.

Restoring it writes the legacy file's own claim into the modern record.
The next preview then reads that claim back as the outside witness for
the same file's operator keys, and every one of them becomes a
candidate — self-vouching in two moves instead of one. Nothing in
production ever writes `associated_operator_identity` fresh, so an
offered operator link was always in that unverifiable state.

An operator link the modern record does not carry is now excluded as
`LinkedIdentityUnverified`, like the keys held on that identity.

Also renames M8, whose name and opening sentence claimed it proved the
whole legacy key set is offered while its own assertions exclude two of
five items.

* fix(masternodes): drop the missing-voter message that can never be shown

The node page carried a second missing-voter message offering restore as
the primary remedy, chosen whenever the recovery plan held a voting-role
candidate. Since the voter-key check began reading the modern record
only, that candidate exists only when the record already carries the
voter link — and a record carrying the voter link is written in the same
branch that writes the voting key, so it is never in the missing-voter
state to begin with. The two conditions are mutually exclusive: no user,
and no test, has ever seen that message or its companion tooltip.

Gone, along with the predicate behind them. The remaining message is the
honest one and still matches what the screen offers: the in-place "Add
voting key" prompt right underneath it, which needs no re-load.

Restoring a voting key held on a separate voter identity needs an
on-chain check this flow deliberately does not make; that is issue dashpay#942,
not something this branch pretends to do.

* fix(ui): let the recovery section read as a whole when it can restore nothing

A plan holding only unrestorable items — now the ordinary shape for a
masternode loaded from its ProTxHash — opened with "Some keys ... haven't
been brought across", followed by no list, no button, and then a second
heading for the items it cannot bring back. A lead-in that promises an
action the section never offers reads as a broken screen.

That case gets its own lead-in, which introduces the list itself, so the
second heading appears only when there really are two lists to keep
apart.

The reason an item cannot be restored also stops being hover-only. It is
the single sentence that tells the user what to do instead, and a tooltip
reaches neither touch nor keyboard; it is now an inline line under each
item. The reason for an unverifiable linked identity is reworded to read
correctly for the operator link as well as for a key held on one.

* docs(identity): stop promising a voting-key restore that cannot happen

The user story and the changelog both said restoring is offered as the
first remedy for a node with no voting key, with typing the key in as the
fallback. For the flagship case — a masternode loaded from its ProTxHash
alone — that is never true: the voting key sits on a separate voting
identity that only the previous version's data names, so it is listed as
one that cannot be brought back and entering it by hand is the only
remedy. Naming owner, voting and payout together in the same breath
carried the same implication and is dropped.

Both now describe what happens, and point at issue dashpay#942 for the on-chain
check that would let the voting-key case be closed safely later.

The design record gains the two shipped deviations behind this: §2.1's
"restore first, WIF as fallback" branch removed as unreachable, and the
operator link excluded as unverifiable for the same reason its keys are.

* fix(identity): give the identity keys list a way into each key

The identity hub replaced the legacy Identities screen's per-key popup —
the only route into KeyInfoScreen that did not depend on the identity
already holding a usable key — with Settings > Advanced > "Manage keys".
That destination was a read-only table with no route onward, and
RootScreenIdentities is deliberately out of the nav, so the popup it
still hosts is unreachable. Every remaining route (transfer, withdraw,
the token screens) is gated on holding a key of the kind that action
needs, which is false for precisely the identities issue dashpay#889's restore
offer exists to help: a user identity with stranded keys could reach its
own keys only in Developer view, through a send-money screen.

KeysScreen now carries the QualifiedIdentity, so it can say which keys
this device holds and open KeyInfoScreen with that key's private
material. One row per key, named in the shared role vocabulary, held
state stated in words rather than by colour alone, ungated.

The restore offer renders on the list itself, above the rows: it is
scoped to the identity, and making a user pick an arbitrary key to
discover an identity-level offer repeats the defect one level up. It
stays on KeyInfoScreen too, which self-extinguishes and is where the
masternode path lands.

The screen also moves inside the app chrome (top panel, left panel,
island_central_panel). That is not cosmetic: island_central_panel is the
only caller of MessageBanner::show_global, so on a raw CentralPanel the
restore reports nothing at all — a failed restore would be
indistinguishable from a successful one.

key_role_label, manage_keys_labels and the key enumeration move from
detail_screen into ui::masternodes beside the vocabulary they belong to,
so the two "Manage keys" surfaces cannot drift apart; the masternode
kittest covering that list is the regression guard for the move.

The transfer screen's no-key branch gains a "Manage keys" signpost. Its
available_transfer_keys() gate is untouched — it still correctly says
the identity cannot send; the branch simply stops being a dead end.

* test(identity): pin the restore outcome and the key naming on the keys list

The offer self-extinguishes on the next check whether the restore landed
or failed, so its disappearance says nothing about the outcome. Only the
banner distinguishes the two, and the banner only reaches the user
because the screen is built on island_central_panel — the sole caller of
MessageBanner::show_global. Cover both outcomes explicitly, and pin the
render channel itself: reverting the screen to a bare CentralPanel fails
the new guard, which is what makes it worth having.

Also pin AC-6 across the two surfaces that name keys. The restore offer
and the keys list render from different modules over different types,
and nothing but their shared derivation from role_label_and_tip keeps
them saying the same words; a private re-implementation on either side
would read plausibly and drift in silence.

Key Info remains outside that assertion: it does not retain the
PrivateKeyTarget its key was opened with, so it cannot tell a
voter-identity key from a main-identity one, and naming it in role words
today would risk labelling a voting key as an authentication key.
Threading the target through its nine call sites is the fix, and it is
wider than this change.

* test(identity): report both restore outcomes on the keys list in the running app

The component tests prove two things separately: the screen sets a banner
on a finished restore, and it renders whatever banner its context carries.
Neither closes the seam between them — that the context the screen writes
its outcome into is the context it renders from. A restore reporting into
one context while the screen renders another would pass both and still
tell the user nothing.

This drives the real app to the keys list, delivers a completed restore
through visible_screen_mut the way AppState does, and asserts the success
text appears on the screen; then banners a task error on the app context,
as AppState does for a failed restore, and asserts that appears too. The
offer retires itself either way, so the banner is the only thing that
distinguishes them.

Reverting the screen to a bare CentralPanel fails this test, which is what
makes it a guard rather than a decoration.

* fix(identity): make the keys list and Key Info agree on where a key is filed

SEC-001. The keys list resolves each key's PrivateKeyTarget from the identity
it walked, then discarded it; KeyInfoScreen re-derived one from the key's
purpose. impl From<Purpose> for PrivateKeyTarget sends every voting key to the
voter identity, so for a Purpose::VOTING key filed on the main identity — a
supported shape, which masternode_key_presence reads as voting readiness on its
own — the two disagree. The list reports the key as held, Key Info re-reads the
record on arrival, looks in a store the key was never filed under, finds
nothing, and reports it missing. On the screen built to answer that question,
about a key whose private half is listed one screen earlier.

KeyInfoScreen now accepts the resolved target via with_target and uses it for
every lookup, falling back to the old derivation when no caller supplied one, so
the call sites this change does not touch keep their current behaviour. Only the
keys list passes it. The same derivation still governs the pre-existing callers'
reads, writes and deletes — including a delete that can land on a different key
when voter and main key-id spaces overlap — which is filed separately.

Also from QA:

- SEC-002: the held/not-held column called get_cloned_private_key_data_and_
  wallet_info, copying raw key bytes out of the vault unscrubbed, every frame
  for every key, to read is_some(). KeyStorage::has answers it without a copy.
- QA-005: display_message ended a restore on any MessageType::Error. Results
  are not screen-affine, so an unrelated task's failure re-armed Restore
  mid-flight and it could be dispatched twice. Attribution moves to
  display_task_error, which receives the typed error; it returns false so the
  user still gets AppState's banner. Residual documented on is_recovery_error:
  the same variant from another task during a restore still ends it, which needs
  the task's identity to reach the screen.
- QA-003: the completion-banner test asserted only that some banner existed.
  It now pins attribution instead — another identity's restore reports nothing
  here — since fresh_app_context drops the harness its context belongs to and
  the text cannot be read back at that level. The running-app test already
  pins the text.
- QA-006/007: coverage for the transfer-screen signpost and for the Expert-only
  detail row in both directions.
- Empty-state copy said "no keys saved on this device", which in this screen's
  own per-key vocabulary means held=false. An empty record is a different
  statement; the test that codified the wrong string is fixed with it.
- QA-002: "island_central_panel is the only caller of show_global" is false —
  contracts_documents_screen calls it directly. Corrected where it was written
  down as justification.
- key_role_label back to pub(crate); ui/masternodes' module header no longer
  implies its key vocabulary is Expert-Mode-scoped, since an Everyday identity
  screen depends on it.
- Docs: §2.1 and §7 row 1 restored as written, per the document's own
  proposal-plus-deviations model, with §10.13 carrying the correction. CHANGELOG
  no longer claims transfer was the only route to a key's page, and no longer
  promises signing or password protection to an identity that holds no keys yet.

* fix(identity): return from a key to a current list, in the identity's own words

Track A of round 3.

Leaving a key now pops with a refresh, and the keys list re-reads both halves of
its state on the way back. Only re-reading the record would show a key restored
from the pushed Key Info screen as held while still offering to restore it —
and pressing Restore then reports there was nothing left to do. Consolidating
into refresh() also retires the refresh_on_arrival override, which never ran:
the framework dispatches that hook for root screens, and this screen is pushed.

KeyInfoScreen gains a with_parent breadcrumb crumb so the trail leads back to
the screen the key was opened from. Parameterised, not hardcoded: it has nine
parents and naming one of them for all of them would mislabel the other eight,
which keep their two-level trail unchanged.

Key role words now follow the identity that owns the key. The DIP-3 wording
describes masternode registration duties — updating a registration, receiving
node rewards — and on a user identity that does not read as jargon, it asserts
the user owns a masternode. A user's TRANSFER key is a Transfer key, not a
payout address. Both the keys list and the restore offer take the same
vocabulary from the same identity: scoping only one would have a user's offer
say "Payout address key" six lines above a list saying "Transfer key", which is
the two-names-for-one-key defect the shared vocabulary exists to prevent.

Empty-state copy no longer says "no keys saved on this device" — these rows are
the identity's on-chain public keys, and that phrase means held=false per-row on
this same screen.

Track B, reads only, after verifying the read/write split against the source
rather than the report: :746 (feeds the vault chokepoint, whose consequence is
signing with and displaying the wrong key's material) and :887 (reload) are
reads and now resolve the target the caller established. The keys list also
resolves held-ness against both conventions, since real installs hold material
written under each; looking in a second place can only find material that is
already there.

The writes are deliberately reverted to the purpose-derived target, which the
previous commit had changed. QualifiedIdentity::sign and can_sign_with resolve
that way, so material stored anywhere else is material that can never sign: for
a voting key filed on the main identity, the previous commit would have taken a
hand-entered private key and stored it where the signer never looks. Removal
keeps the derived target for the same reason, which leaves wrong-key removal as
a documented residual. Both sites carry the reasoning, and reconciling the two
conventions is a migration this PR is not the place for.

* fix(identity): one attribution rule for both hosts of the recovery offer

The keys list gained typed error attribution while the Key Info screen kept
ending a restore on any MessageType::Error, so the two screens hosting the same
identity-scoped offer disagreed about whether that restore had ended. An
unrelated task's failure still re-armed Restore on one of them.

Attribution moves onto LegacyRecoveryState::owns_error, which already knows the
identity it belongs to, and both screens call it from display_task_error. One
implementation, so the two hosts cannot drift; returning false keeps AppState's
banner, because the user still has to see the error.

Key Info keeps its own blanket handling for the separate protection-migration
gate, which is a different state machine with a different in-flight guard.

* fix(identity): identify a key by its public half, and name it the same everywhere

Two reviewers found the same hole in the two-candidate held-state lookup. It
derived its fallback target by looking key_id up in the main identity's key set
even while checking a voter-identity row, and KeyStorage::has only proves a slot
is filled, not whose material fills it. A main identity holding a voting key at
id N alongside a linked voting identity holding its own key at id N would report
the main key as held on the strength of the other key's private half — and hand
that material to Key Info. The loop already has the key, so the fallback comes
from key.purpose(), and each candidate now has to hold this exact public key.
Reverting to slot presence fails the new test.

AC-6's third surface: the Key Info page still named its key by raw Debug
purpose, so the list and the restore offer agreed with each other and not with
the page they open. It now takes the same identity-scoped vocabulary, with the
raw purpose kept as Expert detail rather than dropped.

The CHANGELOG's known-limitation note said what the app does internally and left
the reader nothing to do about it. It now names the situation, what can go wrong,
and the check that resolves it — and drops the claim that saving uses "the older
of the two places", which is not what makes saving risky. The collision with a
same-numbered key is.

* fix(identity): Key Info names a user's keys the way the keys list does

The restore offer is hosted by three screens — the keys list, Key Info, and
masternode detail — and each names the keys it offers to bring back. Two of
them were scoped to the identity's vocabulary; Key Info was not. A plain
user's transfer key was therefore offered as a "Payout address key" there,
asserting the user owns a masternode, and contradicting the keys list one
screen away, which calls the same key a "Transfer key". Someone deciding
whether to restore a key should not have to work out that the two names are
the same key.

The literal fix is one argument, but a third site forgetting the same call is
a design problem, so the footgun goes with it: `vocabulary` moves from a
builder option defaulting to the masternode wording to a required constructor
argument. Omitting it is now a compile error rather than a plausible-looking
wrong label, and a fourth host cannot inherit masternode words by accident.

A required argument catches a *missing* vocabulary, not a *wrong* one, which
is exactly what happened here — so the guard is at the host: the new test
drives Key Info through the route the user takes, arms the offer, and reads
the words off the rendered offer. Reinstating the wrong vocabulary fails it.

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

* fix(identity): a disabled key the device holds is still reported as held

The stored copy of a public key is a snapshot taken when its private half
was saved. A Platform identity public key is immutable once added with one
exception — disabling rewrites `disabled_at` — so full-struct equality
between the snapshot and the live key stopped matching the moment a key was
disabled or rotated, and the row reported a key this device demonstrably
holds as missing. Same symptom as the voter-identity lookup bug, different
trigger. It fails safe, understating possession rather than over-, which is
why it survived the earlier fix.

`same_key` therefore excludes the one field that legitimately moves, and
nothing else. Comparing just the id and the key material would also fix this
and reopen a worse hole in the other direction: `id` is already the lookup
key, and a main identity's voting key and a linked voter identity's key can
carry identical `data`, leaving `purpose` as the only thing telling them
apart. Conflating those reports the main identity's key as held on the
strength of a different key's private half and hands that material to Key
Info — the exact defect this lookup exists to prevent. Verified both ways:
the narrow id+data comparison passes the new disabled-key test and fails
`a_same_numbered_key_on_the_voter_identity_is_not_mistaken_for_this_one`.

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

* fix(masternodes): a key opened from a node's page keeps the name the list gave it

The detail view's key rows already knew where each key's private half was —
they looked there to decide whether it was held — but dropped that on the way
to `KeyInfoScreen`, which then re-derived the location from the key's purpose.
The two disagree for a key filed on the voter identity whose purpose is not
`VOTING`, and `role_label_and_tip` exists precisely because that shape occurs:
a key on a voter identity is the node's voting key whatever its purpose field
says.

So the list said "Voting key" and the page one click later said
"Authentication key", about one key. The same wrong target drives the page's
own re-read, so it also reported a key the device demonstrably holds as
missing — the identity-path defect, on the masternode path.

Forwarding the row's target closes both. The new test drives the real route
(card → detail → key row → Key Info) rather than the pieces, since this
drifted where nothing crossed the two surfaces; reverting the fix fails it on
the name assertion.

Also corrects a comment falsified by this branch: the identity keys list is no
longer "the static read-only KeysScreen table".

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

* refactor(identity): one held-key resolution rule for every Manage keys surface

`identity_keys()`'s doc comment claimed that sharing key enumeration is "what
keeps the two surfaces agreeing on which keys are held". It wasn't true.
Enumeration was shared; resolution was not. The identity keys list tried both
filing conventions and verified the match, while the masternode detail view
passed the structural target straight through — so a voting key held on the
main identity but filed by purpose derivation, which is what entering one by
hand produces, read as saved on the keys list and missing on the node's page.

So `filed_at` and `same_key` move out of `keys_screen` to sit beside
`identity_keys` as `key_filed_at`, and both surfaces call it. Enumerate alike,
resolve alike. The doc comments on both now describe what the code does: the
structural target is half of pairing a public key with its private material,
and this is the other half.

Only reads change. The two conventions still disagree on the write path;
reconciling that is a migration, tracked separately.

Coverage at both levels: a unit test next to the function pins all four
properties of the rule (either convention found, wrong key at the same id
rejected, `disabled_at` tolerated), and a kittest drives the node's real route
to prove the surfaces now agree. Reverting `detail_screen` to the raw
structural target fails the kittest.

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

* refactor(identity): make an upstream key field a build failure, not a silent gap

`same_key` compares an identity public key field by field, deliberately
skipping `disabled_at`. Written as a chain of accessor calls, a field added
upstream would simply not be compared: two keys differing only in that field
would compare equal, and this function decides which stored private material
belongs to which key.

Destructuring `IdentityPublicKeyV0` exhaustively, without `..`, moves that
decision to whoever adds the field — the build stops until they say whether it
identifies a key or, like `disabled_at`, only describes its state. Verified the
guard bites: dropping a field from the pattern fails with E0027 "pattern does
not mention field", which is exactly what an upstream addition would produce.
Behaviour is unchanged, so the existing tests hold as written.

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

* fix(identity): reach Key Info's re-read, share the restore offer, name keys whole

Three defects a review found on the Key Info navigation work.

Key Info's arrival re-read hung off `refresh_on_arrival`, which `AppState`
dispatches only to root screens. This screen is always pushed onto the screen
stack, so the reload, the protection-status reset and the offer re-arm were
unreachable: a write landing off-screen was erased by the next ordinary key
edit, which persists the whole identity clone the screen opened with. The body
moves onto `refresh`, which does reach a pushed screen; `refresh_on_arrival`
delegates there by default, so both hooks still run it. The regression test
moves to kittest and drives the app's own `TaskResult::Refresh` dispatch, since
hand-calling the hook passed against the bug.

The offer to restore stranded keys was hand-copied across its three hosts:
render, result routing, error routing, dispatch. The render chain becomes
`legacy_recovery_section::host_offer` and the attribution rules become
`LegacyRecoveryState::absorb_result` / `absorb_error`, leaving each screen only
its own side-effects. A shared rule applied to two hosts of three is what caused
an earlier bug here; a fourth host now cannot repeat it.

Key role labels were assembled from fragments — a bare role word, an English
noun, a state, a positional key id — and two surfaces interpolated a raw
`Purpose` through `Debug`. `role_label_and_tip` now returns whole `&'static str`
captions keyed on vocabulary, purpose and retired state, so composing one at a
callsite is impossible rather than discouraged, and matches every `Purpose` by
name so a variant added upstream breaks this build instead of reaching a user as
`{:?}`. The raw purpose keeps its place on Key Info as its own Expert field.

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

* docs(user-stories): point IDN-008 at the key list that shipped

The catalog entry for viewing identity keys still described criteria that
predate a reachable route into a key's own page — this branch is what first
gives `KeysScreen` a way into `KeyInfoScreen` at all. The real criteria had
landed under IDN-020, a legacy-migration story, and IDN-008 also overstated the
Everyday view: the new list gates key id, purpose, security level, type and
read-only behind the Expert view.

Restates the criteria as what ships — reachable from the identity's Settings →
Advanced without changing interface mode, every key openable whether or not the
device holds it, role and held state in words, on-chain specifics as Expert
detail — and cross-references IDN-020 for the restore offer rather than
duplicating it.

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

* docs(changelog): document the Key Info refresh and label-consistency fixes

Covers the two user-facing fixes from this round's review-comment pass on
PR dashpay#945: the stale-clone refresh wiring and the whole-caption key-role
labels.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M9RDy2kYvhYUdFhEYZ1QRd

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 06e64e7)
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.

3 participants