feat(storage): a member holds a generic binding record and exchanges it conditionally, blind to settlement - #772
Merged
Conversation
…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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 —counterfirst,proposer_idsecond — and the struct's field order is what makes the derivedOrdthat order.CompareExchangeManyRequestV1/ResponseV1with the four outcomesAPPLIED / EXPECTATION_MISMATCH / UNAVAILABLE / INVALID_STORAGE_ENCODING,resulting_digest,member_id, and — new to the write side —register_incarnation.ReadBindingRequestV1/ResponseV1returning cells (absence is the member's assertion), the set digest,member_id,register_incarnation.POST /api/v2/storage/binding/cas(device auth — a registered device is writing; no claimant check, there is no claimant in a generic record) andPOST /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, orq. There is noforeign-setcheck 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_exchangeis 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_digesthasheskey ‖ 0x00for an absent cell andkey ‖ 0x01 ‖ record_digestfor 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-incarnationand carry both in the body. A write acknowledgement can now be attributed on(member_id, register_incarnation)— the gap gate D found inMemberClaimOutcome— once PR 4 teaches the client to require it.AppStategainsconfigured_member_id(the raw[node] idthe catalog names) beside the canonicalStorageNodeId, 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
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.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.db::binding_properties, 5): all-or-none; 16 concurrent exchanges from one expectation → exactly oneAPPLIED, 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.INVALID_STORAGE_ENCODINGand write nothing; a node with no established incarnation answers 503 on both operations.db::binding_properties.Mutation controls
Each restored on an identical tree stamp (
git diff | shasumbefore/after).if falseattempt was a no-op becausestrictmade 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_nonea_round_never_moves_backwardsan_exchange_applies_to_every_key_or_to_none(on Postgres)cas_applies_from_empty_re_acks_identical_and_refuses_stale_or_lower_roundstorage_domain_refusals_are_invalid_storage_encoding_and_write_nothingOne finding caught by the Postgres board itself, not by review: the advisory-lock parameter used
hashtext($1::text), which tokio-postgres infers astextand cannot serialise a&[u8]into — every Postgres property failed while SQLite passed. Fixed by computing the lock id in Rust as ani64from the key's leading eight bytes.A second gap the gates closed, not review: the SQLite
read_heldrow was typedOption<(Vec<u8>, Vec<u8>, i64, Vec<u8>)>, whichclippy::type_complexityrejectsunder the node's
#![deny(warnings)].make lintruns clippy with defaultfeatures (
strict,postgres), so thelocal-devSQLite path never compiled there andthe lint passed.
ci/production_safety_checks.shrunsclippy --workspace --all-features,which woke that path and failed the gate. Factored into a
type BindingRowalias; bothmake lintand the--all-featuressafety 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)
make lint— exit 0ci_scan,no_clock_and_no_json,codegen_enforce,guard_protos,check_forbidden_symbols,production_safety_checks(incl. TLA+) — all exit 0Not 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_idis 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.