Skip to content

feat(platform-wallet)!: consumer hardening — CODE-001/003-callsite/017/018 + PROJ-001 FFI + CODE-008/012/013#3750

Draft
Claudius-Maginificent wants to merge 41 commits into
feat/platform-wallet-sqlite-persistorfrom
feat/platform-wallet-consumer-hardening
Draft

feat(platform-wallet)!: consumer hardening — CODE-001/003-callsite/017/018 + PROJ-001 FFI + CODE-008/012/013#3750
Claudius-Maginificent wants to merge 41 commits into
feat/platform-wallet-sqlite-persistorfrom
feat/platform-wallet-consumer-hardening

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

Summary

Consumer-side hardening for the platform-wallet stack, extracted from PR #3743 to keep that PR focused on the rs-platform-wallet-storage crate landing + the minimal trait surface that crate needs.

Stack chain

feat/platform-wallet-consumer-hardening   (this PR)
        ↓ stacks on
fix/3625-thepastaclaw-hardening           (PR #3743 — storage crate + trait surface)
        ↓ stacks on
feat/platform-wallet-sqlite-persistor     (PR #3625 — persister base)
        ↓ stacks on
v3.1-dev                                  (release base)

When #3625 merges, #3743 rebases onto v3.1-dev. When #3743 merges, this PR rebases onto #3625's merged tip. Standard chain — each layer cleanly isolates a concern.

What's in this PR

Consumer logic (rs-platform-wallet)

  • CODE-001: refuse silent drop of orphan platform_addresses on load — PlatformWalletError::PersistorMissingWalletRehydration raised when persister returns addresses but no wallets.
  • CODE-003 (caller-side): wire remove_wallet to call persister.delete_wallet(...) (the trait method itself ships in feat(platform-wallet)!: rs-platform-wallet-storage crate (SQLite persister) + trait surface #3743).
  • CODE-017: cache ClientStartState slices in PlatformWalletManager to drop register_wallet N+1 load() calls; helpers take_persisted_platform_addresses, cached_persisted_shielded, invalidate_persisted_for_wallet.
  • CODE-018: retry-on-transient + undo-in-memory on fatal store error in register_walletPlatformWalletError::WalletRegistrationFailed { wallet_id, reason }.

FFI (rs-platform-wallet-ffi)

  • PROJ-001: platform_wallet_manager_identity_sync_register_identity now requires wallet_id_ptr: *const u8 (32 bytes, rejects null + all-zero sentinel), routes to wallet-aware register_identity_with_wallet. Breaking C ABI change.
  • CODE-012/013: TODO comments at half-wired FFI callback sites pointing at separate follow-up FFI-hardening work.

Swift SDK

  • PROJ-001 mirror: PlatformWalletManagerIdentitySync.swift gains walletId: Data parameter, TokenActions.swift doc-example updated, IdentityDetailView.swift sources walletId from identity.wallet?.walletId.

Trait docs (rs-platform-wallet)

  • Document WalletId::default() = orphan convention on PlatformWalletPersistence::{store, flush, get_core_tx_record, delete_wallet, commit_writes}.
  • Document orphan-bucket appearance in CommitReport.{succeeded, failed, still_pending} and DeleteWalletReport.wallet_id.

Tests

  • 5 consumer integration tests in packages/rs-platform-wallet/tests/: load_from_persistor.rs, persistence_error_taxonomy.rs, persister_load_cache.rs, register_wallet_failure.rs, remove_wallet_delete.rs.
  • 1 consumer↔persister round-trip integration test in packages/rs-platform-wallet-storage/tests/round_trip_consumer.rs (CODE-008) — lives under storage/tests/ but brings rs-platform-wallet as dev-dep.

Breaking changes

  1. C ABI: platform_wallet_manager_identity_sync_register_identity gains required wallet_id_ptr parameter between identity_id_ptr and token_ids_ptr. Swift wrappers updated. Out-of-wallet identities are now rejected at this entry point (orphan-aware registration is a future follow-up — see platform-wallet: orphan identities cannot be loaded — persistor writes them, but no consumer-side reader #3745).
  2. Rust API: 2 new PlatformWalletError variants (PersistorMissingWalletRehydration, WalletRegistrationFailed).

Test plan

  • cargo fmt --all -- --check
  • cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warnings
  • cargo test -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi — 424 passed, 0 failed (incl. all 5 round_trip_consumer tests and the 5 new consumer integration test files)
  • macOS Rust workspace tests via CI
  • Swift SDK build (warnings-as-errors) via CI — exercises the PROJ-001 ABI change end-to-end

Related

🤖 Generated with Claude Code

lklimek and others added 30 commits May 25, 2026 17:38
… (CODE-004)

Replace `PersistenceError::Backend(String)` with
`Backend { kind: PersistenceErrorKind, source: Box<dyn Error + Send + Sync> }`
so the persistor's `is_transient()` contract reaches consumers
without round-tripping through a stringified message.

`PersistenceErrorKind` carries the retry policy explicitly —
`Transient` (caller MAY retry), `Constraint` (SQL constraint /
FK / integrity, caller bug), `Fatal` (everything else). The kind
enum is wildcard-free at every consumer match site so a future
addition forces an explicit classification update.

Persistor side: `From<WalletStorageError> for PersistenceError`
delegates to a new `WalletStorageError::persistence_kind` that
folds in `is_transient()` plus SQLite `ConstraintViolation`
detection. The boxed source preserves the typed `WalletStorageError`
chain so consumers can `Error::source()`-walk to the inner rusqlite
payload — the previous `DisplayChain` flattening goes away.

Backward compat:
- `PersistenceError::backend(source)` defaults to `Fatal` kind.
- `PersistenceError::backend_with_kind(kind, source)` for callers
  that classify (the storage `From` impl).
- `From<String>` / `From<&str>` still work and default to `Fatal` —
  keeps FFI's `format!(...).into()` sites compiling.

This is a breaking change for any out-of-tree impl that
pattern-matched `PersistenceError::Backend(String)`. In-tree consumer
call sites still only log + proceed; the semantic upgrade to inspect
`is_transient()` + retry is T-003 (Wave 2).

Tests:
- `platform-wallet/tests/persistence_error_taxonomy.rs` — 5 tests
  covering TC-CODE-004-a (kind shape + exhaustiveness), -c (source
  is `Send + Sync` + Display), -e (`From<String>` defaults to Fatal).
- `platform-wallet-storage/tests/persistence_error_kind_mapping.rs` —
  7 tests covering TC-CODE-004-b (transient / constraint / fatal
  mapping table + chain preservation) and TC-CODE-004-e
  (wildcard-free invariant guard on `is_transient` / `error_kind_str`
  outer-Self match via source-text parse).
- TC-CODE-004-d (consumer-side retry on Transient) is T-003 per
  Nagatha's plan, not T-001.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…ty refs (CODE-002)

Drops direct `wallet_id` FK columns from every identity-owned table.
The cascade chain becomes wallet_metadata → identities → identity-owned
objects so the consumer can pass the real owning `wallet_id` (or NULL
for orphan identities) without inventing the `WalletId::default()`
sentinel that V001's FK silently rejected.

Schema changes (V001 → V002):
- identities: PK (identity_id); wallet_id BLOB nullable, FK cascade
- identity_keys: drop wallet_id; PK (identity_id, key_id); FK identity_id
- dashpay_profiles: drop wallet_id; PK identity_id; FK identity_id
- dashpay_payments_overlay: drop wallet_id; PK (identity_id, payment_id); FK identity_id
- token_balances: drop wallet_id; PK (identity_id, token_id); FK identity_id
- wallet-scoped tables (accounts, core_*, platform_*, asset_locks,
  contacts_*, wallet_metadata) unchanged

Sentinel-row guard: V002 refuses to run if `token_balances` carries
legacy rows with `wallet_id = X'00…00'`. Implemented via a CHECK on a
temp table; the persister open path re-classifies the failure as
`WalletStorageError::MigrationRequiresManualCleanup { table, count }`
so operators see what to drop instead of a cryptic rusqlite error.

Migration discipline: `run_for_open` disables `PRAGMA foreign_keys`
before BEGIN (and re-enables with assertion on exit). SQLite fires
`ON DELETE CASCADE` on children when their parent is dropped during
the 12-step ALTER, even under `defer_foreign_keys` — wiping the rows
we're migrating. FKs cannot be toggled inside a tx, hence the
wrapper.

Consumer side (`identity_sync.rs`): removed the
`WalletId::default()` sentinel. `IdentitySyncManager` now carries an
identity → `Option<WalletId>` map populated via the new
`register_identity_with_wallet` method; `apply_fresh_balances` looks
the wallet id up and passes it to `persister.store(...)`. The legacy
`register_identity` shim defaults to `None` (orphan) so FFI callers
continue to compile unchanged — V002's nullable
`identities.wallet_id` accepts the orphan case.

PER_WALLET_TABLES gains a `WalletScope { DirectColumn, ViaIdentity }`
discriminant; counting/inspect queries now route through
`count_rows_for_wallet_sql` so identity-scoped tables JOIN through
`identities` to find rows belonging to a wallet. `delete_wallet` is
unaffected — cascade still flows from `wallet_metadata`.

Tests:
- NEW `sqlite_v002_migration.rs` (6 TCs covering rows-preserved,
  cascade-chain, orphan identity, real wallet_id token write,
  sentinel-row refusal end-to-end, post-migration foreign_key_check)
- Existing migration / round-trip / delete-wallet / hardening tests
  updated to the V002 schema (column lists, identity row seeding via
  the new `ensure_identity` helper)
- Error-classification table extended with the new variant; gate
  remains wildcard-free

Closes CODE-002.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… on load (CODE-001)

`load_from_persistor` used to iterate over the persister's `wallets`
map and drain `platform_addresses` per wallet inside the loop. When
the persister reports `LOAD_UNIMPLEMENTED = &["ClientStartState::wallets"]`
(today's `SqlitePersister` contract, pending PR #3692), the loop body
never executes — and every persisted platform-address slice silently
drops at function scope.

The host's per-wallet `register_wallet` re-fetch path does in fact
deliver the addresses correctly, so there is no actual data loss on
PR #3625 today. But the silent-drop is a code smell that will
absolutely bite the next caller who reaches for `load_from_persistor`
expecting the documented contract.

Gate the consumer: when `wallets.is_empty() && !platform_addresses.is_empty()`,
return a new typed `PlatformWalletError::PersistorMissingWalletRehydration`
carrying the unimplemented-area list and the orphan slice count. The
host then either waits for #3692 to land or falls back to per-wallet
`register_wallet`.

Tests:
- TC-CODE-001-a: persister returning two orphan platform_addresses
  slices → `PersistorMissingWalletRehydration { orphan_addresses_count: 2 }`
- Negative control: empty persister payload (the `NoPlatformPersistence`
  shape) still succeeds — gate only trips for the orphan case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…register_wallet (CODE-018)

`register_wallet` used to ignore the result of the registration-time
`persister.store(...)` call — just log on error and proceed with the
wallet half-registered (visible in `wallet_manager`, no
`wallet_metadata` row on disk). Every subsequent per-wallet write
then FK-violates against the missing parent (CODE-002 territory).

Drive the typed `PersistenceError` kind off the wire (introduced by
CODE-004 / T-001):
- `Transient` (e.g. `SQLITE_BUSY`): one 50ms backoff retry; if the
  retry succeeds the wallet registers normally.
- `Fatal` / `Constraint` / `LockPoisoned`: undo the in-memory
  `wallet_manager` insert and surface the new
  `PlatformWalletError::WalletRegistrationFailed { wallet_id, reason }`
  variant.

The retry path uses `tokio::time::sleep` (already in tree) and clones
the registration changeset once — `PlatformWalletChangeSet` already
derives `Clone`.

Tests:
- TC-CODE-018-a: scripted `Fatal` store → returns
  `WalletRegistrationFailed`, single store attempt (no retry),
  `wallet_ids()` is empty.
- TC-CODE-018-b: scripted `Transient → Ok` → succeeds, wallet appears
  in `wallet_ids()`, store called at least twice (original + retry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… trait + wire remove_wallet (CODE-003)

Surface SQLite's cascade-delete through the trait so consumers don't
need a concrete backend. `DeleteWalletReport` moves from
`platform-wallet-storage` into `platform-wallet::changeset::traits`
(the trait owns its return types) — SqlitePersister keeps its
inherent `delete_wallet` (returns `WalletStorageError` for callers
that want the typed error) and adds a trait impl that delegates
through the existing `delete_wallet_inner` helper, mapping the
`WalletStorageError` chain into a classified `PersistenceError`.

`PlatformWalletManager::remove_wallet` now calls
`persister.delete_wallet` after the in-memory cleanup. Error policy
mirrors `register_wallet` (CODE-018): transient → one backoff retry;
anything else → log structured context and continue (the user wanted
this wallet gone, in-memory state is already cleaned up, orphan rows
can be reaped by an admin tool out-of-band).

Trait default returns an empty `DeleteWalletReport` keyed by the
requested id, so backends with no per-wallet disk state
(`NoPlatformPersistence`, `FFIPersister`) inherit cleanly with no
explicit impl required.

TC-CODE-003-1/2/3 exercise happy path + fatal-error + transient-retry
through `PlatformWalletManager::remove_wallet` with a recording
persister. `tests/sqlite_trait_dispatch.rs` adds two more cases for
the trait default and the SQLite trait impl round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… trait (CODE-026)

Surface SQLite's `commit_writes` (batch flush of every dirty wallet
in `FlushMode::Manual`) through the trait so consumers using
deferred-write mode can durable-flush without a concrete backend
type. `CommitReport` moves from `platform-wallet-storage` into
`platform-wallet::changeset::traits` alongside the trait itself.

SqlitePersister splits the inherent `commit_writes` body into a
private `commit_writes_inner` helper that both the inherent and
trait method delegate to — no behavioral drift, no duplication.

Trait default returns an empty `CommitReport`, so backends that
flush inline (`FlushMode::Immediate` on SQLite) or have nothing to
buffer (`NoPlatformPersistence`, `FFIPersister`) inherit cleanly
with no explicit impl required.

TC-CODE-026-1/2 cover the default-impl empty-report contract and
the SQLite trait round-trip flushing two dirty wallets durably.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…flock; drop fs2 dep (CODE-005/007/010/015)

Cross-process exclusion for `restore_from` and `delete_wallet` now
uses SQLite-native `BEGIN EXCLUSIVE` against the destination DB
instead of the prior `fs2::FileExt::try_lock_exclusive` advisory
lock. Advisory `flock(2)` doesn't interlock with SQLite's own page
locking, so a peer rusqlite Connection could race the restore swap
(CODE-005) or commit rows between the pre-delete backup snapshot
and the cascade delete (CODE-007). With the SQLite-native primitive
peers conflict for real: they wait on `busy_timeout`, then surface
BUSY / RestoreDestinationLocked.

`restore_from` opens a short-lived RW connection on the destination,
issues `BEGIN EXCLUSIVE` (mapped to RestoreDestinationLocked on
BUSY/LOCKED), and DROPS that connection BEFORE `tmp.persist` so
SQLite releases its file handle on the old inode before the atomic
rename.

`delete_wallet` runs the pre-delete backup BEFORE acquiring
EXCLUSIVE (rusqlite's online backup can't establish itself when the
source conn is in an active write tx), then takes EXCLUSIVE for the
cascade. A structured `info!` fires when the wallet's row footprint
differs across the EXCLUSIVE acquisition so operators can correlate
peer-committed-between-snapshot-and-lock scenarios. A
`TODO(T-007/CODE-006)` marks the pre-backup flush slot that T-007
will fill inside the same EXCLUSIVE window.

`fs2` is removed from `Cargo.toml`, the dep feature gate, and
`Cargo.lock` (zero remaining transitive uses). The stale
README "advisory-lock warning" and `backup.rs` rustdoc are rewritten
to describe the real (SQLite-native) primitive. The Err arm that
emitted `"advisory lock unsupported on this filesystem"` (CODE-010
false-positive downgrade) is gone with the flock block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tes (CODE-006)

`delete_wallet`'s pre-delete backup snapshot now contains every buffered
write that was pending at the moment of delete — not just the
already-persisted state. Without this fix, rollback-from-backup could
not recover a wallet whose only state lived in the merge buffer
(`FlushMode::Manual` + un-flushed).

Implementation: after draining the wallet's buffered changeset, the
delete path opens its own EXCLUSIVE tx and applies the changeset via
the newly extracted `apply_changeset_to_tx` helper (factored out of
`write_changeset_in_one_tx` so both call sites share schema-write
logic). The pre-flush commits BEFORE `run_auto_backup` runs, so the
snapshot captures the previously-buffered rows. On flush failure the
buffer is restored via the existing CMT-002 restore path and the
delete is aborted with no backup left behind.

The cascade-side backup still runs before the cascade's own
`BEGIN EXCLUSIVE` (rusqlite's `Backup::new` can't establish a backup
whose source connection holds an active write tx — `sqlite3_backup_step`
deadlocks against the in-flight EXCLUSIVE). A cross-process peer that
mutates the wallet between snapshot and lock is handled by the
existing post-EXCLUSIVE re-check, which logs the footprint change and
proceeds — the cascade reports zero per-table counts when the peer
beat it to every row.

Tests (TC-CODE-006-{1,2,3}):
- `pre_delete_backup_includes_buffered_writes`: renamed from
  `pre_delete_backup_excludes_buffered_writes`; assertion flipped to
  require the buffered rows to appear in the backup.
- `pre_flush_failure_preserves_buffer_and_skips_backup`: primes the
  pre-flush injector to fail; asserts buffer survives, backup dir is
  empty, wallet still in live DB, original error propagated.
- `peer_delete_between_backup_and_exclusive_returns_ok_with_zero_counts`:
  arms a post-backup hook that opens a sibling raw connection and
  deletes the wallet's metadata row in the gap between snapshot and
  EXCLUSIVE; asserts the cascade returns Ok with zero counts and the
  backup carries the pre-peer-deletion state.

Test-only helpers added (gated by `cfg(test)` / `__test-helpers`):
- `arm_post_backup_hook` — fires once between backup and EXCLUSIVE.
- `force_next_pre_flush_to_fail` — primes a one-shot pre-flush failure.
- `buffer_has_changeset_for_test` — probes the dirty-wallets set.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…11/014/016/019)

Five small fixes in the SQLite persistor layer, batched into one commit
to keep the diff atomic (4 of 5 tasks touch `sqlite/backup.rs`).

* CODE-009 — `backup::run_to` swaps the pre-`exists()` gate for
  `tempfile::persist_noclobber`. The atomic check-and-rename closes
  the TOCTOU window; `AlreadyExists` maps to the typed
  `BackupDestinationExists` so the CLI's contract stays intact.

* CODE-011 — Sidecar paths in `apply_secure_permissions` and the
  `backup::restore_from` WAL/SHM-unlink loop are built via
  `OsString::push` (non-UTF-8 path bytes round-trip intact). The
  `set_permissions` / `remove_file` calls run unconditionally and
  treat `ErrorKind::NotFound` as a silent no-op, removing the
  `exists()` TOCTOU gate.

* CODE-014 — After every `tempfile::persist*` rename in
  `backup::run_to` and `backup::restore_from`, the destination's
  parent directory is `fsync`-ed via the new `fsync_parent_dir`
  helper (Unix only; no-op on non-Unix). Rustdoc `# Atomicity`
  sections updated to mention the parent-dir fsync.

* CODE-016 — `run_integrity_check` switches from `query_row` to
  `query_map`-collect. All diagnostic rows land in the
  `IntegrityCheckFailed { report }` string joined with `\n`; a
  mid-stream `SqliteFailure` (DatabaseCorrupt) is treated as
  end-of-stream when at least one row arrived (and appended as a
  marker), preserving the existing `atom_013` semantics.

* CODE-019 — `backup::prune` now increments `kept` on a
  `remove_file` failure (the file is still on disk). Documented the
  invariant `kept + removed.len() == total_eligible` and the
  `failed_removals ⊆ kept` subset relationship on `PruneReport`.

Tests:
* TC-CODE-009-a/b — atomic overwrite refusal + Io variant mapping
  (`sqlite_backup_restore.rs`).
* TC-CODE-011-a/b/c — non-UTF-8 sidecar chmod, missing-sidecar Ok
  path, source-level regression for both call sites
  (`sqlite_permissions.rs`).
* TC-CODE-014-a/b — source-level fsync invocation count + rustdoc
  mention assertions (`sqlite_backup_restore.rs`).
* TC-CODE-016-a — multi-page corruption yields a multi-line
  diagnostic report (`sqlite_open_integrity_check.rs`).
* TC-CODE-019-a — chmod-locked prune dir produces
  `kept + removed == total` with `failed_removals` populated
  (`sqlite_backup_restore.rs`).

T-010 fsync is verified at the source level (regression-grade
test asserting `fsync_parent_dir(` appears in `backup.rs` at least
three times — definition + run_to + restore_from). A real
crash-durability harness is impractical in unit-test scope; the
rustdoc + source-level checks pin the contract.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…qlite feature (CODE-020)

Mark `platform-wallet` and `serde` `optional = true` and route them
through the existing `sqlite` feature via `dep:` activators. Without
the feature the bare crate ships only `thiserror`, `tracing` and `hex`
(20 lines of cargo tree -e normal, down from 1124 with default
features), so embedders that only want the future `SecretStore`
submodule no longer transitively pull `platform-wallet` ->
`dpp`/`drive`/`dashcore`.

Add `tests/feature_flag_build.rs` as a TC-CODE-020-1 source-level pin
so a future contributor flipping the manifest back to unconditional
deps trips a unit test instead of only the bare-build CI gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ces (CODE-021)

The `delete-wallet` subcommand was removed from the maintenance CLI in
favour of the library-only `SqlitePersister::delete_wallet` /
`delete_wallet_skip_backup` APIs. The README still listed the
subcommand and described `--no-auto-backup` as opting out of the
pre-delete auto-backup — both stale. Scope the destructive-subcommand
paragraph to `restore` only and steer readers at the library API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-022/023)

The concurrent-store delete-wallet regression test silently swallowed
both the `delete_wallet` Result (with `let _ = ...`) and the
post-delete `COUNT(*)` query failure (with `.unwrap_or(0)`). Either
mishap would have masked a real schema or API regression behind a
green test — `unwrap_or(0)` is especially toxic because it would let
a broken schema look like a clean wipe.

Promote both call sites to fail loud: `expect(...)` for the delete
itself and `unwrap_or_else(|e| panic!(...))` for the COUNT (cheaper
than `.expect(&format!(...))` so clippy stays quiet).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ruption (CODE-024)

The single-page corruption helper guarded against `len > 4096`, but it
then reads page 2 (bytes 4096..8192) — that bound is off by one and
silently corrupts a partially-allocated page on a freshly-opened DB
small enough to land in the [4097, 8191] window. Tighten the
assertion to `>= 8192` so the precondition matches what the helper
actually does, and include the observed length in the panic message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…per (CODE-025)

The CLI binary carried its own `default_auto_backup_dir_for_cli`
mirror of the persister's helper — two definitions of the same
`<db_dir>/backups/auto/` resolution, one drift bug away from the CLI
and the library disagreeing about where auto-backups live.

Promote the persister-side helper to `pub` (the originally preferred
`pub(super)` is incompatible with `pub use` re-exports up to the
crate root — documented in the helper's rustdoc), re-export it from
`sqlite/mod.rs` and the crate root, and switch the CLI to call the
library helper. Single source of truth, zero behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix-up for 7dff6a4 — rustfmt prefers the closing `)` on a separate
line when the raw string spans multiple lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_wallet N+1 load() (CODE-017)

Add an Option<BTreeMap<WalletId, PlatformAddressSyncStartState>> cache
(+ shielded snapshot under cfg(feature = "shielded")) on
PlatformWalletManager, populated by the first call into
`ensure_persisted_state_loaded` and consumed by `load_from_persistor`
and `register_wallet`. `remove_wallet` drops the per-wallet entry so
a future re-registration under the same id cannot apply stale state.

Before: `register_wallet` called `persister.load()` per wallet — M
wallets = M full reads, each holding the connection mutex. After:
exactly one `load()` for the whole register round, whether triggered
by an explicit `load_from_persistor` or lazily by the first
`register_wallet`.

bind_shielded still calls persister.load() per-wallet; routing it
through the cache requires either a manager-side bind_shielded
entrypoint or back-references from PlatformWallet to the manager —
out of scope for this CODE-017 patch and tracked separately.

New test file `tests/persister_load_cache.rs`:
- TC-CODE-017-a: load_from_persistor + N register_wallet = 1 load
- TC-CODE-017-b: cold-start (no load_from_persistor) + N register = 1 load
- TC-CODE-017-c: remove_wallet invalidates only the removed slice;
  re-register under same id triggers no additional load

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(CODE-027)

DRY out the 4 in-storage `SELECT 1 FROM sqlite_master WHERE name = 'refinery_schema_history'`
probes into a single `migrations::has_schema_history` helper. Callers
(`open`, `count_pending`, `restore_from` source + staged) now share one
implementation; the `assert_schema_version_supported` table-presence
check piggy-backs on the same helper.

Adds TC-CODE-027-1 (in-module unit test): helper returns false on a
fresh in-memory DB and true once the table is created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…es! nits (CODE-028/029)

CODE-029 — extend `validate_config`:
  * journal_mode = Memory   → reject (rollback journal in RAM is
    crash-unsafe for a wallet DB)
  * journal_mode = Off      → reject (no rollback journal at all)
  * busy_timeout = 0        → warn via `tracing::warn!` (operator
    almost certainly didn't mean it; a few tests want fail-fast, so we
    don't reject)

Reuses the existing `ConfigInvalid { reason: &'static str }` variant —
no new variant or field, so wildcard-free matches on
`WalletStorageError` keep compiling.

CODE-028 — replace `matches!(self.config.flush_mode, FlushMode::X)`
with `==`/`!=` at the two persister sites. FlushMode already derives
`PartialEq, Eq`, so this is a pure readability cleanup with no semantic
change.

Tests added (sqlite_persist_roundtrip.rs):
  * TC-CODE-029-1 — journal_mode=Memory rejected with typed
    ConfigInvalid; DB not created.
  * TC-CODE-029-2 — journal_mode=Off rejected with typed
    ConfigInvalid; DB not created.
  * TC-CODE-029-3 — busy_timeout=Duration::ZERO opens successfully and
    emits the expected `busy_timeout=0` warning (verified via
    `tracing_test::traced_test`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… in favour of --no-auto-backup (CODE-030)

The CLI used to overload an empty string passed to `--auto-backup-dir`
as "disable auto-backup". That's a non-standard convention and
collides with the dedicated `--no-auto-backup` flag already exposed by
`migrate` and `restore`.

Deprecation strategy: keep the empty-string form parsing for one
release so existing operator scripts don't break overnight, but emit a
loud `warning: ... deprecated; pass --no-auto-backup instead` on
stderr the moment the legacy form is used. The arg's help text and the
README's CLI section steer new callers to `--no-auto-backup`.

Tests (sqlite_cli_smoke.rs):
  * TC-CODE-030-1a — `migrate --no-auto-backup` succeeds, emits the
    expected stderr notice, and writes no pre-migration snapshot.
  * TC-CODE-030-1b — `--auto-backup-dir "" migrate --no-auto-backup`
    still succeeds but emits the deprecation warning steering to
    `--no-auto-backup`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-012/013)

Mark the two half-wired-callback gaps thepastaclaw flagged on PR #3625
with TODO comments that pin them to follow-up issues. Both gaps
pre-existed on v3.1-dev — #3625 didn't introduce them — and a proper
fix needs FFI registration plumbing (paired (fn, free_fn) enforcement)
that's out of scope for this PR.

  * CODE-012 — FFIPersister::load: on_load_wallet_list_fn /
    on_load_wallet_list_free_fn must be paired at registration time.
  * CODE-013 — FFIPersister::get_core_tx_record: same pairing
    requirement for on_get_core_tx_record_fn /
    on_get_core_tx_record_free_fn.

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration tests (CODE-008)

Add `tests/round_trip_consumer.rs` — the meta-fix CI safety net for
PR #3625. Five `#[tokio::test]`s exercise a real
`PlatformWalletManager` (consumer side from `rs-platform-wallet`)
against a real `SqlitePersister` (this crate) so every consumer↔
persister contract drift now fails CI instead of slipping through:

  * TC-CODE-008-1 — register_wallet → drop → reopen: wallet_metadata
    + account_registrations + account_address_pools survive.
  * TC-CODE-008-2 — platform_addresses round-trip through
    persister.store + list_per_wallet across reopen.
  * TC-CODE-008-3 — identity_keys + token_balances round-trip under a
    registered (wallet, identity) pair; regression guard for CODE-002
    (sentinel WalletId::default() FK violation).
  * TC-CODE-008-4 — remove_wallet cascades through storage for the
    removed wallet but leaves the surviving sibling untouched;
    regression guard for CODE-003 (remove_wallet never propagated to
    disk).
  * TC-CODE-008-5 — boot the manager twice over the same DB; the
    persisted wallets are still on disk after a clean reopen + a
    second load_from_persistor() pass; regression guard for CODE-001.

Per user direction ("If possible, put it into persister crate") the
test lives here so the dev-dep cycle stays one-way:
`platform-wallet` ships no dependency on `platform-wallet-storage`,
while the storage crate is free to pull `platform-wallet` into
`[dev-dependencies]` for integration coverage.

Dev-deps added: `dash-sdk` with `mocks` + `wallet` features (needed
by `SdkBuilder::new_mock().build()` for the manager) and a direct
`tokio` so `#[tokio::test]` resolves the macro by name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…entry point (PROJ-001)

The FFI `platform_wallet_manager_identity_sync_register_identity` was
still routing through the orphan-identity shim
(`IdentitySyncManager::register_identity`) which records
`Option<WalletId>::None` and ultimately persists the resulting
`TokenBalanceChangeSet` under the all-zero `WalletId` sentinel. That
defeats the purpose of the wallet-aware path
(`register_identity_with_wallet`) and breaks the
`wallet_metadata → identities → token_balances` cascade for any token
balance learned through this FFI.

This commit:

* Adds a required `wallet_id_ptr` parameter (32 bytes) to the FFI entry
  point, rejecting null with `ErrorNullPointer` and the all-zero
  sentinel with `ErrorInvalidParameter`.
* Routes to `register_identity_with_wallet(identity_id, Some(wallet_id),
  token_ids)` so the recorded parent wallet flows through every
  subsequent `persister.store(wallet_id, …)` call.
* Updates the Swift wrapper `registerIdentityForTokenSync` to take the
  new `walletId: Data` argument, validate length on the Swift side, and
  marshal the buffer through the new FFI parameter.
* Updates the SwiftExampleApp caller to source the parent wallet from
  `identity.wallet?.walletId`, skipping the registration (and the
  display-only fetch still works) when the identity is out-of-wallet.
* Updates the doc-comment usage example in `TokenActions.swift`.

BREAKING CHANGE: the C ABI for
`platform_wallet_manager_identity_sync_register_identity` gains a new
`wallet_id_ptr` parameter between `identity_id_ptr` and
`token_ids_ptr`. Swift callers must pass the parent wallet id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ities upsert

Document that the `wallet_id = COALESCE(excluded.wallet_id,
identities.wallet_id)` upsert clause is the intended merge semantic for
orphan-identity-to-wallet promotion (NULL `wallet_id` is allowed per
the V002 CODE-002 design). Existing `wallet_id` survives a re-upsert;
a freshly known `wallet_id` fills NULL on promotion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… orphan-cleanup contract

Document that orphan `token_balances` cleanup is the host's
responsibility — no automatic prune API is offered. V002 cascades only
through `identities` (the `wallet_id` column was dropped from
`token_balances`), so hosts that delete identities out-of-band must
prune the corresponding rows themselves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-017 follow-up)

Helper added in 6934404 was never called, tripping -D dead-code on
macOS strict profile and blocking Swift SDK build on PR #3743. Wiring
fulfills the original perf intent (drop N+1 persister.load() at
shielded bind time).

- Add PlatformWallet::bind_shielded_with_snapshot taking a pre-loaded
  Arc<ShieldedSyncStartState>; bind_shielded keeps its public signature
  by delegating with None.
- Promote PlatformWalletManager::cached_persisted_shielded from
  pub(super) to pub so the FFI layer (the only direct host of
  bind_shielded) can fetch the shared snapshot once per call.
- platform_wallet_manager_bind_shielded now pulls the cached snapshot
  alongside the coordinator and feeds it through, so every wallet's
  bind reuses the same Arc instead of issuing its own load().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ar chmod test (APFS compat)

tc_code_011_a previously used raw non-UTF-8 bytes (0xff 0xfe ...) which
APFS rejects with EILSEQ. The test's real intent is to verify OsString
sidecar concatenation + chmod survive non-ASCII paths — a valid-UTF-8
multi-byte sequence (ÿþ → \xc3\xbf\xc3\xbe) exercises the same codepath
on both Linux ext4/btrfs and macOS APFS without losing coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on persistence trait

Trait-level contract: passing WalletId::default() to PlatformWalletPersistence
methods marks the entity as orphan (no parent wallet). V002 schema permits
this; higher layers (FFI) may enforce stricter rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ommit/delete reports

Follow-up to d0ed23b: extend the WalletId::default() = orphan convention
to PlatformWalletPersistence::flush parameter, commit_writes return doc,
CommitReport field docs, and DeleteWalletReport.wallet_id field.
…epastaclaw-hardening

# Conflicts:
#	packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs
…3) — moved to follow-up PR

Phase 2 of PR #3743 slim. Restores consumer-side state to
origin/feat/platform-wallet-sqlite-persistor for these files. The trait
surface (delete_wallet, commit_writes, typed PersistenceError) stays so
the storage crate still compiles; only the consumer call-sites and
caching/retry plumbing revert.

- src/error.rs: drop PersistorMissingWalletRehydration +
  WalletRegistrationFailed variants
- src/manager/load.rs: drop CODE-001 orphan-bucket gate and CODE-017
  cache plumbing
- src/manager/mod.rs: drop CODE-017 cache fields on
  PlatformWalletManager
- src/manager/wallet_lifecycle.rs: drop CODE-018 retry, CODE-003 wired
  remove_wallet call site, cache-invalidation hook
- src/wallet/platform_wallet.rs: drop CODE-017
  bind_shielded_with_snapshot shim
- FFI src/shielded_sync.rs: drop CODE-017 cached_persisted_shielded
  wiring

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
lklimek and others added 9 commits May 26, 2026 18:10
…lush + commit/delete reports"

This reverts commit 5747a98.
…vention on persistence trait"

This reverts commit d0ed23b.
…8 consumer-integration test moved to follow-up PR)

This test brings rs-platform-wallet as a dev-dep specifically to
exercise the consumer flow against the storage backend, and it depends
on the PROJ-001 wallet-aware register_identity_with_wallet helper.
Now that the PROJ-001 consumer-side wiring has been reverted from this
PR (kept for the follow-up consolidated PR), the test no longer
compiles. Other storage-crate-internal round-trip coverage
(sqlite_persist_roundtrip, sqlite_load_reconstruction, etc.) remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…/CODE-017/CODE-018) + PROJ-001 FFI

- CODE-001: refuse silent drop of orphan platform_addresses on load
- CODE-003 (call-site): wire remove_wallet to persister.delete_wallet
- CODE-017: cache ClientStartState slices to drop register_wallet N+1 load()
- CODE-018: retry transient + undo on fatal store error in register_wallet
- PROJ-001: FFI register_identity requires wallet_id (Rust + Swift + SwiftExampleApp)
- 5 consumer integration tests covering the above

Originally landed on PR #3743 hardening branch; extracted here to keep #3743
focused on the rs-platform-wallet-storage crate landing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (CODE-012/013)

Originally landed on PR #3743; extracted here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ommit/delete reports

Originally landed on PR #3743; extracted here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on persistence trait

Originally landed on PR #3743; extracted here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tegration tests (CODE-008)

Originally landed on PR #3743; extracted here. Lives under
rs-platform-wallet-storage/tests/ but brings rs-platform-wallet as dev-dep
to exercise the consumer flow against the storage backend — fundamentally
a consumer-integration test, follows the consumer code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2b989e91-5d5f-4f0c-8fa7-97190f263a57

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/platform-wallet-consumer-hardening

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 and usage tips.

lklimek added 2 commits May 27, 2026 09:28
… into feat/platform-wallet-consumer-hardening
Base automatically changed from fix/3625-thepastaclaw-hardening to feat/platform-wallet-sqlite-persistor May 27, 2026 09:42
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.

2 participants