Skip to content

feat(dlv): Class K drives a bundle to a consume-once decision over the generic register - #773

Merged
cryptskii merged 1 commit into
mainfrom
feat/class-k-quorumbind-driver
Sep 6, 2026
Merged

feat(dlv): Class K drives a bundle to a consume-once decision over the generic register#773
cryptskii merged 1 commit into
mainfrom
feat/class-k-quorumbind-driver

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

PR 2 of the QuorumBind restoration (owner decision 2026-09-05: keep Rev-15's architecture, replace the one-shot register with conformant QuorumBind). This is the Class K decision engine — the sans-IO state machine that drives one bundle to a consume-once decision — plus the thin async runner that performs its operations. It consumes the Class N primitive PR #772 landed; the settle paths still call the old register (switched in PR 5).

Builds on feat/class-n-generic-binding-primitive (#772) and is rebased onto main once that merges.

Why two phases

The Class N compare-and-exchange is a max-round CAS register: a strictly higher round with a different value may overwrite what a quorum already holds. A single accept phase over such a register is not safe — a value on a quorum at a low round can be overwritten by a higher round whose proposer read before that value landed, which is exactly the state Paxos forbids and would let two bundles both become binding-final, violating Theorem 18.1. I built a single-phase version first and it had this hole; the exhaustive race test caught it.

Req 6.22 prescribes Paxos-style prepare/accept rounds, and this engine implements them over PR #772's exact primitive:

  • LearnReadBinding(K(B)) a quorum to find the safe value (the value of the highest-round accepted record) and any already-chosen value.
  • Promise — compare-exchange a PROMISED record at round 2·ballot. The member's monotonic-round rule makes this the promise: a member holding a promise at ballot b refuses every accept below b.
  • Accept — compare-exchange an ACCEPTED record at round 2·ballot+1 (which supersedes this ballot's own promise).

The phase rides in the round counter (2·ballot promise, 2·ballot+1 accept), so one monotonic round expresses both and status records which. A bundle either owns every key or yields: the engine proposes only when no key's highest accepted value is a foreign bundle's, so at most one bundle accepts on a shared key.

Sans-IO, by owner decision

The core (dsm::dlv::quorum_bind) performs no networking, sleeping, retry timers, or async I/O. It emits the member operations to perform and folds authenticated answers; poll() returns Contact(ops) / Recovering(reason) / Done(outcome), and the caller feeds results back with deliver_read / deliver_cas. All timing, backoff, and concurrency live in the runner. That is what lets the two-bundle races be driven at single-operation granularity under every interleaving against a deterministic fleet double, rather than forcing real Tokio scheduling.

The runner (dsm_sdk::sdk::quorum_bind_runner) is the only place timing lives: poll → perform each op through a BindingTransportauthenticate every answer against both the committed member id and the committed register incarnation (Req 15.8) → deliver → back off and recover when stuck. BindingTransport is the async boundary (an ordinary async trait — the engine is sans-IO, the plumbing is async); the concrete HTTP client for /api/v2/storage/binding/{cas,read} arrives in PR 4.

Safety, proven by exhaustive interleaving + a mutation control

  • binding-race/ — two complete bundles over one shared parent, driven through all 4096 interleavings of 12 single-operation steps, then both driven home. Asserted: never two Committed, the committed value is exactly the one chosen value, and both bundles win under some schedules (non-vacuity — the race is real).
  • overlap-liveness/K(T1)={A,B} against K(T2)={B,C}, both drivers active, all 4096 interleavings; never both commit; both can win.
  • The fleet double mirrors dsm_storage_node::db::binding::decide_compare_exchange exactly (byte-identical replay; prior set digest must match; replacement round must strictly supersede).
  • Mutation control: degenerating the engine to single-phase (no promise, no foreign-yield) makes binding-race fail with both bundles committed at a concrete schedule (0b…011). Restored on an identical tree. This proves the two-phase mechanism is load-bearing and that the test actually detects a double-commit rather than passing vacuously.

Tests

  • Core conformance (dsm/tests/quorum_bind_conformance.rs, 10): begin refuses a non-strict-majority q and a bad key set; a clean transaction commits at quorum; quorum-fixed (one down still commits, two down never commits — safety, not a timeout); quorum-client (an unattributed answer never counts); a chosen foreign value is ConflictFinal; recovering our own completed transaction is an idempotent Committed (Theorem 18.4); abort is safe only when nothing is chosen; binding-race; overlap-liveness; multi-vault-atomic (a commit covers every key).
  • Runner (dsm_sdk, 2): drives a clean transaction to Committed; an answer echoing the wrong register incarnation is not counted, so a sub-quorum of honest members reports the transaction unresolved rather than counting mis-attributed acks.

Verification (Rust 1.98.0)

  • root make lintexit 0
  • workspace board (--workspace --exclude dsm_storage_node --release, incl. the 4096×2 interleaving tests) — 4001 passed, 0 failed (75 suites)
  • production_safety_checks (all-features clippy + TLA+) — exit 0 (TLA+ included)

Not in this PR (by design)

  • The durable trader-parent fence (Req 6.23), durable INDETERMINATE (Req 16.4), and restart recovery (Req 16.5) — PR 3. The engine already exposes mutated() and the runner returns Unresolved { mutated } for PR 3 to persist and fence.
  • The concrete HTTP BindingTransport over the generic endpoints, with the SDK-side (member, incarnation) attribution wired to real echoes — PR 4.
  • Switching the three settle paths to QuorumBind and deleting the settlement-slot register — PR 5.
  • proposer_id is taken to be the Class K device id (stated assumption).

…e generic register

Rev 15 §6.8 / Def 6.21 / Req 6.22 / Theorem 18.1: the client-driven QuorumBind
transaction, restored as the sans-IO decision engine the owner directed, plus
the thin async runner that performs its operations. Consumes the Class N
primitive from #772; the settle paths still call the one-shot register until
PR 5.

Two phases, because the Class N compare-and-exchange is a max-round CAS register
that lets a strictly higher round overwrite a different value. A single accept
phase is unsafe over it — a value on a quorum at a low round can be overwritten
by a higher round whose proposer read before it landed, letting two bundles both
become binding-final. Req 6.22 prescribes Paxos-style prepare/accept, so:

- Learn: ReadBinding(K(B)) a quorum for the safe value (highest-round accepted)
  and any already-chosen value.
- Promise: compare-exchange a PROMISED record at round 2*ballot. The member's
  monotonic-round refusal is the promise.
- Accept: compare-exchange an ACCEPTED record at round 2*ballot+1.

The phase rides in the round counter and `status` records it. A bundle owns
every key or yields, so at most one bundle accepts on a shared key. COMMITTED is
q distinct authenticated members holding this bundle's accepted record at the
same round on every key; ABORTED only when nothing is chosen anywhere.

The engine (dsm::dlv::quorum_bind) performs no I/O, sleeping, or timers: poll()
emits ops, deliver_* folds authenticated answers, recover() opens the next
ballot. The runner (dsm_sdk::sdk::quorum_bind_runner) is the only place timing
lives, and authenticates every answer against BOTH the committed member id and
the committed register incarnation (Req 15.8) before it counts.

Safety is proven by driving two bundles through ALL 4096 interleavings of 12
single-operation steps against a fleet double that mirrors decide_compare_exchange
exactly (binding-race and overlapping {A,B}/{B,C}), asserting never-two-committed,
one-chosen-value, and non-vacuity. A mutation degenerating the engine to
single-phase fires the double-commit assertion, so the control is real.

Rust 1.98.0: workspace board 4001/0 (incl. the interleaving tests), make lint 0,
production_safety_checks 0.

The trader-parent fence and durable INDETERMINATE (PR 3), the HTTP transport
(PR 4), and switching the settle paths (PR 5) are not in this change.
@cryptskii
cryptskii merged commit 6e2e67f into main Sep 6, 2026
16 checks passed
@cryptskii
cryptskii deleted the feat/class-k-quorumbind-driver branch September 6, 2026 03:37
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