From d8f9a99e0e5efe6968426227f6904b40813d499a Mon Sep 17 00:00:00 2001 From: Cryptskii <47649969+cryptskii@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:09:10 -0400 Subject: [PATCH] feat(dlv): an unresolved settlement fences the trader parent, and a lost commit stays fenced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rev 15 Req 6.23 / 16.4 / 16.5, Theorem 18.2: the initiating-trader parent fence, durable INDETERMINATE, and the restart-recovery primitives the one-shot settlement-register path never had. Builds on the PR 2 engine. Before Class K issues the first mutating binding op for a bundle B, it durably records a fence over the initiating trader's own chain. While the DLV transaction is unresolved, no DIFFERENT successor may advance from the fenced parent, even under a fresh intent or nonce. The load-bearing rule (Req 6.23(4)): COMMITTED fixes the permitted continuation to the EXACT committed trader successor, and the quorum result alone does not consume the fence — only that exact successor, accepted through ordinary DSM bilateral advancement, does. A different successor can never consume it. Tripwire remains the underlying rule. Same split as PR 2: - Pure core (dsm::dlv::trader_fence): FenceState/FenceEvent/next_state/verdict. Holds the safety-relevant transitions, including the WrongSuccessor refusal. - Durable adapter (client_db::trader_parent_fence), modelled on dlv_close_intent: place_fence writes before anything mutates (INSERT OR IGNORE keeps the first frozen inputs); record_event applies the CORE transition and persists state, permitted successor, and ballot; active_verdict is the advancement gate; list_unresolved_fences is the restart work list. Persistence reuses next_state, so it cannot legalize a transition the core forbids. - Orchestration (quorum_bind_runner::run_fenced): places the fence before the first mutating op (FenceNotPersisted if it cannot persist), drives, and records the outcome; an unresolved run keeps the parent fenced as INDETERMINATE with the ballot persisted so restart never reuses one. bind-indeterminate (Req 21.3): a transport lands the accept on a quorum but loses the response and goes dark. Run 1 is unresolved and the fence stays FENCED; the value is chosen on the members; a resumed run above the persisted ballot discovers it and reaches COMMITTED — completion, not a second value. Rust 1.98.0: workspace board 4015/0, make lint 0, production_safety_checks 0. Wiring the gate into the settle path (PR 5) and the GetImmutable restart driver (PR 4) are not in this change. --- .../dsm/src/dlv/mod.rs | 9 +- .../dsm/src/dlv/trader_fence.rs | 333 +++++++++++++++ .../dsm_sdk/src/sdk/quorum_bind_runner.rs | 242 ++++++++++- .../dsm_sdk/src/storage/client_db/mod.rs | 20 + .../storage/client_db/trader_parent_fence.rs | 392 ++++++++++++++++++ 5 files changed, 988 insertions(+), 8 deletions(-) create mode 100644 dsm_client/deterministic_state_machine/dsm/src/dlv/trader_fence.rs create mode 100644 dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/trader_parent_fence.rs diff --git a/dsm_client/deterministic_state_machine/dsm/src/dlv/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/dlv/mod.rs index 0ab69d24..bbfd111b 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/dlv/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/dlv/mod.rs @@ -1,17 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 -//! Tier 2 Foundation DLV primitives — pure-crypto helpers that the +//! Tier 2 Foundation DLV primitives — pure-crypto helpers that the //! `dsm_sdk` and storage layers compose into the off-device SoFi //! flow. This module deliberately holds no proto / I/O / runtime //! state; each submodule is a self-contained crypto primitive. -pub mod beta_storage_profile; // the deployed three-member beta profile — fixed, not a formula +pub mod beta_storage_profile; // the deployed three-member beta profile — fixed, not a formula pub mod controller_rotation; pub mod pair_identity; -pub mod quorum_bind; // Def 6.21 — Class K sans-IO quorum-binding decision engine +pub mod quorum_bind; // Def 6.21 — Class K sans-IO quorum-binding decision engine pub mod route_commit; pub mod settlement_receipt_leaf; pub mod settlement_slot_claim; // write-once claim envelope for the settlement-slot quorum register +pub mod trader_fence; // Req 6.23 — the initiating-trader parent fence (pure state machine) pub mod vault_pending_pointer; pub mod vault_reserve_inclusion; pub mod vault_reserve_leaf; @@ -19,4 +20,4 @@ pub mod vault_smt_leaf; // vault_state_anchor (V1) and vault_state_anchor_v2 are DELETED by the // state-identity cut. Their names and domains are burned, never reused; the // only anchor form is V3 below, whose sole content is c_n. -pub mod vault_state_anchor_v3; // Def 6.4a — owner baseline over c_n; the only anchor form after the cut +pub mod vault_state_anchor_v3; // Def 6.4a — owner baseline over c_n; the only anchor form after the cut diff --git a/dsm_client/deterministic_state_machine/dsm/src/dlv/trader_fence.rs b/dsm_client/deterministic_state_machine/dsm/src/dlv/trader_fence.rs new file mode 100644 index 00000000..e3776614 --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/dlv/trader_fence.rs @@ -0,0 +1,333 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! THE INITIATING-TRADER PARENT FENCE (Rev 15 Req 6.23, §22 #14, Theorem 18.2). +//! +//! Before Class K issues the first mutating binding operation for a bundle `B`, +//! it durably records a local settlement fence +//! +//! ```text +//! F_B = (trader_chain_id, trader_parent_state_commitment, b, tx_id) +//! ``` +//! +//! over the initiating trader's own sovereign chain. The fence is an +//! advancement invariant, NOT a storage-node authority object: while the DLV +//! transaction is unresolved, no *different* successor may advance from the +//! fenced trader parent, even under a fresh intent or nonce. Tripwire remains +//! the underlying one-successor state rule; the fence prevents a conforming +//! client from *accidentally* advancing the parent while the quorum result is +//! in doubt. +//! +//! This module is the PURE state machine of the fence: the legal transitions +//! and the verdict a caller consults before creating a trader successor. The +//! durable row and the restart-recovery list live in the SDK +//! (`storage::client_db::trader_parent_fence`); the live advancement gate is +//! wired where the settle path is rewritten to QuorumBind. No I/O, no clock. +//! +//! The load-bearing rule is transition (4) of Req 6.23: `COMMITTED(B)` fixes +//! the permitted continuation to the EXACT `trader_successor` committed inside +//! `B`, and the quorum result alone does not consume the fence — Class K +//! releases it only when that exact successor is accepted through ordinary DSM +//! bilateral advancement. A different successor can never consume the fence. + +/// Where the fenced trader parent sits relative to its DLV transaction. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FenceState { + /// The fence is placed and the DLV transaction is unresolved — pending, + /// `RECOVERING`, or `INDETERMINATE`. No successor may advance from the + /// parent (Req 6.23 (2)). + Fenced, + /// `COMMITTED(B)`: the ONLY permitted continuation is this exact committed + /// trader successor. The quorum result has not consumed the fence + /// (Req 6.23 (4)). + CommittedAwaitingAcceptance { permitted_successor: [u8; 32] }, + /// The exact permitted successor was accepted through ordinary DSM + /// bilateral advancement. The fence is consumed. Terminal. + Released, + /// `ABORTED(B)` or `CONFLICT_FINAL`: the fence is released WITHOUT advancing + /// the trader chain (Req 6.23 (3)). Terminal. + ReleasedNoAdvance, +} + +impl FenceState { + pub fn as_str(self) -> &'static str { + match self { + FenceState::Fenced => "fenced", + FenceState::CommittedAwaitingAcceptance { .. } => "committed_awaiting_acceptance", + FenceState::Released => "released", + FenceState::ReleasedNoAdvance => "released_no_advance", + } + } + + /// A fence in a terminal state imposes no further advancement constraint; + /// ordinary DSM rules (Tripwire) govern from here. + pub fn is_terminal(self) -> bool { + matches!(self, FenceState::Released | FenceState::ReleasedNoAdvance) + } + + /// Unresolved fences are the restart-recovery work list (Req 16.5): they + /// must be restored before the recovered trader chain may advance. + pub fn is_unresolved(self) -> bool { + matches!( + self, + FenceState::Fenced | FenceState::CommittedAwaitingAcceptance { .. } + ) + } +} + +/// What just happened to the DLV transaction (or its trader successor). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FenceEvent { + /// A recovery round is in progress; the outcome is still open. + Recovering, + /// A mutating request's outcome was lost. Never a non-commit: the parent + /// stays fenced (Req 16.4). + Indeterminate, + /// `COMMITTED(B)` for the exact bundle, carrying the trader successor `B` + /// committed. + Committed { successor: [u8; 32] }, + /// `ABORTED(B)`. + Aborted, + /// `CONFLICT_FINAL`: an overlapping key belongs to a different binding-final + /// transaction. + ConflictFinal, + /// The exact committed trader successor was accepted through ordinary DSM + /// bilateral state advancement — the only thing that consumes a committed + /// fence. + SuccessorAccepted { successor: [u8; 32] }, +} + +/// Why a transition is refused. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FenceTransitionError { + /// The fence is already terminal; nothing regresses it. + Terminal { state: &'static str }, + /// A `SuccessorAccepted` for a value other than the one `COMMITTED` fixed. + /// This is the rule that stops a different successor from consuming the + /// fence (Req 6.23 (4)). + WrongSuccessor { + permitted: [u8; 32], + offered: [u8; 32], + }, + /// A `SuccessorAccepted` arrived before any `COMMITTED` fixed a successor. + AcceptanceBeforeCommit, +} + +impl core::fmt::Display for FenceTransitionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + FenceTransitionError::Terminal { state } => { + write!(f, "the fence is terminal ({state}) and does not transition") + } + FenceTransitionError::WrongSuccessor { .. } => write!( + f, + "only the exact committed successor may consume the fence" + ), + FenceTransitionError::AcceptanceBeforeCommit => { + write!(f, "no successor is permitted until the bundle is COMMITTED") + } + } + } +} +impl std::error::Error for FenceTransitionError {} + +/// The legal transition function. A placed fence starts in [`FenceState::Fenced`]. +/// +/// - `Recovering` / `Indeterminate` keep it `Fenced` — an unresolved outcome +/// never releases the parent, and elapsed time never converts it (Req 15.11, +/// §22 #17). +/// - `Committed{successor}` fixes the permitted continuation. +/// - `Aborted` / `ConflictFinal` release without advancing. +/// - `SuccessorAccepted{s}` consumes the fence ONLY when `s` is exactly the +/// committed successor. +pub fn next_state( + state: &FenceState, + event: &FenceEvent, +) -> Result { + if state.is_terminal() { + return Err(FenceTransitionError::Terminal { + state: state.as_str(), + }); + } + Ok(match (state, event) { + // Unresolved outcomes keep the parent fenced. + (_, FenceEvent::Recovering) | (_, FenceEvent::Indeterminate) => FenceState::Fenced, + // Terminal DLV outcomes. + (_, FenceEvent::Committed { successor }) => FenceState::CommittedAwaitingAcceptance { + permitted_successor: *successor, + }, + (_, FenceEvent::Aborted) | (_, FenceEvent::ConflictFinal) => FenceState::ReleasedNoAdvance, + // Only the exact committed successor consumes a committed fence. + ( + FenceState::CommittedAwaitingAcceptance { + permitted_successor, + }, + FenceEvent::SuccessorAccepted { successor }, + ) => { + if successor == permitted_successor { + FenceState::Released + } else { + return Err(FenceTransitionError::WrongSuccessor { + permitted: *permitted_successor, + offered: *successor, + }); + } + } + // A successor acceptance before COMMITTED fixed one is illegal. + (FenceState::Fenced, FenceEvent::SuccessorAccepted { .. }) => { + return Err(FenceTransitionError::AcceptanceBeforeCommit) + } + (FenceState::Released | FenceState::ReleasedNoAdvance, _) => { + unreachable!("terminal handled") + } + }) +} + +/// What the fence permits when a caller wants to create a trader successor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FenceVerdict { + /// No successor may advance from the parent (Req 6.23 (2)). + BlocksAllSuccessors, + /// Exactly this successor may advance, and no other (Req 6.23 (4)). + PermitsOnly([u8; 32]), + /// The fence imposes no constraint; ordinary DSM rules govern. + Clear, +} + +/// The verdict for an active fence. A caller creating a trader successor from a +/// fenced parent must obey this BEFORE advancing. +pub fn verdict(state: &FenceState) -> FenceVerdict { + match state { + FenceState::Fenced => FenceVerdict::BlocksAllSuccessors, + FenceState::CommittedAwaitingAcceptance { + permitted_successor, + } => FenceVerdict::PermitsOnly(*permitted_successor), + FenceState::Released | FenceState::ReleasedNoAdvance => FenceVerdict::Clear, + } +} + +/// Whether the fence permits advancing to `candidate` from the fenced parent. +/// A fresh intent or nonce cannot change this answer — the fence is keyed on the +/// parent, not the intent. +pub fn permits_successor(state: &FenceState, candidate: &[u8; 32]) -> bool { + match verdict(state) { + FenceVerdict::BlocksAllSuccessors => false, + FenceVerdict::PermitsOnly(s) => &s == candidate, + FenceVerdict::Clear => true, + } +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal +mod tests { + use super::*; + + const SUCC: [u8; 32] = [0xAA; 32]; + const OTHER: [u8; 32] = [0xBB; 32]; + + #[test] + fn an_unresolved_outcome_keeps_the_parent_fenced() { + for ev in [FenceEvent::Recovering, FenceEvent::Indeterminate] { + let s = next_state(&FenceState::Fenced, &ev).unwrap(); + assert_eq!(s, FenceState::Fenced); + assert_eq!(verdict(&s), FenceVerdict::BlocksAllSuccessors); + assert!( + !permits_successor(&s, &SUCC), + "unresolved blocks all successors" + ); + } + } + + #[test] + fn commit_permits_exactly_the_committed_successor_and_no_other() { + let s = next_state( + &FenceState::Fenced, + &FenceEvent::Committed { successor: SUCC }, + ) + .unwrap(); + assert_eq!(verdict(&s), FenceVerdict::PermitsOnly(SUCC)); + assert!(permits_successor(&s, &SUCC)); + assert!( + !permits_successor(&s, &OTHER), + "a different successor is not permitted by a committed fence" + ); + } + + #[test] + fn only_the_exact_successor_consumes_the_fence() { + let committed = next_state( + &FenceState::Fenced, + &FenceEvent::Committed { successor: SUCC }, + ) + .unwrap(); + // A DIFFERENT successor cannot consume it. + assert_eq!( + next_state( + &committed, + &FenceEvent::SuccessorAccepted { successor: OTHER } + ), + Err(FenceTransitionError::WrongSuccessor { + permitted: SUCC, + offered: OTHER + }) + ); + // The exact one does, and releases. + let released = next_state( + &committed, + &FenceEvent::SuccessorAccepted { successor: SUCC }, + ) + .unwrap(); + assert_eq!(released, FenceState::Released); + assert_eq!(verdict(&released), FenceVerdict::Clear); + } + + #[test] + fn abort_and_conflict_release_without_advancing() { + for ev in [FenceEvent::Aborted, FenceEvent::ConflictFinal] { + let s = next_state(&FenceState::Fenced, &ev).unwrap(); + assert_eq!(s, FenceState::ReleasedNoAdvance); + assert_eq!(verdict(&s), FenceVerdict::Clear); + } + } + + #[test] + fn acceptance_before_commit_is_illegal() { + assert_eq!( + next_state( + &FenceState::Fenced, + &FenceEvent::SuccessorAccepted { successor: SUCC } + ), + Err(FenceTransitionError::AcceptanceBeforeCommit) + ); + } + + #[test] + fn terminal_states_do_not_regress() { + for terminal in [FenceState::Released, FenceState::ReleasedNoAdvance] { + for ev in [ + FenceEvent::Recovering, + FenceEvent::Committed { successor: OTHER }, + FenceEvent::Aborted, + FenceEvent::SuccessorAccepted { successor: OTHER }, + ] { + assert!( + matches!( + next_state(&terminal, &ev), + Err(FenceTransitionError::Terminal { .. }) + ), + "terminal fence must not transition" + ); + } + } + } + + #[test] + fn unresolved_states_are_the_recovery_work_list() { + assert!(FenceState::Fenced.is_unresolved()); + assert!(FenceState::CommittedAwaitingAcceptance { + permitted_successor: SUCC + } + .is_unresolved()); + assert!(!FenceState::Released.is_unresolved()); + assert!(!FenceState::ReleasedNoAdvance.is_unresolved()); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/quorum_bind_runner.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/quorum_bind_runner.rs index 4d4da4fe..e889fdfb 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/quorum_bind_runner.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/quorum_bind_runner.rs @@ -117,8 +117,93 @@ fn counts_for( pub enum RunError { /// The recovery-ballot budget was exhausted before a terminal outcome. The /// transaction is INDETERMINATE if it mutated; the caller keeps the - /// trader-parent fence and may resume recovery later (PR 3). + /// trader-parent fence and may resume recovery later. Unresolved { mutated: bool }, + /// The trader-parent fence could not be durably persisted, so the + /// transaction never began (Req 6.23 (1)). Nothing mutated. + FenceNotPersisted, +} + +/// The identity of the initiating-trader parent fence for a transaction +/// (Req 6.23): the trader's own chain and parent state, plus which DLV +/// transaction fenced it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FenceKey { + pub trader_chain_id: [u8; 32], + pub trader_parent_state_commitment: [u8; 32], + pub tx_id: [u8; 32], +} + +/// Drive a transaction UNDER its trader-parent fence (Req 6.23). +/// +/// 1. The fence is placed durably BEFORE the first mutating op — if it cannot +/// be persisted the transaction does not begin ((1)). +/// 2. The transaction is driven to a terminal outcome (or a lost one). +/// 3. The outcome is recorded against the fence: `COMMITTED` fixes the exact +/// permitted `trader_successor` ((4)); `ABORTED`/`CONFLICT_FINAL` release +/// without advancing ((3)); an unresolved run keeps the parent fenced as +/// INDETERMINATE (Req 16.4) with the ballot persisted so restart never +/// reuses one. +/// +/// This records the DLV outcome only. Releasing a committed fence requires the +/// exact successor to be accepted through ordinary DSM bilateral advancement +/// ((4)); the caller does that with +/// [`crate::storage::client_db::trader_parent_fence::record_event`] and a +/// [`dsm::dlv::trader_fence::FenceEvent::SuccessorAccepted`] afterwards. +#[allow(clippy::too_many_arguments)] +pub async fn run_fenced( + engine: &mut QuorumBind, + members: &[CommittedMember], + keys: &[[u8; 32]], + transport: &T, + backoff: Backoff, + max_ballots: u32, + fence: FenceKey, + trader_successor: [u8; 32], + storage_set_id: [u8; 32], + value_addr: [u8; 32], +) -> Result { + use crate::storage::client_db::trader_parent_fence as fdb; + use dsm::dlv::trader_fence::{FenceEvent, FenceState}; + + // (1) Place the fence before any mutating op. If it can't persist, refuse + // to begin. + let placed = fdb::TraderFence { + trader_chain_id: fence.trader_chain_id, + trader_parent_state_commitment: fence.trader_parent_state_commitment, + tx_id: fence.tx_id, + ballot: engine.ballot(), + storage_set_id, + value_addr, + state: FenceState::Fenced, + insertion_ordinal: 0, + }; + if fdb::place_fence(&placed).is_err() { + return Err(RunError::FenceNotPersisted); + } + + // (2) Drive. + let result = run(engine, members, keys, transport, backoff, max_ballots).await; + + // (3) Record the outcome against the fence, persisting the final ballot. + let event = match &result { + Ok(Outcome::Committed) => FenceEvent::Committed { + successor: trader_successor, + }, + // Neither ABORTED nor a storage-INVALID proposal chose a value; both + // release the parent without advancing. + Ok(Outcome::Aborted) | Ok(Outcome::Invalid) => FenceEvent::Aborted, + Ok(Outcome::ConflictFinal { .. }) => FenceEvent::ConflictFinal, + Err(_) => FenceEvent::Indeterminate, + }; + let _ = fdb::record_event( + &fence.trader_chain_id, + &fence.trader_parent_state_commitment, + &fence.tx_id, + &event, + Some(engine.ballot()), + ); + result } /// Drive one transaction to a terminal [`Outcome`], authenticating every answer @@ -212,7 +297,10 @@ pub async fn run( #[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal mod tests { use super::*; - use dsm::dlv::quorum_bind::{strict_majority, BindingTransaction}; + use dsm::dlv::quorum_bind::{strict_majority, BindingTransaction, BINDING_STATUS_ACCEPTED}; + use dsm::dlv::trader_fence::{FenceEvent, FenceVerdict}; + use crate::storage::client_db::trader_parent_fence as fdb; + use serial_test::serial; use dsm::storage::binding_record::{record_digest_of_bytes, record_set_digest, Round, SetCell}; use std::collections::BTreeMap; use std::sync::Mutex; @@ -238,6 +326,11 @@ mod tests { cells: Mutex, Round)>>>, members: Vec, wrong_incarnation_for: Vec, + /// When set, an ACCEPT still lands in the cells but its response is + /// lost, and every later op goes dark — modelling a COMMIT response + /// lost after the value was already chosen. + hide_accepts: Mutex, + dark: Mutex, } impl MockTransport { @@ -246,6 +339,8 @@ mod tests { cells: Mutex::new(vec![BTreeMap::new(); n]), members: members(n as u8), wrong_incarnation_for: Vec::new(), + hide_accepts: Mutex::new(false), + dark: Mutex::new(false), } } fn echo(&self, ix: usize) -> (Option>, Option<[u8; 32]>) { @@ -261,6 +356,14 @@ mod tests { #[async_trait] impl BindingTransport for MockTransport { async fn read_binding(&self, ix: usize, keys: &[[u8; 32]]) -> TransportRead { + let (id, inc) = self.echo(ix); + if *self.dark.lock().unwrap() { + return TransportRead { + echoed_member_id: id, + echoed_incarnation: inc, + records: None, + }; + } let cells = self.cells.lock().unwrap(); let recs = keys .iter() @@ -270,7 +373,6 @@ mod tests { .map(|(b, _)| BindingRecord::decode_canonical(b).unwrap()) }) .collect(); - let (id, inc) = self.echo(ix); TransportRead { echoed_member_id: id, echoed_incarnation: inc, @@ -285,10 +387,10 @@ mod tests { repl: &[u8], ) -> TransportCas { let repl_rec = BindingRecord::decode_canonical(repl).unwrap(); + let (id, inc) = self.echo(ix); let mut cells = self.cells.lock().unwrap(); let held: Vec, Round)>> = keys.iter().map(|k| cells[ix].get(k).cloned()).collect(); - let (id, inc) = self.echo(ix); let outcome = if held .iter() .all(|h| h.as_ref().is_some_and(|(b, _)| b == repl)) @@ -316,6 +418,20 @@ mod tests { CasOutcome::Applied } }; + // A landed ACCEPT whose response is hidden: the value is chosen on + // the member, but the client never hears it, and everything goes + // dark from here. + if repl_rec.status == BINDING_STATUS_ACCEPTED + && outcome == CasOutcome::Applied + && *self.hide_accepts.lock().unwrap() + { + *self.dark.lock().unwrap() = true; + return TransportCas { + echoed_member_id: id, + echoed_incarnation: inc, + outcome: None, + }; + } TransportCas { echoed_member_id: id, echoed_incarnation: inc, @@ -372,4 +488,122 @@ mod tests { .await; assert_eq!(out, Err(RunError::Unresolved { mutated: false })); } + + fn fence_key() -> FenceKey { + FenceKey { + trader_chain_id: [0x11; 32], + trader_parent_state_commitment: [0x22; 32], + tx_id: [9; 32], + } + } + + fn init_db() { + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init"); + } + + #[tokio::test] + #[serial] + async fn run_fenced_commits_and_the_fence_permits_only_that_successor() { + init_db(); + let t = MockTransport::new(3); + let mut engine = QuorumBind::begin(tx(3)).unwrap(); + let succ = [0xAA; 32]; + let out = run_fenced( + &mut engine, + &members(3), + &[key(1)], + &t, + Backoff::default(), + 10, + fence_key(), + succ, + [0x6B; 32], + [10; 32], + ) + .await; + assert_eq!(out, Ok(Outcome::Committed)); + // The committed fence permits ONLY the exact successor, until it is + // accepted through ordinary DSM bilateral advancement. + assert_eq!( + fdb::active_verdict(&[0x11; 32], &[0x22; 32]).unwrap(), + FenceVerdict::PermitsOnly(succ) + ); + fdb::record_event( + &[0x11; 32], + &[0x22; 32], + &[9; 32], + &FenceEvent::SuccessorAccepted { successor: succ }, + None, + ) + .unwrap(); + assert_eq!( + fdb::active_verdict(&[0x11; 32], &[0x22; 32]).unwrap(), + FenceVerdict::Clear + ); + } + + #[tokio::test] + #[serial] + async fn bind_indeterminate_a_lost_commit_stays_fenced_and_recovery_discovers_the_value() { + init_db(); + // Run 1: the accepts land on a quorum, but their responses are lost and + // the transport goes dark. The runner cannot confirm, so it reports the + // transaction unresolved and the fence stays FENCED (Req 16.4). + let t = MockTransport::new(3); + *t.hide_accepts.lock().unwrap() = true; + let mut engine = QuorumBind::begin(tx(3)).unwrap(); + let succ = [0xAA; 32]; + let out = run_fenced( + &mut engine, + &members(3), + &[key(1)], + &t, + Backoff::default(), + 3, + fence_key(), + succ, + [0x6B; 32], + [10; 32], + ) + .await; + assert_eq!(out, Err(RunError::Unresolved { mutated: true })); + assert_eq!( + fdb::active_verdict(&[0x11; 32], &[0x22; 32]).unwrap(), + FenceVerdict::BlocksAllSuccessors, + "a lost commit keeps the parent fenced; a fresh intent cannot pass" + ); + // The value IS chosen on the members even though the client never heard. + let unresolved = fdb::list_unresolved_fences().unwrap(); + assert_eq!(unresolved.len(), 1); + + // Restart recovery: a fresh Class K instance for the SAME transaction, + // resuming above the persisted ballot, discovers the chosen value and + // reaches COMMITTED — completion, not a second value. + *t.dark.lock().unwrap() = false; + *t.hide_accepts.lock().unwrap() = false; + let mut resumed = QuorumBind::begin(BindingTransaction { + base_ballot: unresolved[0].ballot, + ..tx(3) + }) + .unwrap(); + let out2 = run_fenced( + &mut resumed, + &members(3), + &[key(1)], + &t, + Backoff::default(), + 10, + fence_key(), + succ, + [0x6B; 32], + [10; 32], + ) + .await; + assert_eq!(out2, Ok(Outcome::Committed)); + assert_eq!( + fdb::active_verdict(&[0x11; 32], &[0x22; 32]).unwrap(), + FenceVerdict::PermitsOnly(succ) + ); + } } diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs index 506d8a23..df2fefd7 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/mod.rs @@ -52,6 +52,7 @@ pub mod settlement_slot_claim_local; // this device's frozen slot-claim envelope mod system_peers; pub mod token_registry; mod tokens; +pub mod trader_parent_fence; // Req 6.23 durable initiating-trader parent fence (namespaced) mod transactions; pub mod types; pub mod vault_generation_consumption; @@ -597,6 +598,25 @@ fn create_schema(conn: &Connection) -> Result<()> { UNIQUE (vault_id, parent_sequence) ); + -- Req 6.23: the initiating-trader parent fence. Written BEFORE the + -- first mutating QuorumBind op; restored before post-restart bilateral + -- advancement. One row per (trader parent, attempt); at most one is + -- unresolved for a parent at a time. + CREATE TABLE IF NOT EXISTS trader_parent_fence( + insertion_ordinal INTEGER PRIMARY KEY AUTOINCREMENT, + trader_chain_id BLOB NOT NULL, + trader_parent_state_commitment BLOB NOT NULL, + tx_id BLOB NOT NULL, + ballot INTEGER NOT NULL, + storage_set_id BLOB NOT NULL, + value_addr BLOB NOT NULL, + state TEXT NOT NULL CHECK (state IN + ('fenced','committed_awaiting_acceptance', + 'released','released_no_advance')), + permitted_successor BLOB, + UNIQUE (trader_chain_id, trader_parent_state_commitment, tx_id) + ); + CREATE TABLE IF NOT EXISTS settlement_slot_claim_local( vault_id BLOB NOT NULL, parent_sequence INTEGER NOT NULL, diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/trader_parent_fence.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/trader_parent_fence.rs new file mode 100644 index 00000000..044375bc --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/trader_parent_fence.rs @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Durable initiating-trader parent fence (Rev 15 Req 6.23, Req 16.5). +//! +//! The pure state machine is [`dsm::dlv::trader_fence`]; this is its durable +//! home. The fence is written BEFORE the first mutating QuorumBind op — if it +//! cannot be persisted, the transaction must not begin (Req 6.23 (1)) — and is +//! restored before a recovered trader chain may advance (Req 6.23 (5), +//! Req 16.5). +//! +//! ORCHESTRATION, NEVER AUTHORITY. This row never decides that a trade +//! happened; the canonical bilateral state does. It records only which trader +//! parent is fenced, by which unresolved DLV transaction, and — once +//! `COMMITTED` — which exact successor is the sole permitted continuation. The +//! `ballot`, `storage_set_id`, and `value_addr` are what restart recovery needs +//! to re-drive the transaction to a terminal outcome (Req 16.5); the ballot is +//! persisted so a restarted proposer never reuses one. +//! +//! No clock columns: ordering is the local monotonic insertion ordinal. + +use anyhow::{anyhow, Result}; +use dsm::dlv::trader_fence::{next_state, verdict, FenceEvent, FenceState, FenceVerdict}; +use rusqlite::{params, Connection, OptionalExtension}; + +use super::get_connection; + +/// One trader parent fence (in flight or terminal), with what recovery needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TraderFence { + pub trader_chain_id: [u8; 32], + pub trader_parent_state_commitment: [u8; 32], + pub tx_id: [u8; 32], + /// The current QuorumBind ballot, persisted so restart never reuses one. + pub ballot: u64, + pub storage_set_id: [u8; 32], + /// The immutable bundle's content address — recovery retrieves the exact + /// bytes from storage to re-drive the transaction. + pub value_addr: [u8; 32], + pub state: FenceState, + pub insertion_ordinal: i64, +} + +fn state_columns(state: &FenceState) -> (&'static str, Option>) { + match state { + FenceState::CommittedAwaitingAcceptance { + permitted_successor, + } => (state.as_str(), Some(permitted_successor.to_vec())), + other => (other.as_str(), None), + } +} + +fn state_from_row(state: &str, permitted_successor: Option>) -> Result { + Ok(match state { + "fenced" => FenceState::Fenced, + "committed_awaiting_acceptance" => { + let s = permitted_successor + .ok_or_else(|| anyhow!("committed fence row missing permitted_successor"))?; + FenceState::CommittedAwaitingAcceptance { + permitted_successor: fixed32(&s)?, + } + } + "released" => FenceState::Released, + "released_no_advance" => FenceState::ReleasedNoAdvance, + other => return Err(anyhow!("unknown fence state: {other}")), + }) +} + +fn fixed32(v: &[u8]) -> Result<[u8; 32]> { + <[u8; 32]>::try_from(v).map_err(|_| anyhow!("fence column is not 32 bytes")) +} + +/// Place the fence before the first mutating binding op. Idempotent per +/// `(trader_chain_id, trader_parent_state_commitment, tx_id)`: a retry of the +/// same transaction reuses the first row, so its frozen ballot floor and value +/// address never change under it. The initial `state` should be +/// [`FenceState::Fenced`]. +pub fn place_fence_with_conn(conn: &Connection, fence: &TraderFence) -> Result<()> { + let (state, permitted) = state_columns(&fence.state); + conn.execute( + "INSERT OR IGNORE INTO trader_parent_fence + (trader_chain_id, trader_parent_state_commitment, tx_id, ballot, + storage_set_id, value_addr, state, permitted_successor) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + fence.trader_chain_id.as_slice(), + fence.trader_parent_state_commitment.as_slice(), + fence.tx_id.as_slice(), + fence.ballot as i64, + fence.storage_set_id.as_slice(), + fence.value_addr.as_slice(), + state, + permitted, + ], + )?; + Ok(()) +} + +/// Same, on the shared connection. +pub fn place_fence(fence: &TraderFence) -> Result<()> { + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); + place_fence_with_conn(&conn, fence) +} + +/// Apply a [`FenceEvent`] to the fence, enforcing the legal transitions of the +/// core state machine, and persist the new state (and, on `COMMITTED`, the +/// permitted successor). Optionally persist a bumped `ballot`. Returns the new +/// state, or an error if the transition is illegal — including the load-bearing +/// refusal to let a DIFFERENT successor consume a committed fence. +pub fn record_event( + trader_chain_id: &[u8; 32], + trader_parent_state_commitment: &[u8; 32], + tx_id: &[u8; 32], + event: &FenceEvent, + new_ballot: Option, +) -> Result { + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); + let current = get_fence_with_conn( + &conn, + trader_chain_id, + trader_parent_state_commitment, + tx_id, + )? + .ok_or_else(|| anyhow!("no fence to advance for this (parent, tx)"))?; + let next = next_state(¤t.state, event)?; + let (state, permitted) = state_columns(&next); + conn.execute( + "UPDATE trader_parent_fence + SET state = ?4, permitted_successor = ?5, ballot = ?6 + WHERE trader_chain_id = ?1 + AND trader_parent_state_commitment = ?2 + AND tx_id = ?3", + params![ + trader_chain_id.as_slice(), + trader_parent_state_commitment.as_slice(), + tx_id.as_slice(), + state, + permitted, + new_ballot.unwrap_or(current.ballot) as i64, + ], + )?; + Ok(next) +} + +const COLS: &str = "trader_chain_id, trader_parent_state_commitment, tx_id, ballot, \ + storage_set_id, value_addr, state, permitted_successor, insertion_ordinal"; + +fn row_to_fence(r: &rusqlite::Row<'_>) -> Result { + Ok(TraderFence { + trader_chain_id: fixed32(&r.get::<_, Vec>(0)?)?, + trader_parent_state_commitment: fixed32(&r.get::<_, Vec>(1)?)?, + tx_id: fixed32(&r.get::<_, Vec>(2)?)?, + ballot: r.get::<_, i64>(3)? as u64, + storage_set_id: fixed32(&r.get::<_, Vec>(4)?)?, + value_addr: fixed32(&r.get::<_, Vec>(5)?)?, + state: state_from_row(&r.get::<_, String>(6)?, r.get::<_, Option>>(7)?)?, + insertion_ordinal: r.get(8)?, + }) +} + +fn get_fence_with_conn( + conn: &Connection, + trader_chain_id: &[u8; 32], + trader_parent_state_commitment: &[u8; 32], + tx_id: &[u8; 32], +) -> Result> { + conn.query_row( + &format!( + "SELECT {COLS} FROM trader_parent_fence + WHERE trader_chain_id = ?1 + AND trader_parent_state_commitment = ?2 + AND tx_id = ?3" + ), + params![ + trader_chain_id.as_slice(), + trader_parent_state_commitment.as_slice(), + tx_id.as_slice(), + ], + |r| row_to_fence(r).map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into())), + ) + .optional() + .map_err(Into::into) +} + +pub fn get_fence( + trader_chain_id: &[u8; 32], + trader_parent_state_commitment: &[u8; 32], + tx_id: &[u8; 32], +) -> Result> { + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); + get_fence_with_conn( + &conn, + trader_chain_id, + trader_parent_state_commitment, + tx_id, + ) +} + +/// THE ADVANCEMENT GATE (Req 6.23 (2),(4)). The verdict a caller consults +/// before creating a trader successor from this parent: the verdict of the one +/// unresolved fence for the parent, or [`FenceVerdict::Clear`] if none is +/// unresolved. A fresh intent or nonce cannot change this answer — the fence is +/// keyed on the parent, not the intent. +pub fn active_verdict( + trader_chain_id: &[u8; 32], + trader_parent_state_commitment: &[u8; 32], +) -> Result { + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); + let row = conn + .query_row( + &format!( + "SELECT {COLS} FROM trader_parent_fence + WHERE trader_chain_id = ?1 + AND trader_parent_state_commitment = ?2 + AND state IN ('fenced','committed_awaiting_acceptance') + ORDER BY insertion_ordinal DESC LIMIT 1" + ), + params![ + trader_chain_id.as_slice(), + trader_parent_state_commitment.as_slice(), + ], + |r| row_to_fence(r).map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into())), + ) + .optional()?; + Ok(row.map_or(FenceVerdict::Clear, |f| verdict(&f.state))) +} + +/// Every fence that has not reached a terminal state, oldest first — the work +/// list restart recovery must restore and drive to terminal before the trader +/// chain advances (Req 16.5). +pub fn list_unresolved_fences() -> Result> { + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); + let mut stmt = conn.prepare(&format!( + "SELECT {COLS} FROM trader_parent_fence + WHERE state IN ('fenced','committed_awaiting_acceptance') + ORDER BY insertion_ordinal ASC" + ))?; + let rows = stmt + .query_map([], |r| { + row_to_fence(r).map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into())) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal +mod tests { + use super::*; + use serial_test::serial; + + const CHAIN: [u8; 32] = [0x11; 32]; + const PARENT: [u8; 32] = [0x22; 32]; + const SUCC: [u8; 32] = [0xAA; 32]; + const OTHER: [u8; 32] = [0xBB; 32]; + + fn fence(tx: u8) -> TraderFence { + TraderFence { + trader_chain_id: CHAIN, + trader_parent_state_commitment: PARENT, + tx_id: [tx; 32], + ballot: 1, + storage_set_id: [0x6B; 32], + value_addr: [0x7C; 32], + state: FenceState::Fenced, + insertion_ordinal: 0, + } + } + + fn init() { + crate::storage::client_db::reset_database_for_tests(); + crate::storage::client_db::init_database().expect("init"); + } + + #[test] + #[serial] + fn a_fenced_parent_blocks_every_successor_until_resolved() { + init(); + place_fence(&fence(1)).unwrap(); + // The gate blocks all successors while unresolved. + assert_eq!( + active_verdict(&CHAIN, &PARENT).unwrap(), + FenceVerdict::BlocksAllSuccessors + ); + // A fresh tx_id (fresh intent) on the same parent does not lift it. + place_fence(&fence(2)).unwrap(); + assert_eq!( + active_verdict(&CHAIN, &PARENT).unwrap(), + FenceVerdict::BlocksAllSuccessors + ); + } + + #[test] + #[serial] + fn commit_permits_only_the_exact_successor_and_acceptance_releases() { + init(); + place_fence(&fence(1)).unwrap(); + record_event( + &CHAIN, + &PARENT, + &[1; 32], + &FenceEvent::Committed { successor: SUCC }, + Some(3), + ) + .unwrap(); + assert_eq!( + active_verdict(&CHAIN, &PARENT).unwrap(), + FenceVerdict::PermitsOnly(SUCC) + ); + // A different successor cannot consume the fence. + assert!(record_event( + &CHAIN, + &PARENT, + &[1; 32], + &FenceEvent::SuccessorAccepted { successor: OTHER }, + None + ) + .is_err()); + // The exact one releases it, and the gate goes clear. + record_event( + &CHAIN, + &PARENT, + &[1; 32], + &FenceEvent::SuccessorAccepted { successor: SUCC }, + None, + ) + .unwrap(); + assert_eq!( + active_verdict(&CHAIN, &PARENT).unwrap(), + FenceVerdict::Clear + ); + } + + #[test] + #[serial] + fn indeterminate_keeps_the_fence_and_restart_recovery_restores_it() { + init(); + place_fence(&fence(1)).unwrap(); + // A lost outcome keeps it fenced and bumps the persisted ballot. + record_event( + &CHAIN, + &PARENT, + &[1; 32], + &FenceEvent::Indeterminate, + Some(2), + ) + .unwrap(); + assert_eq!( + active_verdict(&CHAIN, &PARENT).unwrap(), + FenceVerdict::BlocksAllSuccessors + ); + // Restart recovery finds it, with the bumped ballot and the recovery + // inputs it needs. + let open = list_unresolved_fences().unwrap(); + assert_eq!(open.len(), 1); + assert_eq!(open[0].ballot, 2); + assert_eq!(open[0].value_addr, [0x7C; 32]); + assert_eq!(open[0].storage_set_id, [0x6B; 32]); + } + + #[test] + #[serial] + fn abort_releases_without_advancing_and_leaves_no_unresolved_work() { + init(); + place_fence(&fence(1)).unwrap(); + record_event(&CHAIN, &PARENT, &[1; 32], &FenceEvent::Aborted, None).unwrap(); + assert_eq!( + active_verdict(&CHAIN, &PARENT).unwrap(), + FenceVerdict::Clear + ); + assert!(list_unresolved_fences().unwrap().is_empty()); + } + + #[test] + #[serial] + fn the_first_placement_freezes_the_recovery_inputs() { + init(); + place_fence(&fence(1)).unwrap(); + let mut b = fence(1); + b.value_addr = [0xEE; 32]; + b.storage_set_id = [0xEF; 32]; + place_fence(&b).unwrap(); // INSERT OR IGNORE: no change + let got = get_fence(&CHAIN, &PARENT, &[1; 32]).unwrap().unwrap(); + assert_eq!(got.value_addr, [0x7C; 32], "first bytes win"); + assert_eq!(got.storage_set_id, [0x6B; 32]); + } +}