diff --git a/ci/admitted_predecessor_readers_fenced.sh b/ci/admitted_predecessor_readers_fenced.sh index 6d8c28981..41ad39510 100755 --- a/ci/admitted_predecessor_readers_fenced.sh +++ b/ci/admitted_predecessor_readers_fenced.sh @@ -34,7 +34,7 @@ declare -a EXEMPT=( "$core/dsm_sdk/src/sdk/economic_admission_flow.rs|sources the predecessor root rather than checking a supplied one; also names a (position, root) pair for a foreign verifier, and re-derives nothing" "$core/dsm/src/economic/peer_lineage.rs|builds a SingleRoot from this verifier's own settled memo, never from the admitted store" "$core/dsm/src/economic/lineage.rs|DEFINES the reader; the only production mention is its own doc" - "$core/dsm_sdk/src/sdk/sofi_evidence.rs|gathers SetupValid evidence: the claim this lineage accepted at a setup's position (SoFi Amendment S9); it creates no position, and an unresolved position yields no claim because the rehydration refuses it" + "$core/dsm_sdk/src/sdk/sofi_reads.rs|answers the verifier's read of the claim this lineage accepted at a setup's position (SoFi Amendment S9, SetupValid evidence); it creates no position, and an unresolved position yields no claim because the rehydration refuses it" ) [[ -d "$core" ]] || { echo "[FAIL] $core not found"; exit 1; } diff --git a/ci/sofi_validated_root_constructors.sh b/ci/sofi_validated_root_constructors.sh index f7e2f0f7e..85aa8647b 100755 --- a/ci/sofi_validated_root_constructors.sh +++ b/ci/sofi_validated_root_constructors.sh @@ -93,7 +93,7 @@ known_rehydrate_callers=( "$core/dsm_sdk/src/sdk/core_sdk.rs" # the head's validated root from the admitted store "$core/dsm_sdk/src/sdk/economic_admission_flow.rs" # the validated predecessor of a pending admission "$core/dsm_sdk/src/sdk/sofi_advance.rs" # the pending SoFi position's validated predecessor - "$core/dsm_sdk/src/sdk/sofi_evidence.rs" # accepted_claim_at: the accepted claim at a setup's position + "$core/dsm_sdk/src/sdk/sofi_reads.rs" # accepted_claim_at: the accepted claim at a setup's position, answered to the verifier ) rehydrate_callers=$(grep -rl 'rehydrate_from_admitted_store' "$core/dsm/src" "$core/dsm_sdk/src" dsm_storage_node/src 2>/dev/null \ | grep -v '_tests\.rs$' | grep -v '/test_support/' | sort) @@ -278,25 +278,31 @@ if ! grep -q 'pub(crate) fn resolve_position' "$resolution"; then fi echo " ✓ advance_resolved runs the ladder over the established facts and takes no verdict" -# Production callers only: the facts tests state a chain through the memo -# constructor for a fixture vault, which is test text (ci/production_text.py). +# The memo constructor has exactly one production caller: the verifier's +# chain walk (`dsm/src/sofi/resolve.rs`), which anchors the recorded rows at +# the genesis it accepted and checks their links before it stands on them. +# Test text (ci/production_text.py) may state a chain for a fixture. memo_callers="" while IFS= read -r f; do [[ -z "$f" ]] && continue prod=$(python3 ci/production_text.py "$f") - grep -q 'from_recorded_generations' <<<"$prod" && memo_callers="$memo_callers$f"$'\n' -done < <(grep -rln 'from_recorded_generations' "$core/dsm/src" "$core/dsm_sdk/src" dsm_storage_node/src 2>/dev/null \ + grep -q 'from_recorded(' <<<"$prod" && memo_callers="$memo_callers$f"$'\n' +done < <(grep -rln 'from_recorded(' "$core/dsm/src" "$core/dsm_sdk/src" dsm_storage_node/src 2>/dev/null \ | grep -v "sofi/resolution.rs" | sort) memo_callers=${memo_callers%$'\n'} -expected_memo="$core/dsm_sdk/src/sdk/sofi_chain.rs" +expected_memo="$core/dsm/src/sofi/resolve.rs" if [[ "$memo_callers" != "$expected_memo" ]]; then - echo "[FAIL] VaultChain::from_recorded_generations must be called only from $expected_memo" + echo "[FAIL] VaultChain::from_recorded must be called only from $expected_memo" echo " production callers found: ${memo_callers:-none}" exit 1 fi -count=$(python3 ci/production_text.py "$expected_memo" | grep -c 'from_recorded_generations') +count=$(python3 ci/production_text.py "$expected_memo" | grep -c 'from_recorded(') if [[ "$count" -ne 1 ]]; then - echo "[FAIL] $expected_memo references from_recorded_generations $count times; the chain's start is one call" + echo "[FAIL] $expected_memo references from_recorded $count times; the chain's start is one call" + exit 1 +fi +if grep -rn 'from_recorded_generations' "$core/dsm/src" "$core/dsm_sdk/src" >/dev/null 2>&1; then + echo "[FAIL] the unchecked memo constructor from_recorded_generations is back" exit 1 fi literals=$(grep -rn 'EstablishedFacts {' "$core/dsm/src" "$core/dsm_sdk/src" dsm_storage_node/src 2>/dev/null \ @@ -311,6 +317,6 @@ while IFS= read -r hit; do exit 1 fi done <<<"$literals" -echo " ✓ one caller of the chain memo, at the walk's start; facts are built by establish only" +echo " ✓ one caller of the anchored, linked chain memo, at the walk's start; facts are built by establish only" echo "✓ raw envelope -> verified claim -> registered root: every arrow is opaque" diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs index e089b6ca0..35220cf00 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/facts.rs @@ -672,7 +672,7 @@ mod tests { }; let mut roots: Vec = (0..generation).map(|g| [0xF0 ^ (g as u8); 32]).collect(); roots.push(root); - VaultChain::from_recorded_generations(roots) + VaultChain::of_roots_for_test(roots) } /// The validated predecessor the fixture's `P` was built on. diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs index 0b028d22c..f8c7af15c 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/lineage.rs @@ -1807,7 +1807,7 @@ mod tests { #[cfg(test)] #[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal -mod genesis_acceptance { +pub(crate) mod genesis_acceptance { //! SoFi §19.8 `GenesisAccepted` over a creation signed with the owner's //! key and policy bytes that re-hash to their commits. Each test changes //! one thing the owner's validated creation or `V_0` holds and names the @@ -1836,7 +1836,7 @@ mod genesis_acceptance { } /// A creation as the owner signed it, with what a verifier fetched. - struct Creation { + pub(crate) struct Creation { preimage_bytes: Vec, operation: Operation, token_policies: BTreeMap>, @@ -1900,7 +1900,7 @@ mod genesis_acceptance { } } - fn valid() -> Creation { + pub(crate) fn valid() -> Creation { let pair = (tokens()[0].0, tokens()[1].0); let (state, market_bytes) = genesis_state(pair); creation_of(state, market_bytes, pair) @@ -1920,7 +1920,7 @@ mod genesis_acceptance { ) } - fn accept(c: &Creation) -> Result { + pub(crate) fn accept(c: &Creation) -> Result { accept_at(c, P_CREATE) } diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs index e1c3ccc9f..925f6d280 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/mod.rs @@ -44,6 +44,7 @@ pub mod lineage; pub mod publication; pub mod registration; pub mod resolution; +pub mod resolve; pub mod signature; pub mod smt; pub mod storage; diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs index 0fab46977..38f6b0927 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolution.rs @@ -160,8 +160,9 @@ pub fn parent_status(established: Option<[u8; 32]>, claimed: &[u8; 32]) -> Paren /// A chain is established, never assembled: it starts at an accepted genesis /// ([`VaultChain::from_genesis`]) and grows by one Core-recomputed /// consumption at a time ([`VaultChain::extend`]). The one other way in is -/// this verifier's own memo of generations it established before -/// ([`VaultChain::from_recorded_generations`]), which the CI gate +/// this verifier's own memo of generations it established before, anchored +/// at the genesis it accepts now and linked row to row before it is stood on +/// ([`VaultChain::from_recorded`]), which the CI gate /// `ci/sofi_validated_root_constructors.sh` pins to its one caller. Nothing /// read off the network becomes a root here. #[derive(Debug, Clone, PartialEq, Eq, Default)] @@ -169,6 +170,32 @@ pub struct VaultChain { roots: Vec<[u8; 32]>, } +/// One generation as this device recorded it (`VaultChain::from_recorded`): +/// the root, and — past genesis — the root it was built on and the operation +/// that consumed that root. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecordedGeneration { + pub generation: u64, + pub root: [u8; 32], + pub pre_root: Option<[u8; 32]>, + pub consumed_by: Option<[u8; 32]>, +} + +/// This device's own record of a chain contradicts itself, or the genesis it +/// is anchored at. Not a network status: the local store is incoherent, and +/// nothing is stood on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MemoBroken { + pub generation: u64, + pub why: &'static str, +} + +impl core::fmt::Display for MemoBroken { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "recorded generation {}: {}", self.generation, self.why) + } +} + /// Why a post state does not extend a chain: it was not built on the chain's /// head. A chain grows one realized consumption at a time, from its head. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -218,14 +245,67 @@ impl VaultChain { Ok(()) } - /// THE MEMO PUNCTURE: the generations this verifier itself established - /// earlier, as it recorded them — contiguous from generation zero, a - /// root per generation. A memo proves nothing by existing: what it holds - /// is this device's own earlier conclusion, read back, and a caller that - /// hands it anything else has fabricated a chain. That is why the CI gate - /// pins this constructor to its one caller, the chain walker's start. - pub fn from_recorded_generations(roots: Vec<[u8; 32]>) -> Self { - Self { roots } + /// THE MEMO: the generations this verifier itself established earlier, + /// as it recorded them, anchored at the genesis it accepts NOW and + /// linked one to the next before any of it is stood on. Row zero is the + /// accepted genesis root; every later row was built on the root before + /// it and names the operation that consumed it. A memo proves nothing by + /// existing — what it holds is this device's own earlier conclusion, read + /// back — so what can be checked is checked here, and a record that does + /// not anchor or does not link is a contradiction, never a chain. The CI + /// gate pins this constructor to its one caller, the chain walk's start. + pub fn from_recorded( + genesis: &super::lineage::AcceptedVaultGenesis, + rows: &[RecordedGeneration], + ) -> Result { + let mut chain = Self::from_genesis(genesis); + for (index, row) in rows.iter().enumerate() { + let generation = u64::try_from(index).map_err(|_| MemoBroken { + generation: row.generation, + why: "generation overflow", + })?; + if row.generation != generation { + return Err(MemoBroken { + generation: row.generation, + why: "the rows are not contiguous from generation zero", + }); + } + if generation == 0 { + if row.root != *genesis.genesis_root() { + return Err(MemoBroken { + generation: 0, + why: "the recorded genesis is not the accepted genesis", + }); + } + if row.pre_root.is_some() || row.consumed_by.is_some() { + return Err(MemoBroken { + generation: 0, + why: "the genesis generation records a consumption", + }); + } + continue; + } + let Some((.., head)) = chain.head() else { + return Err(MemoBroken { + generation, + why: "no head to link to", + }); + }; + if row.pre_root != Some(head) { + return Err(MemoBroken { + generation, + why: "the generation was not built on the one before it", + }); + } + if row.consumed_by.is_none() { + return Err(MemoBroken { + generation, + why: "no operation is recorded as consuming the generation before it", + }); + } + chain.roots.push(row.root); + } + Ok(chain) } /// `R*_g` for every generation established, in generation order. @@ -233,6 +313,12 @@ impl VaultChain { &self.roots } + /// A chain stated for a test of what reads it; in-crate only. + #[cfg(test)] + pub(crate) fn of_roots_for_test(roots: Vec<[u8; 32]>) -> Self { + Self { roots } + } + /// The status of a parent asked about at `generation` — [`parent_status`] /// over what this chain established there. pub fn status_of(&self, generation: u64, claimed: &[u8; 32]) -> ParentStatus { @@ -992,6 +1078,106 @@ mod tests { assert_eq!(resolve_position(&realized(&refuted)), Ok(Resolution::Void)); } + /// The genesis the memo tests anchor at, accepted as the fixture owner's + /// creation. + fn accepted_genesis() -> crate::sofi::lineage::AcceptedVaultGenesis { + use crate::sofi::lineage::genesis_acceptance::{accept, valid}; + accept(&valid()).expect("the fixture's genesis is accepted") + } + + /// Rows as this device records them: the genesis at zero, then each + /// root built on the one before it and consumed by a distinct operation. + fn recorded(genesis: &[u8; 32], roots: &[[u8; 32]]) -> Vec { + let mut rows = vec![RecordedGeneration { + generation: 0, + root: *genesis, + pre_root: None, + consumed_by: None, + }]; + let mut previous = *genesis; + for (i, root) in roots.iter().enumerate() { + rows.push(RecordedGeneration { + generation: i as u64 + 1, + root: *root, + pre_root: Some(previous), + consumed_by: Some([0xC0 | i as u8; 32]), + }); + previous = *root; + } + rows + } + + /// THE MEMO IS ANCHORED AND LINKED, OR IT IS NOTHING. The rows this + /// device recorded become a chain only from the genesis it accepts now, + /// each generation built on the one before it and consumed by a named + /// operation; a record that does not anchor, does not link, names no + /// consumption or skips a generation is a contradiction and no chain. + /// MUTATION CONTROL: a constructor that takes the rows as they are turns + /// this red. + #[test] + fn the_memo_becomes_a_chain_only_anchored_at_the_genesis_and_linked_row_to_row() { + let genesis = accepted_genesis(); + let g = *genesis.genesis_root(); + let (r1, r2) = ([0xA1; 32], [0xA2; 32]); + + assert_eq!( + VaultChain::from_recorded(&genesis, &[]).unwrap().roots(), + &[g][..], + "no rows: the chain is the genesis alone" + ); + let rows = recorded(&g, &[r1, r2]); + assert_eq!( + VaultChain::from_recorded(&genesis, &rows).unwrap().roots(), + &[g, r1, r2][..] + ); + + // Not anchored: row zero is not the genesis accepted now. + let mut unanchored = rows.clone(); + unanchored[0].root = OTHER_ROOT; + assert_eq!( + VaultChain::from_recorded(&genesis, &unanchored) + .unwrap_err() + .generation, + 0 + ); + // The genesis row records a consumption. + let mut consumed_genesis = rows.clone(); + consumed_genesis[0].pre_root = Some(OTHER_ROOT); + assert_eq!( + VaultChain::from_recorded(&genesis, &consumed_genesis) + .unwrap_err() + .generation, + 0 + ); + // Not linked: generation 2 was not built on generation 1. + let mut unlinked = rows.clone(); + unlinked[2].pre_root = Some(OTHER_ROOT); + assert_eq!( + VaultChain::from_recorded(&genesis, &unlinked) + .unwrap_err() + .generation, + 2 + ); + // No consumption named. + let mut unnamed = rows.clone(); + unnamed[1].consumed_by = None; + assert_eq!( + VaultChain::from_recorded(&genesis, &unnamed) + .unwrap_err() + .generation, + 1 + ); + // A generation skipped. + let mut gapped = rows.clone(); + gapped.remove(1); + assert_eq!( + VaultChain::from_recorded(&genesis, &gapped) + .unwrap_err() + .generation, + 2 + ); + } + /// A leg that consumed its canonical parent on this operation's E. fn good_leg() -> LegFacts { LegFacts { diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs new file mode 100644 index 000000000..7f43f824d --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/resolve.rs @@ -0,0 +1,1593 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! The SoFi verifier: what is read, in what order, and what it establishes — +//! stage 3 and stages 9–10 of §31, the walk of §30, rebuild steps R5, R7, +//! R10–R12 and R14 — over a [`SofiReads`] the SDK implements with bytes, +//! cells and its own local records, and nothing else. +//! +//! Core decides; the reads supply. Every cell is derived here from committed +//! state (`AttemptCell`, `PositionCells`) and evaluated here from every +//! seat's reads; every object is recognized here from its bytes; every +//! predicate is recomputed here over the evidence gathered; every fact the +//! ladder reads is established here ([`super::facts::establish`]) and the +//! ladder runs inside `advance_resolved`. Nothing an implementor of +//! [`SofiReads`] answers is a verdict: a member that did not answer is a +//! [`ReadFailure`], a network status; bytes are bytes until Core recognizes +//! them; a local record is this device's own earlier conclusion, checked +//! before it is stood on. +//! +//! The one thing this module does not do is I/O, and the one thing the SDK +//! does with a [`Verifier`] is answer its reads — the shape of the peer +//! lineage walker (`economic::peer_lineage::PeerEvidenceFetcher`), which +//! walks a foreign lineage the same way. + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ccb::StorageSetMembers; +use crate::economic::lineage::{AcceptedClaim, AdmittedEconomicPosition}; +use crate::economic::provenance::{PeerLineageFailure, ValidatedPeerTransition}; +use crate::economic::register::root_completion; +use crate::economic::state::EconomicLeafState; +use crate::economic::tree::EconomicSmt; +use crate::route_chain::{ + CellEvidence, CellFact, ChainState, CompletionProof, Missing as CellMissing, RouteEntry, + RoutedCell, +}; + +use super::conformance::{ + fulfillment_conformance, ConformanceEvidence, ConformanceMissing, FulfillmentConformance, + Validation, +}; +use super::derive; +use super::exercise::{ + attempt_completion, attempt_resolution, AttemptCell, AttemptCellRead, RecognizedExercise, +}; +use super::facts::{ + establish, refuted_in_hand, Established, EstablishedFacts, ExerciseReads, InHandRefutation, + LegReads, NotEstablished, +}; +use super::lineage::{ + genesis_accepted, genesis_root, vault_leaves_at_genesis, AcceptedVaultGenesis, GenesisInvalid, + GenesisMissing, GenesisRefusal, +}; +use super::publication::{recognize_fulfillment, recognize_setup, Signed}; +use super::registration::{ + fulfillment_completion, fulfillment_registered, PositionCells, Registration, RegistrationRead, +}; +use super::resolution::{walk, AttemptWalk, KeyFacts, RecordedGeneration, VaultChain, WalkOutcome}; +use super::storage::{Discovered, Resolved}; +use super::validation::{ + route_validation, vault_post_states, Evidence, EvidenceNeeds, Missing, TraderLeafPre, + VaultLeafPre, VaultPostState, +}; +use super::wire::{ + ParentClaimRef, SettlementPreimage, TraderCore, TraderFulfillmentBody, TraderPrecommitBody, + ValidationRef, VaultGenesisPreimage, VaultStateLeaf, +}; + +type D32 = [u8; 32]; + +/// The pre values of one vault's leaves, by `(vault_id, key)`. +pub type VaultLeaves = BTreeMap<(D32, D32), VaultLeafPre>; + +/// Keys one walk examines before it hands back a cursor (Section 23.6): a +/// budget only, never a verdict — resuming at the cursor lands the same +/// answer as one longer walk. +pub const WALK_BUDGET: usize = 16; + +/// How far the facts of one exercise reach into the chains of its OTHER legs: +/// a two-leg route's liveness at leg 2 is a walk over leg 2's earlier keys, +/// whose exercises may themselves be routes. Past this depth a leg's +/// liveness is not established, and the facts of the exercise are not +/// complete. +pub const CHAIN_DEPTH: usize = 2; + +/// How many generations one call extends a vault's chain by. A budget only, +/// never a verdict: a chain that stops here is short, not complete, and a +/// generation it did not reach is `Unavailable`. +pub const GENERATION_BUDGET: usize = 16; + +/// How far the chain walk recurses into OTHER vaults' chains. A multi-leg +/// route can only have consumed this vault's root if every leg's parent was +/// canonical, so establishing this chain can require establishing a +/// sibling's. Beta routes are two hops, so two levels cover them; past this +/// depth a sibling is unestablished, which stops this chain rather than +/// guessing at it. +pub const SIBLING_DEPTH: usize = 2; + +/// Acquisition rounds before the evidence is `Exhausted`. +pub const ACQUIRE_ROUNDS: usize = 3; + +/// Cells read per leg when acquiring what an attempt skipped past: the same +/// bound the walk uses, for the same reason. Past it the earlier keys are +/// unread, never assumed skipped. +pub const PRIOR_ATTEMPT_BUDGET: usize = WALK_BUDGET; + +/// Fulfillment candidates one read of `K_ful(q)` may name before the pair is +/// unavailable to this verifier: each names a `P` fetched by id (R8). +pub const NAMED_PRECOMMIT_BUDGET: usize = 64; + +/// A read that could not be made: a member did not answer, a local store +/// failed. A network status, never a verdict, and never a fact about the +/// operation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadFailure(pub String); + +impl core::fmt::Display for ReadFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.0) + } +} + +/// Why the verifier could not proceed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VerifierFailure { + /// A read could not be made. + Read(String), + /// What was read refutes the premise the verifier was working from: a + /// vault's genesis is refused, this device's own record of a chain + /// contradicts itself. + Refused(String), +} + +impl core::fmt::Display for VerifierFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Read(why) => write!(f, "read: {why}"), + Self::Refused(why) => write!(f, "refused: {why}"), + } + } +} + +impl From for VerifierFailure { + fn from(failure: ReadFailure) -> Self { + Self::Read(failure.0) + } +} + +/// One generation this device recorded for a vault, as its store holds it: +/// the root at that generation, and — past genesis — the root it was built +/// on and the operation that consumed it. Rows are handed contiguously from +/// generation zero; what they link to is checked by +/// [`VaultChain::from_recorded`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RecordedGenerationRow { + pub generation: u64, + pub root: D32, + pub pre_root: Option, + pub consumed_by: Option, +} + +/// What the verifier reads. Every answer is bytes, cells or this device's +/// own records; the verifier recognizes, evaluates and checks all of it. +pub trait SofiReads { + /// Every seat's reads of `cell` (storage spec §9): what each seat holds, + /// its committed state from a mirror, and each later seat's own mirror + /// of the leader. The verifier evaluates the route chains. + fn cell(&self, cell: &RoutedCell) -> Result; + /// `P` by `PrecommitId`: the one stored envelope under the id whose body + /// re-derives it (R8). + fn precommit(&self, id: &D32) -> Result>, ReadFailure>; + /// `F` by `FulfillmentId`. + fn fulfillment(&self, id: &D32) + -> Result>, ReadFailure>; + /// The exact stored envelope bytes of the setup at `ρ`. + fn setup_bytes(&self, setup_ref: &D32) -> Result>, ReadFailure>; + /// The exact bytes at a content address once `Stored` holds for them; + /// `None` when they are not established. + fn stored_bytes(&self, addr: &D32) -> Result>, ReadFailure>; + /// `TokenPolicyV3` bytes for `policy_commit`, rooted on the creator's + /// chain. The verifier re-hashes them; `Err` when they are not in hand. + fn token_policy_bytes(&self, policy_commit: &D32) -> Result, ReadFailure>; + /// Every preimage published under vault `vault_id`'s genesis locator that + /// recognizes to it, with its exact bytes, in append order. + fn vault_genesis_candidates( + &self, + vault_id: &D32, + ) -> Result)>, ReadFailure>; + /// The owner's transition at its creation position, validated by the + /// peer lineage walk. + fn vault_owner( + &self, + genesis: &D32, + device_id: &D32, + position: u64, + ) -> Result; + /// This device's own record of vault `vault_id`'s leaves at the + /// generation it established `root` at, for `keys` — a record that + /// reproduces `root`, or `None`. + fn vault_leaves_at( + &self, + vault_id: &D32, + root: &D32, + keys: &BTreeSet, + ) -> Result, ReadFailure>; + /// The claim this device's own lineage accepted at `position` of trader + /// `(genesis, device_id)`, if it accepted one there. + fn accepted_claim_at( + &self, + genesis: &D32, + device_id: &D32, + position: u64, + ) -> Result, ReadFailure>; + /// The generations this device recorded for `vault_id`, contiguous from + /// zero, in generation order. + fn recorded_generations( + &self, + vault_id: &D32, + ) -> Result, ReadFailure>; + /// Record a generation the walk established. + fn record_generation(&self, post: &VaultPostState) -> Result<(), ReadFailure>; + /// Keep the completion proof of the value final at `cell` (storage spec + /// §9 rule 11). + fn keep_completion( + &self, + cell: &RoutedCell, + proof: &CompletionProof, + ) -> Result<(), ReadFailure>; +} + +/// Every value the cell's copies carry, seat by seat in route order and in +/// each seat's arrival order: the bytes a recognizer is shown. What does not +/// decode as a route entry carries nothing. +pub fn carried_values(evidence: &CellEvidence) -> impl Iterator> + '_ { + evidence + .seats + .iter() + .filter_map(|seat| seat.values.as_ref()) + .flatten() + .filter_map(|bytes| RouteEntry::decode(bytes)) + .map(|entry| entry.value) +} + +/// The value the cell's copies carry whose entry digest is `id`. +pub fn value_of(evidence: &CellEvidence, id: &D32) -> Option> { + carried_values(evidence).find(|value| crate::storage_cell::entry_digest(value) == *id) +} + +/// This device's own `R_econ` leaves, checked against the root they claim to +/// form. Built from the device's leaf cache for its validated root, or by a +/// test from leaves it holds — never from a root somebody sent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalLeaves { + genesis: D32, + device_id: D32, + root: D32, + leaves: BTreeMap, +} + +impl LocalLeaves { + /// Leaves of `(genesis, device_id)` that must recompute `root`; anything + /// else is refused. + pub fn checked( + genesis: D32, + device_id: D32, + root: D32, + leaves: impl IntoIterator, + ) -> Result { + let mut tree = EconomicSmt::new(); + let mut map = BTreeMap::new(); + for (key, state) in leaves { + let value = state + .leaf_value() + .map_err(|e| LeavesDoNotRecomputeTheRoot(format!("encode leaf state: {e}")))?; + tree.insert(key, value); + map.insert(key, state); + } + if tree.root() != root { + return Err(LeavesDoNotRecomputeTheRoot( + "local leaves do not recompute the validated root — discarded".to_string(), + )); + } + Ok(Self { + genesis, + device_id, + root, + leaves: map, + }) + } + + pub fn root(&self) -> D32 { + self.root + } + + /// Evidence holding the pre value of every key `core` names, from these + /// leaves alone: what `Fold(T°, E)` reads, before anything is published. + /// A key holding a write-once record has no trader-leaf pre value and is + /// refused. + pub fn trader_evidence( + &self, + core: &TraderCore, + ) -> Result { + let mut trader_leaves = BTreeMap::new(); + for entry in core.entries() { + let key = entry.key(); + let pre = self.pre(&key).ok_or_else(|| { + LeavesDoNotRecomputeTheRoot( + "a trader core names a key holding a write-once record".to_string(), + ) + })?; + trader_leaves.insert(key, pre); + } + Ok(Evidence::acquired( + BTreeMap::new(), + trader_leaves, + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + BTreeMap::new(), + )) + } + + /// Every relationship leaf this device holds: the vaults it is set up + /// with. + pub fn relationships(&self) -> Vec { + let mut out = Vec::new(); + for state in self.leaves.values() { + if let EconomicLeafState::Relationship(leaf) = state { + out.push(*leaf); + } + } + out + } + + /// The relationship leaf this device holds with `vault_id`, if any. + pub fn relationship(&self, vault_id: &D32) -> Option { + let key = derive::relationship_key(&self.genesis, &self.device_id, vault_id); + match self.leaves.get(&key) { + Some(EconomicLeafState::Relationship(leaf)) => Some(*leaf), + Some(..) | None => None, + } + } + + /// Whether `precommit` is this device's own: the only routes whose trader + /// leaves and accepted claims this device holds. + fn owns(&self, precommit: &TraderPrecommitBody) -> bool { + *precommit.genesis() == self.genesis && *precommit.device_id() == self.device_id + } + + /// The pre value at `key` as Core reads a trader leaf. The tree is whole, + /// so a key it does not hold is absent. A key holding a write-once record + /// has no trader-leaf pre value: Core reads trader leaves only at balance + /// and relationship keys, which are domain-separated from every record + /// key. + pub fn pre(&self, key: &D32) -> Option { + match self.leaves.get(key) { + None => Some(TraderLeafPre::Absent), + Some(EconomicLeafState::Balance(b)) => Some(TraderLeafPre::Balance(b.clone())), + Some(EconomicLeafState::Relationship(r)) => Some(TraderLeafPre::Relationship(*r)), + Some( + EconomicLeafState::ConsumedSource(..) + | EconomicLeafState::VaultCreation(..) + | EconomicLeafState::TokenCreation(..), + ) => None, + } + } +} + +/// Leaves that do not form the root they were said to form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LeavesDoNotRecomputeTheRoot(pub String); + +impl core::fmt::Display for LeavesDoNotRecomputeTheRoot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(&self.0) + } +} + +/// What this verifier established about vault `v`'s genesis (SoFi §19.8; +/// §30 step 1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VaultGenesis { + /// The owner's validated creation carried this genesis, and it is + /// accepted. + Accepted(Box), + /// No preimage the owner's creation carried is published under the + /// vault's locator. + NotPublished, + /// The owner's lineage reaches `p_create` through a position whose route + /// has not resolved: nothing a fetch can supply decides it yet. + OwnerUnresolved(String), + /// The genesis is refused: the owner's lineage is invalid or quarantined, + /// or the genesis the owner created fails acceptance. + Refused(String), +} + +/// What an acquisition produced: the complete evidence a Core predicate +/// consumes, or what is still not in hand. Predicates are Valid or Invalid +/// only; these are the separate acquisition statuses (Amendment S3, owner +/// 2026-09-23). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Acquired { + /// Everything the predicate consumes is in hand and authenticates. + Complete(T), + /// The retry budget is spent and these items are still not in hand: the + /// operation fails on the network. The caller evaluates nothing and + /// records nothing. + Exhausted(Vec), + /// These items have no source this verifier can acquire them from, so no + /// retry can supply them. The caller evaluates nothing and records + /// nothing. + NoSource(Vec), +} + +/// The objects one exercise's conformance is decided over: the trader's +/// signed `P` and `F`, `P(E)`, and the exact bytes the trader holds for the +/// closure references only it can supply — the registered claim envelope a +/// `SingleRootClaim` names, the final claim bytes at `K_root(p)` a +/// `ConditionalClaim` names. Core re-derives every reference from the +/// bytes; nothing here is trusted. +#[derive(Debug, Clone, Copy)] +pub struct ExerciseObjects<'a> { + pub precommit: &'a TraderPrecommitBody, + pub precommit_signature: &'a [u8], + pub preimage: &'a SettlementPreimage, + pub fulfillment: &'a TraderFulfillmentBody, + pub fulfillment_signature: &'a [u8], + pub own_objects: &'a BTreeMap>, +} + +/// What the verifier brings: its reads, the network's pinned set, the +/// network, its own leaves, and the position it resolved itself. Every +/// field is an established fact of THIS verifier; none is trusted because +/// somebody sent it. +pub struct Verifier<'a, R: SofiReads> { + pub reads: &'a R, + /// The network's pinned set, as the local catalog resolves it: cells + /// are routed over it, and `RoutedCell::new` refuses members that do + /// not re-derive `set_id`. + pub members: &'a StorageSetMembers, + pub set_id: D32, + pub network_id: &'a [u8], + /// This device's own `R_econ` leaves, for the trader-leaf pre values of + /// its own routes; `None` for a verifier that is not a trader (a relay, + /// a reader of another trader's position), whose routes then have no + /// source for those values. + pub local: Option<&'a LocalLeaves>, + /// This verifier's own admitted position, when it resolved a conditional + /// one: what a `P` naming that fulfillment as its parent was built on. + /// Core reads what it selected; nothing else resolves a parent, and a + /// conditional parent this verifier did not resolve is not established. + pub parent: Option<&'a AdmittedEconomicPosition>, +} + +/// Where a walk over one parent's attempt chain ended, with the exercise +/// that consumed the parent when one did — its consumed route's `V°` post +/// root is the next parent (Section 30, step 3) — and, when it stopped +/// unresolved at a key it could not classify, why. Carries what it +/// classified, so that a walk resumed at its cursor ([`Verifier::continue_walk`]) +/// still stands on every earlier key. +#[derive(Debug)] +pub struct Walked { + pub outcome: WalkOutcome, + /// The walk as [`walk`] made it: what a leg's liveness is read from. + pub walk: AttemptWalk, + pub consumed: Option, + pub not_established: Option, + known: BTreeMap, +} + +/// What this verifier knows about one exercise. +#[derive(Debug)] +enum Known { + /// Refuted by its own bytes: nothing else was read. + RefutedInHand(InHandRefutation), + /// The complete facts established over the reads. + Facts(Box), +} + +/// One key the walk classified: the read that found the exercise holding +/// it, and what is known about that exercise. +#[derive(Debug)] +struct KeyKnown { + read: AttemptCellRead, + known: Known, +} + +impl KeyKnown { + /// The facts of this key as Core binds them to it: the exercise the read + /// holds, and the facts established for that exercise. + fn key_facts(&self) -> Option> { + match &self.known { + Known::Facts(facts) => KeyFacts::of(&self.read, facts), + Known::RefutedInHand(refutation) => KeyFacts::refuted(&self.read, refutation), + } + } +} + +/// The key an exercise was found at while walking a chain: its read, and the +/// walk over the keys before it, which reached it by skipping every one. +#[derive(Clone, Copy)] +struct WalkedKey<'a> { + read: &'a AttemptCellRead, + reached: &'a AttemptWalk, +} + +fn short_id(id: &D32) -> String { + crate::utils::text_id::encode_base32_crockford(id) +} + +impl Verifier<'_, R> { + // ── cells ─────────────────────────────────────────────────────────── + + /// `K^(attempt)` of `vault_id` at `parent_root`, routed over the set. + pub fn attempt_cell( + &self, + vault_id: &D32, + parent_root: &D32, + attempt: u64, + ) -> Result { + AttemptCell::new(vault_id, parent_root, attempt, self.members, &self.set_id) + .map_err(|e| VerifierFailure::Refused(format!("attempt cell: {e:?}"))) + } + + /// The two cells of trader `(genesis, device_id)`'s position `position`, + /// routed by `s(q)` from `parent_root` over the set. + pub fn position_cells( + &self, + genesis: &D32, + device_id: &D32, + position: u64, + parent_root: &D32, + ) -> Result { + PositionCells::new( + genesis, + device_id, + position, + parent_root, + self.members, + &self.set_id, + ) + .map_err(|e| VerifierFailure::Refused(format!("position cells: {e:?}"))) + } + + // ── reads ─────────────────────────────────────────────────────────── + + /// `SuccessorResolution(K^(attempt))` of `vault_id` at `parent_root`, as + /// the ladder reads it (Section 23.1): the cell's route chains evaluated + /// from every seat's reads into a read bound to the key. An exercise + /// final at the cell has its completion proof kept (storage spec §9 rule + /// 11). Reads that do not decide the cell yet — its leader unread, or its + /// leader link not yet committed — are the inner `Err`: a network status + /// for the caller to retry, never an open cell. + pub fn read_attempt_cell( + &self, + vault_id: &D32, + parent_root: &D32, + attempt: u64, + ) -> Result, VerifierFailure> { + let cell = self.attempt_cell(vault_id, parent_root, attempt)?; + let evidence = self.reads.cell(cell.routed())?; + let read = match attempt_resolution(&cell, &evidence) { + Ok(read) => read, + Err(missing) => return Ok(Err(missing)), + }; + if let CellFact::Held { + state: ChainState::Final, + .. + } = read.fact() + { + let (.., proof) = attempt_completion(&cell, &evidence) + .map_err(|missing| { + VerifierFailure::Read(format!("attempt completion: {missing:?}")) + })? + .ok_or_else(|| { + VerifierFailure::Read( + "attempt completion: a final exercise has no completion proof".to_string(), + ) + })?; + self.reads.keep_completion(cell.routed(), &proof)?; + } + Ok(Ok(read)) + } + + /// `FulfillmentRegistered` at position `position` of trader `(genesis, + /// device_id)`, derived from the route-chain reads of the two cells + /// (Part II §13, rebuild step R10). `parent_root` is `R_p`, the root the + /// verifier validated itself, from which `s(q)` and the route follow. + /// Once registered, the completion proofs of both cells are kept (SoFi + /// Amendment S10). + /// + /// Recognizing a value at `K_ful(q)` needs the `P` it names: every + /// fulfillment envelope any seat holds at the key names one, and those + /// are read by id (R8), at most [`NAMED_PRECOMMIT_BUDGET`] of them. A `P` + /// the read could not decide leaves the cell undecided — the call fails, + /// and a later read can answer — so that no later value is read as the + /// first recognized one while an earlier one's `P` is merely not in + /// hand. The inner `Err` is what the reads do not yet show. + pub fn read_registration( + &self, + genesis: &D32, + device_id: &D32, + position: u64, + parent_root: &D32, + ) -> Result, VerifierFailure> { + let cells = self.position_cells(genesis, device_id, position, parent_root)?; + let ful_evidence = self.reads.cell(cells.fulfillment())?; + let root_evidence = self.reads.cell(cells.root().routed())?; + + let mut precommits: BTreeMap = BTreeMap::new(); + let mut named: Vec = Vec::new(); + for value in carried_values(&ful_evidence) { + if let Some((.., signed)) = recognize_fulfillment(&value) { + let id = *signed.body.precommit_id(); + if !named.contains(&id) { + named.push(id); + } + } + } + if named.len() > NAMED_PRECOMMIT_BUDGET { + return Err(VerifierFailure::Read(format!( + "fulfillment register: {} precommits are named at K_ful({position}), past the \ + budget of {NAMED_PRECOMMIT_BUDGET}", + named.len() + ))); + } + for id in named { + match self.reads.precommit(&id)? { + Resolved::Kept(precommit) => { + precommits.insert(id, precommit.body); + } + Resolved::None => {} + Resolved::Unavailable => { + return Err(VerifierFailure::Read( + "fulfillment register: a precommit a candidate names could not be read" + .to_string(), + )) + } + } + } + + let registration = + match fulfillment_registered(&cells, &ful_evidence, &root_evidence, &precommits) { + Ok(registration) => registration, + Err(missing) => return Ok(Err(missing)), + }; + if let Registration::Registered(..) = registration.registration() { + let (.., ful_proof) = fulfillment_completion(&cells, &ful_evidence, &precommits) + .map_err(|missing| { + VerifierFailure::Read(format!("fulfillment completion: {missing:?}")) + })? + .ok_or_else(|| { + VerifierFailure::Read( + "fulfillment completion: a final value has no proof".to_string(), + ) + })?; + self.reads + .keep_completion(cells.fulfillment(), &ful_proof)?; + let (.., root_proof) = root_completion(cells.root(), &root_evidence) + .map_err(|missing| { + VerifierFailure::Read(format!("root claim completion: {missing:?}")) + })? + .ok_or_else(|| { + VerifierFailure::Read( + "root claim completion: a final value has no proof".to_string(), + ) + })?; + self.reads + .keep_completion(cells.root().routed(), &root_proof)?; + } + Ok(Ok(registration)) + } + + /// The cells an attempt above zero skips past, read from the committed + /// set: for every leg the fulfillment names at attempt `a`, the storage + /// fact at `K^(0) … K^(a-1)` of that leg's vault at its parent root. + /// + /// ONE PATH, SHARED (owner ruling, §44.4). The producer's install (R9) + /// and the verifier's resolution (R12) read the same cells the same way, + /// because they answer the same question: conformance item 5 requires + /// the key before this one to have a permanent storage resolution. A key + /// whose reads do not decide it yet, or past `budget`, is absent from the + /// map, and conformance names it missing. An attempt of zero has no + /// earlier key and contributes no entry. + pub fn acquire_prior_attempts( + &self, + precommit: &TraderPrecommitBody, + fulfillment: &TraderFulfillmentBody, + budget: usize, + ) -> Result, VerifierFailure> { + let reach = u64::try_from(budget).unwrap_or(u64::MAX); + let mut cells = BTreeMap::new(); + for entry in fulfillment.attempts() { + // F naming a leg P does not is conformance item 4's refusal; + // there is no parent root to read a cell at. + let Some(leg) = precommit + .legs() + .iter() + .find(|l| l.vault_id == entry.vault_id) + else { + continue; + }; + for earlier in 0..entry.attempt.min(reach) { + match self.read_attempt_cell(&leg.vault_id, &leg.parent_root, earlier)? { + Ok(read) => { + cells.insert((entry.vault_id, earlier), read.fact()); + } + Err(undecided) => { + log::info!( + "[sofi verifier] K^({earlier}) is not decided yet: {undecided:?}" + ) + } + } + } + } + Ok(cells) + } + + /// One round of reading what `FulfillmentConformance(F)` reads: `P` and + /// `P(E)` from the objects, every leg's setup under its `ρ` (R8), the + /// closure objects by the rule of each reference kind, and item 5's + /// earlier attempt cells. + fn gather_conformance( + &self, + objects: &ExerciseObjects<'_>, + ) -> Result { + let mut setups = BTreeMap::new(); + for leg in objects.precommit.legs() { + if let Resolved::Kept(bytes) = self.reads.setup_bytes(&leg.setup_ref)? { + setups.insert(leg.setup_ref, bytes); + } + } + let mut closure = BTreeMap::new(); + for reference in objects.preimage.settlement().closure().refs() { + let bytes = match reference { + ValidationRef::ContentAddr { addr, .. } => self.reads.stored_bytes(addr)?, + ValidationRef::Setup { setup_ref } => match self.reads.setup_bytes(setup_ref)? { + Resolved::Kept(bytes) => Some(bytes), + Resolved::None | Resolved::Unavailable => None, + }, + ValidationRef::SingleRootClaim { .. } | ValidationRef::ConditionalClaim { .. } => { + objects.own_objects.get(reference).cloned() + } + }; + if let Some(bytes) = bytes { + closure.insert(*reference, bytes); + } + } + // Item 1 for a conditional parent: the F whose id P names, by that + // id. Its id is the hash of its body, so the kept bytes are that F. + let parent_fulfillment = match objects.precommit.parent_claim_ref() { + ParentClaimRef::Conditional { fulfillment_id } => { + match self.reads.fulfillment(fulfillment_id)? { + Resolved::Kept(signed) => Some(signed.body), + Resolved::None | Resolved::Unavailable => None, + } + } + ParentClaimRef::SingleRoot { .. } => None, + }; + Ok(ConformanceEvidence { + precommit: Signed { + body: objects.precommit.clone(), + signature: objects.precommit_signature.to_vec(), + }, + preimage: objects.preimage.clone(), + closure, + setups, + prior_attempts: self.acquire_prior_attempts( + objects.precommit, + objects.fulfillment, + PRIOR_ATTEMPT_BUDGET, + )?, + parent_fulfillment, + }) + } + + /// Acquire what `FulfillmentConformance(F)` reads, and ask the predicate + /// whether it is complete: `Complete` once conformance reaches a verdict + /// over it, `Exhausted` naming what is still missing after the retry + /// budget (Amendment S3). + pub fn acquire_conformance_evidence( + &self, + objects: &ExerciseObjects<'_>, + ) -> Result, VerifierFailure> { + let mut missing = Vec::new(); + for round in 1..=ACQUIRE_ROUNDS { + let evidence = self.gather_conformance(objects)?; + match fulfillment_conformance( + objects.fulfillment, + objects.fulfillment_signature, + &evidence, + ) { + Ok(FulfillmentConformance::Valid | FulfillmentConformance::Invalid(..)) => { + return Ok(Acquired::Complete(evidence)) + } + Err(what) => { + log::info!( + "[sofi verifier] conformance round {round}/{ACQUIRE_ROUNDS}: not in hand: {what:?}" + ); + missing = vec![what]; + } + } + } + Ok(Acquired::Exhausted(missing)) + } + + /// Acquire everything `P` and `P(E)` need, from storage and this device's + /// own state, and ask the predicate whether it is complete. `Complete` + /// once `route_validation` reaches a verdict over it; `Exhausted` naming + /// what is still missing after [`ACQUIRE_ROUNDS`] rounds; `NoSource` for + /// another trader's route: `TraderSideValid` reads the trader's leaf pre + /// values, the trader core carries only their hashes, and no section + /// names where a verifier that is not the trader gets them (SoFi §17.5, + /// an open hole) — nothing is supplied in their place. + pub fn acquire_evidence( + &self, + precommit: &TraderPrecommitBody, + preimage: &SettlementPreimage, + ) -> Result, VerifierFailure> { + let needs = EvidenceNeeds::of(precommit, preimage); + if !self.local.is_some_and(|local| local.owns(precommit)) { + return Ok(Acquired::NoSource( + needs + .trader_keys + .iter() + .map(|key| Missing::TraderLeaf { key: *key }) + .collect(), + )); + } + let mut missing = Vec::new(); + for round in 1..=ACQUIRE_ROUNDS { + let evidence = self.gather(precommit, preimage, &needs)?; + match route_validation(precommit, preimage, &evidence) { + Ok(Validation::Valid | Validation::Invalid) => { + return Ok(Acquired::Complete(evidence)) + } + Err(what) => { + log::info!( + "[sofi verifier] evidence round {round}/{ACQUIRE_ROUNDS}: not in hand: {what:?}" + ); + missing = vec![what]; + } + } + } + Ok(Acquired::Exhausted(missing)) + } + + /// One round of reading every item `needs` names, for this device's own + /// route: trader leaf pre values from its own leaves, vault leaf pre + /// values from the vault's accepted genesis at `R_0` or the generation + /// this device established at the root the core names, policy objects + /// from the immutable store under the addresses the vault state commits, + /// token policies rooted by this device, setups at each `ρ`, and the + /// claims this device's lineage accepted at each setup's position. + fn gather( + &self, + precommit: &TraderPrecommitBody, + preimage: &SettlementPreimage, + needs: &EvidenceNeeds, + ) -> Result { + let trader_leaves: BTreeMap = needs + .trader_keys + .iter() + .filter_map(|key| { + self.local + .and_then(|local| local.pre(key)) + .map(|pre| (*key, pre)) + }) + .collect(); + + let mut vault_leaves: VaultLeaves = BTreeMap::new(); + let mut objects: BTreeMap> = BTreeMap::new(); + let mut token_policies: BTreeMap> = BTreeMap::new(); + for (vault_id, keys) in &needs.vaults { + let Some(state) = self.vault_pre(preimage, vault_id, keys, &mut vault_leaves)? else { + continue; + }; + for (class, addr) in EvidenceNeeds::policies_of(&state) { + let Some(bytes) = self.reads.stored_bytes(&addr)? else { + continue; + }; + // The tokens a market names are what the transferable check + // reads. Bytes that are not a market name none, and the + // predicate refuses them. + if class == crate::ccb::class::MARKET_POLICY { + if let Ok(market) = crate::ccb::decode::decode_market_policy(&bytes) { + for commit in EvidenceNeeds::token_policies_of(&market) { + if crate::core::token::builtin_token_id_for_policy_commit(&commit) + .is_some() + { + continue; + } + match self.reads.token_policy_bytes(&commit) { + Ok(policy) => { + token_policies.insert(commit, policy); + } + Err(failure) => { + log::info!( + "[sofi verifier] token policy not in hand: {failure}" + ) + } + } + } + } + } + objects.insert(addr, bytes); + } + } + + let mut setups: BTreeMap> = BTreeMap::new(); + let mut accepted_claims: BTreeMap = BTreeMap::new(); + for setup_ref in &needs.setups { + let Resolved::Kept(bytes) = self.reads.setup_bytes(setup_ref)? else { + continue; + }; + if let Some((.., signed)) = recognize_setup(&bytes) { + let position = signed.body.position(); + if let Some(claim) = self.reads.accepted_claim_at( + precommit.genesis(), + precommit.device_id(), + position, + )? { + accepted_claims.insert(position, claim); + } + } + setups.insert(*setup_ref, bytes); + } + + Ok(Evidence::acquired( + objects, + trader_leaves, + vault_leaves, + setups, + token_policies, + accepted_claims, + )) + } + + /// The pre values of vault `v`'s leaves at the root the operation's core + /// names, into `vault_leaves`, and the vault state they hold. At `R_0` + /// they are the accepted genesis; past it, the generation this device + /// established at the root the core was built on, which must reproduce + /// that root. `None` when they are not in hand. + fn vault_pre( + &self, + preimage: &SettlementPreimage, + vault_id: &D32, + keys: &BTreeSet, + vault_leaves: &mut VaultLeaves, + ) -> Result, VerifierFailure> { + let Some(pre_root) = preimage + .dlv_cores() + .iter() + .find(|core| core.vault_id() == vault_id) + .map(|core| *core.pre_root()) + else { + return Ok(None); + }; + let genesis = match self.vault_genesis(vault_id)? { + VaultGenesis::Accepted(genesis) => genesis, + VaultGenesis::NotPublished | VaultGenesis::OwnerUnresolved(..) => return Ok(None), + VaultGenesis::Refused(why) => { + return Err(VerifierFailure::Refused(format!( + "vault {} genesis refused: {why}", + short_id(vault_id) + ))) + } + }; + if genesis_root(vault_id, genesis.state()).ok() == Some(pre_root) { + vault_leaves.extend(vault_leaves_at_genesis(vault_id, genesis.state(), keys)); + return Ok(Some(genesis.state().clone())); + } + let Some(leaves) = self.reads.vault_leaves_at(vault_id, &pre_root, keys)? else { + return Ok(None); + }; + let state_key = derive::vault_state_key(vault_id); + let Some(VaultLeafPre::State(state)) = leaves.get(&(*vault_id, state_key)).cloned() else { + return Ok(None); + }; + vault_leaves.extend(leaves); + Ok(Some(state)) + } + + /// Vault `v`'s genesis as this verifier accepts it: every preimage + /// published under `vault_genesis_locator(v)` that recognizes to `v`, + /// bound to the owner's creation as a walk of the owner's lineage + /// validates it. + /// + /// Every candidate is tried, so a preimage appended first by anyone else + /// cannot stand in front of the owner's: only the bytes the owner's + /// creation carried are accepted. Every candidate names the same owner + /// and `p_create`, because `v` derives from them. + pub fn vault_genesis(&self, vault_id: &D32) -> Result { + // A candidate the scan could not establish may be the owner's + // genesis, so only a complete scan says it is not published + // (storage §4). + let (candidates, complete) = match self.reads.vault_genesis_candidates(vault_id)? { + Discovered::Complete(candidates) => (candidates, true), + Discovered::Partial(candidates) => (candidates, false), + }; + let not_published = || { + if complete { + Ok(VaultGenesis::NotPublished) + } else { + Err(VerifierFailure::Read( + "vault genesis: the locator scan did not establish every candidate".to_string(), + )) + } + }; + let Some((first, ..)) = candidates.first() else { + return not_published(); + }; + let owner = match self.reads.vault_owner( + &first.owner_genesis, + &first.owner_device_id, + first.create_position, + ) { + Ok(owner) => owner, + Err(PeerLineageFailure::Incomplete(why)) => { + return Err(VerifierFailure::Read(format!("vault owner lineage: {why}"))) + } + Err(PeerLineageFailure::Unresolved(why)) => { + return Ok(VaultGenesis::OwnerUnresolved(why)) + } + Err(PeerLineageFailure::Invalid(why) | PeerLineageFailure::Quarantined(why)) => { + return Ok(VaultGenesis::Refused(format!("the owner's lineage: {why}"))) + } + }; + let mut refused = None; + for (.., bytes) in &candidates { + match self.accept_with_policies(bytes, &owner)? { + Ok(accepted) => return Ok(VaultGenesis::Accepted(Box::new(accepted))), + Err(GenesisInvalid::NotTheCreationTheOwnerMade) => {} + Err(why) => refused = Some(why), + } + } + match refused { + Some(why) => Ok(VaultGenesis::Refused(format!("{why:?}"))), + None => not_published(), + } + } + + /// `GenesisAccepted` over one candidate, reading the token policies the + /// predicate names as missing. It consults at most the two tokens of the + /// market, and a policy it names again after it was supplied is not the + /// committed one. + fn accept_with_policies( + &self, + bytes: &[u8], + owner: &ValidatedPeerTransition, + ) -> Result, VerifierFailure> { + let mut policies = BTreeMap::new(); + loop { + match genesis_accepted(self.network_id, bytes, owner, &policies) { + Ok(accepted) => return Ok(Ok(accepted)), + Err(GenesisRefusal::Invalid(why)) => return Ok(Err(why)), + Err(GenesisRefusal::Missing(GenesisMissing::TokenPolicy { commit })) => { + if policies.contains_key(&commit) { + return Err(VerifierFailure::Refused( + "vault token policy: the rooted bytes are not the committed policy" + .to_string(), + )); + } + let policy = self.reads.token_policy_bytes(&commit).map_err(|failure| { + VerifierFailure::Read(format!("vault token policy: {failure}")) + })?; + policies.insert(commit, policy); + } + } + } + } + + // ── the chain (§30, R14) ───────────────────────────────────────────── + + /// The canonical chain of `vault_id`, extended as far as the committed + /// set and the budget allow, recording each generation it establishes. + /// + /// The chain starts where every chain starts, at the accepted genesis + /// (§30 step 1), read from the network every time: the generations this + /// device recorded before are its own memo, anchored at that genesis and + /// linked one to the next ([`VaultChain::from_recorded`]) before they + /// are stood on. Past the memo the chain grows by one consumption at a + /// time — the exercise the walk classifies `Consumed` at the head, and + /// the post state `vault_post_states` recomputes from it. + pub fn chain(&self, vault_id: &D32) -> Result { + self.chain_to_depth(*vault_id, SIBLING_DEPTH) + } + + fn chain_to_depth(&self, vault_id: D32, depth: usize) -> Result { + let genesis = match self.vault_genesis(&vault_id)? { + VaultGenesis::Accepted(genesis) => *genesis, + // Not established yet. The chain is empty, and an empty chain + // refutes nothing. + VaultGenesis::NotPublished | VaultGenesis::OwnerUnresolved(..) => { + return Ok(VaultChain::default()) + } + VaultGenesis::Refused(why) => { + return Err(VerifierFailure::Refused(format!( + "chain: vault {} genesis refused: {why}", + short_id(&vault_id) + ))) + } + }; + let recorded: Vec = self + .reads + .recorded_generations(&vault_id)? + .into_iter() + .map(|row| RecordedGeneration { + generation: row.generation, + root: row.root, + pre_root: row.pre_root, + consumed_by: row.consumed_by, + }) + .collect(); + let mut chain = VaultChain::from_recorded(&genesis, &recorded).map_err(|e| { + VerifierFailure::Refused(format!( + "chain: this device's record of vault {}: {e}", + short_id(&vault_id) + )) + })?; + // What the resolver is told while this chain is being extended: + // this vault's chain SO FAR, plus any sibling chain a multi-leg + // consumption forced us to establish. The chain so far is not an + // assumption — it is the induction, from a genesis nobody + // resolved through one realized consumption per step. + let mut chains: BTreeMap = BTreeMap::new(); + chains.insert(vault_id, chain.clone()); + let mut extended = 0; + while extended < GENERATION_BUDGET { + extended += 1; + // The chain is non-empty here, but say so in the type rather + // than in a panic: an empty chain means nothing was + // established, which is a stop, never a crash. + let Some((.., current)) = chain.head() else { + break; + }; + let Some(post) = self.next_generation(&vault_id, ¤t, &mut chains, depth)? else { + break; + }; + self.reads.record_generation(&post)?; + chain.extend(&post).map_err(|e| { + VerifierFailure::Refused(format!( + "chain: the consumption does not extend the chain: {e}" + )) + })?; + chains.insert(vault_id, chain.clone()); + } + Ok(chain) + } + + /// The post state of the exercise that consumed `current`, or `None` when + /// nothing has consumed it yet, nothing could be read, or the consumption + /// cannot be recomputed. Every `None` stops the chain WITHOUT refuting + /// anything. + fn next_generation( + &self, + vault_id: &D32, + current: &D32, + chains: &mut BTreeMap, + depth: usize, + ) -> Result, VerifierFailure> { + // Two passes at most. The first can stall because a SIBLING leg's + // parent is not established yet, which is not a fact about this + // vault; the second runs once those chains have been walked. + for pass in 0..2 { + let walked = self.walk_chain( + &*chains, + *vault_id, + *current, + 0, + WALK_BUDGET, + CHAIN_DEPTH, + BTreeMap::new(), + )?; + match walked.outcome { + WalkOutcome::Consumed { .. } => { + let Some(exercise) = walked.consumed else { + return Ok(None); + }; + return self.post_state_of(vault_id, current, &exercise); + } + WalkOutcome::Unresolved { attempt } if pass == 0 && depth > 0 => { + if !self.establish_siblings(vault_id, current, attempt, chains, depth)? { + return Ok(None); + } + } + WalkOutcome::Unresolved { .. } + | WalkOutcome::CounterExhausted { .. } + | WalkOutcome::Continue { .. } => { + if let Some(why) = walked.not_established { + log::info!("[sofi chain] the walk stopped short: {why:?}"); + } + return Ok(None); + } + } + } + Ok(None) + } + + /// Recompute what `exercise` did to this vault. The post root is the + /// fold's, over the pre state the evidence holds — never the value the + /// producer wrote into `V°`, which is what `vault_post_states` checks. + fn post_state_of( + &self, + vault_id: &D32, + current: &D32, + exercise: &RecognizedExercise, + ) -> Result, VerifierFailure> { + let precommit = &exercise.precommit.body; + let evidence = match self.acquire_evidence(precommit, &exercise.preimage)? { + Acquired::Complete(evidence) => evidence, + Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { + log::info!("[sofi chain] a consumption's evidence is not in hand: {missing:?}"); + return Ok(None); + } + }; + // The evidence this verifier holds may not let it recompute the + // consumption. The chain then stops; nothing is refuted. + match vault_post_states(precommit, &exercise.preimage, &evidence) { + Ok(posts) => Ok(posts + .into_iter() + .find(|p| p.vault_id() == vault_id && p.pre_root() == current)), + Err(refusal) => { + log::info!("[sofi chain] the consumption is not recomputable: {refusal:?}"); + Ok(None) + } + } + } + + /// Establish the parents of the OTHER legs of whatever sits at this key, + /// so a multi-leg consumption can be classified. Returns whether anything + /// new was established — if nothing was, retrying the walk would read the + /// same cells and reach the same answer. + fn establish_siblings( + &self, + vault_id: &D32, + current: &D32, + attempt: u64, + chains: &mut BTreeMap, + depth: usize, + ) -> Result { + let exercise = match self.read_attempt_cell(vault_id, current, attempt)? { + Ok(read) => read.into_exercise(), + Err(missing) => { + log::info!("[sofi chain] attempt {attempt} is not decided yet: {missing:?}"); + None + } + }; + let Some(exercise) = exercise else { + return Ok(false); + }; + let mut learned = false; + for leg in exercise.precommit.body.legs() { + if leg.vault_id == *vault_id || chains.contains_key(&leg.vault_id) { + continue; + } + // POSITIVE evidence only for the RETRY decision: a sibling chain + // that names the parent is new information and the walk is worth + // repeating; one that does not name it changes no answer, so + // repeating would read the same cells and stall the same way. + // The chain is handed over either way — the resolver, not this + // loop, decides what it means. + let sibling = self.chain_to_depth(leg.vault_id, depth - 1)?; + learned |= sibling.names(&leg.parent_root); + chains.insert(leg.vault_id, sibling); + } + Ok(learned) + } + + // ── resolution (§31 stage 9, R12) ──────────────────────────────────── + + /// Section 30, step 2: walk the attempt keys of `vault_id` at + /// `parent_root` from `cursor`, reading each cell, establishing what is + /// known about the exercise it holds and classifying it. A skipped key + /// moves on, a consumed key stops with its exercise, anything else is + /// unresolved; a spent budget hands back a cursor. `chains` is what this + /// verifier established for the vaults the walked exercises name. + pub fn walk_parent( + &self, + chains: &BTreeMap, + vault_id: &D32, + parent_root: &D32, + cursor: u64, + budget: usize, + ) -> Result { + self.walk_chain( + chains, + *vault_id, + *parent_root, + cursor, + budget, + CHAIN_DEPTH, + BTreeMap::new(), + ) + } + + /// Resume a walk whose budget ran out (`WalkOutcome::Continue`) at its + /// cursor, over everything it already classified: the answer is the same + /// as one longer walk's, and a key reached this way still has every + /// earlier key behind it for its liveness. + pub fn continue_walk( + &self, + chains: &BTreeMap, + previous: Walked, + budget: usize, + ) -> Result { + let cursor = match previous.outcome { + WalkOutcome::Continue { cursor } => cursor, + WalkOutcome::Consumed { attempt } + | WalkOutcome::Unresolved { attempt } + | WalkOutcome::CounterExhausted { attempt } => attempt, + }; + self.walk_chain( + chains, + *previous.walk.vault_id(), + *previous.walk.parent_root(), + cursor, + budget, + CHAIN_DEPTH, + previous.known, + ) + } + + /// Stage 9 of §31: what this verifier establishes about the trader's own + /// exercise, read back from a leg's cell (`read_attempt_cell`) — how the + /// device finds its own exercise again after a restart (R13) — over the + /// registration of its position, which the caller read to find that + /// exercise. A refuted exercise reads nothing more: its registration is + /// the one fact the ladder asks of it (§24 step 0), and it is in hand. + /// The ladder runs inside `advance_resolved`, over what is returned here. + pub fn establish_own( + &self, + chains: &BTreeMap, + recognized: &RecognizedExercise, + registration: &RegistrationRead, + ) -> Result, VerifierFailure> { + Ok( + match self.facts_of(chains, recognized, None, CHAIN_DEPTH, Some(registration))? { + Ok(Known::Facts(facts)) => Ok(Established::Facts(facts)), + Ok(Known::RefutedInHand(refutation)) => { + Established::refuted(recognized, &refutation, registration) + } + Err(why) => Err(why), + }, + ) + } + + #[allow(clippy::too_many_arguments)] + fn walk_chain( + &self, + chains: &BTreeMap, + vault_id: D32, + parent_root: D32, + cursor: u64, + budget: usize, + depth: usize, + mut known: BTreeMap, + ) -> Result { + // The walk asks for keys in order and stops on the first it cannot + // classify; a key it asks for that is not read yet is read, and the + // walk resumes. The chunking is invisible to the answer. + loop { + let walked = walk(&vault_id, &parent_root, cursor, budget, |attempt| { + known.get(&attempt).and_then(KeyKnown::key_facts) + }); + let outcome = walked.outcome(); + let done = |consumed, not_established, known| Walked { + outcome, + walk: walked, + consumed, + not_established, + known, + }; + match outcome { + WalkOutcome::Unresolved { attempt } if !known.contains_key(&attempt) => { + let read = match self.read_attempt_cell(&vault_id, &parent_root, attempt)? { + Ok(read) => read, + Err(missing) => { + return Ok(done( + None, + Some(NotEstablished::AttemptCell { + vault_id, + attempt, + missing, + }), + known, + )) + } + }; + // An open key is unresolved, never a skip: no key is ever + // dead. + let Some(exercise) = read.exercise().cloned() else { + return Ok(done(None, None, known)); + }; + // The walk reached this key by skipping every one before + // it. That liveness is stated as the walk over them, for + // the facts to read, never as a flag. + let reached = walk( + &vault_id, + &parent_root, + 0, + usize::try_from(attempt).unwrap_or(usize::MAX), + |a| known.get(&a).and_then(KeyKnown::key_facts), + ); + let key = WalkedKey { + read: &read, + reached: &reached, + }; + match self.facts_of(chains, &exercise, Some(key), depth, None)? { + Ok(facts) => { + known.insert(attempt, KeyKnown { read, known: facts }); + } + Err(why) => return Ok(done(None, Some(why), known)), + } + } + WalkOutcome::Consumed { attempt } => { + let consumed = known + .get(&attempt) + .and_then(|key| key.read.exercise().cloned()); + return Ok(done(consumed, None, known)); + } + WalkOutcome::Unresolved { .. } + | WalkOutcome::CounterExhausted { .. } + | WalkOutcome::Continue { .. } => return Ok(done(None, None, known)), + } + } + } + + /// What is known about one exercise: refuted by its own bytes, or the + /// complete facts established over the reads made here. `walked` is the + /// key the exercise was found at, whose cell is in hand and whose + /// liveness the walk established by reaching it. `registration` is the + /// position's registration when the caller already read it; otherwise it + /// is read here. + fn facts_of( + &self, + chains: &BTreeMap, + exercise: &RecognizedExercise, + walked: Option>, + depth: usize, + registration: Option<&RegistrationRead>, + ) -> Result, VerifierFailure> { + if let Some(refutation) = refuted_in_hand(exercise) { + log::info!( + "[sofi verifier] the exercise is refuted in hand: {:?}", + refutation.refuted() + ); + return Ok(Ok(Known::RefutedInHand(refutation))); + } + let precommit = &exercise.precommit.body; + let fulfillment = &exercise.fulfillment.body; + + // Registration from the position pair (R10). The pair decides for + // every F at q at once. + let registration = match registration { + Some(registration) => registration.clone(), + None => match self.read_registration( + precommit.genesis(), + precommit.device_id(), + fulfillment.position(), + precommit.void_root(), + )? { + Ok(registration) => registration, + Err(missing) => return Ok(Err(NotEstablished::Registration(missing))), + }, + }; + + // What FulfillmentConformance reads (R7), the exercise supplying the + // objects only its trader held. + let own: BTreeMap> = exercise + .preimage + .settlement() + .closure() + .refs() + .iter() + .copied() + .zip(exercise.closure.iter().cloned()) + .collect(); + let objects = ExerciseObjects { + precommit, + precommit_signature: &exercise.precommit.signature, + preimage: &exercise.preimage, + fulfillment, + fulfillment_signature: &exercise.fulfillment.signature, + own_objects: &own, + }; + let conformance = match self.acquire_conformance_evidence(&objects)? { + Acquired::Complete(evidence) => evidence, + Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { + return Ok(Err(NotEstablished::ConformanceEvidence(missing))) + } + }; + + // What RouteValidation reads (R5). + let evidence = match self.acquire_evidence(precommit, &exercise.preimage)? { + Acquired::Complete(evidence) => evidence, + Acquired::Exhausted(missing) => return Ok(Err(NotEstablished::RouteEvidence(missing))), + Acquired::NoSource(missing) => { + return Ok(Err(NotEstablished::RouteEvidenceHasNoSource(missing))) + } + }; + + // Every leg of P at the attempt F fixed for it: its cell, and the + // walk over the earlier keys of its chain when its attempt is above + // zero. The attempts cover the legs exactly: that is conformance + // item 4, decided in hand. + let mut cells = Vec::with_capacity(precommit.legs().len()); + let mut walks: Vec> = Vec::with_capacity(precommit.legs().len()); + for leg in precommit.legs() { + let attempt = fulfillment + .attempts() + .iter() + .find(|a| a.vault_id == leg.vault_id) + .map(|a| a.attempt) + .ok_or_else(|| { + VerifierFailure::Refused( + "resolve: F names no attempt for a leg of P, past its in-hand check" + .to_string(), + ) + })?; + let at_walked_key = walked.filter(|k| { + *k.read.vault_id() == leg.vault_id + && *k.read.parent_root() == leg.parent_root + && k.read.attempt() == attempt + }); + let (cell, walk) = match at_walked_key { + Some(key) => (key.read.clone(), Some(*key.reached)), + None => { + let cell = + match self.read_attempt_cell(&leg.vault_id, &leg.parent_root, attempt)? { + Ok(read) => read, + Err(missing) => { + return Ok(Err(NotEstablished::AttemptCell { + vault_id: leg.vault_id, + attempt, + missing, + })) + } + }; + // `AttemptLive`: every earlier key of this leg's chain is + // skipped, established by walking them. + let walk = if attempt == 0 { + None + } else { + let not_live = NotEstablished::AttemptLiveness { + vault_id: leg.vault_id, + attempt, + }; + let Some(below) = depth.checked_sub(1) else { + return Ok(Err(not_live)); + }; + let earlier = + usize::try_from(attempt).map_or(WALK_BUDGET, |a| a.min(WALK_BUDGET)); + let chain = self.walk_chain( + chains, + leg.vault_id, + leg.parent_root, + 0, + earlier, + below, + BTreeMap::new(), + )?; + if let Some(why) = chain.not_established { + return Ok(Err(why)); + } + Some(chain.walk) + }; + (cell, walk) + } + }; + cells.push(cell); + walks.push(walk); + } + let legs: Vec> = precommit + .legs() + .iter() + .zip(cells.iter().zip(walks.iter())) + .map(|(leg, (cell, walk))| LegReads { + cell, + chain: chains.get(&leg.vault_id), + walk: walk.as_ref(), + }) + .collect(); + let reads = ExerciseReads { + exercise, + registration: ®istration, + conformance: &conformance, + evidence: &evidence, + parent: self.parent, + legs: &legs, + }; + Ok(establish(&reads).map(|facts| Known::Facts(Box::new(facts)))) + } +} diff --git a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs index 45bb48c75..aca4385f6 100644 --- a/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs +++ b/dsm_client/deterministic_state_machine/dsm/src/sofi/validation.rs @@ -632,6 +632,9 @@ pub struct VaultPostState { /// trader's leaf in THIS vault's tree, which is not the trader's own /// relationship leaf in its own tree. relationship: Option<(D32, VaultRelationshipLeaf)>, + /// `E` of the operation that consumed `pre_root`: what a recorded + /// generation names as the consumption that produced it. + consumed_by: D32, } impl VaultPostState { @@ -639,6 +642,12 @@ impl VaultPostState { &self.vault_id } + /// `E` of the operation whose consumption of `pre_root` produced this + /// state. + pub fn consumed_by(&self) -> &D32 { + &self.consumed_by + } + /// `R_g`, the root the operation was built on. pub fn pre_root(&self) -> &D32 { &self.pre_root @@ -754,6 +763,7 @@ pub fn vault_post_states( root, state: post_state, relationship, + consumed_by: e, }); } Ok(out) diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs index 1619b5eb8..6fab2a103 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/handlers/node_e2e_tests.rs @@ -18,6 +18,7 @@ use dsm::sofi::derive; use dsm::sofi::exercise::{recognize_exercise, RecognizedExercise}; use dsm::sofi::publication::Publication; use dsm::sofi::resolution::WalkOutcome; +use dsm::sofi::resolve::{LocalLeaves, WALK_BUDGET}; use dsm::sofi::wire::{ AttemptEntry, DlvPolicyFulfillmentBody, PrecommitLeg, SofiExercise, TraderFulfillmentBody, TraderPrecommitBody, @@ -29,11 +30,9 @@ use serial_test::serial; use crate::bridge::{AppInvoke, AppQuery, AppResult, AppRouter as _}; use crate::economic_fixtures::NETWORK; -use crate::sdk::sofi_chain::ChainWalker; -use crate::sdk::sofi_evidence::LocalLeaves; -use crate::sdk::sofi_exercise::{attempt_cell, read_attempt_cell, write_exercise}; +use crate::sdk::sofi_exercise::{attempt_cell, write_exercise}; +use crate::sdk::sofi_reads::{local_leaves_of_validated, VerifierContext}; use crate::sdk::sofi_register::position_cells; -use crate::sdk::sofi_resolve::{Resolver, WALK_BUDGET}; use crate::sdk::storage_set::canonical_set; use crate::storage::client_db::economic_lineage; use crate::test_support::two_device::{Pair, TestDevice}; @@ -366,7 +365,7 @@ fn standing_of(d: &TestDevice) -> (LocalLeaves, Option let validated = ValidatedEconomicRoot::rehydrate_from_admitted_store(admitted) .expect("a resolved predecessor"); let local = - LocalLeaves::of_validated(&d.genesis, &d.device_id, &validated).expect("own leaves"); + local_leaves_of_validated(&d.genesis, &d.device_id, &validated).expect("own leaves"); // The position this device resolved itself, for Core to read what it // selected when a P names it as its parent. let parent = @@ -577,20 +576,15 @@ async fn a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_byte // The vault's chain as B established it: the genesis root and the // generation B's trade produced. let (local, parents) = standing_of(&p.b); - let chain = ChainWalker { - set: &set, - local: &local, - parent: parents.as_ref(), - } - .chain(&m.vault_id) - .await - .expect("the vault's chain"); + let ctx = VerifierContext::new(&set, Some(&local), parents.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let chain = verifier.chain(&m.vault_id).expect("the vault's chain"); assert_eq!(chain.roots().len(), 2, "genesis and one consumption"); let (r0, r1) = (chain.roots()[0], chain.roots()[1]); // B's exercise, read back from the key it consumed, and re-aimed. - let honest = read_attempt_cell(&set, &m.vault_id, &r0, 0) - .await + let honest = verifier + .read_attempt_cell(&m.vault_id, &r0, 0) .expect("read") .expect("decided") .into_exercise() @@ -620,8 +614,8 @@ async fn a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_byte .await .expect("any party may write an exercise"); assert!(writes.iter().all(|w| w.reached_leader), "{writes:?}"); - let held = read_attempt_cell(&set, &m.vault_id, &r1, 0) - .await + let held = verifier + .read_attempt_cell(&m.vault_id, &r1, 0) .expect("read") .expect("decided"); assert_eq!( @@ -638,15 +632,8 @@ async fn a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_byte node.forget_requests(); } let chains = BTreeMap::from([(m.vault_id, chain)]); - let resolver = Resolver { - set: &set, - local: &local, - parent: parents.as_ref(), - chains: &chains, - }; - let walked = resolver - .walk_parent(&m.vault_id, &r1, 0, WALK_BUDGET) - .await + let walked = verifier + .walk_parent(&chains, &m.vault_id, &r1, 0, WALK_BUDGET) .expect("the walk"); assert_eq!(walked.outcome, WalkOutcome::Unresolved { attempt: 1 }); assert_eq!(walked.not_established, None); @@ -688,8 +675,8 @@ async fn a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_byte // The next trade takes the next key, and the vault prices it at the // reserves B's first trade left. let q2 = realized_trade(&p, &m, 10).await; - let next = read_attempt_cell(&set, &m.vault_id, &r1, 1) - .await + let next = verifier + .read_attempt_cell(&m.vault_id, &r1, 1) .expect("read") .expect("decided") .into_exercise() @@ -717,18 +704,13 @@ async fn an_unsigned_exercise_at_a_successor_key_takes_nothing() { realized_trade(&p, &m, 10).await; let set = canonical_set(NETWORK).expect("the pinned set"); let (local, parents) = standing_of(&p.b); - let chain = ChainWalker { - set: &set, - local: &local, - parent: parents.as_ref(), - } - .chain(&m.vault_id) - .await - .expect("the vault's chain"); + let ctx = VerifierContext::new(&set, Some(&local), parents.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let chain = verifier.chain(&m.vault_id).expect("the vault's chain"); assert_eq!(chain.roots().len(), 2, "genesis and one consumption"); let (r0, r1) = (chain.roots()[0], chain.roots()[1]); - let honest = read_attempt_cell(&set, &m.vault_id, &r0, 0) - .await + let honest = verifier + .read_attempt_cell(&m.vault_id, &r0, 0) .expect("read") .expect("decided") .into_exercise() @@ -741,15 +723,15 @@ async fn an_unsigned_exercise_at_a_successor_key_takes_nothing() { .await .expect("the nodes keep whatever they are given"); assert!(write.reached_leader()); - let read = read_attempt_cell(&set, &m.vault_id, &r1, 0) - .await + let read = verifier + .read_attempt_cell(&m.vault_id, &r1, 0) .expect("read") .expect("decided"); assert_eq!(read.fact(), CellFact::Open, "unsigned bytes hold nothing"); let q2 = realized_trade(&p, &m, 10).await; - let second = read_attempt_cell(&set, &m.vault_id, &r1, 0) - .await + let second = verifier + .read_attempt_cell(&m.vault_id, &r1, 0) .expect("read") .expect("decided") .into_exercise() @@ -791,14 +773,9 @@ async fn a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lan .expect("B's next position pair"); let pair_leader = member_name(pair.fulfillment().route().leader()); let (local, parents) = standing_of(&p.b); - let chain = ChainWalker { - set: &set, - local: &local, - parent: parents.as_ref(), - } - .chain(&m.vault_id) - .await - .expect("the vault's chain"); + let ctx = VerifierContext::new(&set, Some(&local), parents.as_ref()).expect("a verifier"); + let verifier = ctx.verifier(); + let chain = verifier.chain(&m.vault_id).expect("the vault's chain"); assert_eq!(chain.roots().len(), 1, "the vault is at its genesis"); let attempt = attempt_cell(&set, &m.vault_id, &chain.roots()[0], 0).expect("the first attempt key"); diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs index c849804b7..edda72f8f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/mod.rs @@ -27,7 +27,6 @@ pub mod kv; pub mod native_reserve; pub mod runtime_config; pub mod sdk_context; -pub mod sofi_evidence; // Re-export SdkContext for convenient access pub use sdk_context::SdkContext; @@ -47,13 +46,12 @@ pub mod kyber_identity; // ML-KEM identity binding for online contact establishm pub mod session_manager; // Native-first session state projection pub mod signing_authority; pub mod sofi_advance; -pub mod sofi_chain; /// SoFi v8 producers: setup, vault creation, trade, route and close. pub mod sofi_exercise; pub mod sofi_publish; +pub mod sofi_reads; pub mod sofi_register; pub mod sofi_relay; -pub mod sofi_resolve; pub mod sofi_sdk; pub mod tls_transport_sdk; pub mod token_sdk; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs index 173301bb0..37988fd04 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/route_seats.rs @@ -311,27 +311,6 @@ fn recorded( } } -/// The value a Core reading of `evidence` names by `id`: the exact bytes a -/// seat's copy carries, whose entry digest is `id`. A seat's log holds the -/// cell's route entries; the value is the one an entry carries, never the -/// entry's own bytes. -pub(crate) fn value_of(evidence: &CellEvidence, id: &[u8; 32]) -> Option> { - carried_values(evidence).find(|value| dsm::storage_cell::entry_digest(value) == *id) -} - -/// Every value the cell's copies carry, seat by seat in route order and in -/// each seat's arrival order: the bytes a recognizer is shown. What does not -/// decode as a route entry carries nothing. -pub(crate) fn carried_values(evidence: &CellEvidence) -> impl Iterator> + '_ { - evidence - .seats - .iter() - .filter_map(|seat| seat.values.as_ref()) - .flatten() - .filter_map(|bytes| dsm::route_chain::RouteEntry::decode(bytes)) - .map(|entry| entry.value) -} - /// Keep the completion proof of the value final at `cell` (storage spec §9 /// rule 11), keyed by the cell and the value it proves. pub(crate) fn keep_completion( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs index 387aa3081..48a12e1fb 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_advance.rs @@ -37,6 +37,7 @@ use dsm::sofi::lineage::{advance_resolved, descendant_fence, AdvanceError}; use dsm::sofi::publication::Publication; use dsm::sofi::registration::Registration; use dsm::sofi::resolution::{PositionEffect, Resolution}; +use dsm::sofi::resolve::{value_of, Acquired, LocalLeaves}; use dsm::sofi::storage::Resolved; use dsm::sofi::validation::{trader_post_states, vault_post_states}; use dsm::sofi::wire::{ @@ -49,16 +50,11 @@ use dsm::types::operations::Operation; use crate::sdk::core_sdk::CoreSDK; use crate::sdk::economic_admission_flow::validated_root_or_activate; -use crate::sdk::route_seats::{read_cell, value_of, NodeSeats}; -use crate::sdk::sofi_evidence::{Acquired, LocalLeaves}; -use crate::sdk::sofi_exercise::{build_exercise, read_attempt_cell, write_exercise, LegWrite}; +use crate::sdk::route_seats::{read_cell, NodeSeats}; +use crate::sdk::sofi_exercise::{build_exercise, write_exercise, LegWrite}; use crate::sdk::sofi_publish::{fetch_fulfillment, fetch_precommit, fetch_preimage}; -use crate::sdk::sofi_chain::ChainWalker; -use crate::sdk::sofi_register::{ - acquire_conformance_evidence, install_fulfillment, position_cells, read_registration, - InstallRequest, Installed, -}; -use crate::sdk::sofi_resolve::Resolver; +use crate::sdk::sofi_reads::{local_leaves_of_validated, verifier_error, VerifierContext}; +use crate::sdk::sofi_register::{install_fulfillment, position_cells, InstallRequest, Installed}; use crate::sdk::storage_set::StorageSet; use dsm::sofi::resolution::{Incomplete, VaultChain}; use crate::storage::client_db::economic_lineage; @@ -226,7 +222,12 @@ pub async fn fulfill( &set.id(), None, )?; - let installed = install_pair(set, request).await?; + let admitted = economic_lineage::get_admitted() + .map_err(|e| storage("load admitted", e))? + .ok_or_else(|| refuse("no admitted predecessor for the fulfillment"))?; + let standing = OwnStanding::of(core, admitted, &validated)?; + let ctx = standing.context(set)?; + let installed = install_pair(&ctx, set, request).await?; Ok(Fulfilled { position: q, fulfillment_id, @@ -245,13 +246,39 @@ fn install_request<'a>(request: &FulfillRequest<'a>) -> InstallRequest<'a> { } } +/// This device as the verifier of its own position: its leaves at its +/// validated root, and the position it resolved itself. +struct OwnStanding { + local: LocalLeaves, + admitted: AdmittedEconomicPosition, +} + +impl OwnStanding { + fn of( + core: &CoreSDK, + admitted: AdmittedEconomicPosition, + validated: &ValidatedEconomicRoot, + ) -> Result { + let head = core + .device_head() + .ok_or_else(|| storage("device head", "none"))?; + let local = local_leaves_of_validated(&head.genesis_digest(), &head.devid(), validated)?; + Ok(Self { local, admitted }) + } + + fn context<'a>(&'a self, set: &'a StorageSet) -> Result, DsmError> { + VerifierContext::new(set, Some(&self.local), Some(&self.admitted)) + } +} + /// Stage 7: the pair at `s(q)`, conformance first (R9). Idempotent at the /// members, which keep what they are given. async fn install_pair( + ctx: &VerifierContext<'_>, set: &StorageSet, request: &FulfillRequest<'_>, ) -> Result { - install_fulfillment(set, &install_request(request)) + install_fulfillment(&ctx.verifier(), set, &install_request(request)) .await .map_err(|e| refuse(format!("install: {e:?}"))) } @@ -259,11 +286,16 @@ async fn install_pair( /// Stage 8: the exercise, built over the evidence its conformance was /// decided on, at every leg's key (R11). async fn exercise_legs( + ctx: &VerifierContext<'_>, set: &StorageSet, request: &FulfillRequest<'_>, ) -> Result, DsmError> { let install = install_request(request); - let evidence = match acquire_conformance_evidence(set, &install).await? { + let evidence = match ctx + .verifier() + .acquire_conformance_evidence(&install.objects()) + .map_err(verifier_error)? + { Acquired::Complete(evidence) => evidence, Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { return Err(storage( @@ -450,8 +482,20 @@ pub async fn complete_pending_fulfillment( fulfillment_signature: &fulfillment.signature, own_objects: &own, }; - let installed = install_pair(set, &request).await?; - let exercise = exercise_legs(set, &request).await?; + let admitted = economic_lineage::get_admitted() + .map_err(|e| storage("load admitted", e))? + .ok_or_else(|| refuse("no admitted predecessor for the pending position"))?; + // THE FENCE, again on the path that finishes the descendant at q: the + // pending position stands on the root its resolved predecessor selected, + // or it is not finished. + descendant_fence(admitted.predecessor_claim(), &pending.pre_economic_root) + .map_err(|e| refuse(e.to_string()))?; + let validated = ValidatedEconomicRoot::rehydrate_from_admitted_store(admitted) + .map_err(|e| refuse(e.to_string()))?; + let standing = OwnStanding::of(core, admitted, &validated)?; + let ctx = standing.context(set)?; + let installed = install_pair(&ctx, set, &request).await?; + let exercise = exercise_legs(&ctx, set, &request).await?; Ok(Completed { position: pending.economic_position, fulfillment_id: derive::fulfillment_id(&fulfillment.body), @@ -600,11 +644,16 @@ pub async fn resolve_pending_position( // Registration, from the pair (R10). The F at q must be THIS one: the // device only ever writes its own claims at its own positions, so any // other outcome is a local incoherence, not a race to wait out. - let registration = - match read_registration(set, &genesis, &device_id, q, &validated.economic_root()).await? { - Ok(registration) => registration, - Err(missing) => return not_yet(NotResolved::Registration(missing)), - }; + let standing = OwnStanding::of(core, admitted, &validated)?; + let ctx = standing.context(set)?; + let verifier = ctx.verifier(); + let registration = match verifier + .read_registration(&genesis, &device_id, q, &validated.economic_root()) + .map_err(verifier_error)? + { + Ok(registration) => registration, + Err(missing) => return not_yet(NotResolved::Registration(missing)), + }; let fulfillment = match registration.registration() { Registration::Registered(signed) if derive::fulfillment_id(&signed.body) == fulfillment_id => @@ -635,42 +684,38 @@ pub async fn resolve_pending_position( .find(|a| a.vault_id == first.vault_id) .map(|a| a.attempt) .ok_or_else(|| refuse("F names no attempt for P's first leg"))?; - let exercise = - match read_attempt_cell(set, &first.vault_id, &first.parent_root, attempt).await? { - Ok(read) => match read.into_exercise() { - Some(exercise) => exercise, - None => return not_yet(NotResolved::ExerciseNotRead), - }, - Err(missing) => { - log::info!("[sofi advance] the first leg's cell is not decided yet: {missing:?}"); - return not_yet(NotResolved::ExerciseNotRead); - } - }; + let exercise = match verifier + .read_attempt_cell(&first.vault_id, &first.parent_root, attempt) + .map_err(verifier_error)? + { + Ok(read) => match read.into_exercise() { + Some(exercise) => exercise, + None => return not_yet(NotResolved::ExerciseNotRead), + }, + Err(missing) => { + log::info!("[sofi advance] the first leg's cell is not decided yet: {missing:?}"); + return not_yet(NotResolved::ExerciseNotRead); + } + }; // What this verifier brings: its leaves, the position it resolved // itself, and the canonical chain of each vault a leg names — walked // forward from the genesis or from the generations this device already // recorded, so a parent past genesis is decided rather than deferred. - let local = LocalLeaves::of_validated(&genesis, &device_id, &validated)?; let mut chains: BTreeMap = BTreeMap::new(); - let walker = ChainWalker { - set, - local: &local, - parent: Some(&admitted), - }; for leg in precommit.legs() { if chains.contains_key(&leg.vault_id) { continue; } - chains.insert(leg.vault_id, walker.chain(&leg.vault_id).await?); + chains.insert( + leg.vault_id, + verifier.chain(&leg.vault_id).map_err(verifier_error)?, + ); } - let resolver = Resolver { - set, - local: &local, - parent: Some(&admitted), - chains: &chains, - }; - let established = match resolver.establish_own(&exercise, ®istration).await? { + let established = match verifier + .establish_own(&chains, &exercise, ®istration) + .map_err(verifier_error)? + { Ok(established) => established, Err(why) => return not_yet(NotResolved::Facts(why)), }; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_chain.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_chain.rs deleted file mode 100644 index 50db060a2..000000000 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_chain.rs +++ /dev/null @@ -1,315 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! The forward walk over successor cells (Section 30, rebuild step R14): the -//! producer of [`ParentStatus::Orphaned`]. -//! -//! Every other fact a leg needs had a producer before this module. Its parent -//! did not: the resolver mapped the `(vault, root)` pairs it happened to walk -//! to onto `Canonical`, and everything else onto `Unavailable`, so a route -//! built on a refuted parent waited forever instead of being defeated. The -//! fact that decides it is `R*_g` — the canonical root of that vault at that -//! GENERATION — and that is what this module establishes. -//! -//! # The chain, and why it is walked forward -//! -//! A vault's lineage is a chain, not a set. It starts at the accepted genesis -//! and advances exactly one step per realized consumption: -//! -//! ```text -//! R*_0 --consumed by X_0--> R*_1 --consumed by X_1--> R*_2 ... -//! ``` -//! -//! So the chain is walked by finding, at each root, the exercise that consumed -//! it, and recomputing what that exercise did to the vault. Both halves are -//! borrowed rather than reinvented: `Resolver::walk_parent` classifies the -//! attempt keys with Core's own ladder, and `vault_post_states` recomputes the -//! post state from the pre state and the settlement's terms rather than -//! reading back what the producer stated. -//! -//! # Absence never refutes -//! -//! The rule this module does NOT implement is worth naming, because it is the -//! obvious one and it is wrong: *a root the chain does not name is orphaned.* -//! A head is open precisely so the NEXT root can still arrive, so a root the -//! chain does not name may be one an in-flight operation is about to realize. -//! Refuting it answers `Orphaned`, a `RouteImpossible` arm, hence a permanent -//! `Void` on a route whose only defect is that this verifier looked early. -//! -//! Only a DIFFERENT root at the SAME generation refutes, which is -//! [`VaultChain::status_of`] over `dsm::sofi::resolution::parent_status`. That -//! refutation is permanent without any argument of this module's own: a -//! successor cell admits at most one realized consumption per attempt key -//! (`OneConsumerPerParent`, model-checked across crash and recover), so `R*_g` -//! is unique and never changes once established. -//! -//! # It records what it establishes -//! -//! Each generation is written to the vault head store through `record_walked` -//! — the same writer the resolved path uses, because the walk establishes a -//! generation by the same predicate, and because the NEXT walk must not -//! re-derive what this one proved. That is also what lets `acquire_evidence` -//! serve a vault this device never traded with: the walk reconstructs the leaf -//! set generation by generation, and the leaves are what a later acquisition -//! consumes. -use std::collections::BTreeMap; -use std::future::Future; -use std::pin::Pin; - -use dsm::economic::lineage::AdmittedEconomicPosition; -use dsm::sofi::exercise::RecognizedExercise; -use dsm::sofi::resolution::{VaultChain, WalkOutcome}; -use dsm::sofi::validation::{vault_post_states, VaultPostState}; -use dsm::types::error::DsmError; - -use crate::sdk::sofi_evidence::{ - acquire_evidence, fetch_vault_genesis, Acquired, LocalLeaves, VaultGenesis, -}; -use crate::sdk::sofi_exercise::read_attempt_cell; -use crate::sdk::sofi_resolve::{Resolver, WALK_BUDGET}; -use crate::sdk::storage_set::StorageSet; -use crate::storage::client_db::sofi_vault_head; - -type D32 = [u8; 32]; - -/// How many generations one call extends a vault's chain by. A budget only, -/// never a verdict: a chain that stops here is short, not complete, and a -/// generation it did not reach is `Unavailable`. -pub const GENERATION_BUDGET: usize = 16; - -/// How far the walk recurses into OTHER vaults' chains. A multi-leg route can -/// only have consumed this vault's root if every leg's parent was canonical, -/// so establishing this chain can require establishing a sibling's. Beta -/// routes are two hops, so two levels cover them; past this depth a sibling is -/// unestablished, which stops this chain rather than guessing at it. -pub const SIBLING_DEPTH: usize = 2; - -type Fut<'s, T> = Pin> + Send + 's>>; - -/// What the walk brings: the committed set, this verifier's own leaves, and -/// the conditional position it resolved itself — the same three the resolver -/// stands on, because the walk classifies keys with the resolver. -pub struct ChainWalker<'a> { - pub set: &'a StorageSet, - pub local: &'a LocalLeaves, - pub parent: Option<&'a AdmittedEconomicPosition>, -} - -impl ChainWalker<'_> { - /// The canonical chain of `vault_id`, extended as far as the committed set - /// and the budget allow, recording each generation it establishes. - pub async fn chain(&self, vault_id: &D32) -> Result { - self.chain_to_depth(*vault_id, SIBLING_DEPTH).await - } - - fn chain_to_depth(&self, vault_id: D32, depth: usize) -> Fut<'_, VaultChain> { - Box::pin(async move { - let recorded = stored_prefix(&vault_id)?; - let mut chain = if recorded.is_empty() { - // Nothing recorded: the chain starts where every chain starts, - // at the accepted genesis (Section 30, step 1). - match fetch_vault_genesis(self.set, &vault_id).await? { - VaultGenesis::Accepted(genesis) => VaultChain::from_genesis(&genesis), - // Not established yet. The chain is empty, and an empty - // chain refutes nothing. - VaultGenesis::NotPublished | VaultGenesis::OwnerUnresolved(..) => { - return Ok(VaultChain::default()) - } - VaultGenesis::Refused(why) => { - return Err(DsmError::invalid_operation(format!( - "chain: vault {} genesis refused: {why}", - crate::util::text_id::encode_base32_crockford(&vault_id) - ))) - } - } - } else { - // This device's own memo of the generations it established - // before: the one memo puncture of a chain, pinned to this - // call by `ci/sofi_validated_root_constructors.sh`. - VaultChain::from_recorded_generations(recorded) - }; - // What the resolver is told while this chain is being extended: - // this vault's chain SO FAR, plus any sibling chain a multi-leg - // consumption forced us to establish. The chain so far is not an - // assumption — it is the induction, from a genesis nobody - // resolved through one realized consumption per step. - let mut chains: BTreeMap = BTreeMap::new(); - chains.insert(vault_id, chain.clone()); - let mut extended = 0; - while extended < GENERATION_BUDGET { - extended += 1; - // The chain is non-empty here, but say so in the type rather - // than in a panic: an empty chain means nothing was - // established, which is a stop, never a crash. - let Some((.., current)) = chain.head() else { - break; - }; - let Some(post) = self - .next_generation(&vault_id, ¤t, &mut chains, depth) - .await? - else { - break; - }; - sofi_vault_head::record_walked(&post).map_err(|e| { - DsmError::storage( - format!("chain: record generation: {e}"), - None::, - ) - })?; - // Core grows the chain by the consumption it recomputed, from - // the head it was walked at. - chain.extend(&post).map_err(|e| { - DsmError::invalid_operation(format!( - "chain: the consumption does not extend the chain: {e}" - )) - })?; - chains.insert(vault_id, chain.clone()); - } - Ok(chain) - }) - } - - /// The post state of the exercise that consumed `current`, or `None` when - /// nothing has consumed it yet, nothing could be read, or the consumption - /// cannot be recomputed. Every `None` stops the chain WITHOUT refuting - /// anything. - fn next_generation<'s>( - &'s self, - vault_id: &'s D32, - current: &'s D32, - chains: &'s mut BTreeMap, - depth: usize, - ) -> Fut<'s, Option> { - Box::pin(async move { - // Two passes at most. The first can stall because a SIBLING leg's - // parent is not established yet, which is not a fact about this - // vault; the second runs once those chains have been walked. - for pass in 0..2 { - let walked = { - let resolver = Resolver { - set: self.set, - local: self.local, - parent: self.parent, - chains: &*chains, - }; - resolver - .walk_parent(vault_id, current, 0, WALK_BUDGET) - .await? - }; - match walked.outcome { - WalkOutcome::Consumed { .. } => { - let Some(exercise) = walked.consumed else { - return Ok(None); - }; - return self.post_state_of(vault_id, current, &exercise).await; - } - WalkOutcome::Unresolved { attempt } if pass == 0 && depth > 0 => { - if !self - .establish_siblings(vault_id, current, attempt, chains, depth) - .await? - { - return Ok(None); - } - } - WalkOutcome::Unresolved { .. } - | WalkOutcome::CounterExhausted { .. } - | WalkOutcome::Continue { .. } => { - if let Some(why) = walked.not_established { - log::info!("[sofi chain] the walk stopped short: {why:?}"); - } - return Ok(None); - } - } - } - Ok(None) - }) - } - - /// Recompute what `exercise` did to this vault. The post root is the - /// fold's, over the pre state the evidence holds — never the value the - /// producer wrote into `V°`, which is what `vault_post_states` checks. - async fn post_state_of( - &self, - vault_id: &D32, - current: &D32, - exercise: &RecognizedExercise, - ) -> Result, DsmError> { - let precommit = &exercise.precommit.body; - let evidence = - match acquire_evidence(self.set, precommit, &exercise.preimage, self.local).await? { - Acquired::Complete(evidence) => evidence, - Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { - log::info!("[sofi chain] a consumption's evidence is not in hand: {missing:?}"); - return Ok(None); - } - }; - // The evidence this verifier holds may not let it recompute the - // consumption. The chain then stops; nothing is refuted. - match vault_post_states(precommit, &exercise.preimage, &evidence) { - Ok(posts) => Ok(posts - .into_iter() - .find(|p| p.vault_id() == vault_id && p.pre_root() == current)), - Err(refusal) => { - log::info!("[sofi chain] the consumption is not recomputable: {refusal:?}"); - Ok(None) - } - } - } - - /// Establish the parents of the OTHER legs of whatever sits at this key, - /// so a multi-leg consumption can be classified. Returns whether anything - /// new was established — if nothing was, retrying the walk would read the - /// same cells and reach the same answer. - async fn establish_siblings( - &self, - vault_id: &D32, - current: &D32, - attempt: u64, - chains: &mut BTreeMap, - depth: usize, - ) -> Result { - let exercise = match read_attempt_cell(self.set, vault_id, current, attempt).await? { - Ok(read) => read.into_exercise(), - Err(missing) => { - log::info!("[sofi chain] attempt {attempt} is not decided yet: {missing:?}"); - None - } - }; - let Some(exercise) = exercise else { - return Ok(false); - }; - let mut learned = false; - for leg in exercise.precommit.body.legs() { - if leg.vault_id == *vault_id || chains.contains_key(&leg.vault_id) { - continue; - } - // POSITIVE evidence only for the RETRY decision: a sibling chain - // that names the parent is new information and the walk is worth - // repeating; one that does not name it changes no answer, so - // repeating would read the same cells and stall the same way. - // The chain is handed over either way — the resolver, not this - // loop, decides what it means. - let sibling = self.chain_to_depth(leg.vault_id, depth - 1).await?; - learned |= sibling.names(&leg.parent_root); - chains.insert(leg.vault_id, sibling); - } - Ok(learned) - } -} - -/// The recorded roots of a vault, from generation zero, stopping at the first -/// gap. A CONTIGUOUS prefix, because `roots[g]` is read positionally and a gap -/// would silently shift every generation after it. -fn stored_prefix(vault_id: &D32) -> Result, DsmError> { - let mut roots = Vec::new(); - loop { - let generation = u64::try_from(roots.len()).map_err(|e| { - DsmError::storage(format!("chain: generation: {e}"), None::) - })?; - let found = sofi_vault_head::root_at(vault_id, generation).map_err(|e| { - DsmError::storage(format!("chain: stored root: {e}"), None::) - })?; - match found { - Some(root) => roots.push(root), - None => return Ok(roots), - } - } -} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_evidence.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_evidence.rs deleted file mode 100644 index c6eea8d3b..000000000 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_evidence.rs +++ /dev/null @@ -1,589 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -//! Evidence acquisition (rebuild step R5): the only way production code -//! builds an [`Evidence`], from fetched bytes and the verifier's own validated -//! tree, never from a default. -//! -//! What a preimage needs is Core's question ([`EvidenceNeeds::of`]). What -//! each item is, once fetched, is Core's question again — `validate` -//! re-authenticates every object against its address and every leaf against -//! the core that names it. This module fetches: -//! -//! | item | from | -//! |---|---| -//! | trader leaf pre values | this device's own leaves, checked against its validated root — for its own routes only | -//! | vault leaf pre values | the vault's accepted genesis (SoFi §19.8) at `R_0`; past it, the generation this device established at the root the operation names | -//! | policy objects | the immutable store, under the address the vault state commits, `Stored` on three members | -//! | token policies | the policy each market token commits, rooted by this device | -//! | setups | the envelope `Stored` at each leg's `ρ` | -//! | accepted claims | the claims this device's lineage accepted, at each setup's position — for its own routes only | -//! -//! Amendment S3: Core's predicates are binary and see only complete evidence. -//! Whether the evidence is complete is Core's answer too: an acquisition -//! round ends by asking `route_validation`, and a `Missing` answer is -//! retried up to the budget, then reported as [`Acquired::Exhausted`] — a -//! network failure, never a predicate value. -//! -//! Another trader's route is a hole: SoFi §17.5 says an exercise proves -//! itself from its own bytes and state the reader already holds, but -//! `TraderSideValid` reads the trader's leaf pre values, the trader core -//! carries only their hashes, and no section names where a verifier that is -//! not the trader gets them. Acquisition answers [`Acquired::NoSource`] for -//! them rather than supplying anything in their place. - -use std::collections::BTreeMap; - -use dsm::common::domain_tags::TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR; -use dsm::economic::lineage::{AcceptedClaim, ValidatedEconomicRoot}; -use dsm::economic::provenance::{PeerLineageFailure, ValidatedPeerTransition}; -use dsm::economic::state::EconomicLeafState; -use dsm::economic::tree::EconomicSmt; -use dsm::sofi::derive; -use dsm::sofi::lineage::{ - genesis_accepted, genesis_root, vault_leaves_at_genesis, AcceptedVaultGenesis, GenesisInvalid, - GenesisMissing, GenesisRefusal, -}; -use dsm::sofi::publication::recognize_setup; -use dsm::sofi::storage::{Discovered, Resolved}; -use dsm::sofi::conformance::Validation; -use dsm::sofi::validation::{ - route_validation, Evidence, EvidenceNeeds, Missing, TraderLeafPre, VaultLeafPre, -}; -use dsm::sofi::wire::{SettlementPreimage, TraderCore, TraderPrecommitBody, VaultGenesisPreimage}; -use dsm::types::error::DsmError; - -use crate::sdk::economic_registers::{ - anchored_policy_bytes, resolve_peer_with_cache, LiveRegisterResolver, -}; -use crate::sdk::storage_set::StorageSet; -use crate::storage::client_db::sofi_vault_head; - -type D32 = [u8; 32]; - -/// Candidates one locator scan may examine before it is `Unavailable`. -pub const LOCATOR_BUDGET: usize = 64; - -/// Acquisition rounds before the evidence is `Exhausted`. -pub const ACQUIRE_ROUNDS: usize = 3; - -fn storage_err(what: &str, e: impl core::fmt::Display) -> DsmError { - DsmError::storage(format!("{what}: {e}"), None::) -} - -/// This device's own `R_econ` leaves, checked against the root they claim to -/// form. Built from the leaf cache for the device's validated root, or by a -/// test from leaves it holds — never from a root somebody sent. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LocalLeaves { - genesis: D32, - device_id: D32, - root: D32, - leaves: BTreeMap, -} - -impl LocalLeaves { - /// The leaves of this device's validated root, from the leaf cache; the - /// cache is a cache, so its root is recomputed and must equal the - /// validated one. - pub fn of_validated( - genesis: &D32, - device_id: &D32, - validated: &ValidatedEconomicRoot, - ) -> Result { - let leaves = if validated.economic_position() == 0 { - Vec::new() - } else { - crate::storage::client_db::economic_lineage::load_leaf_cache() - .map_err(|e| storage_err("load leaf cache", e))? - }; - let decoded: Vec<(D32, EconomicLeafState)> = leaves - .iter() - .map(|(key, .., ccb)| { - dsm::economic::decode::decode_leaf_state(ccb) - .map(|state| (*key, state)) - .map_err(|e| storage_err("decode cached leaf state", e)) - }) - .collect::>()?; - Self::checked(*genesis, *device_id, validated.economic_root(), decoded) - } - - /// Leaves of `(genesis, device_id)` that must recompute `root`; anything - /// else is refused. - pub fn checked( - genesis: D32, - device_id: D32, - root: D32, - leaves: impl IntoIterator, - ) -> Result { - let mut tree = EconomicSmt::new(); - let mut map = BTreeMap::new(); - for (key, state) in leaves { - let value = state - .leaf_value() - .map_err(|e| storage_err("encode leaf state", e))?; - tree.insert(key, value); - map.insert(key, state); - } - if tree.root() != root { - return Err(DsmError::storage( - "local leaves do not recompute the validated root — discarded".to_string(), - None::, - )); - } - Ok(Self { - genesis, - device_id, - root, - leaves: map, - }) - } - - pub fn root(&self) -> D32 { - self.root - } - - /// Evidence holding the pre value of every key `core` names, from these - /// leaves alone: what `Fold(T°, E)` reads, before anything is published. - /// A key holding a write-once record has no trader-leaf pre value and is - /// refused. - pub fn trader_evidence(&self, core: &TraderCore) -> Result { - let mut trader_leaves = BTreeMap::new(); - for entry in core.entries() { - let key = entry.key(); - let pre = self.pre(&key).ok_or_else(|| { - DsmError::invalid_operation("a trader core names a key holding a write-once record") - })?; - trader_leaves.insert(key, pre); - } - Ok(Evidence::acquired( - BTreeMap::new(), - trader_leaves, - BTreeMap::new(), - BTreeMap::new(), - BTreeMap::new(), - BTreeMap::new(), - )) - } - - /// Every relationship leaf this device holds: the vaults it is set up - /// with. - pub fn relationships(&self) -> Vec { - let mut out = Vec::new(); - for state in self.leaves.values() { - if let EconomicLeafState::Relationship(leaf) = state { - out.push(*leaf); - } - } - out - } - - /// The relationship leaf this device holds with `vault_id`, if any. - pub fn relationship(&self, vault_id: &D32) -> Option { - let key = derive::relationship_key(&self.genesis, &self.device_id, vault_id); - match self.leaves.get(&key) { - Some(EconomicLeafState::Relationship(leaf)) => Some(*leaf), - Some(..) | None => None, - } - } - - /// Whether `precommit` is this device's own: the only routes whose trader - /// leaves and accepted claims this device holds. - fn owns(&self, precommit: &TraderPrecommitBody) -> bool { - *precommit.genesis() == self.genesis && *precommit.device_id() == self.device_id - } - - /// The pre value at `key` as Core reads a trader leaf. The tree is whole, - /// so a key it does not hold is absent. A key holding a write-once record - /// has no trader-leaf pre value: Core reads trader leaves only at balance - /// and relationship keys, which are domain-separated from every record - /// key. - pub fn pre(&self, key: &D32) -> Option { - match self.leaves.get(key) { - None => Some(TraderLeafPre::Absent), - Some(EconomicLeafState::Balance(b)) => Some(TraderLeafPre::Balance(b.clone())), - Some(EconomicLeafState::Relationship(r)) => Some(TraderLeafPre::Relationship(*r)), - Some( - EconomicLeafState::ConsumedSource(..) - | EconomicLeafState::VaultCreation(..) - | EconomicLeafState::TokenCreation(..), - ) => None, - } - } -} - -/// What this verifier established about vault `v`'s genesis (SoFi §19.8; -/// §30 step 1). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VaultGenesis { - /// The owner's validated creation carried this genesis, and it is - /// accepted. - Accepted(Box), - /// No preimage the owner's creation carried is published under the - /// vault's locator. - NotPublished, - /// The owner's lineage reaches `p_create` through a position whose route - /// has not resolved: nothing a fetch can supply decides it yet. - OwnerUnresolved(String), - /// The genesis is refused: the owner's lineage is invalid or quarantined, - /// or the genesis the owner created fails acceptance. - Refused(String), -} - -/// Vault `v`'s genesis as this verifier accepts it: every preimage published -/// under `vault_genesis_locator(v)` that recognizes to `v`, bound to the -/// owner's creation as a walk of the owner's lineage validates it. -/// -/// Every candidate is tried, so a preimage appended first by anyone else -/// cannot stand in front of the owner's: only the bytes the owner's creation -/// carried are accepted. Every candidate names the same owner and `p_create`, -/// because `v` derives from them. -pub async fn fetch_vault_genesis( - set: &StorageSet, - vault_id: &D32, -) -> Result { - let locator = derive::vault_genesis_locator(vault_id); - let resolved = crate::sdk::storage_io::resolve_locator_all( - set, - TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR.source_bytes(), - &locator, - LOCATOR_BUDGET, - |bytes| { - let preimage = VaultGenesisPreimage::decode(bytes).ok()?; - Some(( - derive::vault_genesis_locator(&preimage.vault_id()), - (preimage, bytes.to_vec()), - )) - }, - ) - .await?; - // A candidate the scan could not establish may be the owner's genesis, - // so only a complete scan says it is not published (storage §4). - let (candidates, complete) = match resolved { - Discovered::Complete(candidates) => (candidates, true), - Discovered::Partial(candidates) => (candidates, false), - }; - let not_published = || { - if complete { - Ok(VaultGenesis::NotPublished) - } else { - Err(storage_err( - "vault genesis", - "the locator scan did not establish every candidate", - )) - } - }; - let Some((first, ..)) = candidates.first() else { - return not_published(); - }; - let network = crate::sdk::economic_admission_flow::committed_network_id()?; - let resolver = LiveRegisterResolver { - set, - runtime: tokio::runtime::Handle::current(), - expected_network_id: network.clone(), - }; - let owner = match resolve_peer_with_cache( - &resolver, - &network, - &first.owner_genesis, - &first.owner_device_id, - first.create_position, - ) { - Ok(owner) => owner, - Err(PeerLineageFailure::Incomplete(why)) => { - return Err(storage_err("vault owner lineage", why)) - } - Err(PeerLineageFailure::Unresolved(why)) => return Ok(VaultGenesis::OwnerUnresolved(why)), - Err(PeerLineageFailure::Invalid(why) | PeerLineageFailure::Quarantined(why)) => { - return Ok(VaultGenesis::Refused(format!("the owner's lineage: {why}"))) - } - }; - let mut refused = None; - for (.., bytes) in &candidates { - match accept_with_policies(set, &resolver, &network, bytes, &owner)? { - Ok(accepted) => return Ok(VaultGenesis::Accepted(Box::new(accepted))), - Err(GenesisInvalid::NotTheCreationTheOwnerMade) => {} - Err(why) => refused = Some(why), - } - } - match refused { - Some(why) => Ok(VaultGenesis::Refused(format!("{why:?}"))), - None => not_published(), - } -} - -/// `GenesisAccepted` over one candidate, fetching the token policies Core -/// names as missing. Core consults at most the two tokens of the market, and -/// a policy it names again after it was supplied is not the committed one. -fn accept_with_policies( - set: &StorageSet, - resolver: &LiveRegisterResolver<'_>, - network: &[u8], - bytes: &[u8], - owner: &ValidatedPeerTransition, -) -> Result, DsmError> { - let mut policies = BTreeMap::new(); - loop { - match genesis_accepted(network, bytes, owner, &policies) { - Ok(accepted) => return Ok(Ok(accepted)), - Err(GenesisRefusal::Invalid(why)) => return Ok(Err(why)), - Err(GenesisRefusal::Missing(GenesisMissing::TokenPolicy { commit })) => { - if policies.contains_key(&commit) { - return Err(storage_err( - "vault token policy", - "the rooted bytes are not the committed policy", - )); - } - let policy = anchored_policy_bytes(set, &commit, &resolver.runtime) - .map_err(|failure| storage_err("vault token policy", failure))?; - policies.insert(commit, policy); - } - } - } -} - -/// What an acquisition produced: the complete evidence a Core predicate -/// consumes, or what is still not in hand. Predicates are Valid or Invalid -/// only; these are the separate acquisition statuses (Amendment S3, owner -/// 2026-09-23). -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Acquired { - /// Everything the predicate consumes is in hand and authenticates. - Complete(T), - /// The retry budget is spent and these items are still not in hand: the - /// operation fails on the network. The caller evaluates nothing and - /// records nothing. - Exhausted(Vec), - /// These items have no source this verifier can acquire them from, so no - /// retry can supply them. The caller evaluates nothing and records - /// nothing. - NoSource(Vec), -} - -/// Acquire everything `P` and `P(E)` need, from storage and this device's -/// own state, and ask Core whether it is complete. `Complete` once -/// `route_validation` reaches a verdict over it; `Exhausted` naming what Core -/// still misses after [`ACQUIRE_ROUNDS`] rounds; `NoSource` for another -/// trader's route (module docs). -pub async fn acquire_evidence( - set: &StorageSet, - precommit: &TraderPrecommitBody, - preimage: &SettlementPreimage, - local: &LocalLeaves, -) -> Result, DsmError> { - let needs = EvidenceNeeds::of(precommit, preimage); - if !local.owns(precommit) { - return Ok(Acquired::NoSource( - needs - .trader_keys - .iter() - .map(|key| Missing::TraderLeaf { key: *key }) - .collect(), - )); - } - let mut missing = Vec::new(); - for round in 1..=ACQUIRE_ROUNDS { - let evidence = gather(set, precommit, preimage, local, &needs).await?; - match route_validation(precommit, preimage, &evidence) { - Ok(Validation::Valid | Validation::Invalid) => return Ok(Acquired::Complete(evidence)), - Err(what) => { - log::info!("[sofi evidence] round {round}/{ACQUIRE_ROUNDS}: not in hand: {what:?}"); - missing = vec![what]; - } - } - } - Ok(Acquired::Exhausted(missing)) -} - -/// One round of fetching every item `needs` names, for this device's own -/// route. -async fn gather( - set: &StorageSet, - precommit: &TraderPrecommitBody, - preimage: &SettlementPreimage, - local: &LocalLeaves, - needs: &EvidenceNeeds, -) -> Result { - let trader_leaves: BTreeMap = needs - .trader_keys - .iter() - .filter_map(|key| local.pre(key).map(|pre| (*key, pre))) - .collect(); - - let runtime = tokio::runtime::Handle::current(); - let mut vault_leaves: BTreeMap<(D32, D32), VaultLeafPre> = BTreeMap::new(); - let mut objects: BTreeMap> = BTreeMap::new(); - let mut token_policies: BTreeMap> = BTreeMap::new(); - for (vault_id, keys) in &needs.vaults { - let Some(state) = vault_pre(set, preimage, vault_id, keys, &mut vault_leaves).await? else { - continue; - }; - for (class, addr) in EvidenceNeeds::policies_of(&state) { - let Some(bytes) = crate::sdk::storage_io::read_stored_bytes(set, &addr).await? else { - continue; - }; - // The tokens a market names are what the transferable check reads. - // Bytes that are not a market name none, and Core refuses them. - if class == dsm::ccb::class::MARKET_POLICY { - if let Ok(market) = dsm::ccb::decode::decode_market_policy(&bytes) { - for commit in EvidenceNeeds::token_policies_of(&market) { - if dsm::core::token::builtin_token_id_for_policy_commit(&commit).is_some() { - continue; - } - match anchored_policy_bytes(set, &commit, &runtime) { - Ok(policy) => { - token_policies.insert(commit, policy); - } - Err(failure) => { - log::info!("[sofi evidence] token policy not in hand: {failure}") - } - } - } - } - } - objects.insert(addr, bytes); - } - } - - let mut setups: BTreeMap> = BTreeMap::new(); - let mut accepted_claims: BTreeMap = BTreeMap::new(); - for setup_ref in &needs.setups { - let Resolved::Kept(bytes) = - crate::sdk::sofi_publish::fetch_setup_bytes(set, setup_ref).await? - else { - continue; - }; - if let Some((.., signed)) = recognize_setup(&bytes) { - let position = signed.body.position(); - if let Some(claim) = accepted_claim_at(precommit, position)? { - accepted_claims.insert(position, claim); - } - } - setups.insert(*setup_ref, bytes); - } - - Ok(Evidence::acquired( - objects, - trader_leaves, - vault_leaves, - setups, - token_policies, - accepted_claims, - )) -} - -/// The claim this device's lineage accepted at `position`, from its -/// admitted history. `None` when it admitted no position there, or the -/// position is conditional and unresolved — registered, never accepted. -fn accepted_claim_at( - precommit: &TraderPrecommitBody, - position: u64, -) -> Result, DsmError> { - let Some(admitted) = crate::storage::client_db::economic_lineage::get_admitted_at(position) - .map_err(|e| storage_err("admitted history", e))? - else { - return Ok(None); - }; - match AcceptedClaim::rehydrate_from_admitted_store( - *precommit.genesis(), - *precommit.device_id(), - admitted, - ) { - Ok(claim) => Ok(Some(claim)), - Err(unresolved) => { - log::info!("[sofi evidence] no accepted claim at {position}: {unresolved:?}"); - Ok(None) - } - } -} - -/// The pre values of vault `v`'s leaves at the root the operation's core -/// names, into `vault_leaves`, and the vault state they hold. At `R_0` they -/// are the accepted genesis; past it, the generation this device established -/// at the root the core was built on, which must reproduce that root. `None` -/// when they are not in hand. -async fn vault_pre( - set: &StorageSet, - preimage: &SettlementPreimage, - vault_id: &D32, - keys: &std::collections::BTreeSet, - vault_leaves: &mut BTreeMap<(D32, D32), VaultLeafPre>, -) -> Result, DsmError> { - let Some(pre_root) = preimage - .dlv_cores() - .iter() - .find(|core| core.vault_id() == vault_id) - .map(|core| *core.pre_root()) - else { - return Ok(None); - }; - let genesis = match fetch_vault_genesis(set, vault_id).await? { - VaultGenesis::Accepted(genesis) => genesis, - VaultGenesis::NotPublished | VaultGenesis::OwnerUnresolved(..) => return Ok(None), - VaultGenesis::Refused(why) => { - return Err(DsmError::invalid_operation(format!( - "vault {} genesis refused: {why}", - crate::util::text_id::encode_base32_crockford(vault_id) - ))) - } - }; - if genesis_root(vault_id, genesis.state()).ok() == Some(pre_root) { - vault_leaves.extend(vault_leaves_at_genesis(vault_id, genesis.state(), keys)); - return Ok(Some(genesis.state().clone())); - } - let Some((.., leaves)) = sofi_vault_head::leaves_at(vault_id, &pre_root, keys) - .map_err(|e| storage_err("vault head", e))? - else { - return Ok(None); - }; - let state_key = derive::vault_state_key(vault_id); - let Some(VaultLeafPre::State(state)) = leaves.get(&(*vault_id, state_key)).cloned() else { - return Ok(None); - }; - vault_leaves.extend(leaves); - Ok(Some(state)) -} - -#[cfg(test)] -#[allow(clippy::disallowed_methods)] -mod tests { - use super::*; - use crate::sdk::storage_node_sdk::SetClient; - use dsm::crypto::domain::TaggedHashDomain; - - /// MR-STOR-0021 (storage §4): a candidate under a vault's genesis locator - /// whose bytes no member holds may be the genesis, so the scan is a - /// network failure, never "not published"; a candidate whose bytes are - /// held and are not a genesis is established, and nothing. On the storage - /// node's own code, on Postgres. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - #[serial_test::serial] - async fn an_unestablished_genesis_candidate_is_not_read_as_unpublished() { - let _fleet = crate::test_support::one_device::Fleet::start(); - let set = crate::sdk::storage_set::canonical_set(crate::economic_fixtures::NETWORK) - .expect("the pinned set"); - let client = SetClient::new(&set).expect("a client of the set"); - let index = TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR.source_bytes(); - - // Held bytes that are not a genesis: every candidate established. - let held_vault = [0x71; 32]; - let domain = TaggedHashDomain::try_new(b"DSM/test/not-a-vault-genesis").expect("domain"); - let garbage = b"these bytes decode as no vault genesis preimage"; - assert_eq!(client.put_immutable(domain, garbage).await, 5); - let held = dsm::storage_object::immutable_addr(domain, garbage); - let locator = derive::vault_genesis_locator(&held_vault); - assert_eq!(client.append_index(index, &locator, &held).await, 5); - assert!(matches!( - fetch_vault_genesis(&set, &held_vault).await, - Ok(VaultGenesis::NotPublished) - )); - - // An address whose bytes no member holds: not established. - let unknown_vault = [0x72; 32]; - let locator = derive::vault_genesis_locator(&unknown_vault); - assert_eq!(client.append_index(index, &locator, &[0x99; 32]).await, 5); - assert!( - fetch_vault_genesis(&set, &unknown_vault).await.is_err(), - "a candidate nobody holds must not read as an unpublished genesis" - ); - } -} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs index 4b33b8e17..6bd59153d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_exercise.rs @@ -13,17 +13,14 @@ //! key is Core's (`sofi::exercise::exercise_names_key`): the first exercise //! at the leader whose `F` names `(v, a)` and whose `P` names `(v, R_n)`. -use dsm::route_chain::CellFact; use dsm::sofi::conformance::{derive_policy_fulfillments, ConformanceEvidence}; use dsm::sofi::derive; -use dsm::sofi::exercise::{ - attempt_completion, attempt_resolution, AttemptCell, AttemptCellRead, RecognizedExercise, -}; +use dsm::sofi::exercise::{AttemptCell, RecognizedExercise}; use dsm::sofi::publication::Publication; use dsm::sofi::wire::{DlvPolicyFulfillmentBody, SofiExercise}; use dsm::types::error::DsmError; -use crate::sdk::route_seats::{read_cell, write_recorded, NodeSeats}; +use crate::sdk::route_seats::write_recorded; use crate::sdk::sofi_register::InstallRequest; use crate::sdk::storage_set::StorageSet; @@ -146,49 +143,3 @@ pub async fn write_exercise( } Ok(writes) } - -/// `SuccessorResolution(K^(attempt))` of `vault_id` at `parent_root`, as the -/// ladder reads it (Section 23.1): Core evaluates the cell's route chains -/// from every seat's reads into a read bound to the key. An exercise final -/// at the cell has its completion proof kept (storage spec §9 rule 11). -/// Reads that do not decide the cell yet — its leader unread, or its leader -/// link not yet committed — are the inner `Err`, what Core names as missing: -/// a network status for the caller to retry, never an open cell. -pub async fn read_attempt_cell( - set: &StorageSet, - vault_id: &D32, - parent_root: &D32, - attempt: u64, -) -> Result, DsmError> { - let cell = attempt_cell(set, vault_id, parent_root, attempt)?; - let seats = NodeSeats::new(set)?; - let evidence = read_cell(&seats, cell.routed()).await; - let read = match attempt_resolution(&cell, &evidence) { - Ok(read) => read, - Err(missing) => return Ok(Err(missing)), - }; - if let CellFact::Held { - state: dsm::route_chain::ChainState::Final, - .. - } = read.fact() - { - keep_attempt_completion(&cell, &evidence)?; - } - Ok(Ok(read)) -} - -/// Keep the completion proof of the exercise final at `cell`. -fn keep_attempt_completion( - cell: &AttemptCell, - evidence: &dsm::route_chain::CellEvidence, -) -> Result<(), DsmError> { - let (.., proof) = attempt_completion(cell, evidence) - .map_err(|missing| err("attempt completion", format!("{missing:?}")))? - .ok_or_else(|| { - err( - "attempt completion", - "a final exercise has no completion proof", - ) - })?; - crate::sdk::route_seats::keep_completion(cell.routed(), &proof) -} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs index a1b63294a..3b057dde4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_flow.rs @@ -24,6 +24,7 @@ use dsm::sofi::derive; use dsm::sofi::publication::{Publication, VaultPolicyClass}; use dsm::sofi::registration::Registration; use dsm::sofi::resolution::{VaultChain, WalkOutcome}; +use dsm::sofi::resolve::{Acquired, LocalLeaves, VaultGenesis, Verifier, WALK_BUDGET}; use dsm::sofi::storage::Discovered; use dsm::sofi::validation::{close_vault_post, swap_vault_post, Evidence, EvidenceNeeds, Policies}; use dsm::sofi::wire::{ @@ -43,15 +44,11 @@ use crate::sdk::sofi_advance::{ complete_pending_fulfillment, fulfill, own_closure_objects, own_parent_claim, resolve_pending_position, Advanced, FulfillRequest, }; -use crate::sdk::sofi_chain::ChainWalker; -use crate::sdk::sofi_evidence::{ - acquire_evidence, fetch_vault_genesis, Acquired, LocalLeaves, VaultGenesis, +use crate::sdk::sofi_reads::{ + local_leaves_of_validated, verifier_error, LiveSofiReads, VerifierContext, }; -use crate::sdk::sofi_exercise::read_attempt_cell; use crate::sdk::sofi_publish::{fetch_setup_for, publish, publish_produced, Published}; -use crate::sdk::sofi_register::read_registration; use crate::sdk::sofi_relay::relay_fulfillment; -use crate::sdk::sofi_resolve::{Resolver, WALK_BUDGET}; use crate::sdk::sofi_sdk::{ build_fulfillment, build_setup, build_vault_create, check_draft, draft_close, draft_route, ToPublish, TraderContext, UncheckedDraft, @@ -355,7 +352,19 @@ pub async fn setup( intent: &SetupIntent, ) -> Result { let (genesis, device_id) = identity(core)?; - match fetch_vault_genesis(set, &intent.vault_id).await? { + let admitted = economic_lineage::get_admitted() + .map_err(|e| storage("load admitted", e))? + .ok_or_else(|| { + refuse("no admitted position: a setup names the claim registered at its position") + })?; + let validated = validated_root_or_activate(core)?; + let local = local_leaves_of_validated(&genesis, &device_id, &validated)?; + let ctx = VerifierContext::new(set, Some(&local), Some(&admitted))?; + match ctx + .verifier() + .vault_genesis(&intent.vault_id) + .map_err(verifier_error)? + { VaultGenesis::Accepted(..) => {} VaultGenesis::NotPublished => { return Err(refuse( @@ -369,12 +378,6 @@ pub async fn setup( } VaultGenesis::Refused(why) => return Err(refuse(format!("vault genesis refused: {why}"))), } - let admitted = economic_lineage::get_admitted() - .map_err(|e| storage("load admitted", e))? - .ok_or_else(|| { - refuse("no admitted position: a setup names the claim registered at its position") - })?; - let validated = validated_root_or_activate(core)?; let (pre_tree, ..) = producer_tree_and_pre_state(&validated)?; let claim_ref = own_claim_ref(&admitted)?; let public_key = crate::sdk::signing_authority::current_public_key()?; @@ -459,7 +462,7 @@ fn standing(core: &CoreSDK) -> Result { .map_err(|e| storage("load admitted", e))? .ok_or_else(|| refuse("no admitted position to build on"))?; let (tree, pre_state) = producer_tree_and_pre_state(&validated)?; - let local = LocalLeaves::of_validated(&genesis, &device_id, &validated)?; + let local = local_leaves_of_validated(&genesis, &device_id, &validated)?; Ok(Standing { genesis, device_id, @@ -502,15 +505,15 @@ async fn vault_policies(set: &StorageSet, state: &VaultStateLeaf) -> Result, + verifier: &Verifier<'_, LiveSofiReads<'_>>, vault_id: &D32, ) -> Result<(VaultAtHead, VaultChain), DsmError> { - let chain = walker.chain(vault_id).await?; + let chain = verifier.chain(vault_id).map_err(verifier_error)?; let (generation, root) = chain .head() .ok_or_else(|| refuse("no head of this vault is established"))?; let (state, tree) = if generation == 0 { - let accepted = match fetch_vault_genesis(set, vault_id).await? { + let accepted = match verifier.vault_genesis(vault_id).map_err(verifier_error)? { VaultGenesis::Accepted(accepted) => accepted, VaultGenesis::NotPublished => { return Err(refuse( @@ -564,12 +567,10 @@ async fn vault_at_head( } impl Standing { - fn walker<'a>(&'a self, set: &'a StorageSet) -> ChainWalker<'a> { - ChainWalker { - set, - local: &self.local, - parent: Some(&self.admitted), - } + /// This device as the verifier: its own leaves and the position it + /// resolved itself, over the pinned set. + fn context<'a>(&'a self, set: &'a StorageSet) -> Result, DsmError> { + VerifierContext::new(set, Some(&self.local), Some(&self.admitted)) } } @@ -645,10 +646,11 @@ pub async fn find_route( intent: &FindRouteIntent, ) -> Result, DsmError> { let standing = standing(core)?; - let walker = standing.walker(set); + let ctx = standing.context(set)?; + let verifier = ctx.verifier(); let mut heads = Vec::new(); for leaf in standing.local.relationships() { - match vault_at_head(set, &walker, &leaf.vault_id).await { + match vault_at_head(set, &verifier, &leaf.vault_id).await { Ok((head, ..)) if head.state.status == VAULT_STATUS_ACTIVE => heads.push(head), Ok(..) => {} Err(e) => log::info!( @@ -741,20 +743,23 @@ async fn own_setup_ref( /// The live attempt of a leg at `parent_root`: the walk's first unresolved /// key, advanced past keys an exercise still in flight holds (§31 stage 6). -async fn live_attempt( - set: &StorageSet, - resolver: &Resolver<'_>, +fn live_attempt( + verifier: &Verifier<'_, LiveSofiReads<'_>>, + chains: &BTreeMap, vault_id: &D32, parent_root: &D32, ) -> Result { - let mut cursor = 0; + let mut walked = verifier + .walk_parent(chains, vault_id, parent_root, 0, WALK_BUDGET) + .map_err(verifier_error)?; let first = loop { - let walked = resolver - .walk_parent(vault_id, parent_root, cursor, WALK_BUDGET) - .await?; match walked.outcome { WalkOutcome::Unresolved { attempt } => break attempt, - WalkOutcome::Continue { cursor: next } => cursor = next, + WalkOutcome::Continue { .. } => { + walked = verifier + .continue_walk(chains, walked, WALK_BUDGET) + .map_err(verifier_error)? + } WalkOutcome::Consumed { attempt } => { return Err(refuse(format!( "the vault's parent was consumed at attempt {attempt}: its head moved" @@ -770,7 +775,10 @@ async fn live_attempt( let mut attempt = first; let mut advanced = 0; while advanced <= ATTEMPT_ADVANCE { - match read_attempt_cell(set, vault_id, parent_root, attempt).await? { + match verifier + .read_attempt_cell(vault_id, parent_root, attempt) + .map_err(verifier_error)? + { Ok(read) if read.exercise().is_none() => return Ok(attempt), Ok(..) => { attempt = next_attempt(attempt).map_err(refuse)?; @@ -968,8 +976,11 @@ async fn exercise_draft( draft: UncheckedDraft, ) -> Result { // Stage 3. - let evidence = match acquire_evidence(set, draft.precommit(), draft.preimage(), &standing.local) - .await? + let ctx = standing.context(set)?; + let verifier = ctx.verifier(); + let evidence = match verifier + .acquire_evidence(draft.precommit(), draft.preimage()) + .map_err(verifier_error)? { Acquired::Complete(evidence) => evidence, Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { @@ -984,21 +995,17 @@ async fn exercise_draft( // Stage 6: each leg's live attempt, from the walk at its parent. let mut chains = BTreeMap::new(); - let walker = standing.walker(set); for leg in checked.precommit().legs() { - chains.insert(leg.vault_id, walker.chain(&leg.vault_id).await?); + chains.insert( + leg.vault_id, + verifier.chain(&leg.vault_id).map_err(verifier_error)?, + ); } - let resolver = Resolver { - set, - local: &standing.local, - parent: Some(&standing.admitted), - chains: &chains, - }; let mut attempts = Vec::new(); for leg in checked.precommit().legs() { attempts.push(( leg.vault_id, - live_attempt(set, &resolver, &leg.vault_id, &leg.parent_root).await?, + live_attempt(&verifier, &chains, &leg.vault_id, &leg.parent_root)?, )); } let produced = @@ -1062,14 +1069,15 @@ pub async fn trade( intent: &TradeIntent, ) -> Result { let standing = standing(core)?; - let walker = standing.walker(set); + let ctx = standing.context(set)?; + let verifier = ctx.verifier(); let mut token = intent.token_in_policy_commit; let mut amount = intent.amount_in; let mut hops = Vec::new(); let mut cores = Vec::new(); let mut vaults = Vec::new(); for (index, vault_id) in intent.vault_ids.iter().enumerate() { - let (vault, ..) = vault_at_head(set, &walker, vault_id).await?; + let (vault, ..) = vault_at_head(set, &verifier, vault_id).await?; let setup_ref = own_setup_ref(set, &standing.genesis, &standing.device_id, vault_id).await?; let base = relationship_base(&standing, vault_id)?; @@ -1115,8 +1123,9 @@ pub async fn close( intent: &CloseIntent, ) -> Result { let standing = standing(core)?; - let walker = standing.walker(set); - let (vault, ..) = vault_at_head(set, &walker, &intent.vault_id).await?; + let ctx = standing.context(set)?; + let verifier = ctx.verifier(); + let (vault, ..) = vault_at_head(set, &verifier, &intent.vault_id).await?; if vault.state.owner_genesis != standing.genesis || vault.state.owner_device_id != standing.device_id { @@ -1195,15 +1204,17 @@ pub async fn relay(set: &StorageSet, intent: &RelayIntent) -> Result signed, Registration::NeverRegistered { .. } => { diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_publish.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_publish.rs index 56a53015d..47b60238f 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_publish.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_publish.rs @@ -33,7 +33,8 @@ use dsm::sofi::wire::{ }; use dsm::types::error::DsmError; -use crate::sdk::sofi_evidence::LOCATOR_BUDGET; +/// Candidates one locator scan may examine before it is `Unavailable`. +pub const LOCATOR_BUDGET: usize = 64; use crate::sdk::sofi_sdk::{Produced, ToPublish}; use crate::sdk::storage_io::{ append_to_index, put_immutable, read_stored_bytes, resolve_locator, resolve_locator_all, diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs new file mode 100644 index 000000000..ee5f834bd --- /dev/null +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_reads.rs @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: Apache-2.0 +//! What the SoFi verifier reads, answered from the storage nodes and this +//! device's own records: the SDK's [`SofiReads`]. +//! +//! Core decides what is read and what it means (`dsm::sofi::resolve`); this +//! module only answers — bytes, cells, this device's own rows — the way +//! `LiveRegisterResolver` answers the peer lineage walk. Every network read +//! runs on the SDK's multi-thread runtime from the verifier's synchronous +//! call (`block_in_place`), which is the shape the peer walk already has. + +use std::collections::BTreeSet; +use std::future::Future; + +use dsm::ccb::StorageSetMembers; +use dsm::common::domain_tags::TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR; +use dsm::economic::lineage::{AcceptedClaim, AdmittedEconomicPosition, ValidatedEconomicRoot}; +use dsm::economic::provenance::{PeerLineageFailure, ValidatedPeerTransition}; +use dsm::route_chain::{CellEvidence, CompletionProof, RoutedCell}; +use dsm::sofi::derive; +use dsm::sofi::publication::Signed; +use dsm::sofi::resolve::{ + LocalLeaves, ReadFailure, RecordedGenerationRow, SofiReads, VaultLeaves, Verifier, + VerifierFailure, +}; +use dsm::sofi::storage::{Discovered, Resolved}; +use dsm::sofi::validation::VaultPostState; +use dsm::sofi::wire::{TraderFulfillmentBody, TraderPrecommitBody, VaultGenesisPreimage}; +use dsm::types::error::DsmError; + +use crate::sdk::economic_admission_flow::committed_network_id; +use crate::sdk::economic_registers::{ + anchored_policy_bytes, resolve_peer_with_cache, LiveRegisterResolver, +}; +use crate::sdk::route_seats::{keep_completion, read_cell, NodeSeats}; +use crate::sdk::sofi_publish::{fetch_fulfillment, fetch_precommit, fetch_setup_bytes, LOCATOR_BUDGET}; +use crate::sdk::storage_io::{read_stored_bytes, resolve_locator_all}; +use crate::sdk::storage_set::{as_ccb_members, StorageSet}; +use crate::storage::client_db::{economic_lineage, sofi_vault_head}; + +type D32 = [u8; 32]; + +fn storage_err(what: &str, e: impl core::fmt::Display) -> DsmError { + DsmError::storage(format!("{what}: {e}"), None::) +} + +/// A verifier failure as the SDK reports it: a read that could not be made +/// is a storage error; what the reads refuted is an invalid operation. +pub fn verifier_error(failure: VerifierFailure) -> DsmError { + match failure { + VerifierFailure::Read(why) => storage_err("sofi verifier", why), + VerifierFailure::Refused(why) => { + DsmError::invalid_operation(format!("sofi verifier: {why}")) + } + } +} + +/// The leaves of this device's validated root, from the leaf cache; the +/// cache is a cache, so its root is recomputed and must equal the validated +/// one. +pub fn local_leaves_of_validated( + genesis: &D32, + device_id: &D32, + validated: &ValidatedEconomicRoot, +) -> Result { + let leaves = if validated.economic_position() == 0 { + Vec::new() + } else { + economic_lineage::load_leaf_cache().map_err(|e| storage_err("load leaf cache", e))? + }; + let decoded: Vec<(D32, dsm::economic::state::EconomicLeafState)> = leaves + .iter() + .map(|(key, .., ccb)| { + dsm::economic::decode::decode_leaf_state(ccb) + .map(|state| (*key, state)) + .map_err(|e| storage_err("decode cached leaf state", e)) + }) + .collect::>()?; + LocalLeaves::checked(*genesis, *device_id, validated.economic_root(), decoded) + .map_err(|e| storage_err("local leaves", e)) +} + +/// The verifier's reads over the storage nodes of the pinned set and this +/// device's own records. +pub struct LiveSofiReads<'a> { + set: &'a StorageSet, + runtime: tokio::runtime::Handle, + network: Vec, +} + +impl<'a> LiveSofiReads<'a> { + pub fn new(set: &'a StorageSet) -> Result { + Ok(Self { + set, + runtime: tokio::runtime::Handle::current(), + network: committed_network_id()?, + }) + } + + fn block(&self, fut: impl Future) -> T { + tokio::task::block_in_place(|| self.runtime.block_on(fut)) + } + + fn read( + &self, + what: &str, + fut: impl Future>, + ) -> Result { + self.block(fut) + .map_err(|e| ReadFailure(format!("{what}: {e}"))) + } +} + +impl SofiReads for LiveSofiReads<'_> { + fn cell(&self, cell: &RoutedCell) -> Result { + let seats = NodeSeats::new(self.set).map_err(|e| ReadFailure(format!("seats: {e}")))?; + Ok(self.block(read_cell(&seats, cell))) + } + + fn precommit(&self, id: &D32) -> Result>, ReadFailure> { + self.read("precommit", fetch_precommit(self.set, id)) + } + + fn fulfillment( + &self, + id: &D32, + ) -> Result>, ReadFailure> { + self.read("fulfillment", fetch_fulfillment(self.set, id)) + } + + fn setup_bytes(&self, setup_ref: &D32) -> Result>, ReadFailure> { + self.read("setup", fetch_setup_bytes(self.set, setup_ref)) + } + + fn stored_bytes(&self, addr: &D32) -> Result>, ReadFailure> { + self.read("stored bytes", read_stored_bytes(self.set, addr)) + } + + fn token_policy_bytes(&self, policy_commit: &D32) -> Result, ReadFailure> { + anchored_policy_bytes(self.set, policy_commit, &self.runtime) + .map_err(|failure| ReadFailure(format!("token policy: {failure}"))) + } + + fn vault_genesis_candidates( + &self, + vault_id: &D32, + ) -> Result)>, ReadFailure> { + let locator = derive::vault_genesis_locator(vault_id); + self.read( + "vault genesis candidates", + resolve_locator_all( + self.set, + TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR.source_bytes(), + &locator, + LOCATOR_BUDGET, + |bytes| { + let preimage = VaultGenesisPreimage::decode(bytes).ok()?; + Some(( + derive::vault_genesis_locator(&preimage.vault_id()), + (preimage, bytes.to_vec()), + )) + }, + ), + ) + } + + fn vault_owner( + &self, + genesis: &D32, + device_id: &D32, + position: u64, + ) -> Result { + let resolver = LiveRegisterResolver { + set: self.set, + runtime: self.runtime.clone(), + expected_network_id: self.network.clone(), + }; + resolve_peer_with_cache(&resolver, &self.network, genesis, device_id, position) + } + + fn vault_leaves_at( + &self, + vault_id: &D32, + root: &D32, + keys: &BTreeSet, + ) -> Result, ReadFailure> { + Ok(sofi_vault_head::leaves_at(vault_id, root, keys) + .map_err(|e| ReadFailure(format!("vault head: {e}")))? + .map(|(.., leaves)| leaves)) + } + + fn accepted_claim_at( + &self, + genesis: &D32, + device_id: &D32, + position: u64, + ) -> Result, ReadFailure> { + let Some(admitted) = economic_lineage::get_admitted_at(position) + .map_err(|e| ReadFailure(format!("admitted history: {e}")))? + else { + return Ok(None); + }; + match AcceptedClaim::rehydrate_from_admitted_store(*genesis, *device_id, admitted) { + Ok(claim) => Ok(Some(claim)), + Err(unresolved) => { + log::info!("[sofi reads] no accepted claim at {position}: {unresolved:?}"); + Ok(None) + } + } + } + + fn recorded_generations( + &self, + vault_id: &D32, + ) -> Result, ReadFailure> { + sofi_vault_head::recorded_generations(vault_id) + .map_err(|e| ReadFailure(format!("recorded generations: {e}"))) + } + + fn record_generation(&self, post: &VaultPostState) -> Result<(), ReadFailure> { + sofi_vault_head::record_walked(post) + .map_err(|e| ReadFailure(format!("record generation: {e}"))) + } + + fn keep_completion( + &self, + cell: &RoutedCell, + proof: &CompletionProof, + ) -> Result<(), ReadFailure> { + keep_completion(cell, proof).map_err(|e| ReadFailure(format!("keep completion: {e}"))) + } +} + +/// Everything a [`Verifier`] borrows, held together: the reads over the +/// pinned set, the set's members and id, the committed network, and — when +/// the verifier is a trader — its own leaves and the position it resolved +/// itself. +pub struct VerifierContext<'a> { + reads: LiveSofiReads<'a>, + members: StorageSetMembers, + set_id: D32, + network: Vec, + local: Option<&'a LocalLeaves>, + parent: Option<&'a AdmittedEconomicPosition>, +} + +impl<'a> VerifierContext<'a> { + /// A verifier over `set`. `local` and `parent` are this device's own + /// leaves and resolved predecessor when it verifies as a trader; a relay + /// or a reader of another trader's position brings neither. + pub fn new( + set: &'a StorageSet, + local: Option<&'a LocalLeaves>, + parent: Option<&'a AdmittedEconomicPosition>, + ) -> Result { + Ok(Self { + reads: LiveSofiReads::new(set)?, + members: as_ccb_members(set)?, + set_id: set.id(), + network: committed_network_id()?, + local, + parent, + }) + } + + pub fn verifier(&self) -> Verifier<'_, LiveSofiReads<'a>> { + Verifier { + reads: &self.reads, + members: &self.members, + set_id: self.set_id, + network_id: &self.network, + local: self.local, + parent: self.parent, + } + } +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] +mod tests { + use super::*; + use crate::sdk::storage_node_sdk::SetClient; + use dsm::crypto::domain::TaggedHashDomain; + use dsm::sofi::resolve::VaultGenesis; + + /// MR-STOR-0021 (storage §4): a candidate under a vault's genesis locator + /// whose bytes no member holds may be the genesis, so the scan is a + /// network failure, never "not published"; a candidate whose bytes are + /// held and are not a genesis is established, and nothing. On the storage + /// node's own code, on Postgres. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + #[serial_test::serial] + async fn an_unestablished_genesis_candidate_is_not_read_as_unpublished() { + let _fleet = crate::test_support::one_device::Fleet::start(); + let set = crate::sdk::storage_set::canonical_set(crate::economic_fixtures::NETWORK) + .expect("the pinned set"); + let client = SetClient::new(&set).expect("a client of the set"); + let index = TAG_DSM_SOFI_VAULT_GENESIS_LOCATOR.source_bytes(); + let ctx = VerifierContext::new(&set, None, None).expect("a verifier over the set"); + + // Held bytes that are not a genesis: every candidate established. + let held_vault = [0x71; 32]; + let domain = TaggedHashDomain::try_new(b"DSM/test/not-a-vault-genesis").expect("domain"); + let garbage = b"these bytes decode as no vault genesis preimage"; + assert_eq!(client.put_immutable(domain, garbage).await, 5); + let held = dsm::storage_object::immutable_addr(domain, garbage); + let locator = derive::vault_genesis_locator(&held_vault); + assert_eq!(client.append_index(index, &locator, &held).await, 5); + assert!(matches!( + ctx.verifier().vault_genesis(&held_vault), + Ok(VaultGenesis::NotPublished) + )); + + // An address whose bytes no member holds: not established. + let unknown_vault = [0x72; 32]; + let locator = derive::vault_genesis_locator(&unknown_vault); + assert_eq!(client.append_index(index, &locator, &[0x99; 32]).await, 5); + assert!( + matches!( + ctx.verifier().vault_genesis(&unknown_vault), + Err(VerifierFailure::Read(..)) + ), + "a candidate nobody holds must not read as an unpublished genesis" + ); + } +} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs index 480fb2cff..452f6fde4 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_register.rs @@ -10,42 +10,33 @@ //! computed from the verified `P` and `F` and never caller-supplied, so no //! claim that disagrees with `F` can be `F`'s claim. A member stores bytes; //! it establishes nothing. Whether `F` registered is Core's conclusion from -//! the cells' route chains (`FulfillmentRegistered`, rebuild step R10), never +//! the cells' route chains (`FulfillmentRegistered`, rebuild step R10), read +//! by the verifier (`dsm::sofi::resolve::Verifier::read_registration`), never //! a fact this module produces. //! //! Before anything is written the producer obtains -//! `FulfillmentConformance(F) = Valid` over evidence it acquired from storage -//! (Section 20.2: "A producer MUST obtain Valid before it publishes F"). Rule -//! T5: while evidence is not in hand nothing is written and what is missing -//! is named; on `Invalid` the fulfillment is refused with its reason. +//! `FulfillmentConformance(F) = Valid` over evidence the verifier acquired +//! from storage (Section 20.2: "A producer MUST obtain Valid before it +//! publishes F"). Rule T5: while evidence is not in hand nothing is written +//! and what is missing is named; on `Invalid` the fulfillment is refused with +//! its reason. use std::collections::BTreeMap; -use dsm::economic::register::root_completion; -use dsm::route_chain::{CellFact, Missing as CellMissing}; use dsm::sofi::conformance::{ - fulfillment_conformance, ConformanceEvidence, ConformanceMissing, FulfillmentConformance, + fulfillment_conformance, ConformanceMissing, FulfillmentConformance, FulfillmentConformanceError, }; use dsm::sofi::derive; -use dsm::sofi::publication::{recognize_fulfillment, Publication, Signed}; -use dsm::sofi::registration::{ - fulfillment_completion, fulfillment_registered, PositionCells, Registration, RegistrationRead, -}; -use dsm::sofi::storage::Resolved; +use dsm::sofi::publication::Publication; +use dsm::sofi::registration::PositionCells; +use dsm::sofi::resolve::{Acquired, ExerciseObjects, SofiReads, Verifier}; use dsm::sofi::wire::{ - ParentClaimRef, SettlementPreimage, SofiWireError, TraderFulfillmentBody, TraderPrecommitBody, - ValidationRef, + SettlementPreimage, SofiWireError, TraderFulfillmentBody, TraderPrecommitBody, ValidationRef, }; use dsm::types::error::DsmError; -use crate::sdk::route_seats::{ - keep_completion, read_cell, write_recorded_position, NodeSeats, WriteReport, -}; -use crate::sdk::sofi_evidence::{Acquired, ACQUIRE_ROUNDS, LOCATOR_BUDGET}; -use crate::sdk::sofi_exercise::read_attempt_cell; -use crate::sdk::sofi_publish::{fetch_fulfillment, fetch_precommit, fetch_setup_bytes}; -use crate::sdk::storage_io::read_stored_bytes; +use crate::sdk::route_seats::{write_recorded_position, WriteReport}; use crate::sdk::storage_set::StorageSet; type D32 = [u8; 32]; @@ -71,6 +62,20 @@ pub struct InstallRequest<'a> { pub own_objects: &'a BTreeMap>, } +impl<'a> InstallRequest<'a> { + /// The objects the verifier decides conformance over. + pub fn objects(&self) -> ExerciseObjects<'a> { + ExerciseObjects { + precommit: self.precommit, + precommit_signature: self.precommit_signature, + preimage: self.preimage, + fulfillment: self.fulfillment, + fulfillment_signature: self.fulfillment_signature, + own_objects: self.own_objects, + } + } +} + /// Why nothing was installed, or the install stopped before the leader. #[derive(Debug, Clone, PartialEq, Eq)] pub enum InstallError { @@ -143,157 +148,27 @@ pub fn cells_of( /// What the install wrote: the pair's cells, and what the write produced at /// each route position — `K_ful(q)` first, then `K_root(q)`. Registration is -/// not among these: Core reads it from the cells ([`read_registration`]). +/// not among these: the verifier reads it from the cells. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Installed { pub cells: PositionCells, pub reports: [WriteReport; 2], } -/// Cells read per leg when acquiring what an attempt skipped past. The same -/// bound the walk uses (`sofi_resolve::WALK_BUDGET`), for the same reason: -/// past it the earlier keys are unread, never assumed skipped. -pub const PRIOR_ATTEMPT_BUDGET: usize = crate::sdk::sofi_resolve::WALK_BUDGET; - -/// The cells an attempt above zero skips past, read from the committed set: -/// for every leg the fulfillment names at attempt `a`, the storage fact at -/// `K^(0) … K^(a-1)` of that leg's vault at its parent root. -/// -/// ONE PATH, SHARED (owner ruling, §44.4). The producer's install (R9) and -/// the verifier's resolution (R12) read the same cells the same way, because -/// they answer the same question: conformance item 5 requires the key before -/// this one to have a permanent storage resolution. A key whose reads do not -/// decide it yet, or past `budget`, is absent from the map, and conformance -/// names it missing. An attempt of zero has no earlier key and contributes no -/// entry. -pub async fn acquire_prior_attempts( - set: &StorageSet, - precommit: &TraderPrecommitBody, - fulfillment: &TraderFulfillmentBody, - budget: usize, -) -> Result, DsmError> { - let reach = u64::try_from(budget).unwrap_or(u64::MAX); - let mut cells = BTreeMap::new(); - for entry in fulfillment.attempts() { - // F naming a leg P does not is conformance item 4's refusal; there is - // no parent root to read a cell at. - let Some(leg) = precommit - .legs() - .iter() - .find(|l| l.vault_id == entry.vault_id) - else { - continue; - }; - for earlier in 0..entry.attempt.min(reach) { - match read_attempt_cell(set, &leg.vault_id, &leg.parent_root, earlier).await? { - Ok(read) => { - cells.insert((entry.vault_id, earlier), read.fact()); - } - Err(undecided) => { - log::info!("[sofi register] K^({earlier}) is not decided yet: {undecided:?}") - } - } - } - } - Ok(cells) -} - -/// One round of fetching what `FulfillmentConformance(F)` reads: `P` and -/// `P(E)` from the request, every leg's setup under its `ρ` (R8), the -/// closure objects by the rule of each reference kind, and item 5's earlier -/// attempt cells from [`acquire_prior_attempts`]. -async fn gather_conformance( - set: &StorageSet, - request: &InstallRequest<'_>, -) -> Result { - let mut setups = BTreeMap::new(); - for leg in request.precommit.legs() { - if let Resolved::Kept(bytes) = fetch_setup_bytes(set, &leg.setup_ref).await? { - setups.insert(leg.setup_ref, bytes); - } - } - let mut closure = BTreeMap::new(); - for reference in request.preimage.settlement().closure().refs() { - let bytes = match reference { - ValidationRef::ContentAddr { addr, .. } => read_stored_bytes(set, addr).await?, - ValidationRef::Setup { setup_ref } => match fetch_setup_bytes(set, setup_ref).await? { - Resolved::Kept(bytes) => Some(bytes), - Resolved::None | Resolved::Unavailable => None, - }, - ValidationRef::SingleRootClaim { .. } | ValidationRef::ConditionalClaim { .. } => { - request.own_objects.get(reference).cloned() - } - }; - if let Some(bytes) = bytes { - closure.insert(*reference, bytes); - } - } - // Item 1 for a conditional parent: the F whose id P names, by that id. - // Its id is the hash of its body, so the kept bytes are that F. - let parent_fulfillment = match request.precommit.parent_claim_ref() { - ParentClaimRef::Conditional { fulfillment_id } => { - match fetch_fulfillment(set, fulfillment_id).await? { - Resolved::Kept(signed) => Some(signed.body), - Resolved::None | Resolved::Unavailable => None, - } - } - ParentClaimRef::SingleRoot { .. } => None, - }; - Ok(ConformanceEvidence { - precommit: Signed { - body: request.precommit.clone(), - signature: request.precommit_signature.to_vec(), - }, - preimage: request.preimage.clone(), - closure, - setups, - prior_attempts: acquire_prior_attempts( - set, - request.precommit, - request.fulfillment, - PRIOR_ATTEMPT_BUDGET, - ) - .await?, - parent_fulfillment, - }) -} - -/// Acquire what `FulfillmentConformance(F)` reads, and ask Core whether it is -/// complete: `Complete` once conformance reaches a verdict over it, -/// `Exhausted` naming what Core still misses after the retry budget -/// (Amendment S3). -pub async fn acquire_conformance_evidence( - set: &StorageSet, - request: &InstallRequest<'_>, -) -> Result, DsmError> { - let mut missing = Vec::new(); - for round in 1..=ACQUIRE_ROUNDS { - let evidence = gather_conformance(set, request).await?; - match fulfillment_conformance( - request.fulfillment, - request.fulfillment_signature, - &evidence, - ) { - Ok(FulfillmentConformance::Valid | FulfillmentConformance::Invalid(..)) => { - return Ok(Acquired::Complete(evidence)) - } - Err(what) => { - log::info!("[sofi register] round {round}/{ACQUIRE_ROUNDS}: not in hand: {what:?}"); - missing = vec![what]; - } - } - } - Ok(Acquired::Exhausted(missing)) -} - -/// Install `F` at its position: conformance first, then the pair at the -/// leader of `s(q)` and the same bytes at each later seat, continuing an -/// earlier install of the same pair. -pub async fn install_fulfillment( +/// Install `F` at its position: conformance first — over evidence the +/// verifier acquires and decides — then the pair at the leader of `s(q)` +/// and the same bytes at each later seat, continuing an earlier install of +/// the same pair. +pub async fn install_fulfillment( + verifier: &Verifier<'_, R>, set: &StorageSet, request: &InstallRequest<'_>, ) -> Result { - let evidence = match acquire_conformance_evidence(set, request).await? { + let objects = request.objects(); + let evidence = match verifier + .acquire_conformance_evidence(&objects) + .map_err(|e| InstallError::Storage(e.to_string()))? + { Acquired::Complete(evidence) => evidence, Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { return Err(InstallError::Unavailable(missing)) @@ -322,81 +197,3 @@ pub async fn install_fulfillment( } Ok(Installed { cells, reports }) } - -/// `FulfillmentRegistered` at position `q` of trader `(G, DevID)`, derived by -/// Core from the route-chain reads of the two cells (Part II §13, rebuild -/// step R10). `parent_root` is `R_p`, the root the verifier validated itself, -/// from which `s(q)` and the route follow. No member computes or writes any -/// of this. Once registered, the completion proofs of both cells are kept -/// (SoFi Amendment S10). -/// -/// Recognizing a value at `K_ful(q)` needs the `P` it names: every -/// fulfillment envelope any seat holds at the key names one, and those are -/// fetched by id (R8), at most [`LOCATOR_BUDGET`] of them. A `P` the fetch -/// could not decide leaves the cell undecided — the call fails, and a later -/// read can answer — so that no later value is read as the first recognized -/// one while an earlier one's `P` is merely not in hand. The inner `Err` is -/// what Core names as missing from the reads. -pub async fn read_registration( - set: &StorageSet, - genesis: &D32, - device_id: &D32, - position: u64, - parent_root: &D32, -) -> Result, DsmError> { - let cells = position_cells(set, genesis, device_id, position, parent_root)?; - let seats = NodeSeats::new(set)?; - let ful_evidence = read_cell(&seats, cells.fulfillment()).await; - let root_evidence = read_cell(&seats, cells.root().routed()).await; - - let mut precommits: BTreeMap = BTreeMap::new(); - let mut named: Vec = Vec::new(); - for value in crate::sdk::route_seats::carried_values(&ful_evidence) { - if let Some((.., signed)) = recognize_fulfillment(&value) { - let id = *signed.body.precommit_id(); - if !named.contains(&id) { - named.push(id); - } - } - } - if named.len() > LOCATOR_BUDGET { - return Err(storage_err( - "fulfillment register", - format!( - "{} precommits are named at K_ful({position}), past the budget of {LOCATOR_BUDGET}", - named.len() - ), - )); - } - for id in named { - match fetch_precommit(set, &id).await? { - Resolved::Kept(precommit) => { - precommits.insert(id, precommit.body); - } - Resolved::None => {} - Resolved::Unavailable => { - return Err(storage_err( - "fulfillment register", - "a precommit a candidate names could not be fetched", - )) - } - } - } - - let registration = - match fulfillment_registered(&cells, &ful_evidence, &root_evidence, &precommits) { - Ok(registration) => registration, - Err(missing) => return Ok(Err(missing)), - }; - if let Registration::Registered(..) = registration.registration() { - let (.., ful_proof) = fulfillment_completion(&cells, &ful_evidence, &precommits) - .map_err(|missing| storage_err("fulfillment completion", format!("{missing:?}")))? - .ok_or_else(|| storage_err("fulfillment completion", "a final value has no proof"))?; - keep_completion(cells.fulfillment(), &ful_proof)?; - let (.., root_proof) = root_completion(cells.root(), &root_evidence) - .map_err(|missing| storage_err("root claim completion", format!("{missing:?}")))? - .ok_or_else(|| storage_err("root claim completion", "a final value has no proof"))?; - keep_completion(cells.root().routed(), &root_proof)?; - } - Ok(Ok(registration)) -} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs index 18a612fa7..8cc420db0 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_relay.rs @@ -21,13 +21,14 @@ //! every carrier. use dsm::route_chain::CellFact; use dsm::sofi::exercise::attempt_resolution; +use dsm::sofi::resolve::value_of; use dsm::sofi::publication::{Publication, Signed}; use dsm::sofi::storage::Resolved; use dsm::sofi::wire::{TraderFulfillmentBody, TraderPrecommitBody}; use dsm::types::error::DsmError; use crate::sdk::route_seats::{ - read_cell, value_of, write_recorded, write_recorded_position, NodeSeats, WriteReport, + read_cell, write_recorded, write_recorded_position, NodeSeats, WriteReport, }; use crate::sdk::sofi_exercise::{attempt_cell, LegWrite}; use crate::sdk::sofi_publish::{fetch_fulfillment, fetch_precommit}; diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_resolve.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_resolve.rs deleted file mode 100644 index 46e4db2e9..000000000 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_resolve.rs +++ /dev/null @@ -1,476 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -//! Stage 9 of §31 and the walk of §30, rebuild step R12: the reads a -//! resolution stands on, fetched here; the facts, established by Core. -//! -//! Core decides; this module only fetches. For one exercise at one key it -//! first asks Core what the exercise's own bytes refute -//! (`facts::refuted_in_hand`, MR-DSM-0041, MR-DSM-0042): a refuted exercise -//! is classified with nothing read about it — at a walked key, nothing beyond -//! the cell it was found at; for the trader's own position, nothing beyond -//! the registration its caller read to find it. Otherwise it reads what the -//! ladder's facts are established from and hands the reads to -//! `facts::establish`: the position pair (R10), the objects -//! `FulfillmentConformance` and `RouteValidation` consume (R5, R7), each leg's -//! cell at its successor key (R11), and the walk over the earlier keys of that -//! leg's chain. Every read is a Core-evaluated witness bound to what it was -//! read at; every fact is Core's conclusion over them; the verdict is formed -//! nowhere here — `advance_resolved` runs the ladder over the established -//! facts, and `walk` classifies the keys of a chain. -//! -//! Core establishes only over complete reads (Amendment S7). A fact this -//! verifier has not established is never handed to Core in another fact's -//! place: it is [`NotEstablished`], named, and the caller reads and relays -//! again. The one three-valued fact is a leg's parent status, whose -//! `Unavailable` is Core's own value for a parent the verifier's chain has -//! not reached. -use std::collections::BTreeMap; -use std::future::Future; -use std::pin::Pin; - -use dsm::economic::lineage::AdmittedEconomicPosition; -use dsm::sofi::exercise::{AttemptCellRead, RecognizedExercise}; -use dsm::sofi::facts::{ - establish, refuted_in_hand, Established, EstablishedFacts, ExerciseReads, InHandRefutation, - LegReads, NotEstablished, -}; -use dsm::sofi::registration::RegistrationRead; -use dsm::sofi::resolution::{walk, AttemptWalk, KeyFacts, VaultChain, WalkOutcome}; -use dsm::sofi::wire::ValidationRef; -use dsm::types::error::DsmError; - -use crate::sdk::sofi_evidence::{acquire_evidence, Acquired, LocalLeaves}; -use crate::sdk::sofi_exercise::read_attempt_cell; -use crate::sdk::sofi_register::{acquire_conformance_evidence, read_registration, InstallRequest}; -use crate::sdk::storage_set::StorageSet; - -type D32 = [u8; 32]; - -/// Keys one walk examines before it hands back a cursor (Section 23.6): a -/// budget only, never a verdict — resuming at the cursor lands the same -/// answer as one longer walk. -pub const WALK_BUDGET: usize = 16; - -/// How far the facts of one exercise reach into the chains of its OTHER legs: -/// a two-leg route's liveness at leg 2 is a walk over leg 2's earlier keys, -/// whose exercises may themselves be routes. Past this depth a leg's -/// liveness is not established, and the facts of the exercise are not -/// complete. -pub const CHAIN_DEPTH: usize = 2; - -/// What the verifier brings to a resolution: the committed set, its own -/// leaves, the position it resolved itself and the vault ancestry it walked. -/// Every field is an established fact of THIS verifier; none is trusted -/// because somebody sent it. -pub struct Resolver<'a> { - pub set: &'a StorageSet, - /// This device's own `R_econ` leaves, for the trader-leaf pre values of - /// its own routes. - pub local: &'a LocalLeaves, - /// This verifier's own admitted position, when it resolved a conditional - /// one: what a `P` naming that fulfillment as its parent was built on. - /// Core reads what it selected; nothing else resolves a parent, and a - /// conditional parent this verifier did not resolve is not established. - pub parent: Option<&'a AdmittedEconomicPosition>, - /// The canonical chain this verifier established for each vault it needs - /// one for (Section 30), built by `sofi_chain::ChainWalker`. A chain - /// carries generations, which is what lets a parent be refuted - /// (`ParentStatus::Orphaned`) rather than only waited on. A vault with - /// no chain here is `Unavailable` for every leg naming it. - pub chains: &'a BTreeMap, -} - -/// Where a walk over one parent's attempt chain ended, with the exercise -/// that consumed the parent when one did — its consumed route's `V°` post -/// root is the next parent (Section 30, step 3) — and, when it stopped -/// unresolved at a key it could not classify, why. Carries what it -/// classified, so that a walk resumed at its cursor ([`Resolver::continue_walk`]) -/// still stands on every earlier key. -#[derive(Debug)] -pub struct Walked { - pub outcome: WalkOutcome, - /// The walk as Core made it: what a leg's liveness is read from. - pub walk: AttemptWalk, - pub consumed: Option, - pub not_established: Option, - known: BTreeMap, -} - -/// What this verifier knows about one exercise. -#[derive(Debug)] -enum Known { - /// Refuted by its own bytes: nothing else was read. - RefutedInHand(InHandRefutation), - /// The complete facts Core established over the reads. - Facts(Box), -} - -/// One key the walk classified: the read that found the exercise holding -/// it, and what is known about that exercise. -#[derive(Debug)] -struct KeyKnown { - read: AttemptCellRead, - known: Known, -} - -impl KeyKnown { - /// The facts of this key as Core binds them to it: the exercise the read - /// holds, and the facts established for that exercise. - fn key_facts(&self) -> Option> { - match &self.known { - Known::Facts(facts) => KeyFacts::of(&self.read, facts), - Known::RefutedInHand(refutation) => KeyFacts::refuted(&self.read, refutation), - } - } -} - -/// The key an exercise was found at while walking a chain: its read, and the -/// walk over the keys before it, which reached it by skipping every one. -#[derive(Clone, Copy)] -struct WalkedKey<'a> { - read: &'a AttemptCellRead, - reached: &'a AttemptWalk, -} - -type Fut<'s, T> = Pin> + Send + 's>>; - -impl Resolver<'_> { - /// Section 30, step 2: walk the attempt keys of `vault_id` at - /// `parent_root` from `cursor`, reading each cell, establishing what is - /// known about the exercise it holds and letting Core classify it. A - /// skipped key moves on, a consumed key stops with its exercise, anything - /// else is unresolved; a spent budget hands back a cursor. - pub async fn walk_parent( - &self, - vault_id: &D32, - parent_root: &D32, - cursor: u64, - budget: usize, - ) -> Result { - self.walk_chain( - *vault_id, - *parent_root, - cursor, - budget, - CHAIN_DEPTH, - BTreeMap::new(), - ) - .await - } - - /// Resume a walk whose budget ran out (`WalkOutcome::Continue`) at its - /// cursor, over everything it already classified: the answer is the same - /// as one longer walk's, and a key reached this way still has every - /// earlier key behind it for its liveness. - pub async fn continue_walk(&self, previous: Walked, budget: usize) -> Result { - let cursor = match previous.outcome { - WalkOutcome::Continue { cursor } => cursor, - WalkOutcome::Consumed { attempt } - | WalkOutcome::Unresolved { attempt } - | WalkOutcome::CounterExhausted { attempt } => attempt, - }; - self.walk_chain( - *previous.walk.vault_id(), - *previous.walk.parent_root(), - cursor, - budget, - CHAIN_DEPTH, - previous.known, - ) - .await - } - - /// Stage 9 of §31: what this verifier establishes about the trader's own - /// exercise, read back from a leg's cell (`read_attempt_cell`) — how the - /// device finds its own exercise again after a restart (R13) — over the - /// registration of its position, which the caller read to find that - /// exercise. A refuted exercise reads nothing more: its registration is - /// the one fact the ladder asks of it (§24 step 0), and it is in hand. - /// The ladder runs inside `advance_resolved`, over what is returned here. - pub async fn establish_own( - &self, - recognized: &RecognizedExercise, - registration: &RegistrationRead, - ) -> Result, DsmError> { - Ok( - match self - .facts_of(recognized, None, CHAIN_DEPTH, Some(registration)) - .await? - { - Ok(Known::Facts(facts)) => Ok(Established::Facts(facts)), - Ok(Known::RefutedInHand(refutation)) => { - Established::refuted(recognized, &refutation, registration) - } - Err(why) => Err(why), - }, - ) - } - - fn walk_chain( - &self, - vault_id: D32, - parent_root: D32, - cursor: u64, - budget: usize, - depth: usize, - mut known: BTreeMap, - ) -> Fut<'_, Walked> { - Box::pin(async move { - // Core's walk asks for keys in order and stops on the first it - // cannot classify; a key it asks for that is not read yet is read, - // and the walk resumes. The chunking is invisible to the answer. - loop { - let walked = walk(&vault_id, &parent_root, cursor, budget, |attempt| { - known.get(&attempt).and_then(KeyKnown::key_facts) - }); - let outcome = walked.outcome(); - let done = |consumed, not_established, known| Walked { - outcome, - walk: walked, - consumed, - not_established, - known, - }; - match outcome { - WalkOutcome::Unresolved { attempt } if !known.contains_key(&attempt) => { - let read = - match read_attempt_cell(self.set, &vault_id, &parent_root, attempt) - .await? - { - Ok(read) => read, - Err(missing) => { - return Ok(done( - None, - Some(NotEstablished::AttemptCell { - vault_id, - attempt, - missing, - }), - known, - )) - } - }; - // An open key is unresolved, never a skip: no key is - // ever dead. - let Some(exercise) = read.exercise().cloned() else { - return Ok(done(None, None, known)); - }; - // The walk reached this key by skipping every one - // before it. That liveness is stated as the walk - // over them, for Core to read, never as a flag. - let reached = walk( - &vault_id, - &parent_root, - 0, - usize::try_from(attempt).unwrap_or(usize::MAX), - |a| known.get(&a).and_then(KeyKnown::key_facts), - ); - let key = WalkedKey { - read: &read, - reached: &reached, - }; - match self.facts_of(&exercise, Some(key), depth, None).await? { - Ok(facts) => { - known.insert(attempt, KeyKnown { read, known: facts }); - } - Err(why) => return Ok(done(None, Some(why), known)), - } - } - WalkOutcome::Consumed { attempt } => { - let consumed = known - .get(&attempt) - .and_then(|key| key.read.exercise().cloned()); - return Ok(done(consumed, None, known)); - } - WalkOutcome::Unresolved { .. } - | WalkOutcome::CounterExhausted { .. } - | WalkOutcome::Continue { .. } => return Ok(done(None, None, known)), - } - } - }) - } - - /// What is known about one exercise: refuted by its own bytes, or the - /// complete facts Core established over the reads made here. `walked` is - /// the key the exercise was found at, whose cell is in hand and whose - /// liveness the walk established by reaching it. `registration` is the - /// position's registration when the caller already read it; otherwise it - /// is read here. - fn facts_of<'s>( - &'s self, - exercise: &'s RecognizedExercise, - walked: Option>, - depth: usize, - registration: Option<&'s RegistrationRead>, - ) -> Fut<'s, Result> { - Box::pin(async move { - if let Some(refutation) = refuted_in_hand(exercise) { - log::info!( - "[sofi resolve] the exercise is refuted in hand: {:?}", - refutation.refuted() - ); - return Ok(Ok(Known::RefutedInHand(refutation))); - } - let precommit = &exercise.precommit.body; - let fulfillment = &exercise.fulfillment.body; - - // Registration from the position pair (R10). The pair decides - // for every F at q at once; Core reads it. - let registration = match registration { - Some(registration) => registration.clone(), - None => match read_registration( - self.set, - precommit.genesis(), - precommit.device_id(), - fulfillment.position(), - precommit.void_root(), - ) - .await? - { - Ok(registration) => registration, - Err(missing) => return Ok(Err(NotEstablished::Registration(missing))), - }, - }; - - // What FulfillmentConformance reads (R7), the exercise supplying - // the objects only its trader held. - let own: BTreeMap> = exercise - .preimage - .settlement() - .closure() - .refs() - .iter() - .copied() - .zip(exercise.closure.iter().cloned()) - .collect(); - let request = InstallRequest { - precommit, - precommit_signature: &exercise.precommit.signature, - preimage: &exercise.preimage, - fulfillment, - fulfillment_signature: &exercise.fulfillment.signature, - own_objects: &own, - }; - let conformance = match acquire_conformance_evidence(self.set, &request).await? { - Acquired::Complete(evidence) => evidence, - Acquired::Exhausted(missing) | Acquired::NoSource(missing) => { - return Ok(Err(NotEstablished::ConformanceEvidence(missing))) - } - }; - - // What RouteValidation reads (R5). - let evidence = match acquire_evidence( - self.set, - precommit, - &exercise.preimage, - self.local, - ) - .await? - { - Acquired::Complete(evidence) => evidence, - Acquired::Exhausted(missing) => { - return Ok(Err(NotEstablished::RouteEvidence(missing))) - } - Acquired::NoSource(missing) => { - return Ok(Err(NotEstablished::RouteEvidenceHasNoSource(missing))) - } - }; - - // Every leg of P at the attempt F fixed for it: its cell, and the - // walk over the earlier keys of its chain when its attempt is - // above zero. The attempts cover the legs exactly: that is - // conformance item 4, decided in hand. - let mut cells = Vec::with_capacity(precommit.legs().len()); - let mut walks: Vec> = Vec::with_capacity(precommit.legs().len()); - for leg in precommit.legs() { - let attempt = fulfillment - .attempts() - .iter() - .find(|a| a.vault_id == leg.vault_id) - .map(|a| a.attempt) - .ok_or_else(|| { - DsmError::verification( - "resolve: F names no attempt for a leg of P, past its in-hand check", - ) - })?; - let at_walked_key = walked.filter(|k| { - *k.read.vault_id() == leg.vault_id - && *k.read.parent_root() == leg.parent_root - && k.read.attempt() == attempt - }); - let (cell, walk) = match at_walked_key { - Some(key) => (key.read.clone(), Some(*key.reached)), - None => { - let cell = match read_attempt_cell( - self.set, - &leg.vault_id, - &leg.parent_root, - attempt, - ) - .await? - { - Ok(read) => read, - Err(missing) => { - return Ok(Err(NotEstablished::AttemptCell { - vault_id: leg.vault_id, - attempt, - missing, - })) - } - }; - // `AttemptLive`: every earlier key of this leg's - // chain is skipped, established by walking them. - let walk = if attempt == 0 { - None - } else { - let not_live = NotEstablished::AttemptLiveness { - vault_id: leg.vault_id, - attempt, - }; - let Some(below) = depth.checked_sub(1) else { - return Ok(Err(not_live)); - }; - let earlier = usize::try_from(attempt) - .map_or(WALK_BUDGET, |a| a.min(WALK_BUDGET)); - let chain = self - .walk_chain( - leg.vault_id, - leg.parent_root, - 0, - earlier, - below, - BTreeMap::new(), - ) - .await?; - if let Some(why) = chain.not_established { - return Ok(Err(why)); - } - Some(chain.walk) - }; - (cell, walk) - } - }; - cells.push(cell); - walks.push(walk); - } - let legs: Vec> = precommit - .legs() - .iter() - .zip(cells.iter().zip(walks.iter())) - .map(|(leg, (cell, walk))| LegReads { - cell, - chain: self.chains.get(&leg.vault_id), - walk: walk.as_ref(), - }) - .collect(); - let reads = ExerciseReads { - exercise, - registration: ®istration, - conformance: &conformance, - evidence: &evidence, - parent: self.parent, - legs: &legs, - }; - Ok(establish(&reads).map(|facts| Known::Facts(Box::new(facts)))) - }) - } -} diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs index d8c942c27..a05359f6d 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/sdk/sofi_sdk.rs @@ -59,7 +59,7 @@ use dsm::sofi::wire::{ }; use dsm::types::operations::Operation; -use crate::sdk::sofi_evidence::LocalLeaves; +use dsm::sofi::resolve::LocalLeaves; type D32 = [u8; 32]; 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 01f0b2db3..606468315 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 @@ -420,7 +420,7 @@ fn get_database_path() -> Result { /// sent root, a bearer step's anchor leaf and allocation spend) and the frame /// it owes its counterparty, so a restart continues a session instead of /// failing it and a returning link delivers what is owed. -pub const CLIENT_DB_SCHEMA_VERSION: i64 = 23; +pub const CLIENT_DB_SCHEMA_VERSION: i64 = 24; /// A 32-byte column, exactly. Any other length is a corrupt row and an error — /// never padded, never truncated. @@ -573,9 +573,15 @@ fn create_schema(conn: &Connection) -> Result<()> { -- leaf PREIMAGES after this device has walked past g, and a mixture -- of two generations is not a tree. CREATE TABLE IF NOT EXISTS sofi_vault_root( - vault_id BLOB NOT NULL CHECK (length(vault_id) = 32), - generation INTEGER NOT NULL CHECK (generation >= 0), - root BLOB NOT NULL CHECK (length(root) = 32), + vault_id BLOB NOT NULL CHECK (length(vault_id) = 32), + generation INTEGER NOT NULL CHECK (generation >= 0), + root BLOB NOT NULL CHECK (length(root) = 32), + -- v24: past genesis, the root this generation was built on and + -- the E of the operation that consumed it, so the recorded + -- chain links row to row and Core can check the links before it + -- stands on this device's memo (`VaultChain::from_recorded`). + pre_root BLOB CHECK (pre_root IS NULL OR length(pre_root) = 32), + consumed_by BLOB CHECK (consumed_by IS NULL OR length(consumed_by) = 32), PRIMARY KEY (vault_id, generation) ) WITHOUT ROWID; CREATE TABLE IF NOT EXISTS sofi_vault_leaf( diff --git a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/sofi_vault_head.rs b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/sofi_vault_head.rs index 5449b0cef..4603d2c19 100644 --- a/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/sofi_vault_head.rs +++ b/dsm_client/deterministic_state_machine/dsm_sdk/src/storage/client_db/sofi_vault_head.rs @@ -30,6 +30,7 @@ use rusqlite::{params, OptionalExtension, Transaction}; use dsm::economic::tree::EconomicSmt; use dsm::sofi::derive; +use dsm::sofi::resolve::RecordedGenerationRow; use dsm::sofi::validation::{VaultLeafPre, VaultPostState}; use dsm::sofi::wire::{VaultRelationshipLeaf, VaultStateLeaf}; @@ -91,24 +92,125 @@ pub fn record_resolved_with_conn(tx: &Transaction<'_>, post: &VaultPostState) -> } // BOTH ends of the link. A store that kept only post roots would hold a // set and not a chain, and `root_at(v, g)` is what a parent's status is - // asked about. Idempotent: re-resolving the same position writes the same - // two rows. - write_root(tx, vault_id, post.pre_generation(), post.pre_root())?; - write(tx, vault_id, post.generation(), post.root(), &leaves) + // asked about. The pre generation's row stands as it is (it was the post + // of the consumption before, or the genesis); the post generation's row + // records what it was built on and what consumed it. Idempotent: + // re-resolving the same position writes the same two rows, and a + // DIFFERENT root or link at an established generation is a + // contradiction, refused, never an update. + write_root(tx, vault_id, post.pre_generation(), post.pre_root(), None)?; + write( + tx, + vault_id, + post.generation(), + post.root(), + Some((post.pre_root(), post.consumed_by())), + &leaves, + ) } -fn write_root(tx: &Transaction<'_>, vault_id: &D32, generation: u64, root: &D32) -> Result<()> { - tx.execute( - "INSERT INTO sofi_vault_root (vault_id, generation, root) - VALUES (?1, ?2, ?3) - ON CONFLICT(vault_id, generation) DO UPDATE SET root = excluded.root", +/// One generation's row, as recorded. +struct RootRow { + root: D32, + pre_root: Option, + consumed_by: Option, +} + +fn root_row_with_conn( + conn: &rusqlite::Connection, + vault_id: &D32, + generation: u64, +) -> Result> { + conn.query_row( + "SELECT root, pre_root, consumed_by FROM sofi_vault_root + WHERE vault_id = ?1 AND generation = ?2", params![ vault_id.as_slice(), - i64::try_from(generation).map_err(|e| anyhow!("generation overflow: {e}"))?, - root.as_slice(), + i64::try_from(generation).map_err(|e| anyhow!("generation overflow: {e}"))? ], - )?; - Ok(()) + |r| { + Ok(( + r.get::<_, Vec>(0)?, + r.get::<_, Option>>(1)?, + r.get::<_, Option>>(2)?, + )) + }, + ) + .optional()? + .map(|(root, pre_root, consumed_by)| { + Ok(RootRow { + root: digest32(root, "vault root")?, + pre_root: pre_root + .map(|b| digest32(b, "vault pre root")) + .transpose()?, + consumed_by: consumed_by + .map(|b| digest32(b, "consuming operation")) + .transpose()?, + }) + }) + .transpose() +} + +/// Write generation `generation`'s root, with its link — the root it was +/// built on and the operation that consumed that root — when the caller +/// records a consumption. A row already there must agree: the same root, +/// and the same link or none recorded yet. `R*_g` is unique (a successor +/// cell admits one realized consumption per attempt key), so a second, +/// different answer at a generation is a contradiction, never an update. +fn write_root( + tx: &Transaction<'_>, + vault_id: &D32, + generation: u64, + root: &D32, + link: Option<(&D32, &D32)>, +) -> Result<()> { + let generation_i64 = + i64::try_from(generation).map_err(|e| anyhow!("generation overflow: {e}"))?; + match root_row_with_conn(tx, vault_id, generation)? { + None => { + tx.execute( + "INSERT INTO sofi_vault_root (vault_id, generation, root, pre_root, consumed_by) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + vault_id.as_slice(), + generation_i64, + root.as_slice(), + link.map(|(pre, ..)| pre.as_slice()), + link.map(|(.., by)| by.as_slice()), + ], + )?; + Ok(()) + } + Some(existing) => { + if existing.root != *root { + return Err(anyhow!( + "generation {generation} of this vault already established another root" + )); + } + let Some((pre_root, consumed_by)) = link else { + return Ok(()); + }; + match (existing.pre_root, existing.consumed_by) { + (None, None) => { + tx.execute( + "UPDATE sofi_vault_root SET pre_root = ?3, consumed_by = ?4 + WHERE vault_id = ?1 AND generation = ?2", + params![ + vault_id.as_slice(), + generation_i64, + pre_root.as_slice(), + consumed_by.as_slice() + ], + )?; + Ok(()) + } + (Some(pre), Some(by)) if pre == *pre_root && by == *consumed_by => Ok(()), + _ => Err(anyhow!( + "generation {generation} of this vault already records another consumption" + )), + } + } + } } fn write( @@ -116,9 +218,10 @@ fn write( vault_id: &D32, generation: u64, root: &D32, + link: Option<(&D32, &D32)>, leaves: &[(D32, D32, i64, Vec)], ) -> Result<()> { - write_root(tx, vault_id, generation, root)?; + write_root(tx, vault_id, generation, root, link)?; let generation = i64::try_from(generation).map_err(|e| anyhow!("generation overflow: {e}"))?; tx.execute( "DELETE FROM sofi_vault_leaf WHERE vault_id = ?1 AND generation = ?2", @@ -198,33 +301,46 @@ fn root_at_with_conn( /// that attempt key `Consumed`, which is `resolve_position` over the whole /// operation, and `vault_post_states` recomputed the post state rather than /// reading back what the producer stated. Two callers, one writer, one -/// meaning for a row. -/// -/// It never rewrites a generation. `R*_g` is unique — a successor cell admits -/// at most one realized consumption per attempt key — so a second, different -/// answer at the same generation is a contradiction, not an update, and this -/// refuses it instead of silently preferring the later one. +/// meaning for a row, and the writer refuses a different root or link at an +/// established generation (`write_root`). pub fn record_walked(post: &VaultPostState) -> Result<()> { let binding = get_connection()?; let mut conn = binding.lock().unwrap_or_else(|p| p.into_inner()); - for (generation, root) in [ - (post.pre_generation(), *post.pre_root()), - (post.generation(), *post.root()), - ] { - if let Some(existing) = root_at_with_conn(&conn, post.vault_id(), generation)? { - if existing != root { - return Err(anyhow!( - "generation {generation} of this vault already established another root" - )); - } - } - } let tx = conn.transaction()?; record_resolved_with_conn(&tx, post)?; tx.commit()?; Ok(()) } +/// The generations this device recorded for `vault_id`, from generation +/// zero and contiguous, each with the link it recorded: what the verifier +/// anchors at the accepted genesis and checks link by link before it stands +/// on any of it (`VaultChain::from_recorded`). A gap ends the memo there. +pub fn recorded_generations(vault_id: &D32) -> Result> { + let binding = get_connection()?; + let conn = binding.lock().unwrap_or_else(|p| p.into_inner()); + recorded_generations_with_conn(&conn, vault_id) +} + +fn recorded_generations_with_conn( + conn: &rusqlite::Connection, + vault_id: &D32, +) -> Result> { + let mut rows = Vec::new(); + loop { + let generation = u64::try_from(rows.len()).map_err(|e| anyhow!("generation: {e}"))?; + let Some(row) = root_row_with_conn(conn, vault_id, generation)? else { + return Ok(rows); + }; + rows.push(RecordedGenerationRow { + generation, + root: row.root, + pre_root: row.pre_root, + consumed_by: row.consumed_by, + }); + } +} + /// The stored leaves of a vault at one generation, as rows. pub fn leaf_rows(vault_id: &D32, generation: u64) -> Result)>> { let binding = get_connection()?; @@ -369,3 +485,94 @@ pub fn leaves_at( } Ok(Some((head, out))) } + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] // test asserts; a failure here is the signal +mod tests { + use super::*; + use rusqlite::Connection; + + fn db() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory db"); + super::super::create_schema(&conn).expect("the client schema"); + conn.execute_batch("PRAGMA foreign_keys = ON;").ok(); + conn + } + + const V: D32 = [0x51; 32]; + const G0: D32 = [0xA0; 32]; + const R1: D32 = [0xA1; 32]; + const R2: D32 = [0xA2; 32]; + const E1: D32 = [0xE1; 32]; + const E2: D32 = [0xE2; 32]; + const OTHER: D32 = [0x77; 32]; + + /// A recorded generation is written once and stands: the same root and + /// link again is idempotent; a different root, or a different link, at + /// an established generation is refused, never an update — `R*_g` is + /// unique, and the record is this device's own memo of it. + /// MUTATION CONTROL: a writer that updates on conflict turns this red. + #[test] + fn an_established_generation_is_never_rewritten() { + let mut conn = db(); + let tx = conn.transaction().unwrap(); + write_root(&tx, &V, 0, &G0, None).unwrap(); + write_root(&tx, &V, 1, &R1, Some((&G0, &E1))).unwrap(); + // The same again: idempotent. + write_root(&tx, &V, 0, &G0, None).unwrap(); + write_root(&tx, &V, 1, &R1, Some((&G0, &E1))).unwrap(); + // Another root at an established generation. + assert!(write_root(&tx, &V, 1, &OTHER, Some((&G0, &E1))).is_err()); + assert!(write_root(&tx, &V, 0, &OTHER, None).is_err()); + // Another link at an established generation. + assert!(write_root(&tx, &V, 1, &R1, Some((&OTHER, &E1))).is_err()); + assert!(write_root(&tx, &V, 1, &R1, Some((&G0, &E2))).is_err()); + // A pre-generation write of an established post generation leaves + // its link as it is. + write_root(&tx, &V, 1, &R1, None).unwrap(); + let row = root_row_with_conn(&tx, &V, 1).unwrap().unwrap(); + assert_eq!( + (row.root, row.pre_root, row.consumed_by), + (R1, Some(G0), Some(E1)) + ); + // A row first written without its link takes the link once. + write_root(&tx, &V, 2, &R2, None).unwrap(); + write_root(&tx, &V, 2, &R2, Some((&R1, &E2))).unwrap(); + assert!(write_root(&tx, &V, 2, &R2, Some((&R1, &E1))).is_err()); + tx.commit().unwrap(); + } + + /// The memo is read from generation zero, contiguously, with the links + /// each row recorded; a gap ends it there. + #[test] + fn the_recorded_generations_are_read_contiguously_with_their_links() { + let mut conn = db(); + let tx = conn.transaction().unwrap(); + write_root(&tx, &V, 0, &G0, None).unwrap(); + write_root(&tx, &V, 1, &R1, Some((&G0, &E1))).unwrap(); + write_root(&tx, &V, 3, &R2, Some((&R1, &E2))).unwrap(); + tx.commit().unwrap(); + let rows = recorded_generations_with_conn(&conn, &V).unwrap(); + assert_eq!( + rows, + vec![ + RecordedGenerationRow { + generation: 0, + root: G0, + pre_root: None, + consumed_by: None + }, + RecordedGenerationRow { + generation: 1, + root: R1, + pre_root: Some(G0), + consumed_by: Some(E1) + }, + ], + "generation 2 is not recorded, so the memo ends at 1" + ); + assert!(recorded_generations_with_conn(&conn, &OTHER) + .unwrap() + .is_empty()); + } +} diff --git a/specs/requirements/CONFORMANCE_GAPS.md b/specs/requirements/CONFORMANCE_GAPS.md index 5005a048e..98020b76c 100644 --- a/specs/requirements/CONFORMANCE_GAPS.md +++ b/specs/requirements/CONFORMANCE_GAPS.md @@ -1042,31 +1042,32 @@ Tests for policy publication: `dsm_sdk::handlers::token_routes::tests::bytes_tha - Kotlin · `SinglePathWebViewBridge.handleBinaryRpcInternal`: no gate proves that every bridge RPC name the frontend sends is one Kotlin matches. `hasIdentityDirect` was sent for months and answered by the unknown-method arm. - Kotlin · androidTest `AndroidLayerProofTest.claimFaucet`: hand-encodes the ArgPack's `schema_hash` as 32 zero bytes, where the frontend sends none, and swallows the claim's failure. -### 6.30 The SoFi verdict is Core's: facts built from reads, the ladder inside the advance (`fix/sofi-verdict-core-resolver`, 2026-09-26) +### 6.30 The SoFi verdict is Core's: the reads, the facts and the ladder (`fix/sofi-verdict-core-resolver`, 2026-09-26) -Owner ruling (plan of 2026-09-25): the SoFi verdict is Core-derived; Core decides, the SDK fetches. +Owner ruling (plan of 2026-09-25, restated 2026-09-26 — "get the real wire in"): the SoFi verdict is Core-derived; Core decides what is read and what it means, the SDK answers reads. -**Finding.** `dsm::sofi::lineage::advance_resolved(.., resolution: Resolution, receipt)` installed `P.realize_root` (and moved balances) or the predecessor's root on whatever `Resolution` the caller passed; nothing bound that value to the ladder. The SDK ran the ladder in `sofi_resolve::resolve_recognized` over a `RouteFacts` it assembled itself from plain values — `registered` from its own comparison, `conformance` and `validation` as bare `Validation` values, each leg's `CellFact`, a `ParentStatus` read off a `VaultChain` whose `pub roots` were filled from local rows and post states, `attempt_live` from its own reading of a `WalkOutcome` — and then re-stated the answer to Core. `Void` needed no evidence at all. A forged Void un-spends a consumed debit leg on the trader's own lineage; a forged Realized credits an unconsumed route. Peers catch neither today: the peer walk refuses a resolved SoFi position (P15-9), so the damage was confined to the forger's own head. +**Finding.** `dsm::sofi::lineage::advance_resolved(.., resolution: Resolution, receipt)` installed `P.realize_root` (and moved balances) or the predecessor's root on whatever `Resolution` the caller passed; nothing bound that value to the ladder. The SDK decided what to read (`sofi_resolve.rs`, `sofi_chain.rs`, `sofi_evidence.rs`, `sofi_register.rs`), ran the ladder itself over a `RouteFacts` it assembled from plain values — `registered` from its own comparison, bare `Validation` values for conformance and validation, each leg's `CellFact`, a `ParentStatus` read off a `VaultChain` whose `pub roots` were filled from local rows never checked against anything, `attempt_live` from its own reading of a `WalkOutcome` — and re-stated the answer to Core. `Void` needed no evidence at all. A forged Void un-spends a consumed debit leg on the trader's own lineage; a forged Realized credits an unconsumed route. Peers catch neither today: the peer walk refuses a resolved SoFi position (P15-9), so the damage was confined to the forger's own head. **Resolved** | Location | Finding | State | |---|---|---| -| `dsm/src/sofi/lineage.rs` · `advance_resolved` | Took the resolution from the caller; a realized advance took its evidence from a caller-built `RealizedReceipt`; `RegisteredClaims.conditional` compared a value the SDK read against the derived `C_q`. | Takes `Established` — the facts Core built, or the exercise's in-hand refutation — and the receiving head; runs `resolve_position` itself; installs on that answer alone. `Resolution` is an argument of nothing that installs. The evidence a realized advance derives credits and balances from is the evidence inside the facts, the same bytes the verdict was reached on. `RealizedReceipt` and `RegisteredClaims` deleted: registration is a fact Core read from the pair. Tests: `dsm::sofi::lineage::tests::the_installed_root_follows_the_ladder_over_the_facts`, `facts_the_ladder_does_not_resolve_install_nothing`, `a_refuted_exercise_installs_nothing`, `facts_established_for_another_operation_are_refused`, `a_realized_advance_without_the_evidence_it_was_reached_on_is_refused`. | -| `dsm/src/sofi/facts.rs` (new) · `establish`, `refuted_in_hand`, `Established::refuted` | The facts were SDK-assembled. | Core builds them from reads Core evaluated: a `RegistrationRead` bound to trader, position and `R_p`; an `AttemptCellRead` per leg bound to `(v, R, a)`; an `AttemptWalk` bound to `(v, R)` from key zero for liveness; the vault's `VaultChain`; conformance and validation recomputed over the evidence handed in; the parent from this device's own admitted position. A read of another position or key is `NotEstablished::NotThisExercise`, never a fact. Tests on real cells and the fixture's signed exercise: `dsm::sofi::facts::tests::a_consumed_route_is_established_from_its_reads_and_installs_the_realize_root` (reads → facts → ladder → installed root, nothing stated between), `reads_of_another_position_or_key_establish_nothing`, `an_unregistered_position_is_not_resolved`, `a_parent_the_chain_refutes_voids_and_one_it_has_not_placed_waits`, `an_open_leg_keeps_the_position_unresolved`, `liveness_above_attempt_zero_is_the_walk_that_reached_it`, `a_refuted_position_is_bound_to_its_exercise_and_its_registration`. | -| `dsm/src/sofi/resolution.rs` · `RouteFacts`, `LegFacts`, `KeyFacts`, `VaultChain`, `walk` | Every field public; `walk` classified whatever facts it was handed; `VaultChain.roots` was a public `Vec`. | Fields `pub(crate)` (the ladder's own tests state facts; nothing outside the crate can). `KeyFacts::of` / `KeyFacts::refuted` bind a cell read and the facts of the exercise it holds to the key; `walk` takes the chain's coordinates, treats facts bound to another key as no fact (`dsm::sofi::resolution::tests::a_walk_takes_only_facts_bound_to_the_key_it_asks_about`), and returns an `AttemptWalk` whose `liveness_of` is the one reading of a leg's liveness (`a_walk_establishes_liveness_only_as_far_as_it_reached`). `VaultChain::from_genesis`, `extend` (a post state built on the head only), `from_recorded_generations` (the memo puncture: one caller, pinned). `resolve_position` and `resolve_refuted_in_hand` are `pub(crate)`. | +| `dsm/src/sofi/lineage.rs` · `advance_resolved` | Took the resolution from the caller; a realized advance took its evidence from a caller-built `RealizedReceipt`; `RegisteredClaims.conditional` compared a value the SDK read against the derived `C_q`. | Takes `Established` — the facts Core built, or the exercise's in-hand refutation — and the receiving head; runs `resolve_position` itself; installs on that answer alone. `Resolution` is an argument of nothing that installs. A realized advance derives credits and balances from the evidence inside the facts, the same bytes the verdict was reached on. `RealizedReceipt` and `RegisteredClaims` deleted. Tests: `dsm::sofi::lineage::tests::the_installed_root_follows_the_ladder_over_the_facts`, `facts_the_ladder_does_not_resolve_install_nothing`, `a_refuted_exercise_installs_nothing`, `facts_established_for_another_operation_are_refused`, `a_realized_advance_without_the_evidence_it_was_reached_on_is_refused`. | +| `dsm/src/sofi/resolve.rs` (new) · `Verifier` over `SofiReads` | The SDK decided what was read and in what order: the position pair, the attempt cells, the objects conformance and validation consume, a vault's genesis and the walk of its chain, with acquisition rounds and budgets of its own. | The verifier is Core's, in the shape of the peer lineage walker: `Verifier::{read_registration, read_attempt_cell, acquire_conformance_evidence, acquire_evidence, vault_genesis, chain, walk_parent, continue_walk, establish_own}` decide every read over a `SofiReads` the SDK implements with bytes, cells and this device's own rows (`dsm_sdk/src/sdk/sofi_reads.rs` · `LiveSofiReads`, `block_in_place` on the multi-thread runtime, as `LiveRegisterResolver` does). Every cell is derived by Core from committed state, every read evaluated by Core, every object recognized by Core, every predicate recomputed by Core, every budget Core's (`WALK_BUDGET`, `CHAIN_DEPTH`, `SIBLING_DEPTH`, `GENERATION_BUDGET`, `ACQUIRE_ROUNDS`, `PRIOR_ATTEMPT_BUDGET`, `NAMED_PRECOMMIT_BUDGET`). `LocalLeaves`, `VaultGenesis`, `Acquired` moved into Core. `sofi_resolve.rs`, `sofi_chain.rs`, `sofi_evidence.rs` deleted; `sofi_register.rs` keeps the install only; `sofi_exercise.rs` keeps the build and the write. On the nodes: `dsm_sdk::handlers::node_e2e_tests::*` (the resolve path end to end), `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`. | +| `dsm/src/sofi/facts.rs` (new) · `establish`, `refuted_in_hand`, `Established::refuted` | The facts were SDK-assembled. | Core builds them from reads Core evaluated, each bound to what it was read at: a `RegistrationRead` (trader, position, `R_p`); an `AttemptCellRead` per leg (`(v, R, a)`); an `AttemptWalk` bound to `(v, R)` from key zero, for liveness; the vault's `VaultChain`; conformance and validation recomputed over the evidence; the parent from this device's own admitted position. A read of another position or key is `NotEstablished::NotThisExercise`, never a fact. Tests on real cells and the fixture's signed exercise: `dsm::sofi::facts::tests::a_consumed_route_is_established_from_its_reads_and_installs_the_realize_root`, `reads_of_another_position_or_key_establish_nothing`, `an_unregistered_position_is_not_resolved`, `a_parent_the_chain_refutes_voids_and_one_it_has_not_placed_waits`, `an_open_leg_keeps_the_position_unresolved`, `liveness_above_attempt_zero_is_the_walk_that_reached_it`, `a_refuted_position_is_bound_to_its_exercise_and_its_registration`. | +| `dsm/src/sofi/resolution.rs` · `RouteFacts`, `LegFacts`, `KeyFacts`, `VaultChain`, `walk` | Every field public; `walk` classified whatever facts it was handed; `VaultChain.roots` was a public `Vec`. | Fields `pub(crate)` (the ladder's own tests state facts; nothing outside the crate can). `KeyFacts::of` / `KeyFacts::refuted` bind a cell read and the facts of the exercise it holds to the key; `walk` takes the chain's coordinates, treats facts bound to another key as no fact (`a_walk_takes_only_facts_bound_to_the_key_it_asks_about`), and returns an `AttemptWalk` whose `liveness_of` is the one reading of a leg's liveness (`a_walk_establishes_liveness_only_as_far_as_it_reached`). `VaultChain::from_genesis`, `extend` (a post state built on the head only), `from_recorded` (below). `resolve_position` and `resolve_refuted_in_hand` are `pub(crate)`. | +| `dsm/src/sofi/resolution.rs` · `VaultChain::from_recorded`; `dsm_sdk/src/storage/client_db/sofi_vault_head.rs` (schema 24) | The recorded roots of a vault were read back as the chain, unchecked: a `Vec` of roots from `sofi_vault_root` rows, which `record_resolved_with_conn` overwrote on conflict. | The rows record, past genesis, the root each generation was built on and the `E` of the operation that consumed it (`VaultPostState::consumed_by`). The chain walk reads the genesis from the network every time and stands on the memo only anchored at that accepted genesis and linked row to row (`from_recorded`: row zero is the accepted root, every later row is built on the one before it and names its consumption, generations are contiguous; anything else is `MemoBroken`, a refusal, never a chain). The writer refuses a different root or a different link at an established generation (`write_root`), so a record is written once and stands. Tests: `dsm::sofi::resolution::tests::the_memo_becomes_a_chain_only_anchored_at_the_genesis_and_linked_row_to_row`, `dsm_sdk::storage::client_db::sofi_vault_head::tests::an_established_generation_is_never_rewritten`, `the_recorded_generations_are_read_contiguously_with_their_links`. | | `dsm/src/sofi/exercise.rs` · `attempt_resolution`; `registration.rs` · `fulfillment_registered`, `PositionCells`; `validation.rs` · `VaultPostState` | Returned plain values (`CellReading`, `Registration`); `VaultPostState` had public fields. | `AttemptCellRead` and `RegistrationRead`, bound to what they were read at (`PositionCells` keeps `R_p`); `VaultPostState` private fields, built by `vault_post_states` only, which is what `VaultChain::extend` grows by. | -| `dsm_sdk/src/sdk/sofi_resolve.rs`, `sofi_chain.rs`, `sofi_advance.rs`, `sofi_flow.rs` | Assembled the facts and named the verdict; a walk resumed at a cursor claimed the walked key's liveness as a flag. | Fetch, and hand the reads to Core (`Resolver::establish_own`, `facts_of`); a resumed walk carries what it classified (`continue_walk`), so a walked key's liveness is a walk from key zero; the chain grows by `VaultChain::extend`. `resolve_pending_position` calls `advance_resolved` with the established facts and the head; `NotResolved::{RootClaimNotFinal, Evidence}` and the second `acquire_evidence` are gone (registration and evidence are inside the facts). `ChainWalker`/`Resolver` take the device's own admitted position; Core reads what it selected. | -| `ci/sofi_validated_root_constructors.sh` §5 | — | Pins: no fact type has a field visible outside the crate; `advance_resolved` takes no `Resolution` and runs the ladder; `VaultChain::from_recorded_generations` has one caller, the chain walker's start; `EstablishedFacts` is stated as a literal nowhere in production. | +| `ci/sofi_validated_root_constructors.sh` §5 | — | Pins: no fact type has a field visible outside the crate; `advance_resolved` takes no `Resolution` and runs the ladder; `VaultChain::from_recorded` has one production caller, the verifier's chain walk; the unchecked memo constructor is gone; `EstablishedFacts` is stated as a literal nowhere in production. | | `tla/DSM_SofiFulfillment.tla`, `lean4/DSMSofiAtomicity.lean` | The models stated the ladder but took the installed root as a free value (`ValidatedThrough (res : Nat → Resolution)`), the shape of the code defect. | TLA: `installed` and the `Install` action; invariant `InstalledRootIsTheLadders` in the base config (10.2M states, no error); mutation `InstallFromCaller` (`_InstallOnCallerValue.cfg`, expected violation) and non-vacuity `_InstallReachable.cfg`, both registered in `tla_runner.rs`. Lean: `installedRoot` with `realize_root_installed_iff_ladder_realized`, `void_root_installed_iff_ladder_void`, `nothing_installed_iff_ladder_selects_no_root`; `ValidatedThroughFacts` over the ladder with `advance_on_the_ladder_extends_validated_lineage` and `no_install_extends_nothing`. | **Open** | Location | Hole | |---|---| -| `dsm_sdk/src/storage/client_db/sofi_vault_head.rs` · `sofi_vault_root` rows → `VaultChain::from_recorded_generations` | The rows are this device's memo of generations Core established; the constructor checks nothing beyond the caller handing them contiguously from generation zero. A row cannot prove the consumption it memoizes happened — the same class as the peer walker's memo start (`ValidatedEconomicRoot::from_verifier_memo`). `record_walked` refuses a different root at an established generation; `record_resolved_with_conn` still overwrites a generation's root on conflict (schema: no pre-root column to chain rows by). | -| `dsm/src/economic/provenance.rs` · P15-9 | The peer walk still refuses a resolved SoFi position. Lifting it needs the resolver's orchestration — fetch order, budgets, the chain walk — in Core too; that stays in the SDK (`sofi_resolve.rs`, `sofi_chain.rs`). | -| `dsm/src/sofi/facts.rs` · `establish` | The conformance and route evidence bundles are fetched by the SDK; Core recomputes the predicates over them and cannot tell a withheld item from an absent one beyond what the predicates read. Withholding yields `NotEstablished` — no verdict — never a wrong one. Another trader's route remains unresolvable by this verifier (§6.5: no source for its trader leaf pre values). | +| `dsm/src/sofi/resolve.rs` · `Verifier::chain` | What a recorded generation memoizes — that the consumption it names happened — is not re-established from the network on a later walk; the memo is anchored at the accepted genesis and linked, and that is what can be checked without walking again. The same class as the peer walker's memo start. | +| `dsm/src/economic/provenance.rs` · P15-9 | The peer walk still refuses a resolved SoFi position. The verifier that could establish one is Core's now; wiring it into the peer walk (a `Verifier` over the peer's position, with the peer's own leaves as its `NoSource`) is that round's work. | +| `dsm/src/sofi/resolve.rs` · `acquire_evidence` | Another trader's route remains unresolvable by a verifier that is not the trader (`Acquired::NoSource`): `TraderSideValid` reads the trader's leaf pre values and no section names where a foreign verifier gets them (§6.5). | ## 7 Totals @@ -1125,8 +1126,8 @@ Owner ruling (plan of 2026-09-25): the SoFi verdict is Core-derived; Core decide | MR-DSM-0038 | Met | `dsm::economic::lineage::advance_validated` | `dsm::economic_admission_lifecycle::a_registered_root_disagreeing_with_the_witness_is_refused` | `advance_validated` refuses a registered root that disagrees with the witness. | | MR-DSM-0039 | Partial | dsm · economic/lineage.rs::advance_validated (605-610) | — | Registration-then-accept order enforced; finality half of the conjunction depends on arith.rs's pre-A6 mechanism (0036). | | MR-DSM-0040 | Met | `dsm::economic::lineage::advance_validated` | `dsm::economic_admission_lifecycle::a_faucet_claim_transition_advances_the_validated_lineage` | — | -| MR-DSM-0041 | Met | `dsm_sdk::sdk::sofi_resolve::Resolver::facts_of`; `dsm::sofi::conformance::conformance_invalid_in_hand`; `dsm::sofi::validation::route_invalid_in_hand` | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do` | The SoFi locus: what an exercise's own bytes refute is decided before any read, and a refuted exercise is classified with nothing read about it — at a walked key nothing beyond the cell, for the trader's own position nothing beyond the registration its caller read to find it (§24 step 0). The nodes' request logs show the reads. A second registration read on the refuted path was removed (2026-09-24). | -| MR-DSM-0042 | Met | `dsm_sdk::sdk::sofi_resolve::Resolver::facts_of`; `dsm::sofi::resolution::skip_in_hand`; `dsm::sofi::resolution::resolve_refuted_in_hand` | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do` | Nothing is fetched for an exercise refuted in hand: with the in-hand refutation bypassed the nodes are asked for registration cells, indexes and objects, and the test is red. The verdict is the same as over the complete facts (Core). | +| MR-DSM-0041 | Met | `dsm::sofi::resolve::Verifier::facts_of`; `dsm::sofi::conformance::conformance_invalid_in_hand`; `dsm::sofi::validation::route_invalid_in_hand` | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do` | The SoFi locus: what an exercise's own bytes refute is decided before any read, and a refuted exercise is classified with nothing read about it — at a walked key nothing beyond the cell, for the trader's own position nothing beyond the registration its caller read to find it (§24 step 0). The nodes' request logs show the reads. A second registration read on the refuted path was removed (2026-09-24). | +| MR-DSM-0042 | Met | `dsm::sofi::resolve::Verifier::facts_of`; `dsm::sofi::resolution::skip_in_hand`; `dsm::sofi::resolution::resolve_refuted_in_hand` | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone`; `dsm::sofi::resolution::tests::an_in_hand_refutation_answers_as_the_complete_facts_do` | Nothing is fetched for an exercise refuted in hand: with the in-hand refutation bypassed the nodes are asked for registration cells, indexes and objects, and the test is red. The verdict is the same as over the complete facts (Core). | | MR-DSM-0043 | Met | `dsm::economic::register` | `dsm::economic_lineage_register::each_position_of_each_identity_is_its_own_cell` | — | | MR-DSM-0044 | Not code | — | — | Operator durability property. | | MR-DSM-0045 | Not code | — | — | Offline/recovery, out of scope. | @@ -1563,7 +1564,7 @@ Owner ruling (plan of 2026-09-25): the SoFi verdict is Core-derived; Core decide | MR-SOFI-0199 | Met | `dsm::sofi::validation`; `dsm::sofi::admission` (OwnerAuthorityNotActivated, L1441-1446) | `dsm::sofi::validation::tests::a_close_naming_the_reserved_dsm_successor_is_invalid_not_unavailable` | — | | MR-SOFI-0200 | Met | `dsm::economic::write_set::build_write_set` | `dsm::economic::write_set::vault_create_binding_tests::a_creation_whose_every_binding_holds_produces_the_write_set` | — | | MR-SOFI-0201 | Partial | dsm_sdk · sdk/sofi_evidence.rs::fetch_vault_genesis (L127-151, structural decode + vault_id recompute only); dsm · economic/write_set.rs (market/amount/root checks only) | economic/write_set.rs::vault_create_binding_tests (covers root/amount/position/market checks) | Confirmed: no check that generation==0, status==Active, no relationship leaves, storage_set pinned to network set | -| MR-SOFI-0202 | Met | `dsm::sofi::lineage::genesis_accepted`; `dsm_sdk::sdk::sofi_evidence::fetch_vault_genesis` | `dsm::sofi::lineage::genesis_acceptance::a_genesis_the_owner_did_not_create_is_refused` | Stored genesis bytes are accepted only if they are what the owner's validated creation carried; the cited sofi_evidence test module does not exist, and the SDK scan over candidates has no test of its own. | +| MR-SOFI-0202 | Met | `dsm::sofi::lineage::genesis_accepted`; `dsm::sofi::resolve::Verifier::vault_genesis` | `dsm::sofi::lineage::genesis_acceptance::a_genesis_the_owner_did_not_create_is_refused` | Stored genesis bytes are accepted only if they are what the owner's validated creation carried; the cited sofi_evidence test module does not exist, and the SDK scan over candidates has no test of its own. | | MR-SOFI-0203 | Met | `dsm::sofi::validation::swap_vault_post`; `dsm::sofi::validation::validate` | `dsm::sofi::validation::tests::a_well_formed_swap_is_valid` | In the validation fixture the vault owner is the trader and the stated post reserves keep the whole input including the fee, so an owner special case or a fee leaving the reserves would fail this test. | | MR-SOFI-0204 | Met | `dsm::sofi::validation::route_validation`; `dsm::sofi::conformance::fulfillment_conformance`; `dsm::sofi::resolution::resolve_position` | `dsm::sofi::resolution::tests::a_lost_route_is_void_when_valid_and_invalid_when_not`; `dsm::sofi::resolution::tests::a_lost_route_is_void_only_if_it_conformed`; `dsm::sofi::resolution::tests::realized_requires_conformance` | Under Amendment S3 both predicates are now two-valued and the cited three-valued test is gone; the resolution tests change each predicate while the other stays Valid. | | MR-SOFI-0205 | Partial | dsm · sofi/validation.rs::validate (L681-767) | sofi/validation.rs test suite | Same gap as 0136: SetupValid per leg is not evaluated (Evidence has no setup field) | @@ -1600,9 +1601,9 @@ Owner ruling (plan of 2026-09-25): the SoFi verdict is Core-derived; Core decide | MR-SOFI-0236 | Met | `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolution::consumed_route` (one E per route) | `dsm::sofi::resolution::tests::legs_final_on_different_commitments_are_not_one_route` | — | | MR-SOFI-0237 | Met | `dsm::sofi::resolution::route_impossible` | `dsm::sofi::resolution::tests::a_stranded_final_cell_of_an_impossible_route_is_skipped` | — | | MR-SOFI-0238 | Violated | dsm · sofi/resolution.rs · `route_impossible` (arms i–v never read `RouteFacts.conformance`) | none (bug, not absence) | Confirmed by direct read: `route_impossible` checks `validation`/`parent`/`trader_parent`/`position_lost` only; a conformance‑Invalid F whose final cell sits on the walked key is never skippable — `classify_attempt` returns `Unresolved` forever, stranding the DLV attempt chain. | -| MR-SOFI-0239 | Violated | `dsm::sofi::resolution::route_impossible`; `dsm_sdk::sdk::sofi_resolve::Resolver::facts_of` | `dsm::sofi::resolution::tests::a_final_cell_of_a_conformance_invalid_fulfillment_is_skipped`; `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped` | route_impossible leaves conformance out, but its fifth arm PositionLost, which SoFi 23.5 does not list, is set by Resolver::facts_of when the F registered at q is not this F, so the result depends on a particular F. | +| MR-SOFI-0239 | Violated | `dsm::sofi::resolution::route_impossible`; `dsm::sofi::resolve::Verifier::facts_of` | `dsm::sofi::resolution::tests::a_final_cell_of_a_conformance_invalid_fulfillment_is_skipped`; `dsm::sofi::resolution::tests::a_final_cell_whose_fulfillment_lost_its_position_is_skipped` | route_impossible leaves conformance out, but its fifth arm PositionLost, which SoFi 23.5 does not list, is set by Resolver::facts_of when the F registered at q is not this F, so the result depends on a particular F. | | MR-SOFI-0240 | Met | `dsm::sofi::resolution::route_impossible` | `dsm::sofi::resolution::tests::a_statically_invalid_route_is_invalid_before_storage_resolves`; `dsm::sofi::resolution::tests::the_orphan_and_consumed_elsewhere_arms_hold_on_a_valid_route`; `dsm::sofi::resolution::tests::a_conditional_parent_that_selected_another_root_is_invalid` | The cited test was renamed; all four arms are implemented and tested, and the code adds a fifth arm (PositionLost) the spec does not list, recorded under MR-SOFI-0239. | -| MR-SOFI-0241 | Violated | `dsm::sofi::resolution::route_impossible`; `dsm::sofi::resolution::classify_attempt`; `dsm_sdk::sdk::sofi_resolve::Resolver::facts_of` | `dsm::sofi::resolution::tests::a_terminal_parent_that_selected_no_root_is_invalid_and_its_key_skips` | Arm (iv) resolves Invalid, not Void, but Resolver::facts_of stops until conformance and route evidence are complete (never, for another trader's route), so a stranded cell cannot be skipped while evidence is outstanding. | +| MR-SOFI-0241 | Violated | `dsm::sofi::resolution::route_impossible`; `dsm::sofi::resolution::classify_attempt`; `dsm::sofi::resolve::Verifier::facts_of` | `dsm::sofi::resolution::tests::a_terminal_parent_that_selected_no_root_is_invalid_and_its_key_skips` | Arm (iv) resolves Invalid, not Void, but Resolver::facts_of stops until conformance and route evidence are complete (never, for another trader's route), so a stranded cell cannot be skipped while evidence is outstanding. | | MR-SOFI-0242 | Met | `dsm::sofi::resolution::classify_attempt` | `dsm::sofi::resolution::tests::a_final_leg_of_an_incomplete_route_is_not_consumed` | — | | MR-SOFI-0243 | Met | `dsm::sofi::resolution::walk` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent`; `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | On the nodes: a skipped key moves on, and the next trade takes the next key. | | MR-SOFI-0244 | Met | `dsm::sofi::resolution::walk` | `dsm::sofi::resolution::tests::the_walk_is_chunking_equivalent` | — | @@ -1728,7 +1729,7 @@ The deferral also covers MR-DSM-0198 and MR-DSM-0221–0237 (§6.1), and the dBT | MR-STOR-0018 | Met | `dsm::route_chain::evaluate`; `dsm::route_chain::check_completion_proof`; `dsm_storage_node::db::pg::require_durable_commit_posture` | `dsm::route_chain::tests::an_unread_leader_is_missing_and_no_other_seat_stands_in`; `dsm::route_chain::tests::the_state_is_the_count_of_valid_links`; `dsm_storage_node::db::pg::durable_posture_tests::a_weaker_posture_is_refused_and_the_refusal_names_the_setting` | sofi/arith.rs was deleted in #976; an unread leader leaves the cell waiting, a chain short of two further links stays below Final, and a node without durable commit settings refuses to start. | | MR-STOR-0019 | Missing | no code found | — | "seat" is comment vocabulary only | | MR-STOR-0020 | Partial | dsm · sofi/storage.rs `stored`; sofi/arith.rs `resolve` | arith/storage tests | LeaderHeld/Final implemented with the superseded count rule | -| MR-STOR-0021 | Met | `dsm::sofi::storage::keep_verifying`; `dsm::sofi::storage::keep_all_verifying`; `dsm_sdk::sdk::sofi_evidence::fetch_vault_genesis`; `dsm::economic::lineage::advance_validated`; `dsm_sdk::sdk::b0x_sdk::B0xSDK::retrieve_from_b0x_v2`; `dsm_sdk::sdk::inbox_poller::has_pending_settlement_work` | `dsm::sofi::storage::tests::an_unestablished_candidate_is_never_none`; `dsm::sofi::storage::tests::an_unestablished_candidate_makes_discovery_partial`; `dsm_sdk::sdk::sofi_evidence::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`; `dsm_sdk::sdk::sofi_flow::tests::a_setup_scan_that_met_an_unestablished_candidate_is_not_a_refusal`; `dsm::economic_admission_lifecycle::a_register_set_not_established_is_not_a_verdict_about_the_claimant`; `dsm_sdk::sdk::storage_node_sdk::tests::a_member_that_answers_404_took_nothing`; `dsm_sdk::handlers::online_finalize::tests::an_unreadable_counterparty_head_is_never_read_as_genesis`; `dsm_sdk::sdk::inbox_poller::tests::a_lifecycle_stop_is_declined_while_settlement_state_is_unreadable`; `dsm_sdk::handlers::node_e2e_tests::an_inbox_read_that_did_not_cover_every_delivery_is_not_a_complete_sync` | A candidate whose bytes were not established is never read as absence: the single scan is Unavailable past it, discovery is Partial, and the SDK reports a network failure, never "not published" (§6.15). Five more places read an unestablished fact as a verdict and no longer do (§6.25): a register set the resolver could not establish, a 404 from a member, an unreadable cert-chain head, unreadable settlement state, and an inbox no member answered for. | +| MR-STOR-0021 | Met | `dsm::sofi::storage::keep_verifying`; `dsm::sofi::storage::keep_all_verifying`; `dsm::sofi::resolve::Verifier::vault_genesis`; `dsm::economic::lineage::advance_validated`; `dsm_sdk::sdk::b0x_sdk::B0xSDK::retrieve_from_b0x_v2`; `dsm_sdk::sdk::inbox_poller::has_pending_settlement_work` | `dsm::sofi::storage::tests::an_unestablished_candidate_is_never_none`; `dsm::sofi::storage::tests::an_unestablished_candidate_makes_discovery_partial`; `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`; `dsm_sdk::sdk::sofi_flow::tests::a_setup_scan_that_met_an_unestablished_candidate_is_not_a_refusal`; `dsm::economic_admission_lifecycle::a_register_set_not_established_is_not_a_verdict_about_the_claimant`; `dsm_sdk::sdk::storage_node_sdk::tests::a_member_that_answers_404_took_nothing`; `dsm_sdk::handlers::online_finalize::tests::an_unreadable_counterparty_head_is_never_read_as_genesis`; `dsm_sdk::sdk::inbox_poller::tests::a_lifecycle_stop_is_declined_while_settlement_state_is_unreadable`; `dsm_sdk::handlers::node_e2e_tests::an_inbox_read_that_did_not_cover_every_delivery_is_not_a_complete_sync` | A candidate whose bytes were not established is never read as absence: the single scan is Unavailable past it, discovery is Partial, and the SDK reports a network failure, never "not published" (§6.15). Five more places read an unestablished fact as a verdict and no longer do (§6.25): a register set the resolver could not establish, a 404 from a member, an unreadable cert-chain head, unreadable settlement state, and an inbox no member answered for. | | MR-STOR-0022 | Met | `dsm::storage_object::immutable_addr`; `dsm_storage_node::api::objects::immutable::put_immutable` | `dsm::storage_object::tests::the_address_matches_the_spec_construction` | — | | MR-STOR-0023 | Partial | dsm_storage_node · api/objects/immutable.rs `put_immutable` (x-expected-addr) | no test found | No test sends a mismatching address | | MR-STOR-0024 | Violated | dsm_storage_node · db/pg.rs `upsert_object` (legacy store, mounted beside the immutable store) | none | The immutable store has no update path; the legacy store in the same router overwrites | diff --git a/specs/requirements/VERIFICATION_MATRIX.md b/specs/requirements/VERIFICATION_MATRIX.md index afaf71a33..3e1fab901 100644 --- a/specs/requirements/VERIFICATION_MATRIX.md +++ b/specs/requirements/VERIFICATION_MATRIX.md @@ -50,7 +50,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0079, MR-STOR-0094: a ByteCommit backs a route-chain link only when its chain link holds | `dsm` · route_chain.rs · `CommittedAt::chain_link_holds` in `commits`; `dsm_sdk` · sdk/route_seats.rs · `committed_at` (parent fetched) | `dsm::route_chain::tests::a_byte_commit_backs_a_link_only_when_it_follows_its_parent` | Chain-link check removed → red (2026-09-24). | — | | MR-DSM-0083, MR-DSM-0213: a carrier writes to the leader only the values it does not hold | `dsm_sdk` · sdk/route_seats.rs · `from_leader` | `dsm_sdk::sdk::route_seats::tests::a_value_the_leader_already_holds_is_not_written_there_again` | Full batch restored → red (2026-09-24). | — | | MR-SOFI-0306, MR-DSM-0197: no supply is unlimited or zero, and only creation brings supply into being | `dsm` · core/token/policy/policy_validation.rs (`SupplyCap`); policy_enforcement.rs (`SupplyCap`) | `dsm::core::token::policy::policy_validation::tests::a_zero_supply_cap_does_not_validate`; `dsm_sdk::supply_cap_enforcement::supply_cap_denies_a_creation_that_would_exceed_it`; `dsm_sdk::supply_cap_enforcement::supply_cap_gates_only_creation` | Zero check removed → red; gate opened → `supply_cap_partial_history` red; gate over-applied → `supply_cap_gates_only_creation` red (2026-09-24). | — | -| MR-STOR-0021: a storage fact not established from the reads in hand is never read as its negation | `dsm` · sofi/storage.rs · `keep_verifying`, `keep_all_verifying`; `dsm_sdk` · sdk/sofi_evidence.rs · `fetch_vault_genesis`; sdk/sofi_flow.rs · `own_setup_ref` | `dsm::sofi::storage::tests::an_unestablished_candidate_is_never_none`; `dsm::sofi::storage::tests::an_unestablished_candidate_makes_discovery_partial`; `dsm_sdk::sdk::sofi_evidence::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`; `dsm_sdk::sdk::sofi_flow::tests::a_setup_scan_that_met_an_unestablished_candidate_is_not_a_refusal` | `unestablished` flag removed → red; `complete` flag removed → red; `fetch_vault_genesis` completeness ignored → red; `own_setup_ref` completeness ignored → red (2026-09-24). | `lean4/DSMSofiStorage.lean::an_unestablished_candidate_is_never_none`, `lean4/DSMSofiStorage.lean::an_unestablished_candidate_makes_discovery_partial` | +| MR-STOR-0021: a storage fact not established from the reads in hand is never read as its negation | `dsm` · sofi/storage.rs · `keep_verifying`, `keep_all_verifying`; `dsm_sdk` · sdk/sofi_evidence.rs · `fetch_vault_genesis`; sdk/sofi_flow.rs · `own_setup_ref` | `dsm::sofi::storage::tests::an_unestablished_candidate_is_never_none`; `dsm::sofi::storage::tests::an_unestablished_candidate_makes_discovery_partial`; `dsm_sdk::sdk::sofi_reads::tests::an_unestablished_genesis_candidate_is_not_read_as_unpublished`; `dsm_sdk::sdk::sofi_flow::tests::a_setup_scan_that_met_an_unestablished_candidate_is_not_a_refusal` | `unestablished` flag removed → red; `complete` flag removed → red; `fetch_vault_genesis` completeness ignored → red; `own_setup_ref` completeness ignored → red (2026-09-24). | `lean4/DSMSofiStorage.lean::an_unestablished_candidate_is_never_none`, `lean4/DSMSofiStorage.lean::an_unestablished_candidate_makes_discovery_partial` | | DSM Amendment A7: a node holds a spool payload only sealed, and the memo the sender wrote is in no node's bytes | `dsm_sdk` · sdk/b0x_sdk.rs · `seal_for` | `dsm_sdk::handlers::node_e2e_tests::a_transfer_reaches_the_nodes_only_sealed_and_arrives` | Memo replaced by one never sent → red (the positive control) (2026-09-24). | — | | MR-STOR-0143, MR-DSM-0270: at most one value has a valid leader link at a cell; a value is final on three links of one chain | `dsm` · route_chain.rs · `evaluate` | `dsm::route_chain::tests::only_links_of_one_chain_count_toward_final`; `dsm::route_chain::tests::three_links_are_final_two_preserved_one_leader_held` | TLA+: `AnyArrivalLeads` and `CountWithoutLeader` violate `ChainUniqueness`; `NodeRemoves` violates `FinalityStable` (2026-09-24). | `tla/DSM_RouteChain.tla::ChainUniqueness`, `tla/DSM_RouteChain.tla::AtMostOneFinal`, `tla/DSM_RouteChain.tla::StatesNest`, `tla/DSM_RouteChain.tla::FinalSurvivesTwoLosses`, `tla/DSM_RouteChain.tla::FinalityStable` | | Ruling #3: a node starts only on an empty database, created at `SCHEMA_VERSION`, or on one at exactly that version and layout | `dsm_storage_node` · db/pg.rs · `init_db`, `verify_schema_layout` | `dsm_storage_node::db::schema_properties::a_database_at_another_version_is_refused`; `dsm_storage_node::db::schema_properties::a_database_whose_layout_is_not_the_versions_is_refused`; `dsm_storage_node::db::schema_properties::a_database_with_tables_and_no_version_is_refused`; `dsm_storage_node::db::schema_properties::an_empty_database_is_created_at_the_schema_version` | Version check, marker check, column check and index check each removed → red (2026-09-24). | — | @@ -60,7 +60,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | MR-DSM-0270, MR-STOR-0127: a leader link, once held, is held on every later read, and a final value stays final as the cell grows | `dsm` · route_chain.rs · `evaluate` over append-only seats | `dsm::route_chain::tests::the_state_is_the_count_of_valid_links`; `dsm::route_chain::tests::the_first_recognized_value_at_the_leader_holds_the_cell` | TLA+ `NodeRemoves` violates `FinalityStable` (2026-09-24). | `lean4/DSMRouteChain.lean::leader_held_stable`, `lean4/DSMRouteChain.lean::final_stable`, `tla/DSM_RouteChain.tla::FinalityStable`, `tla/DSM_RouteChain.tla::LeaderHeldStable` | | SoFi §50: a token with no genesis supply is not a token; a creation issuing nothing is refused before the fee | `dsm` · types/device_state.rs · `validate_conservation` | `dsm::core::token::policy::policy_validation::tests::a_zero_supply_cap_does_not_validate` | Zero check removed → red (2026-09-24). | `lean4/DSMTokenIssuance.lean::create_with_no_supply_is_refused` | | MR-DSM-0030: every value-moving advance registers its root at the next economic position; a transfer registers the sender's root and a foreign walk validates it as the transfer | `dsm_sdk` · sdk/economic_admission_flow.rs · `finish_admission` → `register_economic_root` | `dsm_sdk::handlers::sender_admission_tests::a_transfer_registers_the_senders_root_at_the_next_position` | `register_economic_root` call removed from `finish_admission` → red (2026-09-24). | — | -| MR-DSM-0041, MR-DSM-0042, SoFi §23–§24: an exercise its own bytes refute is classified with nothing read about it; at a walked key the walk reads the key and nothing else | `dsm_sdk` · sdk/sofi_resolve.rs · `facts_of` (`refuted_in_hand` before any read) | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | In-hand refutation bypassed → the nodes are asked for registration cells, indexes and objects → red (2026-09-24). | — | +| MR-DSM-0041, MR-DSM-0042, SoFi §23–§24: an exercise its own bytes refute is classified with nothing read about it; at a walked key the walk reads the key and nothing else | `dsm` · sofi/resolve.rs · `Verifier::facts_of` (`refuted_in_hand` before any read) | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | In-hand refutation bypassed → the nodes are asked for registration cells, indexes and objects → red (2026-09-24). | — | | SoFi §30, §44.4: the leaves of the generation an operation was built on are served after this device walked past it; a trader's second trade against a vault resolves | `dsm_sdk` · storage/client_db/sofi_vault_head.rs · `leaves_at`, `record_resolved_with_conn` | `dsm_sdk::handlers::node_e2e_tests::a_key_held_by_an_exercise_its_own_bytes_refute_is_skipped_on_those_bytes_alone` | `leaves_at` answers the head's generation whatever root is asked → the second trade is `RetriesExhausted` → red (2026-09-24). | — | | SoFi Amendment S7, storage §3, §6: a trade cut short by a refused write fails on the network with the position fenced, is `RetriesExhausted` while the exercise cannot land, records nothing, and realizes when the writes land | `dsm_sdk` · sdk/sofi_register.rs · `install_fulfillment` (`LeaderUnreached`); sdk/sofi_flow.rs · `settle` | `dsm_sdk::handlers::node_e2e_tests::a_trade_cut_short_by_a_refused_write_is_the_network_status_until_it_lands` | `LeaderUnreached` not refused at install → the trade reports a position it never installed → red (2026-09-24). | — | | SoFi §9, §17.5; storage §9 rule 3: a signed SoFi object is recognized only when its signature verifies under the key its body commits, and an exercise or `K_ful(q)` candidate only when `F`'s key is `P`'s; unsigned bytes first at a leader hold nothing | `dsm` · sofi/publication.rs · `signed_body`; sofi/exercise.rs · `recognize_exercise`; sofi/registration.rs · `names_fulfillment_key` | `dsm::sofi::exercise::tests::an_exercise_counts_only_when_f_and_p_are_signed_under_the_key_p_commits`; `dsm::sofi::registration::tests::only_a_fulfillment_signed_under_its_precommits_key_names_the_key`; `dsm::sofi::publication::tests::an_envelope_is_recognized_only_when_its_signature_verifies_under_its_bodys_key`; `dsm::sofi::validation::tests::a_setup_is_the_traders_only_under_the_key_p_commits`; `dsm_sdk::handlers::node_e2e_tests::an_unsigned_exercise_at_a_successor_key_takes_nothing` | Signature check removed → 4 Core tests + e2e red; `F` key = `P` key removed at either recognizer → its test red (2026-09-25). | — | @@ -105,6 +105,7 @@ Tests named `dsm_sdk::…` run on devices created as wallet creation creates the | One step at a time across the online and offline processes: an offline step in flight with a contact makes the relationship not send-ready, and an online send's reservation holds the offline doors | `dsm_sdk` · handlers/relationship_status.rs · `derive_local_send_status_for_contact`; handlers/app_router_impl.rs · `process_online_transfer_logic`; bluetooth/bilateral_ble_handler.rs · `prepare_bilateral_transaction`, `handle_prepare_request`; security/modal_sync_lock.rs · `STEP_DOOR` | `dsm_sdk::bluetooth::offline_step_tests::an_online_send_waits_for_the_offline_step_in_flight`; `dsm_sdk::bluetooth::offline_step_tests::an_offline_proposal_waits_for_an_online_send_in_progress` | Each red on its named test (2026-09-25): the offline-step clause dropped from the send-ready authority; the reservation check dropped from the offline proposer's door. | `tla/DSM_BilateralLiveness.tla::OnlineSubmit` (no session in flight), `tla/DSM_BilateralLiveness.tla::SenderPrepare` (no reservation) | | One BLE stack per process: init run again for the same identity reuses the live stack, so BLE events and the user's decisions reach one handler, its sessions and its precommitments | `dsm_sdk` · bluetooth/mod.rs · `bluetooth_manager_for`; bluetooth/android_ble_bridge.rs · `get_global_android_bridge` | `dsm_sdk::bluetooth::stack_tests::the_process_holds_one_ble_stack_per_identity` | A stack built on every call → red (2026-09-25). | — | | A refused sender commit is recorded — the failed phase and the relationship's hold for reconcile, one write — or the failure to record it is the answer and the step stays with its ack | `dsm_sdk` · bluetooth/bilateral_ble_handler.rs · `fail_sender_commit`, `fail_session`; storage/client_db/bilateral_sessions.rs · `fail_bilateral_session` | `dsm_sdk::bluetooth::bilateral_ble_handler::tests::a_refusal_that_cannot_be_recorded_changes_nothing`; `dsm_sdk::bluetooth::bilateral_ble_handler::tests::a_sender_commit_that_fails_its_checks_settles_nothing_and_holds_the_relationship` | Each red on its named test (2026-09-25): the hold not required; the hold dropped. | — | -| SoFi §24, MR-SOFI-0195, MR-SOFI-0197: a root is installed at q only on the ladder's answer over facts Core established from its own reads; no caller names the resolution, and a realized route credits only from the evidence the verdict was reached on | `dsm` · sofi/lineage.rs · `advance_resolved` (ladder inside; `Established` and the head are its only inputs); sofi/facts.rs · `establish` | `dsm::sofi::lineage::tests::the_installed_root_follows_the_ladder_over_the_facts`; `dsm::sofi::lineage::tests::facts_established_for_another_operation_are_refused`; `dsm::sofi::lineage::tests::a_realized_advance_without_the_evidence_it_was_reached_on_is_refused`; `dsm::sofi::facts::tests::a_consumed_route_is_established_from_its_reads_and_installs_the_realize_root` | Run 2026-09-26: `advance_resolved` made to install `Resolution::Realized` whatever the ladder answers → `the_installed_root_follows_the_ladder_over_the_facts` and `facts_the_ladder_does_not_resolve_install_nothing` red; restored | TLA `DSM_SofiFulfillment.tla` `InstalledRootIsTheLadders` (falsified by `_InstallOnCallerValue.cfg`; reachable by `_InstallReachable.cfg`); Lean `DSMSofiAtomicity.lean` `realize_root_installed_iff_ladder_realized`, `void_root_installed_iff_ladder_void`, `advance_on_the_ladder_extends_validated_lineage` | +| SoFi §24, MR-SOFI-0195, MR-SOFI-0197: a root is installed at q only on the ladder's answer over facts Core established from its own reads; no caller names the resolution, and a realized route credits only from the evidence the verdict was reached on | `dsm` · sofi/lineage.rs · `advance_resolved` (ladder inside; `Established` and the head are its only inputs); sofi/facts.rs · `establish`; sofi/resolve.rs · `Verifier` (every read Core's, over `SofiReads`) | `dsm::sofi::lineage::tests::the_installed_root_follows_the_ladder_over_the_facts`; `dsm::sofi::lineage::tests::facts_established_for_another_operation_are_refused`; `dsm::sofi::lineage::tests::a_realized_advance_without_the_evidence_it_was_reached_on_is_refused`; `dsm::sofi::facts::tests::a_consumed_route_is_established_from_its_reads_and_installs_the_realize_root` | Run 2026-09-26: `advance_resolved` made to install `Resolution::Realized` whatever the ladder answers → `the_installed_root_follows_the_ladder_over_the_facts` and `facts_the_ladder_does_not_resolve_install_nothing` red; restored | TLA `DSM_SofiFulfillment.tla` `InstalledRootIsTheLadders` (falsified by `_InstallOnCallerValue.cfg`; reachable by `_InstallReachable.cfg`); Lean `DSMSofiAtomicity.lean` `realize_root_installed_iff_ladder_realized`, `void_root_installed_iff_ladder_void`, `advance_on_the_ladder_extends_validated_lineage` | | SoFi §23–§24, storage §9: every fact the ladder reads is bound to the read it came from — the position pair, the attempt key, the chain — and a read of another position or key is no fact | `dsm` · sofi/facts.rs · `establish` (`NotThisExercise`), `Established::refuted`; sofi/resolution.rs · `walk`, `KeyFacts::of` | `dsm::sofi::facts::tests::reads_of_another_position_or_key_establish_nothing`; `dsm::sofi::facts::tests::a_refuted_position_is_bound_to_its_exercise_and_its_registration`; `dsm::sofi::resolution::tests::a_walk_takes_only_facts_bound_to_the_key_it_asks_about` | Run 2026-09-26: the key-binding guard in `walk` removed (facts of any key classified) → `a_walk_takes_only_facts_bound_to_the_key_it_asks_about` red; restored | — | | SoFi §23.6: `AttemptLive(K^(a))` is a walk from the first key of the parent's chain that reached `a`, every earlier key skipped; a walk that started later or stopped short establishes nothing | `dsm` · sofi/resolution.rs · `AttemptWalk::liveness_of`; sofi/facts.rs · `establish` | `dsm::sofi::resolution::tests::a_walk_establishes_liveness_only_as_far_as_it_reached`; `dsm::sofi::facts::tests::liveness_above_attempt_zero_is_the_walk_that_reached_it` | Run 2026-09-26: `AttemptWalk::liveness_of` made to answer for a walk that did not start at key zero → `a_walk_establishes_liveness_only_as_far_as_it_reached` red; restored | — | +| SoFi §30, MR-SOFI-0259: a vault's chain is established from the genesis this verifier accepts, one Core-recomputed consumption at a time; what this device recorded is stood on only anchored at that genesis and linked row to row, and a recorded generation is written once | `dsm` · sofi/resolution.rs · `VaultChain::from_recorded`, `extend`; sofi/resolve.rs · `Verifier::chain`; `dsm_sdk` · storage/client_db/sofi_vault_head.rs · `write_root` | `dsm::sofi::resolution::tests::the_memo_becomes_a_chain_only_anchored_at_the_genesis_and_linked_row_to_row`; `dsm_sdk::storage::client_db::sofi_vault_head::tests::an_established_generation_is_never_rewritten`; `dsm_sdk::storage::client_db::sofi_vault_head::tests::the_recorded_generations_are_read_contiguously_with_their_links` | Run 2026-09-26: `from_recorded` taking the rows as recorded (neither anchored nor linked) → `the_memo_becomes_a_chain_only_anchored_at_the_genesis_and_linked_row_to_row` red; `write_root` updating the root on conflict → `an_established_generation_is_never_rewritten` red; restored | — | diff --git a/tools/vertical_validation/src/tla_runner.rs b/tools/vertical_validation/src/tla_runner.rs index 6e4d7e88c..493c5bea9 100644 --- a/tools/vertical_validation/src/tla_runner.rs +++ b/tools/vertical_validation/src/tla_runner.rs @@ -31,7 +31,10 @@ use crate::tla_trace_replay::{ /// `expected=12` module count in CI, and it exists for the same reason: an /// anti-skip tripwire is cheap, and a silently shrinking formal suite is the /// failure mode that looks most like success. -pub const EXPECTED_STANDARD_SPECS: usize = 85; +/// +/// 87 since `SofiFulfillment/install-on-caller-value` and +/// `SofiFulfillment/install-reachable` (the installed root is the ladder's). +pub const EXPECTED_STANDARD_SPECS: usize = 87; #[derive(Debug, Clone, Serialize)] pub struct TlaSpec {