Skip to content

feat: verified snapshot transport — pointer-swap protocol, model-checked, v0.6.0#6

Merged
jaredLunde merged 3 commits into
mainfrom
jared/doh
Jun 12, 2026
Merged

feat: verified snapshot transport — pointer-swap protocol, model-checked, v0.6.0#6
jaredLunde merged 3 commits into
mainfrom
jared/doh

Conversation

@jaredLunde

Copy link
Copy Markdown
Contributor

Summary

This started as "is the snapshot export/import consistent with multiple exporting nodes?" and ended as a verification campaign that found and fixed three real correctness bugs, replaced the transport protocol with a machine-checked design, and left behind a proof stack that re-verifies the protocol on every test run.

Bugs found (each caught by a different verification layer)

  1. The entire CursorExpired → resync path was dead code. NATS does not error on a resume below first_seq — it silently clamps to the first retained message (pinned live by nats_silently_clamps_resume_below_first_seq). Every expired-cursor resume silently skipped the gap, evicted delete markers included: silent fold divergence with zero log lines. Fixed with proactive detection: first_sequence comparison before and after consumer creation in all three resume paths.

  2. A failed resync degraded to a warning, leaving deleted keys in the fold forever. The model proves warn-and-continue semantics break the convergence theorem (legacy_degrading_resync_diverges); resync failure now fails the watch and the caller's restart retries the whole resume → expiry → resync chain.

  3. The two-object transport layout (payload + sibling manifest, both last-write-wins) allowed: a stalled exporter overrunning its lease to regress the published "latest", and a crash between the two PUTs to tear the pair and break every bootstrap until the next round. Replaced with content-addressed write-once payloads + a single pointer object published last via monotonic conditional swap (If-Match CAS; fail-closed on stores without conditional puts, explicit dev-only opt-in for file://). The model checker additionally caught a dangling-pointer race between prune and an in-flight publish — fixed structurally with the strictly-below-the-pointer prune rule (cursors embedded in payload keys).

The proof stack

  • src/protocol.rs — shared decision kernels: production code and the model checker execute the same guard source, so the guards cannot drift from the proof.
  • tests/model.rs — Stateright model, exhaustive within bounds: no regression, no torn pairs, no dangling pointers, no mixed imports, no silent divergence, and cycle-proof terminal liveness — over multi-round fleets at 2 and 3 exporters, with crashes between every step, zero-grace pruning, and no lease (a strict superset of all clock-skew behaviors). Legacy configurations are kept as machine-checked records of each bug; mutation tests prove every kernel guard is load-bearing. Deep tier: ~250M unique states (cargo test --release --test model -- --ignored).
  • tests/multi_export.rs — prevention proofs on live NATS with real fjall/RocksDB folds: the slow-exporter clobber is refused, the crash window keeps bootstrap available, and multi-SST post-compaction artifacts survive source churn byte-exactly.
  • tests/resync.rs — the expiry → fallback → resync chain end-to-end against a live server (self-validating: the deleted key's marker is purged, so only a working chain can remove it), plus the no-reader divergence pinned.
  • tests/transport_s3.rs — the conditional-put axiom verified on live MinIO: Create, If-Match swap, monotonic refusal, crash-window availability, prune.

Rollout note (wire format change → v0.6.0)

Payloads moved from <key> to <key>.payloads/<cursor>-<hash>.tar; 0.6.0 readers cannot consume 0.5.0 layouts. Upgrade exporters before bootstrap-dependent nodes, or let the first 0.6.0 round publish before relying on it.

Test plan

  • 241 tests across all tiers (unit, integration, model, live NATS, live MinIO), clippy clean, formatted
  • Deep model tier verified in release mode (~250M unique states, 8 min)
  • CI green on this PR

🤖 Generated with Claude Code

jaredLunde and others added 3 commits June 11, 2026 19:33
…ked, v0.6.0

Three real bugs found and fixed, each caught by a new verification layer:

1. NATS silently clamps a resume below first_seq (no error), so the entire
   CursorExpired -> resync path was dead code: every expired resume silently
   skipped the gap, evicted delete markers included. Expiry is now detected
   proactively (first_sequence comparison, re-checked after consumer
   creation), pinned by a live regression test.

2. A failed stale-key resync degraded to a warning, silently leaving deleted
   keys in the fold forever. Resync failure now fails the watch (fail-stop);
   the model proves degrade semantics break the convergence theorem.

3. The transport's two-object layout allowed a stalled exporter to regress
   "latest" (last-write-wins clobber) and a mid-upload crash to tear the
   payload/manifest pair. Replaced with content-addressed write-once
   payloads + a single pointer published last via monotonic conditional
   swap (If-Match CAS; fail-closed on non-CAS stores with explicit dev-only
   opt-in). The model checker additionally caught a dangling-pointer race
   between prune and an in-flight publish; the fix is the strictly-below-
   the-pointer prune rule, with cursors embedded in payload keys.

Proof stack:
- src/protocol.rs: shared decision kernels — production code and the model
  checker execute the same guard source, so guards cannot drift from proof.
- tests/model.rs: Stateright model, exhaustive within bounds; theorems (no
  regression, no torn pairs, no dangling pointers, no mixed imports, no
  silent divergence, terminal liveness) over multi-round fleets at N=2/3;
  legacy configurations kept as machine-checked records of each bug;
  mutation tests prove every kernel guard is load-bearing; deep tier
  (~250M unique states) for release runs.
- tests/multi_export.rs: prevention proofs on live NATS + real fjall/
  RocksDB folds (slow-exporter refused, crash window keeps bootstrap
  available, post-compaction multi-SST fidelity under source churn).
- tests/resync.rs: live expiry->resync chain end-to-end; NATS clamp pinned;
  no-reader divergence pinned.
- tests/transport_s3.rs: conditional-put axiom verified on live MinIO
  (Create, If-Match swap, refusal, crash window, prune).

Wire format change (payload location): upgrade exporters before
bootstrap-dependent nodes. v0.6.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port pipeline

Add the sections and content that were missing from the existing doc:

- Rewrite summary to describe observable input → output behavior
- Add data flow diagrams for artifact bootstrap and export round
- Add 9 new Concepts & Terminology entries covering ExportLease, LeaseGuard,
  ExportManifest, ArtifactTransport, ObjectStoreTransport, PublishOutcome, etc.
- Add Machine-Checked Protocol Kernels section documenting the three guards in
  protocol.rs (pointer_publish_allowed, payload_prunable, resume_window_ok),
  their Stateright theorem names, and why they are shared between production and model
- Add Export Round State Machine with states, transitions, and failure outcomes
- Add Export Lease section covering CAS acquisition, embedded TTL rationale,
  corrupt-lease stealing, and LeaseRecord wire format
- Expand transport object layout to show content-addressed payload key derivation
- Update Package Structure to include all new source files and test files
- Update Failure Modes table with What-Actually-Happens column and export failures
- Expand Trust Model to cover the transport layer's verification gates
- Add two new Design Decisions: monotonic pointer vs. two-register layout and
  protocol.rs shared with model
- Add BatchConfig, ObjectStoreTransport/ExportLease, and per-backend durability
  configuration tables

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A cache-restored rustup 1.92 sometimes lacks the clippy/rustfmt components
mise.toml requests, fast-failing the clippy step ("'cargo-clippy' is not
installed") — a recurrent flake (also 2026-06-04). An idempotent
`rustup component add` is ~2s when present and heals the poisoned cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jaredLunde jaredLunde merged commit d1886ff into main Jun 12, 2026
1 check passed
@jaredLunde jaredLunde deleted the jared/doh branch June 12, 2026 02:59
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.

1 participant