Skip to content

fix(storage): enforce root ownership and detect account corruption - #1937

Open
liamhelmer-bel wants to merge 4 commits into
marmot-protocol:masterfrom
liamhelmer-bel:bead/2c08f79f-8ceb-553d-9dc6-d2f9408cf489/btq-harness-2e48d60f27b73a1770708fe9
Open

liamhelmer-bel wants to merge 4 commits into
marmot-protocol:masterfrom
liamhelmer-bel:bead/2c08f79f-8ceb-553d-9dc6-d2f9408cf489/btq-harness-2e48d60f27b73a1770708fe9

Conversation

@liamhelmer-bel

@liamhelmer-bel liamhelmer-bel commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Direct wn commands and wnd startup bypassed the root lease already held by wn-agent, allowing independent hydrated runtimes to open one Marmot home. Acquire that lease before local execution or daemon socket cleanup, including fallback after an abandoned socket. Existing socket clients continue through their owning daemon. Raw QUIC receive and unanchored send remain independent of account storage; one shared classifier defines that exemption and execution path.

Add a periodic structural integrity probe on the existing keyed account connection, with a SQLite VM interruption budget and fixed healthy/corrupt/incomplete telemetry. This detects damage without returning private diagnostic rows or attempting repair. The incident report records the ownership defect, incomplete recovery evidence, supported access modes, and the manager deployment handoff. The historical cause of the HMAC failure remains unresolved; the retained rebuild has 2,803 foreign-key violations and is not a verified restore candidate.

Validation: just fast-ci passed; all three commits have verified signatures. Complete touched-crate tests pass: CLI 627 unit + 104 integration + two ownership tests; application 1,680 tests (12 ignored); session 22 tests; storage 776 unit + two integration tests (seven ignored). Documentation checks pass. Ownership tests cover logout rejection until daemon shutdown, raw streams while a root lease is held, and anchored streams between separate homes; all three integrity regressions pass.

Earlier runs exposed storage setup/subprocess failures and one stale compiled test fixture that expected an integer from cipher_page_size. The committed fixture parses the returned string; the full current-source storage rerun passes. Ignored tests remain ignored, and the full workspace just ci matrix was not run locally.

Deployment handled by manager. A coherent binary cohort and sender routing through the owning socket facade are required; WN_SOCKET uses the wnd protocol, not the wn-agent protocol. Merge, recovery, deployment, and live messaging verification remain open.

Summary by CodeRabbit

  • Bug Fixes

    • Prevented simultaneous CLI, daemon, and TUI access to the same application data, reducing the risk of session corruption.
    • Commands now clearly report when the application data directory is already in use and recover normally after the owning process exits.
    • Added safeguards for socket fallback and local command execution.
  • New Features

    • Added periodic, privacy-safe storage integrity checks that report healthy, corrupt, or incomplete results without repairing or exposing stored data.
  • Documentation

    • Documented shared-home safety, corruption detection, recovery guidance, and operational requirements.

@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 4cae4368-c979-4a76-8a97-50f3c3f6ba52

📥 Commits

Reviewing files that changed from the base of the PR and between ca267b5 and ab72e9e.

📒 Files selected for processing (3)
  • crates/cli/CHANGELOG.md
  • crates/cli/src/lib.rs
  • crates/cli/tests/cli.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

The change adds privacy-safe SQLite integrity probing, exposes it through account sessions, schedules it in account workers, and enforces exclusive Marmot root ownership in CLI and daemon paths. Tests and documentation cover ownership isolation, probe outcomes, incident findings, and recovery requirements.

Changes

Integrity and ownership hardening

Layer / File(s) Summary
Storage integrity probe
crates/storage-sqlite/..., crates/cgka-session/src/lib.rs
Adds a duration-bounded PRAGMA quick_check(1) probe with Healthy, Corrupt, and Incomplete outcomes. Exposes the probe through AccountDeviceSession and adds corruption, interruption, privacy, and connection-reuse tests.
Periodic worker integration
crates/marmot-app/src/runtime/account_worker.rs, crates/marmot-app/src/runtime/account_worker/storage_integrity.rs
Runs the integrity probe during maintenance ticks at 120-second intervals with a 250 ms budget and logs the resulting status.
Exclusive CLI and daemon ownership
crates/cli/src/..., crates/cli/tests/...
Acquires the root lease before local CLI access or daemon artifact changes. Stateless stream commands bypass local storage leasing. Tests cover ownership rejection, socket fallback, artifact preservation, separate homes, and release and reacquisition.
Incident and architecture documentation
docs/incident-2026-09-18-session-corruption.md, docs/marmot-architecture/overview/*.md, crates/cli/CHANGELOG.md
Documents the incident evidence, ownership rules, integrity-probe limits, recovery requirements, deployment constraints, and related test coverage. The changelog records the TUI ownership behavior.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Severity of issue fixed: Medium

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 9 files. (1 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary changes: enforcing root ownership and detecting account corruption.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 24 functions across 9 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@liamhelmer-bel
liamhelmer-bel marked this pull request as ready for review September 19, 2026 21:27

@Datawav Datawav left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated review

Actionable findings:

  1. [P1] TUI bypasses the new root-ownership gate
    Location: crates/cli/src/lib.rs:297
    Trigger: wn tui is started for a home whose root lease is already held by wn-agent or wnd; the earlier TUI return never reaches this acquisition.
    Impact: Root exclusivity remains unenforced for a storage-backed direct CLI entry point, permitting concurrent hydrated runtimes against one Marmot home and preserving the corruption risk this PR addresses.
    Fix: Acquire MarmotRootRuntimeLease for the resolved home before calling tui::run_tui, or route TUI through the owning runtime.
    Confidence: high

@liamhelmer-bel

Copy link
Copy Markdown
Contributor Author

Reply to Datawav’s review (no inline thread was present).

@Datawav @erskingardner Addressed the P1 TUI startup finding in signed commit ab72e9e5e30a6b51a0b1af665ba6ee9a8cffe450.

wn tui now attempts MarmotRootRuntimeLease acquisition for the resolved home before entering the UI. An already-owned home returns the existing direct-CLI error contract. The lease is explicitly released before run_tui: this TUI is a shell over wn subprocesses and can launch wnd, so holding the lease throughout the UI would block its own children. Each child still uses the existing ownership gate before local runtime access.

Limitation: this is a startup-only check, not a reservation for the UI lifetime. There remains a TOCTOU window between concurrent TUI startups; both may pass the startup check. Their storage-backed children still acquire their own runtime leases. Routing the TUI through the owning runtime is a candidate follow-up. Lease handoff is not implemented: it requires new ownership/subprocess semantics beyond this review fix. This scoped design was explicitly accepted by the operator and manager.

The regression starts a disposable real wnd, asserts the TUI's JSON ownership rejection, stops the daemon, and verifies a headless TUI reaches terminal initialization instead of failing ownership. Disposable-home PTY dogfooding also verifies that the UI remains alive, its startup lease is released, and an accounts list child succeeds while it is open. No live home or services were touched; deployment handled by manager.

Validation on this commit (all exit 0):

  • just fast-ci (workspace formatting/check/clippy and configured feature gates).
  • cargo test -p wn-cli --features test-policy-overrides -- --test-threads=2: 734 passed (627 unit, 105 CLI integration, 2 ownership).
  • cargo test -p storage-sqlite -- --test-threads=2: 778 passed, 7 ignored.
  • New targeted TUI regression and staged-home PTY dogfood passed.

These are the full requested crate suites, not a claim of full workspace just ci. Exact-head CI needs maintainer workflow approval.

@erskingardner erskingardner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed with the branch checked out: built it, ran the new tests, ran clippy on the touched crates, and benchmarked the integrity probe.

The ownership mechanism is implemented correctly and the code is clean. But it lands a large undocumented behavioral regression, and the corruption probe is undersized for the exact database that motivated it. Requesting changes on those two.

Blocking

1. Concurrent wn invocations now fail hard, with no retry and no distinguishable error code

Measured — six concurrent wn --json accounts list against one home:

master:  6/6  {"ok":true,"result":{"accounts":[]}}
PR:      1/6  ok
         5/6  {"error":{"code":"command_failed",
                "message":"marmot runtime root is already in use"},"ok":false}

try_acquire is nonblocking — no backoff, no --wait. It also gates read-only commands, though the stated hazard is two hydrated runtimes mutating.

AppError::code() already yields "runtime_busy" (crates/marmot-app/src/error.rs:396), but the CLI collapses it to command_failed, so automation has to string-match "already in use" — which is exactly what the new tests do. Please plumb the real code through.

wn logout is now unreachable while wnd runs: daemon_socket_for_client refuses to forward Logout without an explicit socket (crates/cli/src/lib.rs:752), and the lease blocks local execution. This PR inverts daemon_running_does_not_auto_forward_logout to assert exactly that.

The incident doc in this PR notes "The babysitter invokes the same CLI binary" — that automation will break.

crates/cli/CHANGELOG.md documents only the TUI change. The much larger "all direct commands now require exclusive ownership" has no entry at all.

2. The lease is root-wide, so one home can no longer serve two accounts concurrently

The evidence is in this PR's own test churn: stream_start_quic_chunks_and_final_payload_verify_through_mls_messages had to be split into home + bob_home because bob's long-lived watcher now blocks alice. --account multi-account homes are a supported feature, and this removes concurrent use of them silently. Needs either an explicit decision plus docs, or a per-account lease.

3. The daemon still opens a second hydrated runtime against its own leased root

crates/cli/src/daemon/mod.rs:608 calls run_cli_local, which builds an independent MarmotApp on the same root while the daemon's own app runtime is live. Same process, lease not re-acquired, so it cannot be caught.

That is precisely the hazard in this PR's root-cause paragraph, in the deployment where the incident occurred. The description discloses it as out of scope, which is fair — but it means this change probably does not close the reported failure mode, and the title reads like it does. Worth making that explicit up front.

Should fix

4. The 250 ms probe budget is too small for the database it was written for

Benchmarked PRAGMA quick_check(1) on SQLCipher — warm cache, local NVMe, single-table schema, i.e. best case:

size on-disk full quick_check with 250 ms budget
8 MB 9.4 MB 95 ms Healthy
38 MB 44.8 MB 348 ms Healthy — 249.9 ms, right at the edge
100 MB 118 MB 445 ms Incomplete (interrupted)

The incident database was 37,994,496 bytes. A real 78-table schema with indexes, a cold cache, or mobile flash all push it over. So the detector silently stops detecting as history grows, emitting status=incomplete every 120 s forever.

Suggest tracking consecutive incompletes and alerting on that — today "never completed a check" and "one slow tick" are indistinguishable to an operator.

Two things I checked that are sound: interruption yields SQLITE_INTERRUPTIncomplete, never a false Corrupt; and SQLite's checkProgress() does fire the progress handler inside sqlite3BtreeIntegrityCheck, so the budget genuinely bounds the single OP_IntegrityCk opcode.

5. The probe blocks the account's only connection on a tokio worker thread

crates/cgka-session/src/lib.rs:345 — "The engine and this handle share one underlying connection." So the probe holds the single ConnectionSlot mutex for 250 ms of VM time plus unbounded, non-preemptible I/O, every 120 s. That stalls engine work, projections, and SqliteAccountStorage::close() — the call required to release locks before iOS suspension.

lifecycle.is_stopping() is checked before the probe, but a probe that started 1 ms earlier makes shutdown wait it out. Consider spawn_blocking, or skipping when a close is pending.

6. Duplicate classifier

stateless_stream_command (crates/cli/src/lib.rs:561) has byte-identical match arms to the existing client_hosted_stream_command (crates/cli/src/lib.rs:762). The description says "one shared classifier defines that exemption and execution path", but there are now two that must stay in sync — and drift means either a spurious hard failure or a storage-opening command gaining a lease exemption. Reuse client_hosted_stream_command(...).is_some().

The exemption itself is safe today: I traced stream_command_local for Receive and Send { start_event_id: None } — QUIC only, never account storage, and the arms exactly match the dispatch they replaced.

Minor

  • storage_integrity.rs's _ => folds Err(StorageError::Closed) into a warning, so every shutdown racing a tick logs a spurious "health is unknown". Match Incomplete explicitly and keep Closed quiet.
  • tracing::info! on healthy probes is ~720 lines/day/account at INFO. debug! for healthy, keep error!/warn!.
  • If progress_handler(0, None) fails, the code returns Incomplete while leaving the always-interrupt closure installed — every later query on that connection would return SQLITE_INTERRUPT permanently. check_owned() effectively cannot fail here, but the code already branches on it, so it should not be able to leave the connection armed.
  • docs/incident-2026-09-18-session-corruption.md: worth deciding whether an operational incident record belongs in the public repo. It carries SHA-256 digests and byte sizes of production database snapshots, row and page counts, file-mode remediation notes, and internal tracker IDs (btq-harness-2e48d60f…) that mean nothing here. No key material is exposed, so this is a scope/disclosure judgment rather than a leak — and the repo-relevant parts (supported access modes, probe semantics and limits) are already in local-artifact-safety.md.

Verified clean

  • cargo clippy -p storage-sqlite -p marmot-app -p wn-cli -p cgka-session --all-targets — clean.
  • crates/cli/tests/root_ownership.rs 2/2 pass; storage-sqlite integrity:: 3/3 pass.
  • All four commits have verified signatures.
  • No Cargo.lock or workspace-version churn. The rusqlite hooks feature is pure Rust and adds no SQLITE_ENABLE_* compile flags.
  • Daemon lease is correctly placed before remove_stale_socket / remove_stale_pid.
  • Lease bindings are _root_lease, not a bare _, so they actually live for the scope.
  • Lock file creation is openat(..., O_NOFOLLOW | O_CLOEXEC, 0600) relative to a prepared directory fd — no symlink swap, even though every wn run now creates it.
  • The TUI really is a subprocess shell (crates/cli/src/tui/client.rs:31) with no in-process storage, so acquire-then-drop is honest. Still a TOCTOU advisory check, which the comment and CHANGELOG both say.
  • New tracing is privacy-compliant: fixed target and method, status only, and the raw SQLite diagnostic string is discarded on the Corrupt path.

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.

3 participants