Skip to content

feat(storage): a member holds a generic binding record and exchanges it conditionally, blind to settlement - #772

Merged
cryptskii merged 1 commit into
mainfrom
feat/class-n-generic-binding-primitive
Sep 6, 2026
Merged

feat(storage): a member holds a generic binding record and exchanges it conditionally, blind to settlement#772
cryptskii merged 1 commit into
mainfrom
feat/class-n-generic-binding-primitive

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

PR 1 of the QuorumBind restoration (owner decision 2026-09-05: keep Rev-15's architecture, do not amend the spec to bless the one-shot register). This is the Class N half only — the application-blind storage primitive the Class K driver (PR 2) will run against. The settlement-slot register is untouched here; it is deleted in PR 5 when the settle paths switch (§22 #2, #12).

What this adds

SoFi Rev 15 §15.5 / Def 6.20, as written.

  • GenericBindingRecordV1 = (schema, round=(counter, proposer_id), tx_id, keyset_digest, value_digest, value_addr, status). Rounds order lexicographically — counter first, proposer_id second — and the struct's field order is what makes the derived Ord that order.
  • CompareExchangeManyRequestV1/ResponseV1 with the four outcomes APPLIED / EXPECTATION_MISMATCH / UNAVAILABLE / INVALID_STORAGE_ENCODING, resulting_digest, member_id, and — new to the write side — register_incarnation.
  • ReadBindingRequestV1/ResponseV1 returning cells (absence is the member's assertion), the set digest, member_id, register_incarnation.
  • Endpoints POST /api/v2/storage/binding/cas (device auth — a registered device is writing; no claimant check, there is no claimant in a generic record) and POST /api/v2/storage/binding/read (public).

What the node inspects, and only that (Req 15.7): schema, round ordering, the exact expected digest of the prior record set, key-set equality. It never decodes the value at value_addr, never checks a storage set, never knows a vault, a trade, or q. There is no foreign-set check here on purpose — that was §22 #12's violation in the register, and its job moves to Class K's authenticated-identity count (Req 15.8) in PR 4.

One decision for both backends. db::binding::decide_compare_exchange is pure and shared; Postgres and SQLite supply only I/O around it (lock, read, decide, write all-or-none, durable commit). Order of checks, each fail-closed: byte-identical replay on every key re-acks (a crashed proposer converges by retrying); the exact prior set digest must match; the replacement's round must be strictly greater than every round held — a matching digest proves the caller saw the state, and a round that doesn't supersede it still must not overwrite it (an equal round with different bytes is a proposer reusing a round for a second value).

Absences are in the digest. record_set_digest hashes key ‖ 0x00 for an absent cell and key ‖ 0x01 ‖ record_digest for a held one, so "nothing held" has a defined digest a first writer exchanges from, and a member that lost a row produces a different set digest rather than a coincidentally matching one.

Every answer names the member twice. The identity echo layer stamps the configured node id; both endpoints stamp x-dsm-register-incarnation and carry both in the body. A write acknowledgement can now be attributed on (member_id, register_incarnation) — the gap gate D found in MemberClaimOutcome — once PR 4 teaches the client to require it. AppState gains configured_member_id (the raw [node] id the catalog names) beside the canonical StorageNodeId, because those are different facts and the proto must carry the one a quorum counts.

Atomicity and concurrency. Postgres: one durable transaction (begin_durable_write), a transaction-scoped advisory lock per key in the caller's strictly-ascending key order (absent keys can't be row-locked; ordered locks can't deadlock), upsert all keys or none. SQLite: the single serialised connection.

Tests

  • Core (dsm::storage::binding_record, 5): lexicographic round order; canonical round-trip with padded/mis-width/dead-schema refusals; key-set validity; set digests name absences; a request binds its own key set.
  • Decision (db::binding, 3): first writer from the empty digest and stale expectation refused; round never moves backwards, equal round with other bytes refused, higher proposer id at the same counter applies; identical replay re-acks regardless of expectation, but identical on some keys is partial state, not a replay.
  • Properties on both backends (db::binding_properties, 5): all-or-none; 16 concurrent exchanges from one expectation → exactly one APPLIED, every loser told the winner's digest; rounds monotonic across the store; {A,B} vs {B,C} serialise on B; a record set survives reopening the store.
  • Endpoint (3): CAS from empty / replay / stale / lower-round / supersede with both identity halves in body and header; the three storage-domain refusals are INVALID_STORAGE_ENCODING and write nothing; a node with no established incarnation answers 503 on both operations.
  • CI: the Postgres job's count floor rises 13 → 18 and its filter includes db::binding_properties.

Mutation controls

Each restored on an identical tree stamp (git diff | shasum before/after).

mutation named test red
expectation check can never refuse (compiling variant — the first if false attempt was a no-op because strict made the unused variable a compile error; redone) a_first_writer_exchanges_from_the_empty_digest_and_a_stale_expectation_is_refused, an_exchange_applies_to_every_key_or_to_none
round rule disabled a_round_never_moves_backwards
all-or-none broken on Postgres (apply only the first key) an_exchange_applies_to_every_key_or_to_none (on Postgres)
incarnation stamp dropped from the CAS body cas_applies_from_empty_re_acks_identical_and_refuses_stale_or_lower_round
key-set equality check disabled (core) storage_domain_refusals_are_invalid_storage_encoding_and_write_nothing

One finding caught by the Postgres board itself, not by review: the advisory-lock parameter used hashtext($1::text), which tokio-postgres infers as text and cannot serialise a &[u8] into — every Postgres property failed while SQLite passed. Fixed by computing the lock id in Rust as an i64 from the key's leading eight bytes.

A second gap the gates closed, not review: the SQLite read_held row was typed
Option<(Vec<u8>, Vec<u8>, i64, Vec<u8>)>, which clippy::type_complexity rejects
under the node's #![deny(warnings)]. make lint runs clippy with default
features (strict,postgres), so the local-dev SQLite path never compiled there and
the lint passed. ci/production_safety_checks.sh runs clippy --workspace --all-features,
which woke that path and failed the gate. Factored into a type BindingRow alias; both
make lint and the --all-features safety gate are now exit 0. This is the
"enabling a previously-unenabled feature is a build-graph change" rule catching a lint,
not a bug.

Verification (Rust 1.98.0)

  • root make lint — exit 0
  • workspace board — 3989 passed, 0 failed (74 suites)
  • node board (SQLite) — 299 passed, 0 failed (12 suites)
  • node board (Postgres 14) — 269 passed, 0 failed (12 suites); the register-properties-executed floor of ≥ 18 is met
  • ci_scan, no_clock_and_no_json, codegen_enforce, guard_protos, check_forbidden_symbols, production_safety_checks (incl. TLA+) — all exit 0

Not in this PR (by design)

Rounds are enforced here and driven in PR 2: no Class K driver, no safe-value recovery, no ABORTED/CONFLICT_FINAL, no fence, no durable INDETERMINATE, no client-side (member, incarnation) attribution on write acks (PR 4), and the settlement-slot register still exists (removed in PR 5). proposer_id is taken to be the Class K device id — stated as an assumption, override if you intend otherwise. The economic-root and faucet-ticket claim endpoints also parse claims at the node; §22 #12's wording reaches them, but they are separate registers and out of this program unless you say so.

…it conditionally, blind to settlement

Rev 15 §15.5 / Def 6.20, the Class N half of the QuorumBind restoration.
The node gains a generic conditional-binding primitive and knows nothing about
settlement: it inspects only schema, round order, the exact expected digest of
the prior record set, and key-set equality. It never decodes the value at
value_addr, never checks a storage set, never knows a vault, a trade, or q.

- GenericBindingRecordV1 = (schema, round=(counter, proposer_id), tx_id,
  keyset_digest, value_digest, value_addr, status). Rounds order
  lexicographically; the struct field order is the derived Ord.
- CompareExchangeMany over the complete sorted key set with the four outcomes
  APPLIED / EXPECTATION_MISMATCH / UNAVAILABLE / INVALID_STORAGE_ENCODING;
  ReadBinding returns cells (an absence is the member's assertion), the set
  digest, and both identity halves.
- One shared pure decision (db::binding::decide_compare_exchange) drives both
  Postgres and SQLite; the backends supply only I/O. Byte-identical replay on
  every key re-acks; the exact prior set digest must match; the replacement
  round must strictly supersede every held round.
- Absences are in the set-digest preimage (key||0x00), so the empty set has a
  defined digest a first writer exchanges from and a lost row changes it.
- Every answer, writes included, names the member twice: configured member id
  plus x-dsm-register-incarnation, so a write ack is attributable on
  (member_id, register_incarnation). AppState carries configured_member_id
  beside the canonical StorageNodeId.

Endpoints: POST /api/v2/storage/binding/{cas,read}. The settlement-slot
register is untouched here; it is removed when the settle paths switch.

16 tests (core, decision, both-backend properties, endpoints); the Postgres CI
count floor rises 13 -> 18. Rust 1.98.0: make lint 0, workspace 3989/0, node
SQLite 299/0, node Postgres 269/0, all purity/safety gates 0.
@cryptskii
cryptskii merged commit 558933e into main Sep 6, 2026
19 checks passed
@cryptskii
cryptskii deleted the feat/class-n-generic-binding-primitive branch September 6, 2026 03:17
cryptskii added a commit that referenced this pull request Sep 6, 2026
…e generic register (#773)

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.
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