Skip to content

refactor(platform-wallet-storage)!: delete removed identities instead of tombstoning - #4496

Draft
Claudius-Maginificent wants to merge 26 commits into
feat/platform-wallet-storage-rehydrationfrom
chore/pws-remove-tombstone
Draft

refactor(platform-wallet-storage)!: delete removed identities instead of tombstoning#4496
Claudius-Maginificent wants to merge 26 commits into
feat/platform-wallet-storage-rehydrationfrom
chore/pws-remove-tombstone

Conversation

@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator

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_identity on 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 tombstoned flag on the identities row. 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 (ManagedIdentity is 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 up token_balances, dashpay_profiles, and dashpay_payments_overlay immediately. A new trigger closes the one place cascades don't reach — SQLite's MATCH SIMPLE skips FK enforcement entirely for out-of-wallet (wallet_id IS NULL) rows — by explicitly brooming identity_keys, contacts, and ignored_senders by identity_id. A re-added identity now starts from zero, matching what already happens in memory.

Detailed discussion

What was done

  • New migration V015__identity_hard_delete.rs: adds cascade_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 the tombstoned column itself.
  • identities::apply split into apply_upserts (unchanged position) and apply_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's TombstonedIdentityOrphan tolerance site is rebased to MissingIdentityOwner: with no tombstone flag left to discriminate on, LoadPolicy::Recovery now forgives any missing identity owner (still counted and logged, never silent); LoadPolicy::Strict still aborts.
  • Docs (SCHEMA.md, rustdoc) updated in the same commits to state the new contract, including why the trigger exists (the MATCH SIMPLE dormancy) rather than leaving the old "retained for cascade integrity" rationale stale.
  • Stacked on feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration #3968: this branch also carries feat(platform-wallet-storage): embeddable SQLite persistence backend with seedless rehydration #3968's own review-triage fixes (identity-index load-path cross-check, orphaned-key re-scoping on wallet promotion, Recovery-mode txid handling, a NULL-scope trigger tightening, public_key_hash cross-check, foreign_key_check on 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.tombstoned has zero consumer-facing exposure — every other "tombstone" hit in those layers is an unrelated contact-profile concept. remove_identity has 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 and shielded feature configs.
  • -p platform-wallet: 783 tests passing (0 failed).
  • clippy --lib --all-features -D warnings and clippy on every touched test target: clean.
  • New RED-first suite (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 public load() API (OrphanedIdentityEntry), not just in isolation.
  • Existing tombstone-era tests rewritten with equivalent assertions against the new shape, not deleted (sqlite_qa_identity_tombstone.rssqlite_qa_identity_removal.rs, plus updates across sqlite_identity_index_uniqueness.rs, sqlite_identity_index_concurrency.rs, sqlite_contacts_keys_rehydration.rs, sqlite_compile_time.rs, sqlite_schema_pinning.rs).
  • One pre-existing, unrelated intermittent noted for visibility: sqlite_restore_cross_process_exclusion::restore_excludes_peer_creating_missing_destination failed 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.
  • Three pre-existing clippy failures on the base branch (fixture_gen.rs type-complexity, manager/mod.rs items-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 !:

  • The identities.tombstoned column is dropped by V015; any code reading it directly (outside this crate) breaks.
  • remove_identity is 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::Recovery now tolerates any missing identity owner, not only a previously-tombstoned one — a deliberate widening, still counted/logged rather than silent.

Checklist

  • Self-review performed
  • Hard-to-understand areas commented (migration rationale, trigger dormancy explanation, transaction-ordering fix)
  • Unit/integration tests added/updated
  • Title carries ! and breaking changes described above
  • Documentation updated (SCHEMA.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

romchornyi and others added 26 commits August 26, 2026 10:41
…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>
)

Co-authored-by: Claude Fable 5 <noreply@anthropic.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.
@coderabbitai

coderabbitai Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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

Copy link
Copy Markdown
Contributor

📖 Book Preview built successfully.

Download the preview from the workflow artifacts.
To view locally: download the artifact, unzip, and open index.html.

Updated at 2026-08-27T11:40:12.318Z

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.

8 participants