feat: masternode owner edition (compile-time nav gating) - #888
Closed
lklimek wants to merge 47 commits into
Closed
Conversation
Additions only (no edits/removals) covering gaps found while auditing v0.10-dev feature parity against PR #860 (DPNS, network/settings, UX, masternodes, DashPay send/receive, wallet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The RPC/Dash-Qt backend mode is gone in the SPV-only rewrite, but NetworkChooserScreen still carried a `dashmate_password_input` that was constructed, seeded from disk at startup, and re-seeded via a synchronous `Config::load_from` on every network switch — while never being rendered anywhere. Delete the field and its disk-read plumbing, along with the now purposeless `prev_network` sentinel that existed only to trigger the re-seed. Removes a blocking file read from the network-switch UI path. `NetworkConfig::core_rpc_password` is left intact: it still round-trips through the `.env` serializer in `config.rs` (settings-schema scope). A10 (expert-mode nav refresh) is deferred: PR #879 (UserRole + composable FeatureGate) is still open and reworks this exact mechanism, and #880 stacks on it. Investigation found the nav-refresh bug already fixed on this base by #876 — every AppContext construction path shares one `Arc<AtomicBool>` developer-mode flag, the nav gate re-reads it each frame, and the Masternodes screen is always registered — so the existing comment describes present behavior correctly and needed no edit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eanup A10 (expert-mode nav-refresh mismatch) was found already fixed on base (fixed in 4343d03, pre-dates the stale 2026-07-10 audit finding) — no behavior change needed, comment already accurate. B3/net-rpc-password: deleted the dead dashmate_password_input field and its per-network-switch synchronous disk read. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the missing paper trail for three undisclosed removals (Masternode List Diff screen gravestone + CHANGELOG + gaps.md row; shielded per-note detail CHANGELOG line; three doc sites amended to disclose the QR-removal notice via CHANGELOG instead of an unshipped in-app notice), strengthens two under-described disclosures (address-table column, Proof Log persistence+viewer loss), and formally signs off ten already-disclosed removals in a new closure record.
CHANGELOG + gap-audit table entries for 3 previously-undisclosed removals, 2 strengthened disclosures, a gravestone comment for the retired Masternode List Diff screen, and a sign-off record for the 10 already-fully-disclosed "not planned" items. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"Stop Tracking Balance" was undone by "Refresh My Tokens": the refresh re-registered the full known-token registry for every local identity, so a dismissed (identity, token) pair was re-watched upstream and its row came back. Upstream owns the watch set in memory only, so the dismissal has to be persisted and re-applied DET-side. Persist dismissed pairs in the per-network k/v store under det:token_untracked:v1 and rebuild each identity's watch set as "local registry minus that identity's dismissals". Re-tracking stays possible through the paths the UI already promises: re-importing a token clears its dismissals for every identity, and explicitly checking one balance clears just that pair. Removing a token from the registry, and the devnet sweep, prune the dismissal list too. Regression test drives the real user action against an offline wired context (stop tracking, then assert the refresh watch set) and was confirmed RED before the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause: token_watch_sets re-registered the full token registry per identity on every refresh, resurrecting dismissed rows. Adds DET-side per-identity dismissal persistence in the platform-wallet k/v store (contract_token_db.rs) and excludes dismissed pairs from the refresh watch set. TOK-018 wording corrected to match fixed behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Users could not view or copy their own shielded receive address, so they
could not receive a private transfer at all: the Shielded tab rendered
only a placeholder because the live address read is async-only and the
egui frame loop is synchronous.
Bridge it through the push-snapshot seam this codebase already uses for
shielded/platform balances rather than inventing a new one:
- AppContext::shielded_addresses — frame-safe snapshot, written on the
async backend side by cache_shielded_receive_address() right after
ensure_shielded_bound() in bootstrap_wallet_addresses_jit (the seam
reached from both cold boot and the unlock gesture), read each frame
via the synchronous shielded_receive_address().
- Evicted on wallet removal: a receive address is a payment destination
and must not outlive the wallet that owns it.
- model::address::encode_shielded_address() — the pure raw->bech32m
inverse of parse_shielded_recipient; the MCP tool now shares it.
- Shielded tab renders the address with a hint, hover-for-full, and copy
on either the address or the Copy button; the truncation is display
only and the clipboard always receives the full string.
Funds safety: the address comes from the upstream-owned key slot
(PlatformWallet::shielded_default_address), i.e. the same OrchardKeySet
that bind_shielded registered with the NetworkShieldedCoordinator as the
viewing keys it scans with. It is never re-derived DET-side, so a
displayed address is always one the wallet can detect notes for. It is
Orchard account 0 — the only account DET binds and the only one its
spend path (shielded_transfer(.., 0, ..)) can spend from.
Diversified-address generation ("+") stays out of scope: upstream exposes
no per-index accessor (OrchardKeySet::address_at is reachable only via
the crate-private shielded_keys slot). Deriving them DET-side would
duplicate Orchard key handling outside the coordinator seam, and mapping
"+" onto a new ZIP-32 account would strand funds in an account the
single-account spend path cannot spend from. Documented as a TODO and
narrowed in WAL-028.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…and pay
The hub's Contacts tab rendered Accept / Decline / Cancel buttons that did
nothing, hardcoded "Active contacts · 0", and offered no way to pay a contact
or rename an identity without a detour through the retired legacy screens.
- Accept / Decline now dispatch AcceptContactRequest / RejectContactRequest.
- Cancel gains a backend task. A DashPay contactRequest document is immutable
and undeletable (documentsMutable: false, canBeDeleted: false), so a sent
request cannot be withdrawn from Platform. CancelContactRequest therefore
re-verifies state, broadcasts a hidden contactInfo document, and records the
withdrawal in the DET sidecar — the same shape reject_contact_request uses.
The UI copy says so plainly instead of promising a withdrawal the protocol
cannot deliver.
- load_contact_requests now consults the sidecar, so a declined or cancelled
request actually leaves the list instead of reappearing on every reload.
- Active contacts render from LoadContacts, with a working search box.
- Settings tab gains a local alias ("Name on this device") editor.
- Contact rows gain a Pay affordance that opens the existing send-payment
screen — no new signing or broadcast logic.
Contacts-tab state moves to ui/state/contacts_view.rs per the DET module
placement policy (it renders no egui).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Declining a request wrote a permanent local marker, so a request from that person stayed filtered out of the list forever — even after the user deliberately added them again. Sending a contact request now retires the marker, since sending is an explicit re-engagement. Also documents the two new hub stories and the cancel capability in the user-stories catalog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ity)
Bridges the async shielded-address read into the frame loop and restores
address display + copy. Address is sourced from the same OrchardKeySet
the shielded coordinator scans with (no re-derivation, no watch-window
hazard), evicted on wallet removal/switch. Diversified-address generation
("+") is deliberately deferred — the pinned platform-wallet rev has no
FVK accessor for it, and both workarounds available today are
funds-unsafe (account 1+ is unspendable via DET's current spend path).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…acy data.db Upgrading from v0.10-dev booted the app with a blank configuration: the network reset to Mainnet (a testnet user relaunched straight into mainnet), theme/onboarding/paths reset, and scheduled DPNS votes were silently dropped — a real vote-window deadline risk for masternode voters. Three imports, all idempotent and sentinel-guarded: - Settings (network, start screen, theme, onboarding, Dash-Qt path, toggles) are imported in `AppState::new_inner` *before* the settings blob is read, because that read is what selects the active network. It runs synchronously there — no AppContext exists yet. The import overwrites an existing blob: until now, an upgrading user's first launch wrote a `default()` blob over their real preferences, and skipping on "a blob exists" would make that reset permanent. The sentinel, not the blob, is the guard. - Scheduled votes and top-up history are imported by `finish_unwire` under their own per-network sentinel, ahead of the wallet-drain gate: an install that already drained its wallets under an earlier build still has these rows in data.db, and a shared sentinel would declare it "done" and strand them. Votes already in the k/v store are left alone so a retry cannot push a stale `executed = 0` over a vote the user has since cast. An undecodable vote row fails the pass (banner + "Retry now") rather than vanishing silently. `scheduled_votes` and `top_up` join the detection gate: a masternode voter who imported identity keys directly has queued votes but no wallet rows at all. The app-data pass probes those tables before reaching for the wallet backend, so an install with nothing to import still completes without it. Readers live in `database/legacy_import.rs` (typed, counters only, no policy); the "what to do on failure" decision stays in `backend_task/migration`. Legacy rows are never deleted. The v0.9.0 ladder fixture now carries a vote, a top-up and settings, asserting they survive the full v5 → current migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…imitation in-app Single-key (imported WIF) send and balance/UTXO monitoring remain blocked on upstream platform-wallet. This lands the honest, user-facing half and corrects the record on what upstream actually needs. Feasibility (platform-wallet 44c20e3 / key-wallet 48a07d3): the SPV watch set is the union of every managed account's address-pool addresses, and balances/UTXOs come from the funding accounts, so a single imported P2PKH address WOULD be monitored once it sits in a registered wallet's pool. key-wallet can already build such a pool without derivation (AddressPool::new_without_generation + AddressInfo + KeySource::NoKeySource). What is missing is a way to REGISTER it: PlatformWalletManager::register_wallet is private, the public constructors all require an HD seed, and the inner WalletManager (which does expose a public insert_wallet) is reachable only via PlatformWallet::wallet_manager() — i.e. only when a wallet is already registered, so a single-key-only user has no handle at all. Unblocked by a public seedless register_watch_only_wallet. Changes: - Drop the `is_rpc_mode` gate (hardcoded false; RPC mode no longer exists in this SPV-only build) from the single-key detail view and send screen. - Detail view: Send is explicitly disabled, with the reason and the recovery-phrase workaround in a persistent banner and the button tooltip. - Wallets action bar: selecting a single-key wallet no longer routes into a send screen that could only refuse the payment — it states the limitation. - Send screen: no UI gate; the backend stays the authoritative enforcement layer and refuses with the typed TaskError::SingleKeyWalletsUnsupported. - Correct the stale TODOs in core/mod.rs: the previously-assumed key-wallet single-address pool helper is NOT required; only the upstream registration entry point is. Refresh is not re-enabled as a button — monitoring is meant to be automatic, so that task should be deleted once upstream lands. - Tests: lock the user-facing copy contract (states the limitation, names a self-serve action, no jargon) for both the UI copy and the typed error. - user-stories.md: WAL-030 restated as automatic monitoring (no refresh control) and SND-002 updated; both stay [Gap] with the real blocker named. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves two more pills of the FR-GLOBAL-NAV staged rollout from subdued/read-only to fully interactive, and removes a dead click sensor. Masternodes (MN-012, FR-GLOBAL-NAV-3): the page-scoped node pill is now an interactive dropdown of every loaded masternode/evonode, two-way bound with the page — opening a card names that node on the pill, picking a node from the pill opens its detail view. The pill's label follows the node's card heading and its glyph follows the node type (HeroIdentityKind::type_glyph), so the grid and the breadcrumb never name a node differently. The selection stays page-scoped: it maps to SelectPageObject, never SelectIdentity, so a masternode can never become the app-global identity (FR-6). Wallets (FR-GLOBAL-NAV-2 rule 2): the wallet pill is interactive and two-way bound — switching on the pill selects that wallet on the page, and the page's own selection is what the pill reads back. Arrival now adopts a wallet switched from another page's pill, ahead of the first-wallet default that would otherwise silently overrule it. Connection indicator: the click sensor is downgraded to hover-only; the tooltip is its whole interaction. Supporting changes: PageObjectItem carries a type glyph; PageScopedObject carries page-owned tooltip copy, keeping page wording out of the shared component; add_top_panel_with_global_nav_capturing returns the raw GlobalNavEffect so any page can mirror the selection it consumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- A1: Accept/Decline wired to existing DashPay backend; Cancel implemented as an honest hide+notify (contactRequest documents are immutable/ undeletable on Platform, so a true withdrawal is impossible) — fixes two related bugs: rejected requests reappearing on reload, and a permanent rejection marker blocking later re-adds. - A2: real active-contacts list rendered from LoadContacts, search wired. - A3: local alias/nickname edit added to Hub Settings tab. - A4: pay-a-contact entry point added, routes to existing send-payment screen; the stale "requires SPV, dev-mode only" gate removed (SPV is now the standard backend) — flagged for a security pass since it's fund-movement gating. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Imports scheduled DPNS votes, network/theme/onboarding settings, and top-up history from the legacy v0.10-dev data.db on first launch after upgrade. Votes use an independent sentinel ahead of the wallet-drain gate (an identity-only voter with no wallet rows would otherwise never trigger migration). Settings import runs synchronously before AppState::new_inner's network read, since a BackendTask would run too late. No silent loss path remains; a failed pass leaves legacy rows intact and surfaces a retry banner instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…A7, A8) Investigation confirmed the SPV watch mechanism already supports a bare imported address once it's in a registered wallet's pool — the actual blocker is that PlatformWalletManager::register_wallet (the only path that would register a watch-only, non-HD wallet) is private with no public watch-only entry point. Coin selection for send is blocked transitively on the same gap. A synthetic-HD-wallet workaround was evaluated and rejected on fund-safety grounds (unsignable transactions, inconsistent restart behavior). Ships: dead is_rpc_mode UI gate removed, clear in-app limitation surfaced (banner + tooltip), backend TaskError remains the authoritative refusal. No manual refresh control added, per owner direction for A8. Exact upstream ask documented in the core/mod.rs TODO for whoever files it against platform-wallet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ll (A5, A11, B3)
Masternode-scoped pill wired two-way with the card grid/detail view;
Wallets pill made fully interactive (chosen over other candidates since
A5 already covers Masternodes' object pill). Fixes a real bug found
while wiring the Wallets pill: WalletsBalancesScreen::refresh_on_arrival
never adopted the app-global wallet selection, so a wallet switched via
the (already-interactive) Masternodes pill was silently overruled by
the first-wallet default. Also corrects a misattributed code comment
("locked decision #4") and downgrades the dead connection-indicator
click sensor to hover-only (B3).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…dden contacts Cancel could hide a contact that was established mid-flight: the reciprocal check and the contactInfo broadcast are separate Platform round-trips, and a request arriving in between was never noticed. - Restructure cancellation as `cancel_flow` over a `CancelOps` trait: the reciprocal check is the last read before the write, and a second read right after the broadcast detects a reciprocal request that landed inside the window and undoes the hide, leaving the new contact visible. The trait makes the ordering unit-testable — the race is injected between the two probes. - Add a "Show hidden contacts" section to the Hub Contacts tab with a per-row Unhide (contactInfo broadcast with display_hidden cleared, nickname and note preserved), so a hidden contact is never unreachable from the Hub. - Share one contact-search matcher between the Hub and the legacy DashPay contacts screen, which had drifted onto different field sets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s, type the token-dismissal seam Three converged QA findings from the v1.0 parity batch. single-key send screen: the deleted `is_rpc_mode` gate had been the only thing disabling the Send button, so the screen shipped an enabled Send that dispatches a task `CoreTask::SendSingleKeyWalletPayment` always refuses. Its `display_message` only cleared the busy flag on success-shaped text, so the button would also have stuck on "Sending..." forever on that refusal. The screen has no live route today, but the backend handler's TODO names it as the parked send UI to re-point once upstream lands seedless registration, so it is kept and made safe rather than deleted: Send is disabled with the same copy and disabled-hover text as the wallets action bar, every dispatch goes through one choke point that arms the busy flag, and any task result clears it. Regression tests cover the refusal, the arming, and the fee-retry dialog. DashPay: the Identity Hub's "Pay a contact" button was ungated on the premise that no other send flow is dev-gated. It was — the contacts list, contact details and profile viewer all gated the same `DashPaySendPayment` screen behind developer mode, with a stale comment claiming it "requires SPV which is dev mode only" (SPV is the standard backend now). Ungate all three to match, and flag the four entry points for explicit role classification when the UserRole/FeatureGate rework (#879) lands. tokens: the dismissal API took `(identity, token)` at one seam and `(token, identity)` at the next, both bare `Identifier`s — a transposition would have compiled and un-tracked the wrong pair. Thread the existing typed `IdentityTokenIdentifier` through instead. The on-disk payload keeps its `(token_id, identity_id)` layout, now pinned by a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resync/Sync buttons were removed (net-improvement automatic sync); two doc-comments still described the removed action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ared search) - QA-001: cancel_contact_request re-checks for a reciprocal request right after the display_hidden broadcast and auto-corrects (un-hides, returns DashPayContactAlreadyEstablished) if one landed inside the window. Residual risk (a reciprocal request arriving after the second read) is documented and bounded by the new unhide path, not eliminated — Platform has no conditional write, so no window width fully closes this. - QA-005: Hub Contacts tab gains a "Show hidden contacts" section with an Unhide action, giving hidden contacts (from the above race, or the pre-existing display_hidden overload) a recovery path without leaving the Hub. - Adams consistency finding: contact search/hidden-filtering unified into one matches_contact_search() helper shared by the Hub and the legacy DashPay screen (field set = their union; legacy behavior unchanged). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`finish_unwire::run` imported scheduled votes first, unconditionally, and propagated the vote-row failure with `?`. The vote importer is fatal on an unreadable row by design, so a single corrupt legacy `scheduled_votes` row wedged the wallet-seed migration on every launch and every "Retry now" — the row is never deleted and that path has no Skip. A user with funds behind a bad vote row could never reach their wallet again. Decouple the two passes. `run` now holds the app-data result, runs the wallet drain (extracted into `drain_wallets`) regardless, and only judges the app-data outcome once funds are reachable. Undecodable vote rows become a per-row skip-and-count instead of a migration-fatal error: they are surfaced on the new terminal `MigrationState::SucceededWithUnreadableVotes`, which raises a sticky Warning banner naming the recovery action, with no dead-end retry. The app-data sentinel is written once every *importable* row is handled — withholding it would re-run the import each boot and resurrect votes the user has since cast and cleared. Hard app-data failures (unreadable file, k/v write) stay fatal and still leave that sentinel unwritten, but no longer gate the drain. Both invariants hold: the vote sentinel still runs ahead of the wallet-drain gate (identity-only voters keep their import), and no vote is lost in silence — the legacy rows survive in `data.db` and the count reaches the user. Tests: an end-to-end `run()` over a fixture with real wallet rows AND a corrupt vote row proves the wallet lands hydrated + upstream-registered while the bad row is counted (RED before this change: MigrationFailed/ScheduledVotesUnreadable). The tautological TC-MIG-009 sentinel test is replaced by one that calls `run()` twice on the same `AppContext` and pins that the second launch re-fires nothing, including no vote resurrection after the queue is cleared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…onsistency, typed token seam) - QA-102/PROJ-001/SEC-005: single_key_send_screen's Send button re-disabled (matches the sibling screen's pattern) rather than left enabled-but- guaranteed-to-fail; kept the screen (not deleted) since backend_task/ core/mod.rs's TODO documents the parked re-pointing plan for when upstream lands. Also closes a second, previously-unflagged bug: the busy "sending" flag had a double-send window via the fee-retry dialog, fixed by making dispatch the single choke point that arms it. - QA-002/SEC-001: ungated the 3 remaining legacy DashPay Pay/Send entry points (contacts_list.rs, contact_details.rs, contact_profile_viewer.rs) to match the Hub's already-correct behavior; removed the stale "requires SPV, dev mode only" comments; left one TODO on the DashPaySendPayment screen variant itself flagging role classification for the incoming PR #879 UserRole rework. - RUST-001: token-dismissal call seam now threads the existing IdentityTokenIdentifier struct instead of a bare (Identifier, Identifier) tuple, closing the silent-transposition risk; on-disk KV payload order preserved and pinned by a new test. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…t drain QA-101 (HIGH): a single corrupt/unreadable legacy scheduled-vote row previously made finish_unwire::run() propagate a fatal error via ? BEFORE the wallet-drain gate ran, permanently blocking fund-access migration on every launch with no skip path. Fixed by extracting the wallet-drain path into drain_wallets(), running it unconditionally regardless of the app-data (votes/top-ups) migration's outcome. Both original invariants preserved: an identity-only voter with zero wallet rows still triggers vote import (own sentinel, still ahead of the wallet-drain gate), and no vote is silently dropped — an unreadable row now surfaces via a sticky Warning banner (no "Retry now", since a corrupt row won't decode better on a second pass) instead of blocking everything forever. The app-data sentinel is still written once importable rows are handled, deliberately, to avoid re-running the import on every boot and resurrecting an already-cast-and-cleared vote from its stale legacy row. QA-103: replaces the tautological idempotency test (which called read_sentinel directly, never run() twice) with one that actually invokes finish_unwire::run() twice on the same AppContext and is proven non-tautological via mutation (disabling the short-circuit makes it fail). Adds a real end-to-end test proving the wallet drain completes despite a corrupt vote row, confirmed RED before the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every duplicate below had two or more implementations of one rule, which is how the two identity-ID shorteners silently drifted apart. Identity labels — `display_label` gains the DashPay display-name tier and is now the one resolver for the hub-wide priority rule (nickname -> display name -> DPNS handle -> shortened id). `contact_label` delegates to it and the divergent `abbreviate_id` is gone. User-visible change (intentional): a profile-less contact rendered as `US517G59…` on the Contacts tab and `US517…LFx` in its identity pill — the same identity, two spellings. Both surfaces now use `shorten_id`. Covered by a test that fails against the old code. DashPay — the `toUserId` extraction (5 sites) moves to `model::dashpay::contact_request_recipient`, alongside the existing `model::dpns` document-extraction precedent; the `contactRequest` `DocumentQuery` builder (11 sites) moves to a private `dashpay::contact_request_query`. The hand-rolled `Value::Identifier` pattern-matches are replaced by the same typed accessor the rest of the module already used, so the mutual-contact filter and the resolved-request filter can no longer disagree about what a document's recipient is. Database — `table_exists` / `column_exists` become the single schema probe in `database::mod` (4 duplicate impls, 11 inline `pragma_table_info` queries). The migration modules keep their typed `MigrationError` attribution by mapping the shared probe's error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every existing test of the upgrade path starts from an already-normalised fixture: the schema ladder from v5 or v27, the settings import from a v0.10-dev `settings` table. Nothing proved the three subsystems compose from real v0.9.3 raw data (schema v11) in the order `AppState` runs them: ladder -> boot settings import -> wallet drain, with the drain's network coming from the imported settings. Adds that test over a byte-faithful v0.9.3 fixture (v11, no `single_key_wallet` table, no `core_wallet_name` column, no `onboarding_completed` column, raw seed with empty salt/nonce, an Argon2 + AES-GCM protected sibling wallet, a masternode identity, a queued DPNS vote and a top-up row). Asserts the seed arrives verbatim in the vault, the protected envelope byte-for-byte, the alias and main flag in the sidecar, the vote and the top-up history in the k/v store, the identity row still linked to its wallet — and, the headline regression, that a testnet user is not relaunched on mainnet. Plus idempotency: a second launch re-fires nothing and deletes no legacy row. Each assertion was verified to bite by mutation (dropping the imported network, the seed drain, the app-data pass and the top-up write each fail exactly the assertion that should catch them). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dismissing and re-tracking a token balance each read the entire `det:token_untracked` set, mutated a local copy, and wrote the whole blob back. Both are independent backend tasks, each `tokio::spawn`ed, so two overlapping calls could both read the pre-mutation set and have the later write win — the earlier mutation was silently lost and the dismissed token reappeared on the next refresh. Give each dismissed `(token, identity)` pair its own presence-marker key (`det:token_untracked:v2:<token>:<identity>`). Dismiss is now a single `put`, re-track a single `delete`, and reading the set a prefix scan: no read-modify-write window remains for a concurrent mutation to slip into. Token id leads the key, so dropping every dismissal of one token stays a single prefix scan rather than a full-set rewrite. Covered by two threaded races (concurrent dismissals; a dismissal racing a re-track) that lose an update against the previous scheme, plus a structural test pinning each mutation to one write with no read-back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…on markers Three defects in the contact-request path, all found by review on #882. Wrong-identity acts (blocking): accept_contact_request and reject_contact_request took the counterparty from the fetched document without ever checking its toUserId was the acting identity — a stale row clicked after an identity switch could sign a real state transition under the wrong key. They now go through sender_of_received_request, the mirror of the check cancel_contact_request already had, and error with ContactRequestNotAddressedToYou instead. The UI layer is fixed too: the DashPayContactRequests and DashPayContactsWithInfo results now carry the identity they were loaded for, and every consumer discards a result whose identity is no longer selected. Silent cancellation failure (blocking): mark_withdrawn dropped both an unavailable wallet backend and a typed storage error, so a cancellation whose marker never landed still reported success while the request came back as pending on the next reload. It now returns Result and cancel_flow propagates it. Undirected rejection marker: cancel and decline shared one marker, checked symmetrically for both directions, so cancelling a request to Bob silently hid the genuine request Bob sent back afterwards, with no recovery path. The marker is now split by direction (declined / withdrawn), each written and read only for its own direction; sending a request retires both. Pre-existing sidecar markers under the old undirected key are inert: a previously resolved request may list as pending once, which the user can resolve again — the safe direction to fail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Findings 2 (BLOCKING): scope DashPay contact results to their queried identity; add sender_of_received_request() verification to accept/reject so a stale cross-identity row can no longer reach a signed state transition under the wrong key. - Finding 7 (BLOCKING): mark_withdrawn now returns Result and propagates, so a failed cancellation marker write is reported as an error instead of a false success. - Finding 9 (SUGGESTION): split the undirected rejection marker into declined/withdrawn prefixes so a cancelled outgoing request no longer silently hides a later genuine incoming one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… warnings Three defects in the legacy-upgrade path, each able to lose user data or the notice about it. All three are RED-then-GREEN covered. Top-up history was overwritten, and its failures were made permanent. `save_top_ups` replaced the whole stored map instead of merging, so a late migration pass stomped any top-up the user recorded in between. It is now a read-merge-write (incoming wins on a colliding index), matching what the top-up flow already did at the callsite. A top-up read or write failure was also reduced to a `warn!`, so the app-data sentinel was written anyway and the fast path skipped the retry forever — freezing a one-off k/v error into permanent loss. The pass now fails on it (every identity is still attempted first), which withholds the sentinel so the next launch retries. A malformed SQLite column aborted the whole vote import. `read_scheduled_votes` decoded five raw columns with `?` before the row-level skip-and-count logic, so one NULL, type-mismatched or out-of-range value (a negative `time` fails rusqlite's `u64` range check) discarded every valid vote already read and turned a warning into a hard `TaskError`. Column decoding is now per-row, like the domain decoding beside it: log, count `unreadable`, continue. Same treatment for `read_top_ups`. The unreadable-vote warning fired exactly once, ever. The sentinel fast path returned a zero-count outcome on every later launch, so the banner could never be re-published — a user who was away when it appeared never heard about it again, while the vote it names may still have an open deadline. The count now persists in a per-network k/v record, written before the sentinel (a crash in between re-runs the idempotent import rather than losing the warning), and is re-published on every launch until the user acknowledges it via the banner's "Got it" action — a stray dismissal is not an acknowledgement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Finding 5 (BLOCKING): save_top_ups is now read-merge-write instead of a blind overwrite; top-up read/write failures now fail the migration pass and withhold the app-data sentinel so a later launch retries, instead of silently discarding history and marking success permanently. - Finding 6 (BLOCKING): read_scheduled_votes/read_top_ups decode columns per-row via a typed Result; a malformed column now counts as one unreadable row and continues, instead of aborting the whole import and discarding every valid row already accumulated. - Finding 8 (SUGGESTION, prioritized): the unreadable-votes warning is now persisted durably and re-published on every launch until explicitly acknowledged, instead of firing once on the discovery run and being lost forever if missed. Deliberately does NOT gate the sentinel on undecodable rows (only on transient/structural failures) — permanent decode failures must still complete the migration, per the QA-101 lesson that withholding the sentinel there re-runs the import every launch and can resurrect already-cast votes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema ladder preserves the legacy `identity` table, but no production code path imports it into the modern `StoredQualifiedIdentity` k/v store, so an upgrading v0.9.3 user silently loses every identity and all of its key material. Specify the import: what moves, where the step plugs in, its idempotency strategy, the byte contract it must produce, and the test that locks it. Also correct the 2026-05-28 migration notes, whose `identity` entry named a destination that commit b14bf32 had already moved and a version-byte agreement that is not needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… accounts Three defects on the Identity Hub's Contacts tab, one test each, all RED before the fix. The tab hydrated with two separate `AppAction::BackendTask`s in one frame. `AppAction`'s `|=` is last-writer-wins, so `LoadContacts` was dropped on the floor and the active-contacts section stayed empty until an unrelated refresh happened to re-fire it. Both loads now travel as one `BackendTasks(Concurrent)` action, and hydration yields to a click in the same frame rather than clobbering it — the load guard is untouched until it actually dispatches, so it simply goes out on the next paint. Accept, Decline, and Cancel each sign and pay for a state transition, and nothing stopped a second click from buying a second one while the first was still in flight. Each request now holds an in-flight guard: its card's buttons are disabled, and the dispatcher refuses a duplicate even if a click gets through. Success releases the guard by request ID; a failure carries no ID, so the hub releases all of them — a row the user can retry beats a row stuck forever. Unhiding a contact rewrote the whole `contactInfo` document with an empty accepted-accounts list, erasing every account the user had accepted. The write path now takes an `AcceptedAccounts` choice: `Replace` for a caller that owns the list, `Preserve` for one that does not, which reads the stored accounts back out of the existing document. Unhide and the contact-details edit form — neither of which has any say over accepted accounts — now preserve them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Finding 1 (BLOCKING): render_populated() now dispatches LoadContacts and LoadContactRequests as a single AppAction::BackendTasks, instead of the second |= silently overwriting the first via AppAction::BitOrAssign. - Finding 3 (bot: SUGGESTION, treated as BLOCKING): Accept/Decline/Cancel rows now track an in-flight request-ID set and disable their actions while pending, cleared on both success and failure, closing the duplicate-signed-transaction window from repeated clicks. - Finding 4 (SUGGESTION): unhide no longer wipes accepted_accounts — create_or_update_contact_info preserves existing entries when the caller passes None instead of an empty Vec. Extended beyond the review's scope to the identical wipe in the legacy DashPay contact-details save path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A v0.9.3 install that upgrades to v1.0 kept its `identity` rows in `data.db` but nothing ever read them, so the user booted into an empty Identities screen — and a masternode owner silently lost the owner and voting keys they had loaded, since v0.9.3 stores them inside the identity blob and nowhere else. Add a third migration pass, under its own per-network sentinel (`det:migration:identities:<net>:v1`), running after the wallet drain so the backend is wired, the vault is reachable and `ctx.wallets` is hydrated for wallet-derived keys to attach to. Reusing the drain's sentinel would have skipped the import for exactly the installs that already drained under a build without it. Key material is never handled here: each decoded identity goes straight to `AppContext::insert_local_qualified_identity`, which routes keys through the secret seam and leaves only `InVault` placeholders on disk. No new secret-handling path is introduced. Details: - `legacy_import::read_identities` filters `is_local = 1 AND data IS NOT NULL` (v0.9.3's observed-identity cache is not user data) and restores `status` from its column — the bincode blob does not carry it, so every identity would otherwise read back as `Unknown`. - Skip-if-present before insert: the writer is INSERT-OR-REPLACE, so a retry after a withheld sentinel would otherwise overwrite a user's post-import edit with the stale legacy blob. - A link to an absent or still-locked wallet is preserved, never nulled: it is what re-attaches the identity when that wallet is unlocked. - An undecodable blob is counted and reported, never fatal: it withholds the sentinel (an unreadable blob may be a decoder defect a later build fixes) but does not block the identities that do decode. - `identity` joins `LEGACY_TABLES`, so an identity-only install (a masternode voter with no HD wallet) now trips legacy detection. Tests pin the v0.9.3 -> v1.0 contract end to end, including a golden blob produced by the real v0.9.3 binary (bincode 2.0.0-rc.3) asserted to decode on this tree (2.0.1) — the one cross-version claim that could not be settled by reading struct definitions. The no-plaintext-on-disk assertion reads the stored bytes before any load path runs, because the eager load-path repair would otherwise mask an importer that wrote plaintext. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Concurrent push from a parallel session while the bot-review fix wave was in flight. No functional overlap: that commit only adds a new end-to-end test file over the v0.9.3 fixture and its mod declaration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
QA-001 (medium): `run()` unwrapped the app-data result with `?` before the identity import, so a hard failure in the vote/top-up pass — one malformed `det:scheduled_vote_voters:v1` blob is enough — returned early and the identity import never ran. That failure is deterministic and the app-data sentinel is never written on it, so the pass failed identically on every subsequent launch: a masternode owner's owner and voting keys would never reach the vault, on any launch, because of a broken vote queue they cannot see or repair. Run the identity import before either DET-owned result is judged, and fold both outcomes at the terminal-state step. Neither pass gates the other; a hard failure in either still surfaces to the user's retry banner, with the identity failure taking precedence when both fail — keys outrank votes. QA-002 (low): `read_identities` read `status` and `wallet_index` through a narrow `row.get::<u8>` / `row.get::<u32>`, so an out-of-range value raised `IntegralValueOutOfRange` through `?` and took the entire identity read down with it — keys included. Every other row-level corruption in that loop (bad id length, bad seed hash, half-filled wallet link, undecodable blob) is counted as `unreadable` and skipped. The legacy schema puts no `CHECK` on either column, so an out-of-range value is storable; widen the read and apply the same row-level policy. Both fixes carry a regression test confirmed RED against the unfixed code: the vote-index one imports 0 identities under the old ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tc_dev_001_no_live_readers_of_wallet_table failed against the concurrently-merged v093_upgrade.rs (0fcb6e7): its second-launch assertion reads the legacy `wallet` table row count directly from a scratch fixture database to prove the row survives migration. That's a test-only fixture-verification read, never a cold-boot read, the same exemption already granted to wallet_lifecycle/tests.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: stabilize suite timing and close kittest wiring race Three test-suite reliability fixes, no product code touched. 1. Ignore 49 wallet_backend secret-storage tests that each pay real Argon2id (64 MiB) cost through platform_wallet_storage's public SecretStore API. There is no downstream fast-KDF hook yet (dashpay/platform#4111 tracks exposing one), so under whole-suite parallelism their peak memory pressure drives the host into swap and inflates every test's wall-clock. The ~50 sibling vault tests in the same modules stay enabled as canary coverage; CI still runs the ignored set via `-- --ignored` (see PR note — the workflow edit is pending, .github is write-protected here). 2. Close the kittest wallet-backend wiring race. AppState::new spawns backend wiring as a background tokio task; a fixed run_steps(N) races it, so seeding via insert_local_qualified_identity intermittently panicked WalletBackendNotYetWired under load. New shared helper support::wait_for_wallet_backend polls the exact precondition (wallet_backend().is_ok()) up to 30s. mount_app / fresh_app_context and every per-file mount helper that seeds now gate on it instead of a fixed step count. 3. No change for the two nextest LEAK flags — reproduced 6x in isolation under low load, always PASS, never LEAK. Both are pure synchronous unit tests; the flag was nextest's wall-clock leak-timeout heuristic false-firing under the same contention finding #1 removes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: un-ignore 49 Argon2id tests via argon2 opt-level=3; close wallet-registration race ## argon2 opt-level=3 — un-ignore the 49 secret-storage tests PR #884 marked 49 `wallet_backend::{secret_access,identity_key_store,single_key, det_signer,hydration,secret_seam,wallet_seed_store}` tests `#[ignore]` because their real end-to-end `SecretStore` flows each paid a production-strength 64 MiB Argon2id derivation, running 5-23s under whole-suite contention. The dominant cost was `argon2` compiled at the default dev opt-level=0: each derivation ran for seconds AND held its 64 MiB that whole time, so under parallelism they overlapped into swap pressure. Adding `[profile.dev.package .argon2] opt-level=3` (+ the `test` profile) shrinks each derivation to tens of ms and collapses the memory-hold window. Cargo honors `[profile.*]` only from the workspace root, so platform's own argon2 stanza does not propagate to DET — this must be declared independently. Result (forced-fresh): the 49 now run at min 0.14s / mean 0.59s / max 1.53s, down from 5-23s. All 1772 workspace tests pass. This is a DET-local change with no dependency pin and no cross-revision instability; the upstream fast-KDF mock (dashpay/platform#4111) is not required to hit the target. ## Close the wallet-registration race (CI-only failure on e0c81a9) `context::wallet_lifecycle::tests::cache_shielded_receive_address_publishes_ bound_account_zero_address` (and its sibling `remove_wallet_evicts_shielded_ receive_address`) wired the backend BEFORE `register_wallet`. With the backend wired, `register_wallet` spawns the fire-and-forget `wallet_upstream_ registration` subtask, which then races the test's explicit `ensure_upstream_registered`: both call `create_wallet_from_seed_bytes`; the loser sees `WalletAlreadyExists` then `get_wallet` returns `None` in the insert gap, exhausting `resolve_registered_wallet`'s retries → `WalletNotFound`. Production never combines both paths per wallet (fresh uses the subtask; cold-boot/loaded uses `ensure_upstream_registered`), so this is a test-only artifact. Fix: register BEFORE wiring the backend (the pattern already documented in the cold-boot test), so the subtask never spawns and `ensure_upstream_registered` is the single upstream writer. Verified 25 iterations (50 test executions) under single-core pinning + 4x background CPU load, all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…atch Merges origin/docs/platform-wallet-migration-design (17f00d6) into feat/v1.0-parity-batch (64a6d3d), the "Update branch" step for PR #882. Ten conflicts, plus compile fallout from two API changes that landed on the base while this branch was in flight. DashPay pay buttons — #882 ungated the contacts-list, contact-details and profile-viewer entry points, explicitly deferring their classification to the UserRole/FeatureGate rework (#879). That rework has now landed and gates all three on FeatureGate::DashPayOperations, so its classification wins. The fourth entry point, the Identity Hub contacts list, is #882-only and was invisible to #879 — gate it the same way, and discharge the ScreenType::DashPaySendPayment TODO that asked for exactly this. No route to the send-payment screen is left open while the others are shut. Wallet-pill linking — both branches independently wired the Wallets-page nav pill (#882's group2, #860's #878), and the two are complementary rather than rival. Both panel entry points already apply the effect to the app-global selection; the capturing variant additionally returns it. Keep #878's Consumed wallet_only_spec, its resolve_selection_from_store arrival re-sync (which also covers single-key wallets and unknown cross-network hashes) and its dual-hash setter invariant; keep #882's capturing call so an in-place pill click mirrors into the page's own cache in the same frame, which the arrival-only path cannot do. Drop the duplicate interactive_wallet_only_spec and the superseded adopt_app_global_wallet_selection. All 13 tests from both sides pass unchanged. UserMode -> UserRole — #879 retires UserMode. The legacy data.db user_mode column gated nothing, so it records no role: import it through UserRole::from_persisted, which decodes the retired values to None ("no role ever chosen") and lets the app resolve that to WHEN_UNSET, the tier the legacy build exposed to everyone. Seeding Everyday off a legacy Beginner would silently strip capability the user already had. Shielded tab — #860 reclassified a default_open(dev_mode) flag on a line #882 had already replaced: its address section is now a real receive-address panel, deliberately always open. #882's rewrite stands. NetworkChooserScreen::data_dir — each side removed a different consumer, so the union leaves the field dead. Remove it; the surviving config writes go through current_app_context().data_dir. Also adapts test-only AppContext::new callsites to #879's UserRoleCell parameter, and renumbers #882's WAL-028 to WAL-029 (with WAL-030/031 shifting) — #860 already publishes WAL-028 for the wallet pill, and a committed design doc cites it. Tests: cargo build --workspace --all-features clean; clippy --all-features --all-targets -D warnings clean; full suite 1884 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Wallets page caches its own wallet handle, but the shared applier the top panel runs on a pill click writes only the app-global selection. Nothing covered the seam between the two, so removing the page's mirroring step would have compiled, passed, and shipped a pill that moves while the page body stays on the previous wallet — a pill click performs no navigation, so the arrival re-sync never fires to cover it. The new test walks the real sequence: run `apply_global_nav_effect`, assert the app-global selection moved AND the page's cache did not, then mirror and assert the page caught up. The middle assertion is the regression this seam exists to prevent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Merges origin/feat/v1.0-parity-batch (1d61b22 — now carrying the PR #860 platform-wallet rewrite) into feat/legacy-identity-migration, the "Update branch" step for PR #885. Three conflicts, all additive — each side appended a different item at the same location, so every one resolves as a union with nothing dropped: - app/reconcilers.rs: #885 imports migration_unreadable_identities_text, #882 imports MIGRATION_VOTES_ACK_ACTION_ID. - migration/finish_unwire.rs: #885 adds the IdentityImportFailed error variant, #882 adds TopUpHistoryWrite and VoteWarningRecord. All three kept. - database/legacy_import.rs: #885 adds read_identities, #882 adds the decode_scheduled_vote_columns / decode_top_up_columns row helpers. The migration orchestrator `run()` auto-merged, and its two orderings compose: the app-data pass (votes, top-ups) and the identity pass are each held rather than propagated, so neither DET-owned pass can gate the other or the wallet drain; identities outrank votes when both are damaged; and #882's durable vote-warning record is read back from storage, so a warning suppressed by an identity banner on one launch is re-raised on the next rather than lost. `a_corrupt_vote_index_never_strands_the_identity_keys` covers exactly that interaction and passes. Tests: build --workspace --all-features clean; clippy --all-features --all-targets -D warnings clean; full suite 1903 passed, 0 failed. Both sides' migration coverage intact — #885's six identity-import cases and #882's vote/top-up/warning cases all run. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… edge cases Address three review findings on the legacy v0.9.3 identity import path: - read_identities now selects the alias column and applies it as a fallback only when the decoded blob's own alias is None, matching the design doc's documented column-is-fallback contract. - read_identities rejects rows whose blob-embedded identity id disagrees with the row's id column, closing a gap where the skip-if-present precheck (keyed on the row id) could diverge from the actual vault write (keyed on the blob's id) and silently overwrite an unrelated identity. - finish_unwire::run now checks identities.unreadable before unwrapping the app_data result, so a deterministic app-data failure (e.g. a corrupt vote-index blob) can no longer mask the identity-unreadable banner that tells a masternode owner to reload their identity. Adds regression tests for the alias fallback and id-mismatch cases, both confirmed red against the prior code before the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Introduce a compile-time application edition selected by the new `masternode-owner-edition` Cargo feature and read via `Edition::CURRENT`. `Edition::allows` is the pure per-`RootScreenType` screen policy; `Edition::permits` composes it with `UserRole` so the Developer role is a full escape hatch. Home/floor screens back the navigation clamp added next. A UX restriction only — not a security or binary-size boundary; the feature does not dead-code-eliminate hidden screens. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVUVpGxaAsciGSiT7t4kuC
…rnode-owner edition Enforce edition/role screen reachability through one predicate (`root_screen_reachable`) at the single navigation chokepoint (`set_main_screen`), plus `active_root_screen_mut`, the initial-selection resolver, and the left-nav table. Generalises the prior Masternodes de-gate into that predicate — no behaviour change in the Full build. All screens stay registered so the Developer escape hatch reveals them immediately and the `active_root_screen_mut` lookup can never panic. Land the masternode-owner edition on Power the first time it runs (`apply_edition_first_run_role`) so its Power-gated Masternodes surface is reachable; first-run only, never overriding an explicit prior choice, and safe against a settings read failure. Settings stays always-reachable as the recovery path. Adds unit tests (both builds), the design record, and user story MN-013. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVUVpGxaAsciGSiT7t4kuC
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Base automatically changed from
feat/legacy-identity-migration
to
feat/v1.0-parity-batch
July 14, 2026 14:02
Base automatically changed from
feat/v1.0-parity-batch
to
docs/platform-wallet-migration-design
July 14, 2026 14:03
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this PR exists
feat/legacy-identity-migration(feat(migration): import legacy v0.9.3 identities and their keys #885) — depends on its v0.9.3 identity/key import. Also depends on the already-landed persona-capability gating (UserRole/FeatureGate, confirmed present in the base — no additional dependency needed).What was done
src/model/edition.rs:Edition { Full, MasternodeOwner }selected at compile time via themasternode-owner-editionCargo feature. PureEdition::allows(RootScreenType)/Edition::permits(screen, role)—UserRole::Developeris a full escape hatch that reveals every screen regardless of edition.AppState::set_main_screen(),active_root_screen_mut(), the initial-screen resolver, and theleft_panel.rsnav table — one funnel, not scattered filters.AppContext::apply_edition_first_run_role(): lands a masternode-owner-edition build onUserRole::Poweron first run only, keyed on "no role ever recorded," never overriding an explicit choice (including a deliberate switch to Developer).docs/ai-design/2026-07-13-masternode-owner-edition/design.md, including three reasoned deviations from the originally captured plan (no feature-levelCheck::Editionvariant — screen visibility is orthogonal to theChecksystem;main_screensregistration deliberately not filtered — screens must stay constructed for the Developer escape hatch to work; and the originally-flagged "fresh install defaults to Everyday" brick risk turned out to be superseded, sinceUserRole::WHEN_UNSETalready resolves toPower).Testing
cargo test --lib— 10 passed (edition pure logic, Full-edition nav no-op, first-run no-op).cargo test --lib --features masternode-owner-edition— 13 passed (nav clamp to Masternodes+Settings, Developer escape hatch, first-run role-forcing incl. non-override cases).clippyclean for both--features testingand--features masternode-owner-edition,testing.Breaking changes
None for the default build. The
masternode-owner-editionfeature is opt-in at compile time.Checklist
Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent