Skip to content

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

Merged
lklimek merged 403 commits into
v4.2-devfrom
chore/pws-remove-tombstone
Sep 10, 2026
Merged

refactor(platform-wallet-storage)!: delete removed identities instead of tombstoning#4496
lklimek merged 403 commits into
v4.2-devfrom
chore/pws-remove-tombstone

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

TL;DR: Removing an identity now deletes its stored record, keys, contacts, balances, and DashPay data. Re-adding it starts with fresh identity-owned data, including when removal and re-addition are saved together.

User story

As a developer integrating this SDK (DET, iOS/Android), I want removing an identity to clean up its stored data, so removed identities do not leave keys, contacts, and metadata behind.

Scenario

Base flow

A host app removes a wallet-owned or out-of-wallet identity and may later add the same identity again.

Actual behavior

Removal only marks the stored identity as deleted, retaining its dependent data. Memory already drops the managed identity, so adding it again can bring old stored keys back on the next load.

Expected behavior

After successful persistence, removal deletes the identity and its dependent records. A later addition keeps only its new identity-owned data. A manually flushed removal/re-addition sequence preserves that order and commits atomically.

Detailed discussion

What was done

  • Physical deletion and migration: V018__identity_hard_delete.rs adds cascade_children_on_identity_delete, purges historical tombstones and their dependents, then drops identities.tombstoned. V001–V017 remain unchanged.
  • Complete cleanup paths: the new trigger deletes identity_keys, contacts, ignored_senders, and pending_contact_crypto by owner identity. SQLite's composite foreign key is dormant for NULL-scoped keys under MATCH SIMPLE; the other three tables have no identity foreign key. Existing cascades remove token balances, DashPay profiles and payment overlays, while existing triggers clean identity/token metadata and metadata attached to deleted contacts.
  • Indexed cleanup: adds idx_contacts_owner, idx_ignored_senders_owner, and idx_pending_contact_crypto_owner. Historical dependent-row purges are explicit so they also work with foreign-key enforcement disabled.
  • Ordered manual flushes: PendingWrites keeps removal/re-addition boundaries as ordered segments. Each segment applies identity upserts and child writes before identity removals; all segments commit in one transaction. The old incarnation is cleaned before a later segment recreates it. Identity normalization preserves unrelated identities' revision and snapshot merge semantics across boundaries, and slot admission uses the projected final identity state.
  • Retry and wallet deletion: failed flushes restore pending segments for retry; wallet-deletion pre-flushes replay the same ordered segments.
  • Recovery and documentation: MissingIdentityOwner replaces TombstonedIdentityOrphan. Recovery counts/logs skipped key/contact rows with missing owners; Strict returns an error. SCHEMA.md and rustdoc describe the deletion and ordering contract.

Scope and base

This PR targets v4.2-dev and builds on the storage/rehydration work from #3968. The reviewed diff is against base a4bb6a63103a6870c1ac8d6884eb1357f91a762a.

Wallet-scoped DPNS marketplace state and identity-scan progress remain independent of identity removal. Existing documented retention of metadata whose parent never existed remains unchanged; this PR does not add general orphan-metadata garbage collection.

Testing

Verified at 9756bae1e701e39674d3ed5861724887d9c83cf0:

  • 13 identity hard-delete tests passed, including V017-to-V018 migration with foreign keys ON and OFF. Both modes seed and verify DashPay profiles, payment overlays, and token metadata, with live-identity preservation controls. Normal-removal assertions also verify both DashPay tables.
  • Coverage includes wallet and out-of-wallet removal, pending contact-crypto cleanup, strict reopen, remove/re-add sequences, slot reuse, atomic rollback/retry, and unrelated-identity snapshot merging.
  • Formatting and Clippy passed for platform-wallet-storage; Clippy used --all-targets --all-features --locked -- --no-deps -D warnings.
  • The preceding head e8904f844852d3530cd8f492f522c4f206844936 also passed 169 targeted storage/identity-manager tests and formatting/Clippy for both wallet packages. The follow-up changes are confined to tests and a comment.
  • Full workspace tests and downstream FFI/application execution were not run locally.

Breaking changes

  • V018 drops identities.tombstoned; direct SQL consumers of that column must update.
  • Successfully persisted removal deletes identity-owned data instead of retaining it for implicit resurrection on re-addition.
  • Recovery now tolerates missing identity owners beyond previously tombstoned identities, with counted/logged skips; Strict still rejects them.

Checklist

  • Self-review performed
  • Hard-to-understand areas commented
  • Unit/integration tests added or updated
  • Title carries ! and breaking changes are described
  • Documentation updated (SCHEMA.md, rustdoc)

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • New Features

    • Identity removals are now permanent, deleting the identity and its associated wallet data.
    • Re-adding a removed identity starts with a clean state.
    • Removal and re-addition sequences are preserved correctly during manual synchronization and flushing.
    • Cleanup is scoped appropriately so other wallets and identities remain unaffected.
  • Bug Fixes

    • Improved handling of dependent records during identity removal.
    • Missing identity owners are now reported consistently, with recovery mode able to continue while tracking skipped records.

lklimek and others added 30 commits July 13, 2026 15:31
…flict fix

Final-pass QA against 1df6169: re-attempts the original duplicate-entry
repro (now unions, confirmed) plus three new adversarial shapes against
ProviderKeyAccountConflict: a three-way duplicate, a reordered-but-equal
node-key duplicate (confirmed not a conflict), and a same-index/
conflicting-value node-key collision within one store() call.

The last one surfaces a real asymmetry: the account-level conflict check
compares only the encoded (account_type, extended_public_key) payload, so
two entries sharing that payload but disagreeing on a node key's bytes at
the same index reach the child-table's ON CONFLICT ... DO UPDATE with no
arbitration -- silent last-write-wins, unlike the fail-closed reasoning
applied one level up. Reported as a finding, not fixed here (test documents
current behavior and flags it for a design decision).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… index

The account-level conflict check compared only the encoded
(account_type, xpub) payload, so two entries agreeing on the account but
carrying different bytes at the SAME key_index reached
`ON CONFLICT ... DO UPDATE` and silently overwrote one with the other — no
error, no diagnostic, on material nothing can re-derive. Found independently
by Smythe (SEC-007) and Marvin (QA-005).

The asymmetry was the real defect: fail closed on a contradictory xpub one
level up, silently pick a winner one level down. A node key is fully
determined by its account xpub and index — derivation is a pure function and
`node_id` is hash160(public_key) — so two different values at one index mean
one is wrong and the store cannot tell which. Same contradiction, same answer:
refuse the flush (`ProviderNodeKeyConflict`), writing neither key.

Checked in both directions: between entries within a flush, and against the
row already stored — so a stale or corrupted re-registration cannot overwrite
a good key that a later flush disagrees with. The SQL drops to `DO NOTHING`,
which the checks make a no-op for identical bytes and which, if they were ever
bypassed, still cannot destroy a derived key.

Marvin's `marvin_adversarial_same_index_conflicting_node_key_value_is_silently_
overwritten` documented the behavior rather than asserting it; it is now
`sec_007_same_index_conflicting_node_keys_are_rejected`, asserting rejection
and that neither key lands. A second test covers the cross-flush case his
in-batch probe could not reach. Both confirmed failing against 33d18ac.

Refs: #4113

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root-cause confirmation, blob-codec audit, chosen fix (AssetLockEntryWire
mirroring IdentityKeyWire), migration/compat strategy, secondary
AlreadyOpen-masking fix, and test plan. Design only — no implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gging

Addresses the two error-handling-coverage audits of the secrets/ subtree
(PR #3968). All 11 findings are LOW — diagnostic-precision, logging-level,
and lint-hygiene polish; no behavioural/security defects were found.

Security-lens (Smythe):
- SEC-001: SecretString::default() mlock failure now logs at debug! with
  distinct wording (empty buffer, no secret at risk) so it no longer shares
  byte-identical text with the new() warn!; both sites stay greppable.
- SEC-002: demote the documented-non-fatal parent-dir fsync-uncertain log
  from error! to warn! (degraded-but-recoverable), matching the policy that
  reserves error! for the propagated/fatal write failure.
- SEC-003: add SecretStoreError::EntropyUnavailable; random_bytes (which
  backs nonce + salt draws, not just KDF) now reports it instead of the
  misleading KdfFailure.
- SEC-004: thread the store's durability-uncertain counter through the
  initial-create write path so durability_uncertain_count()'s "0 == all
  writes confirmed durable" contract holds for create too.
- SEC-005: switch the five vault_lock unsafe overrides from
  #[allow(unsafe_code)] to #[expect(unsafe_code, reason=...)] so a stale
  override self-reports (M-LINT-OVERRIDE-EXPECT).
- nits: drop the redundant `let _ =` on the ?-propagating validated_label
  guards; drop-time sync now calls the non-logging do_write_vault_at so a
  drop-path failure logs once (with context) instead of twice.

Coverage-lens (Marvin):
- QA-001: all three keyring platform arms now debug!-log the discarded
  backend-init error before falling back to NoDefaultStore.
- QA-002: create_parent_dir (both branches) and VaultLock::acquire's
  non-WouldBlock branch route through SecretStoreError::io_at so the known
  path rides in the error, per the crate's io_at policy.
- QA-003: map_spi only collapses a rejected `user` (label) attribute to
  InvalidLabel; a rejected service (or any other attribute) maps to
  OsKeyring{Backend} instead of mislabelling the caller's label.
- QA-004: add is_recoverable() + error_kind_str() to SecretStoreError,
  mirroring WalletStorageError's SQLite-side classification so both typed
  errors in the crate read as one family.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rain failures

QA-004: SqlitePersister::open() — the crate's highest-stakes failure
boundary (IntegrityCheckFailed, SchemaVersionUnsupported, Migration,
AlreadyOpen) — emitted zero tracing on any failure path. Wrap the body in
open_inner() and log every returned Err classified via error_kind_str():
tracing::error! for real failures, warn! for the benign in-process
AlreadyOpen race. One exit point catches all paths, not just the four
named ones.

QA-003: delete_wallet_inner's post-commit drain used
`if let Ok(Some(_late)) = take_for_flush(..)`, silently swallowing a
possible Err(LockPoisoned) — the one spot in the file breaking its own
convention. Match the Err arm and log it at tracing::error!, matching the
three other LockPoisoned sites (Drop, handle_flush_error, restore_buffer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wallet, preserve typed errors

QA-001: register_wallet's store()/load_persisted() failure paths treated a
transient SQLITE_BUSY/FlushRetryable identically to a permanent failure —
logged, rolled back, aborted — with nothing consuming the crate's own
is_transient() classification. Wire a bounded exponential-backoff retry
(retry_transient helper: 4 attempts, 20→40→80ms capped at 200ms, async
sleep). On a transient store() failure the persister preserves the
buffered changeset, so retries re-drive the write via flush() — no
re-merge, no double-count; the first attempt hands the changeset over,
later attempts flush what the buffer kept. Fatal errors fail fast. The
idempotent load_persisted() read is retried the same way.

QA-002 + QA-005: the three persistence-adjacent register_wallet sites
flattened every failure into WalletCreation(String), discarding retry
classification and the #[source] chain. Route them through dedicated
typed variants: store() → new PersisterStore(PersistenceError), load() →
the pre-existing-but-dead PersisterLoad, initialize_from_persisted() →
new PersisterRestore(Box<PlatformWalletError>). A caller can now
structurally match the phase and recover is_transient() instead of
parsing prose.

Tests: transient-store-retried-and-succeeds, fatal-store-fails-fast
(no retry), persistently-transient-store-exhausts-bounded-retries,
transient-load-retried, fatal-load-surfaces-as-PersisterLoad, and a unit
test pinning classification + structural matching + source chain on all
three variants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ub overwrites

Two blocking review findings on #4117: SqlitePersister::load() dropped
persisted hardened platform-node keys on the floor (no production
accessor existed), and a second store() for an already-persisted
provider account silently overwrote its xpub with no fail-closed
guard. Add SqlitePersister::list_provider_node_keys() to surface the
persisted batch, and reject a persisted-xpub mismatch (plus a
provider-label bypass through the plain ECDSA writer) with the
existing ProviderKeyAccountConflict/ProviderKeyAccountEntryMismatch
policy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Codex Sol <noreply@openai.com>
…ema readers

Audit of PR #3968's sqlite/schema/ readers turned up 7 spots where a typed
error was fumbled — wrong target names, discarded upstream errors, a silent
row-drop, and a silent clamp. All plugged, TDD (repro tests RED first).

- QA-001: 4 hand-rolled u32 casts (platform_addrs nonce/account_index/
  address_index, identity_keys key_id) stamped SafeCastTarget::U64 on a u32
  overflow — misdirecting operators. Route through safe_cast::i64_to_u32,
  which stamps U32.
- QA-002: merge_contacts_and_keys silently dropped any identity_keys/contacts
  entry whose owner wasn't loaded, contradicting load_prekeyed's fail-hard
  doc. Now fallible + tombstone-aware: a known-tombstoned owner's orphans are
  skipped (one summary log per collection, not per entry); any other absent
  owner hard-errors via the new OrphanedIdentityEntry variant. Positive
  tombstone signal read from identities.tombstoned (load_tombstoned_ids).
- QA-003: the production all_platform_payment_registrations reader (+ its
  per-wallet sibling) cross-checked account_type+index but not key_class — the
  very discriminator the widened PK protects. Select and cross-check key_class,
  mirroring load_state.
- QA-004: 3 sites discarded dashcore::address::Error via map_err(|_| ...).
  Add AddressDecode { #[source] } + From impl; route all 3 through it.
- QA-005: provider_key_account_registrations was dropped with zero runtime
  signal. Emit one tracing::warn when non-empty (deferred, #4113).
- QA-006: enqueued_at_ms clamped to i64::MAX instead of erroring — route
  through safe_cast::u64_to_i64.
- QA-008: Txid::from_slice error discarded despite an existing HashDecode
  variant; route through it via ?.

QA-007 (test-helper-gated .expect()) confirmed not exploitable — no change.

Shared-error-type edits (error.rs) and the two exhaustive/allowlist guard
tests (sqlite_error_classification, sqlite_compile_time) updated as the
necessary consequence of the new variants and reader SQL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t a hard MUST

register_wallet's retry_transient retries a transient store() failure via a
bare flush() (no re-supplied changeset), relying on the implementor having
already buffered the changeset. SqlitePersister honors this; FFIPersister is
safe only because it never returns Transient. Nothing in the trait doc stated
this as a requirement, so a future implementor returning Transient without
re-buffering would make flush() a no-op Ok(()) and register_wallet report a
success that never persisted.

State it explicitly on PlatformWalletPersistence::store: returning
PersistenceErrorKind::Transient MUST mean the changeset is preserved for a
subsequent bare flush(); an implementation that can't preserve it MUST
classify Fatal/Constraint instead. Doc-only, no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…re type (#4133)

An `AssetLockEntry` carrying `proof: Some(AssetLockProof)` was written to
`asset_locks.lifecycle_blob` but never read back: the shared blob codec routes
the value through `bincode::serde`, whose deserializer cannot service the
`deserialize_any` an internally-tagged serde enum requires. Every wallet
holding such a row failed rehydration permanently.

Introduce `AssetLockEntryWire`, mirroring the proven `IdentityKeyWire` pattern:
carry `proof` as a natively bincode-pre-encoded `Option<Vec<u8>>` and ride
fields 1-7 on the serde encoder unchanged, so a pre-fix `proof: None` row
decodes byte-identically (no migration for those). `into_entry` rejects
trailing bytes on the inner proof decode.

Ship refinery migration V004 to delete pre-fix proof-bearing rows
(`status IN ('is_locked','chain_locked')`) — unrecoverable by construction and
already unreadable today, so no regression; they re-derive from Core on the
next SPV sync. `max_supported_version` lifts 3 -> 4 automatically.

Tests: Chain/Instant proof round-trip (repro), trailing-byte guard,
None-row byte-identical compat pin, the V004 migration (pre-fix seed -> migrate
-> clean load), a public-path blob round-trip coverage guard, and an advisory
note in `blob.rs`. Updated schema-version pins (3 -> 4), golden migration
fingerprints, and the pre-migration backup-name range for the added migration.

Refs: #4133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ped PersisterLoad (#4133)

`PlatformWalletManager::new` spawns the wallet-event adapter holding an
`Arc<persister>` clone before the fallible `load_from_persistor` runs. A dirty
drop merely detached the join handle, so the still-running adapter kept the
persister "open" and a same-process reconstruct hit
`WalletStorageError::AlreadyOpen` — masking the real load error (the #4133 blob
decode failure).

- Add a `Drop` backstop that cancels the token and aborts the adapter on every
  drop path (covers the dirty-drop leak).
- Release the adapter deterministically on the `load_from_persistor` error
  paths via the awaitable `shutdown()`, so a reconstruct on the same path is
  provably clean.
- Replace the `WalletCreation(format!("...{}", e))` collapse with the typed
  `PlatformWalletError::PersisterLoad(#[from] PersistenceError)` variant,
  preserving the source chain, and log the cause with Debug, not Display.

Deviation from the design's "preferred" option: the adapter spawn stays in
`new()` rather than deferring to a post-registration `start()`. No manager-level
`start()` exists (only per-sub-manager starts, invoked individually by the FFI),
and deferring the wallet-event subscription past `new()` would risk missing
broadcast events emitted before a late subscribe. The Drop backstop +
shutdown-on-load-error achieve the same "no persister retention after a failed
load" guarantee without that risk.

Test: `failed_load_releases_persister_for_reconstruct` proves the persister's
strong count returns to 1 after a failed load + teardown (a lingering adapter
would keep it above 1).

Refs: #4133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dressDecode

The QA-004 fix missed one of the three sites: `core_state::load_used_addresses`
(the address-reuse-guard rehydration path, production-reachable via
persister.rs) still discarded `dashcore::address::Error` through
`map_err(|_| blob_decode(...))`. The prior commit's `replace_all` matched only
the 12-space-indented `load_state` site, not this 8-space one, so an
unparseable stored `core_utxos.script` surfaced a context-free `BlobDecode`
here instead of the rich `AddressDecode`.

Route it through `AddressDecode` via `?` like the other two sites, and add a
repro test (bare OP_RETURN script → AddressDecode), confirmed RED against the
old code first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…op convention doc-comment (#4133)

Fold in audit feedback (RUST-005) and a scope change:

- Exercise `AssetLockProof::Instant` with distinct, non-default field values
  (transaction version/lock_time + a non-zero output_index) in both the unit
  repro (`wire_round_trips_instant_proof`) and the coverage guard, so the
  round-trip proves field fidelity rather than `default() == default()`. Both
  proof variants are now fully-populated — the gap that let the original bug
  (every fixture used `proof: None`) slip through.
- Drop the project-wide `deserialize_any` advisory doc-comment from `blob.rs`;
  the convention documentation is being handled in a separate doc-only PR to
  avoid conflicting edits. The wire-type mechanism comments on
  `AssetLockEntryWire` itself stay (ordinary code comments).

Refs: #4133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ease data to clean up (#4133)

The `AssetLockEntryWire` fix is a pure application-level encoding change: same
table, same columns, no DDL. A refinery migration was only ever needed to clean
up ALREADY-WRITTEN broken rows from before the fix — a live-data concern. This
crate is pre-release with no live data, so there is nothing to migrate: new
writes use the fixed encoding going forward, and a stale pre-fix row in a local
test store is wiped by recreating that store.

Removes `migrations/V004__drop_undecodable_asset_locks.rs` and its test, and
reverts the pins that only existed to accommodate it — `max_supported_version`
stays 3, the golden migration fingerprints and the pre-migration backup-name
range revert, and the `bincode` dev-dependency (added solely to seed the V004
test's old-format row) comes out. No schema/DDL change remains.

The primary fix (`AssetLockEntryWire`), the secondary fix (Drop backstop +
shutdown), the tertiary fix (`PersisterLoad` + Debug logging), and all
round-trip / trailing-byte / None-compat / status-cross-check /
secondary-regression tests are unchanged.

Refs: #4133

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nition sites (#4133)

Three internally-tagged dpp enums — AssetLockProof, IdentityPublicKey,
DataContractConfig — derive native bincode Encode/Decode AND serde
Serialize/Deserialize. Routed through the `bincode::serde` bridge they
encode write-once and then never decode: resolving the `$type` tag needs
`deserialize_any`, which bincode's non-self-describing serde deserializer
rejects with AnyNotSupported. This class corrupted the wallet-storage
blob codec (#4133) and, earlier, IdentityPublicKey (fixed via
IdentityKeyWire).

- Definition-site doc comments on all three enums spelling out the
  native-only-through-bincode rule and citing the prior incidents.
- `bincode_serde_hazard` characterization tests in asset_lock_proof
  pinning the three-way contract: native bincode round-trips both
  variants, platform_value value-conversion round-trips both, and the
  `bincode::serde` bridge fails decode deterministically with
  AnyNotSupported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 findings fixed (Smythe SEC-001..005 + 2 nits, Marvin QA-001..004),
independently re-verified against the final commit.
7 of 8 findings fixed (QA-007 confirmed non-exploitable, left alone),
independently re-verified against the final commit including a
follow-up round that caught a missed third AddressDecode call site.
…fixes (PR #3968)

5 findings fixed (2 HIGH: dead retry architecture wired, dead
PersisterLoad variant now used; 2 MEDIUM logging gaps; 1 LOW
catch-all split), plus a trait-doc hardening follow-up. Independently
re-verified against the final commit including the FFI boundary.
…tics, non-empty InstantLock.inputs (#4133)

Addresses Marvin's post-review findings on the #4133 storage/manager fix.

QA-002 — the `Drop` backstop for the wallet-event adapter overclaimed:
`JoinHandle::abort()` only *requests* cancellation, so the task and its
`Arc<P>` clone are dropped by the runtime at the next poll, not
synchronously inside `Drop::drop`. Reword the doc-comment to state the
release is eventual (only the graceful `shutdown` path guarantees the
reference is gone before it returns), and add
`drop_backstop_eventually_releases_persister_without_shutdown` — a dirty
drop (no `shutdown`) that polls the persister strong count down to 1,
exercising the `abort` branch the graceful-path test never reaches.

QA-003 — soften `failed_load_releases_persister_for_reconstruct`'s
doc-comment: it is a manager-side proxy (strong-count reaches 1), not a
full open→fail→reopen end-to-end proof; the dev-dep cycle precludes the
concrete persister here, and the end-to-end path is covered by the
storage crate's round-trip test.

QA-004 — the Instant-proof round-trips used `InstantAssetLockProof::
default()`, whose nested `InstantLock.inputs` is empty, so the
length-prefixed-vec encoding path every genuine IS-lock hits went
untested. Populate `instant_lock.inputs` with real outpoints in
`wire_round_trips_instant_proof`, the `sqlite_blob_roundtrip_coverage`
test, and the rs-dpp `bincode_serde_hazard` characterization test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…let-storage-rehydration

# Conflicts:
#	Cargo.lock
#	Cargo.toml
Fixes surfaced by merging origin/v4.1-dev into this PR's branch:

- Cargo.toml/Cargo.lock: the two branches had independently diverged
  rust-dashcore pins (be6e776d = PR #851 UTXO-spend fix; 19690d31 = PR
  #893 + the new provider-key derivation API), neither a superset of
  the other. Re-pin to #851's branch tip (73dcf3d0), which was freshly
  merged forward with dev today and is a strict superset of both.

- Two migrations both claimed V003: this PR's `V003__unified.rs` and
  v4.1-dev's DIP-13 `V003__invitations.rs`, causing a
  refinery_schema_history UNIQUE constraint violation on open. Renumber
  the newcomer to V004.

- The V004 migration's FK and its test fixture referenced the retired
  `wallet_metadata` table name (a divergent-branch naming leftover);
  this branch's reconciled table is `wallets`. Caught by this crate's
  own sqlite_schema_pinning retired-name guard.

- `versions.rs::touched_domains`'s deliberately-exhaustive destructure
  of `PlatformWalletChangeSet` caught the new `invitations` field at
  compile time (by design, the R8 forgotten-domain guard) — wired a
  proper `Domain::Invitations` variant rather than silencing it, since
  invitations data is genuinely persisted and needs its
  cache-invalidation version bumped like every other domain.

- Updated the golden schema-freeze fingerprints and the hardcoded
  max-supported-version assertions (3 -> 4) that the new migration
  legitimately changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Content half of the previous rename-only commit. Fixes surfaced by
merging origin/v4.1-dev into this PR's branch:

- Cargo.toml/Cargo.lock: the two branches had independently diverged
  rust-dashcore pins (be6e776d = PR #851 UTXO-spend fix; 19690d31 = PR
  #893 + the new provider-key derivation API), neither a superset of
  the other. Re-pin to #851's branch tip (73dcf3d0), freshly merged
  forward with dev today and a strict superset of both.

- Two migrations both claimed V003: this PR's V003__unified.rs and
  v4.1-dev's DIP-13 V003__invitations.rs (renamed to V004 in the prior
  commit), causing a refinery_schema_history UNIQUE constraint
  violation on open.

- The V004 migration's FK and its test fixture referenced the retired
  wallet_metadata table name (a divergent-branch naming leftover);
  this branch's reconciled table is `wallets`. Caught by this crate's
  own sqlite_schema_pinning retired-name guard.

- versions.rs::touched_domains's deliberately-exhaustive destructure
  of PlatformWalletChangeSet caught the new `invitations` field at
  compile time (by design, the R8 forgotten-domain guard) — wired a
  proper Domain::Invitations variant rather than silencing it, since
  invitations data is genuinely persisted and needs its
  cache-invalidation version bumped like every other domain.

- Updated the golden schema-freeze fingerprints and the hardcoded
  max-supported-version assertions (3 -> 4) that the new migration
  legitimately changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ydration' into feat/platform-wallet-storage-rehydration
…tence trait

grumpy-review follow-up on the prior commit. Bug 1's fix
(list_provider_node_keys) was an inherent SqlitePersister method with no
production caller — every real consumer goes through
PlatformWalletPersistence, so the data was still practically unreachable.
Move it to a default trait method (mirroring get_core_tx_record), wire a
public WalletPersister::provider_node_keys() wrapper, and prove
reachability via PlatformWallet::persister() with a new manager-layer
integration test.

Also: harden the provider parent-account upsert to ON CONFLICT DO NOTHING
(it was DO UPDATE while sibling node-key rows were already DO NOTHING),
replace the ad-hoc provider-type matches!() with an exhaustive match so a
future AccountType variant can't silently bypass the ECDSA-writer guard,
update stale/incomplete doc comments and error messages, and add
regression tests for the zero-state and mixed-batch-atomicity cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Codex Sol <noreply@openai.com>
… V003-max assumptions

The V001-capped fixture (tests/fixtures/populated_v001.db) bincode-encodes
its blobs with whatever struct shapes existed when it was captured; the
v4.1-dev merge changed several of those shapes (DIP-13 invitations,
provider-key persistence, seed-binding, etc.), so the stale fixture failed
to decode once migrated forward. Regenerated via the crate's own
documented fixture-regeneration test helper.

Also caught two more hardcoded assumptions that V004 (the invitations
migration) invalidated, missed by the prior merge-fallout commit because
they don't share exact text with the ones already fixed there:
- tc_b_033's backup-filename check still looked for the old pre-migration
  marker string (a near-duplicate of the already-fixed tc_b_032 check,
  different exact text).
- tc_b_030 asserted a fresh store lands at schema version 3; it now lands
  at 4.

Verified against the project's real CI scope for the wallet crates
(scoped clippy, plus nextest), rather than the broader default test
runner, which spuriously hits an unrelated doctest build-graph flake
already characterized as environmental earlier in this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second grumpy-review follow-up on this PR. Three independent reviewers
converged on the same gap: FFIPersister inherits the new
provider_node_keys trait default (Ok(empty)) even though it genuinely
forwards derived_platform_node_keys to the Swift host on store() — so
an FFI-backed wallet silently reported "no keys" instead of surfacing
that this backend can't read them back yet. Override the method to
return an explicit error instead; a full Swift-side read-back callback
is a separate, larger follow-up (out of scope here).

Also: reworded ProviderKeyAccountEntryMismatch's Display message to
drop internal "ECDSA writer" jargon, and renamed a test whose name
implied transactional rollback when it actually exercises upfront
batch validation (the assertions were already correct).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Codex Sol <noreply@openai.com>
…ydration' into pr4117-codex-fix

# Conflicts:
#	packages/rs-platform-wallet-ffi/src/persistence.rs
#	packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs
#	packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs
#	packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs
#	packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs
#	packages/rs-platform-wallet/src/changeset/mod.rs
…on_bump test

Wraps a long use-statement that cargo fmt --check flagged, fixing the
red macOS CI job on PR #3968.
…ss pool rows

PR #4127 replaced the retired dedicated-batch mechanism for platform-node
Ed25519 keys with the generic account_address_pools pipeline, but only wired
it up for the FFI backend. The SQLite backend silently dropped
AddressInfo::public_key on every store()/load() round trip, so
ProviderPlatformKeys pools came back empty after a restart -- these keys are
SLIP-10 hardened-only and cannot be re-derived from a watch-only xpub.

Adds migration V005 (nullable public_key/key_type columns on
core_address_pool), persists the typed key alongside each pool row, and
restores AbsentHardened platform-node entries during load() by reconstructing
their AddressInfo the same way populate_platform_node_pool does at
registration time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014cKDZc6zhCkstdqE1HcSuU
lklimek and others added 11 commits September 7, 2026 13:55
Wave 3's tail findings (docs, InstantSend restore at load, public-surface
trim, secrets at-rest and open-path gaps, test-case ID cleanup) join wave 1,
wave 2 and the account key mapper guard.

The wave-3 branch had already merged 2cf9b46 forward, so this merge adds
no textual change over its tip; the tree is byte-identical to the automatic
merge of both parents. It is recorded as a merge commit rather than a
fast-forward so fix/3968-round5 keeps a first-parent line of its own.

Neither side's earlier verification described this tree: wave 2's run predates
wave 3's commits and wave 3's predates the load-path rewrite and the account
key mapper guard. The merged tree is verified on its own.
…mode, not merely that one is set

Three defects in the read-back added with the erasing cascade, each one a way
for the guarantee to be reported as held while not being held.

The open-time check accepted any nonzero `secure_delete`. SQLite reports the
mode numerically and the values are distinct — `0` off, `1` ON, `2` FAST — so
"nonzero" would have accepted `ON` where `FAST` was requested and, worse, would
have accepted a cascade's raise that was never restored, leaving every later
write paying the full erase cost with nothing to say so. Both constants now
carry the value they must read back as, and one `set_secure_delete` helper does
set-then-confirm for the raise and the restore alike.

The cascade runs inside an EXCLUSIVE transaction, and a pragma that failed to
carry into that context would fail silently — a delete reporting success over
pages it never scrubbed. `delete_wallet_inner` reads the mode back from inside
its own transaction, at the point of use rather than at the point it was set.
A test pins the SQLite behaviour that read-back relies on, so a change in it
surfaces as a named failure instead of as unexplained residue.

The residue test could also have degraded silently. Sized small enough, its rows
sit inside a page that stays in use — the one case where FAST and ON are
indistinguishable — and it would then have passed while proving nothing about
whole pages released to the freelist, which is what deleting a wallet actually
does. It now asserts the cascade freed at least one page, so a fixture that
stops exercising the hard case fails instead of quietly agreeing with the
implementation.

Two scanner defects in the same test are closed with it. It asserts the marker
is findable BEFORE the delete, because a scan that silently matches nothing
reports a clean file exactly as a clean file does; the experiment that produced
this fix first returned zero hits in all three modes because the scanner refused
to read a binary file. And it scans the `-wal` alongside the `.db`: pages live
there until a checkpoint, so the pre-delete scan would otherwise miss data
plainly still present, and a post-delete scan would miss residue parked in a WAL
that outlived the handle.
41a2b2b landed on fix/3968-round5-wave3 after the previous integration
merge, so the branch carried a real hole rather than a polish: the open-time
guard accepted any nonzero secure_delete, which would have accepted ON where
FAST was requested and, worse, accepted a cascade's raise that was never
restored, leaving every later write paying the full erase cost with nothing
reporting it.

The merge is textually trivial - 41a2b2b is a direct child of the commit
whose tree this branch already carries - but the merged tree is verified on
its own rather than inherited from either side.
…quests

This PR rewrites .cargo/audit.toml, which is the only file
security-audit-rust.yml's audit step reads, and adds a cryptographic
dependency subtree. The workflow fires on workflow_dispatch and a nightly
schedule only, so neither the rewrite nor the new subtree is audited by the
pull request that introduces them; the first run lands after merge.

The fix is a workflow change (a pull_request trigger scoped to the manifest
and lockfile paths) plus getting the job green, both of which belong to the
CI file rather than to this PR's diff. The deferral is recorded where the
next reader of this file will meet it rather than left to memory.

Comment only - the TOML parses to the same ignore list.
Preserve mainline migration V007 and renumber the rehydration migrations.
Reconcile sweep placeholders and ownership tracking with transaction-based
confirmation heights, including cleanup of swept height-only transactions.

Validation: storage tests with all features (952 passed, 2 ignored),
cargo fmt, and targeted Clippy with CI flags.

Co-Authored-By: OpenAI Codex <noreply@openai.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Inject the lock result through a private constructor helper so the refusal
test exercises warning, zero-initialization, and buffer access on the same
allocation. Keep the production constructor bound to the real mlock call.
Remove the assumption that every host rejects a fixed low address.

Validation: 12 guarded-buffer tests with all features, rustfmt, and
storage Clippy with CI flags on Linux.

Co-Authored-By: OpenAI Codex <noreply@openai.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…w-up PR

Keep the insert_platform_node_pool_entry refactor and PlatformNodePoolError
type in this PR (rs-platform-wallet-storage's rehydrate.rs depends on both
to compile), but move the five new unit tests exercising them out of the
storage PR's diff — none are needed for rs-platform-wallet-storage to build
or pass its own tests. They land in a follow-up PR stacked on this branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Preserve the independently published platform-wallet test split alongside
the verified storage migration fixes.

Co-Authored-By: Codex <noreply@openai.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Move provider account and pool reconstruction with their errors and tests
into SQLite storage. Restore wallet pool registration to its base version
and shorten audit exception comments.

Co-Authored-By: Codex <noreply@openai.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Phase 1 + Phase 2

The identity-delete implementation still leaves persisted pending contact-crypto rows behind, which contradicts the PR's complete-cleanup goal. Source-derived SQLite probes confirmed retention after both deletion and migration backfill with foreign-key enforcement enabled and disabled; additional verified issues concern migration coverage and documentation. These are non-consensus suggestions and nitpicks under the supplied severity policy, not blocking findings.

Source: reviewer 1: glm-5.3-flash (agent: phase1-reviewer, role: general); reviewer 2: glm-5.3-flash (agent: phase1-reviewer, role: rust-quality); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 4: gpt-6-astra (agent: phase2-reviewer, role: rust-quality); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

Review provenance

  • Triage: critical by gpt-6-astra (effort low) — This changes destructive wallet-storage behavior and migrates existing databases, where incorrect cascade triggers, NULL-owner handling, backfill purges, or transaction ordering could permanently delete unintended identity data or break persistence and recovery.
  • Phase 1 reviewers: glm-5.3-flash — general (completed, effort max); agent phase1-reviewer, glm-5.3-flash — rust-quality (completed, effort max); agent phase1-reviewer
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — rust-quality (completed, effort xhigh); agent phase2-reviewer

🟡 3 suggestion(s) | 💬 1 nitpick(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/migrations/V016__identity_hard_delete.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/migrations/V016__identity_hard_delete.rs:37-40: Delete pending contact-crypto rows with their owning identity
  `pending_contact_crypto` belongs to the removed identity's in-memory DashPay state, but its only database foreign key references `wallets`. The production changeset writer persists this queue, while `remove_identity` drops the managed identity and emits only an identity removal, not queue clears. Neither this trigger nor the explicit tombstone backfill deletes those rows. Executing the source migration SQL confirmed that both paths leave one pending queue row after its identity is gone, with foreign keys either ON or OFF. This leaves owner/contact identifiers and encrypted payloads on disk contrary to the complete-cleanup contract. Delete by `owner_identity_id` in both cleanup paths and add regression coverage. The queue reader is currently test-gated, so this finding concerns persisted leftovers, not a demonstrated production rehydration of the old queue.
- [SUGGESTION] packages/rs-platform-wallet-storage/migrations/V016__identity_hard_delete.rs:17-20: Correct the claimed load behavior for orphaned ignored senders
  The new documentation says leftover `contacts` and `ignored_senders` rows both produce `OrphanedIdentityEntry` and fail loading. Only contacts take that path. `load_state_with_ctx` loads ignored senders into a map, removes entries for identities it actually loads, and drops the remaining map without an error, recovery count, or log. `merge_contacts_and_keys` explicitly skips ignored/unignored entries. Correct this paragraph and the matching new error-behavior paragraph on `merge_contacts_and_keys` to distinguish contacts from ignored senders. The silent-drop behavior predates this PR; correcting the newly introduced documentation does not require expanding this change into reader hardening.

In `packages/rs-platform-wallet-storage/tests/sqlite_identity_hard_delete.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/tests/sqlite_identity_hard_delete.rs:650-651: Test pragma-independent backfill and DashPay dependent cleanup
  V016 explicitly promises that its backfill works independently of the migrating connection's `foreign_keys` pragma, but this test enables enforcement unconditionally. Consequently, removing the explicit `token_balances` purge would remain undetected because its native cascade would perform the deletion. The migration fixture and normal-removal assertions also omit `dashpay_profiles` and `dashpay_payments_overlay`, despite both being part of the advertised cleanup contract. Parameterize the upgrade test over foreign keys ON/OFF, seed and check both DashPay tables, and retain live-identity controls. Include those tables in the normal-removal fixture and assertions as well.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs`:
- [NITPICK] packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs:188-190: Update the scope-guard reference to the identities DELETE
  This comment describes the key-removal guard as mirroring the `identities` tombstone, but this PR replaces that operation with the wallet-scoped physical DELETE in `identities::apply_removals`. The guard remains correct; update its reference so readers are directed to the current mechanism.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Three pre-existing clippy failures on the base branch — The cited fixture, manager, and feature-gated import cleanup is outside the identity-deletion change. These reported baseline lint issues are routine adjacent work, not in-scope findings or exceptional follow-ups.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.

Comment thread packages/rs-platform-wallet-storage/tests/sqlite_identity_hard_delete.rs Outdated
Comment thread packages/rs-platform-wallet-storage/migrations/V018__identity_hard_delete.rs Outdated
lklimek and others added 8 commits September 9, 2026 08:24
Match the enabled type's derives and conditional serde support. Check
public trait parity and serde round-tripping with shielded off and on.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Validate both owner readers while accepting current and legacy standard
labels. Strict loading fails on invalid labels; Recovery isolates the
wallet so its used-address guard is never silently discarded.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
Replace manual wipe-on-drop code with a zeroizing boxed block slice.
Keep the matrix length stable and verify its contents are wiped in place.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
…orage base

Preserve current migration history, append identity hard delete as V018, and retain deterministic recovery loading and per-wallet degradation context.

Co-authored-by: Codex <noreply@openai.com>
…nto hard delete

Co-authored-by: Codex <noreply@openai.com>
Base automatically changed from feat/platform-wallet-storage-rehydration to v4.2-dev September 9, 2026 13:59
@github-actions github-actions Bot added this to the v4.2.0 milestone Sep 9, 2026
lklimek and others added 4 commits September 9, 2026 14:01
Replay buffered removal and re-addition in one transaction, retaining each
identity incarnation's snapshot merge semantics and retry ordering. Sweep
deferred contact-crypto rows on removal and migration, and correct orphan
ignored-sender documentation.

Co-Authored-By: Codex <noreply@openai.com>

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
Exercise V018 with foreign keys enabled and disabled, including DashPay
profiles, payment overlays, and token metadata with live identity controls.
Check DashPay cleanup on normal removal and update the key scope comment.

Co-Authored-By: Codex <noreply@openai.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Phase 2 only (queue backlog)

The reviewed range correctly implements permanent identity removal, including ordered manual-flush segments, dependent-row cleanup, historical tombstone migration, and recovery-mode handling. The current head addresses all four prior findings with targeted documentation, migration, cleanup, and test changes; no additional in-scope correctness, security, or architectural defects were found.

Source: reviewer 1: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: rust-quality); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

Review provenance

  • Triage: critical by gpt-6-astra (effort low) — This is a large storage and migration refactor that changes identity deletion semantics, cascades across keys, contacts, balances, and DashPay data, and introduces ordered transactional/retry behavior with potentially irreversible data-loss and recovery consequences.
  • Phase 1 reviewers: not run (skipped for throughput: 18 PRs queued, above the 10 limit)
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort xhigh); agent phase2-reviewer, gpt-6-astra — rust-quality (completed, effort xhigh); agent phase2-reviewer

@lklimek
lklimek merged commit edc3298 into v4.2-dev Sep 10, 2026
18 checks passed
@lklimek
lklimek deleted the chore/pws-remove-tombstone branch September 10, 2026 07:13
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.

5 participants