refactor(platform-wallet-storage)!: delete removed identities instead of tombstoning - #4496
Draft
Claudius-Maginificent wants to merge 26 commits into
Draft
Conversation
…vent chain (#4459) Co-authored-by: Roman <51091564+jeanpierreroma@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…redSince) (#4400) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: infraclaw-dash <283232465+infraclaw-dash@users.noreply.github.com> Co-authored-by: Quantum Explorer <quantum@dash.org> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…on files (#4484) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Roman <51091564+jeanpierreroma@users.noreply.github.com>
… and scoped signing keys for DashConnect (#4273) Co-authored-by: Roman <51091564+jeanpierreroma@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…et-lock shortfall (#4361) Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: HashEngineering <hashengineeringsolutions@gmail.com>
…4483) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…aving-range queries (#4401) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…#4486) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…4318) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: HashEngineering <hashengineeringsolutions@gmail.com>
Three textual conflicts, all in the FFI error-code space, all resolved by keeping both sides: v4.2-dev's ErrorAssetLockInsufficientFunds (29, #4361) and this branch's persister pair are independent additions that landed on the same lines. Also resolves a third same-meaning-different-name collision of the kind that produced the V006 migration clash and the wallet_metadata revert. This one was already latent on the branch before this merge: #4451 merged ErrorMasternodeWithdrawalUnconfirmed = 42 into v4.2-dev while this branch held ErrorPersisterTransient = 42, so platform-wallet-ffi carried two variants at discriminant 42 (E0081) and Swift's PlatformWalletResultCode carried a duplicate raw value 42. It went unnoticed because platform-wallet-storage depends on platform-wallet, not on platform-wallet-ffi, so the storage suite compiles and passes with the FFI crate broken. Per the error-code registry imported by #4318, merged ABI wins and an unmerged claim renumbers: the persister pair takes 48 and 49 from the allocation frontier, moved across every layer together — the Rust discriminants and their result_code_discriminants_remain_stable pin, Swift's raw cases and their ErrorHandlingTests pin. Swift's other rule-5 edits (the init(ffi:) arm and the typed PlatformWalletError case) are number-independent and already present; there is no Kotlin mirror. The registry records the claim under rule 2 and its frontier note advances to 50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ps from #3968 triage Read-path integrity, mostly: the write side of this crate has been hardened repeatedly while the readers kept trusting what they found on disk. - identities::load_state cross-checks the identity_index COLUMN against the index inside entry_blob, and reports a duplicate derivation slot instead of letting a BTreeMap::insert silently drop whichever row SQLite yielded first. Two live rows at one index previously loaded as one identity, with the survivor decided by unspecified scan order. The write-path guard (#4441) polices the column while the wallet runs on the blob, so the two are now proven equal on the way in. Adds load_state_with_ctx so a pre-existing duplicate degrades under Recovery rather than bricking a file that has no other way open. - identities::apply re-scopes an orphan identity's NULL-scoped identity_keys rows when it promotes that identity into a wallet. Left behind they are unreachable from the owner's scoped read and sit in the exact state the identity_keys_null_scope_* triggers forbid. The EXISTS guard keeps a cross-wallet upsert that the DO UPDATE WHERE turned into a no-op from moving any keys. - core_state::get_tx_record returns Ok(None) when a tolerated drift means the row names a DIFFERENT transaction, instead of answering a point read with someone else's record. Scoped to txid drift only: height drift keeps the blob authoritative and still serves the record, which its own sibling test pins. The existing test asserted the buggy output and now asserts the contract. - identity_keys::load_state cross-checks public_key_hash, matching the identity_id / key_id / public_key.id() checks beside it. - V014 recreates the identity_keys NULL-scope trigger pair to require the named identity to EXIST and be unowned. V001 accepted a key naming no identity at all, and MATCH SIMPLE leaves both FKs dormant on a NULL-scoped row, so the triggers are the whole guard. Recreated in a new migration rather than edited into V001: refinery never re-runs an applied migration, so an edit would tighten only newly created databases. - run_integrity_check adds PRAGMA foreign_key_check. integrity_check validates page and index structure and says nothing about referential integrity, so a file can be structurally perfect while a child row points at a parent that is gone. Also marks five reviewer-raised trade-offs as accepted with INTENTIONAL rationale, and records one question that needs the key-wallet crate to answer. The V003 marker is a Rust comment rather than SQL: that migration is applied in the field, and editing a rendered body is the drift alarm sqlite_schema_pinning exists to raise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`check_index_conflicts` justified its design on the premise that a wallet with a pre-existing duplicate `(wallet_id, identity_index)` "already fails to load". It did not: `load_state` bucketed by index, so the second row silently displaced the first and the wallet opened one identity short, with the survivor decided by unspecified scan order. The load path now reports the collision, so state what it actually does — fatal under a strict load, counted and degraded under `Recovery` — rather than a claim the reader would have to test to disbelieve. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… of tombstoning `IdentityChangeSet.removed` flagged `identities.tombstoned = 1` and kept the row so its dependents were not wiped. That left storage and memory permanently disagreeing: `IdentityManager::remove_identity` drops the whole `ManagedIdentity`, so a re-added identity was empty in memory while the next `load()` handed it the removed one's keys back. Removal is now a physical `DELETE`, scoped to the flush wallet, and terminal. Two dependent classes no live foreign key reaches: - `identity_keys`' FK to `identities` is compound (`wallet_id, identity_id`), and SQLite's MATCH SIMPLE skips FK enforcement entirely once any child key column is NULL — dormant for an out-of-wallet identity, so its keys survived the delete. - `contacts` and `ignored_senders` key on `owner_id` but carry no FK to `identities` at all. Left behind they surface as `OrphanedIdentityEntry` and fail the whole wallet's load, turning an ordinary `remove_identity` into a bricked wallet. V015 adds `cascade_children_on_identity_delete` (keyed on `identity_id` alone) plus `idx_contacts_owner` / `idx_ignored_senders_owner` as its access path, purges every already-tombstoned identity and its dependents, and drops the column. The writer splits in two for ordering. A merged buffer can carry `removed` for an identity alongside key/contact upserts for it; running the delete where the old UPDATE sat pulls the FK parent out from under those inserts and fails the whole flush. `apply_upserts` stays with the other identity writers, `apply_removals` runs last in the transaction. With no tombstone set to consult, `LoadSite::TombstonedIdentityOrphan` becomes `MissingIdentityOwner`: Strict still aborts on a missing owner, Recovery now skips and counts any of them rather than only the tombstoned ones. Orphans stay reachable — the NULL-scope guard trigger rejects a key naming a wallet-owned identity, not one naming an identity that does not exist. BREAKING CHANGE: a removed identity's keys, contacts, ignored senders, token balances, DashPay rows and metadata are deleted with it, so re-adding the same identity id starts from a blank identity instead of inheriting the removed one's state.
…s-remove-tombstone Reconciles the hard-delete work with the #3968 triage batch, which landed V014 (`identity_keys_null_scope_requires_existing_identity`) and the read-path hardening. Conflicts, and one behavioural interaction: - `identities.rs` `load_state_with_ctx` SELECT — kept the incoming `identity_index` column (its blob-vs-column cross-check) and dropped `tombstoned` with its skip branch. - `sqlite_schema_pinning.rs` — both fingerprints recomputed for the combined V001-V015 chain rather than hand-merged. - V014's tightened trigger retires the last write path that could create an orphaned `identity_keys` row, so `orphaned_null_scoped_key_is_fatal_only_under_strict` no longer had a premise. Replaced with `null_scoped_key_can_neither_name_a_missing_identity_nor_outlive_one`, which pins the two migrations' seam: V014 refuses the write, V015 sweeps the row its identity's removal would otherwise strand. Either alone still leaks; the test fails if either regresses. - `merge_contacts_and_keys`' rustdoc named the NULL-scoped key as the reachable orphan shape. V014 made that false — corrected to name `contacts` / `ignored_senders`, the tables with no FK to `identities`. - Three raw `INSERT INTO identities` sites arriving with the triage batch (backup.rs plus two new tests) lost the retired `tombstoned` column.
Contributor
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks 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 |
Contributor
|
📖 Book Preview built successfully. Download the preview from the workflow artifacts. Updated at 2026-08-27T11:40:12.318Z |
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.
TL;DR: Removing an identity from a wallet now actually deletes it and everything tied to it, instead of just hiding it while its keys, contacts, and metadata sit in the database forever.
User story
As a developer integrating this SDK (DET, iOS/Android), I want removing an identity from a wallet to fully clean up its data, so that a database file doesn't accumulate orphaned keys, contacts, and metadata for identities the user already removed.
Scenario
Base flow
A host app calls
remove_identityon a wallet-owned or out-of-wallet identity — a deliberate, user-facing action (not an internal sync artifact).Actual behavior
Today, removal only flips a
tombstonedflag on theidentitiesrow. The row, its public keys (identity_keys), contacts, token balances, DashPay profile, and per-identity metadata all stay on disk indefinitely — cleaned up only if the entire wallet is later deleted. In-memory, the removed identity's keys/contacts are already gone (ManagedIdentityis dropped in full), so storage and memory silently disagree about what "removed" means, and a re-added identity used to inherit its old on-disk keys back without anyone asking for that.Expected behavior
Removal issues a real
DELETE FROM identities. Existing foreign-key cascades clean uptoken_balances,dashpay_profiles, anddashpay_payments_overlayimmediately. A new trigger closes the one place cascades don't reach — SQLite'sMATCH SIMPLEskips FK enforcement entirely for out-of-wallet (wallet_id IS NULL) rows — by explicitly broomingidentity_keys,contacts, andignored_sendersbyidentity_id. A re-added identity now starts from zero, matching what already happens in memory.Detailed discussion
What was done
V015__identity_hard_delete.rs: addscascade_children_on_identity_delete(broom trigger for the FK-dormant/FK-less children), two new indexes (idx_contacts_owner,idx_ignored_senders_owner) so the broom isn't a full scan, an explicit backfill purge of every already-tombstoned identity's dependents, then drops every already-tombstoned row and thetombstonedcolumn itself.identities::applysplit intoapply_upserts(unchanged position) andapply_removals, moved to run after every identity-scoped child writer in the same transaction — a merged changeset can legitimately carry both a removal and a child-row upsert for the same identity in one flush, and running the DELETE first would hit a live FK violation against a just-deleted parent.LoadCtx'sTombstonedIdentityOrphantolerance site is rebased toMissingIdentityOwner: with no tombstone flag left to discriminate on,LoadPolicy::Recoverynow forgives any missing identity owner (still counted and logged, never silent);LoadPolicy::Strictstill aborts.SCHEMA.md, rustdoc) updated in the same commits to state the new contract, including why the trigger exists (theMATCH SIMPLEdormancy) rather than leaving the old "retained for cascade integrity" rationale stale.public_key_hashcross-check,foreign_key_checkon wallet open) since it forked from and was reconciled against that branch's latest state.Consumer-facing impact
Surveyed every FFI/Swift/Kotlin/JNI call site:
identities.tombstonedhas zero consumer-facing exposure — every other "tombstone" hit in those layers is an unrelated contact-profile concept.remove_identityhas exactly one production call site (the FFI-exposed manager method); no host surface offers "show removed identities" or "undo remove". No consumer contract is broken by this change.Testing
-p platform-wallet-storage: 784 tests passing (0 failed), both default andshieldedfeature configs.-p platform-wallet: 783 tests passing (0 failed).clippy --lib --all-features -D warningsand clippy on every touched test target: clean.tests/sqlite_identity_hard_delete.rs, 8 tests) proven failing before implementation, each for its own distinct reason — including one that reproduced the out-of-wallet FK-dormancy defect end-to-end through the publicload()API (OrphanedIdentityEntry), not just in isolation.sqlite_qa_identity_tombstone.rs→sqlite_qa_identity_removal.rs, plus updates acrosssqlite_identity_index_uniqueness.rs,sqlite_identity_index_concurrency.rs,sqlite_contacts_keys_rehydration.rs,sqlite_compile_time.rs,sqlite_schema_pinning.rs).sqlite_restore_cross_process_exclusion::restore_excludes_peer_creating_missing_destinationfailed once under full-suite parallel contention, passed reliably in isolation and in a clean re-run — reads as a timing-sensitive cross-process SQLite-locking flake, not something this diff touches.fixture_gen.rstype-complexity,manager/mod.rsitems-after-test-module, a feature-gated test file's unused imports under default features) confirmed present at the fork point via a throwaway baseline worktree and left untouched — candidates for a separate follow-up, not blockers here.Breaking changes
This is a schema and behavioral break, hence the
!:identities.tombstonedcolumn is dropped byV015; any code reading it directly (outside this crate) breaks.remove_identityis now genuinely terminal — a removed identity's keys, contacts, token balances, and DashPay data are gone, not recoverable by re-adding the same identity. (Previously this was silently possible via upsert-driven "resurrection," which no consumer relied on per the survey above, and which already contradicted in-memory behavior.)LoadPolicy::Recoverynow tolerates any missing identity owner, not only a previously-tombstoned one — a deliberate widening, still counted/logged rather than silent.Checklist
!and breaking changes described aboveSCHEMA.md, rustdoc)Prior work
Stacked on #3968 (
feat/platform-wallet-storage-rehydration) — this PR's base branch; depends on it merging first.Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent