From 10f2fb622c23947161f1fa0cdca46c6eb76e9e9b Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Mon, 19 Oct 2026 08:56:22 -0300 Subject: [PATCH 1/5] test(node): replay levels wider than a window on the scratch feeder The whole-epoch differentials (the echo golden epoch_root_r44_h48, the runner's root replayed under both tables, and the corpus mcycle cases) asked the dispute facade for a level-0 root on a fresh store. With no runner rows to serve it, the facade replayed every input window on the dispute feeder, which stored each crossed boundary in full. Production never does that: the runner's rows serve those levels. engine_level_root now replays a level wider than one window on the scratch feeder, generalizing scratch_quartet_root to any stride, so the runner's eager fold is still checked against an independent machine replay. Levels inside one window keep the facade and both collectors; engine_root goes through the same dispatch. --- cartesi-rollups/node/tests/engine_machine.rs | 45 +++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/cartesi-rollups/node/tests/engine_machine.rs b/cartesi-rollups/node/tests/engine_machine.rs index ca431d9d..ae0531d2 100644 --- a/cartesi-rollups/node/tests/engine_machine.rs +++ b/cartesi-rollups/node/tests/engine_machine.rs @@ -241,13 +241,9 @@ fn initialized_storage_under( (dir, storage) } -/// The engine answer for the same span, through the facade. +/// The engine answer for the same span over the echo inputs. fn engine_root(image: &Path, log2_stride: u64, height: u64) -> String { - let (guards, mut source) = machine_source(image); - let quartet = Quartet::level_root(0, log2_stride, height); - let root = source.node(&quartet).unwrap().to_hex(); - drop(guards); - root + engine_root_with_inputs(image, echo_inputs(), &three_level(), log2_stride, height) } fn engine_root_with_inputs( @@ -265,16 +261,22 @@ fn engine_root_with_inputs( ) } -/// The facade's root for one level, replayed on a fresh store. A dense -/// level builds both ways, with the production bulk collector and the -/// stepped reference, which must agree: an answer checked here is never -/// the collect API's alone (computation-hash.md). +/// One level's root, replayed from the template. A level inside one +/// window is the facade's on a fresh store, and a dense one builds both +/// ways, with the production bulk collector and the stepped reference, +/// which must agree: an answer checked here is never the collect API's +/// alone (computation-hash.md). A wider level replays on the scratch +/// feeder: the dispute feeder feeds only the window it was positioned +/// at, and production serves such levels from the runner's rows. fn engine_level_root( image: &Path, inputs: Vec>, geometry: &TournamentGeometry, level: &LevelCoords, ) -> String { + if level.log2_stride + level.height > Structure::PRODUCTION.log2_window_span() { + return scratch_quartet_root(image, &inputs, level).to_hex(); + } let collectors: &[Collector] = if level.log2_stride == 0 { &[Collector::Bulk, Collector::Stepped] } else { @@ -1220,9 +1222,9 @@ fn runner_settlement( } /// The eager runner on a real machine. Under both tables, the root it -/// settles must be the reference CLI's, and the dispute facade must serve -/// that same root both from the runner's rows and by replay on a fresh -/// store. +/// settles must be the reference CLI's, the dispute facade must serve +/// that same root from the runner's rows, and a replay from the template +/// must reproduce it. #[test] #[ignore = "requires verified echo and yield machine images; run `just test-engine-machine`"] fn runner_settles_the_reference_root() { @@ -1240,7 +1242,7 @@ fn runner_settles_the_reference_root() { let (settled, served, final_state) = runner_settlement(&image, inputs.clone(), &geometry); let root = &geometry.levels()[0]; - let fresh = engine_root_with_inputs( + let replayed = engine_root_with_inputs( &image, inputs.clone(), &geometry, @@ -1256,7 +1258,10 @@ fn runner_settles_the_reference_root() { served, settled, "{label}: root served from the runner's rows" ); - assert_eq!(fresh, settled, "{label}: root replayed on a fresh store"); + assert_eq!( + replayed, settled, + "{label}: root replayed from the template" + ); assert_eq!( final_state, goldens[&format!("{program}/final_state")], @@ -1543,18 +1548,18 @@ fn positioning_resumes_from_the_nearest_gap_snapshot() { ); } -/// A stride-0 quartet's root replayed from the template on the scratch -/// feeder: no storage, no crossing. +/// A level's root replayed from the template on the scratch feeder: no +/// storage, no crossing. fn scratch_quartet_root(image: &Path, inputs: &[Vec], level: &LevelCoords) -> Digest { let work = scratch(); - let stf = MachineStf::load(image, Hashing::PerStep) + let stf = MachineStf::load(image, Hashing::for_stride(level.log2_stride)) .unwrap() .with_inputs(inputs.to_vec(), work.path().to_path_buf()); let mut ruler = Ruler::new(stf, Structure::PRODUCTION, inputs.len() as u64); ruler.advance(level.base_cycle).unwrap(); - let end = level.base_cycle + (U256::from(1) << level.height); + let end = level.base_cycle + (U256::from(1) << (level.log2_stride + level.height)); let mut builder = cartesi_sling_node::merkle::MerkleBuilder::default(); - for run in ruler.collect(end, 0).unwrap() { + for run in ruler.collect(end, level.log2_stride).unwrap() { builder.append_repeated(run.hash, run.repetitions); } builder.build().root_hash() From ffa2ed5821eada6bec1b6a67e119d699baf621bd Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Mon, 19 Oct 2026 08:58:34 -0300 Subject: [PATCH 2/5] test(node): pin a rejection fed at a crossed dispute boundary No test fed a rejected input from a positioned boundary above the epoch start, so no test reloaded a directory the crossing had published as the revert checkpoint. Echo rejects input 2: a middle-level root over window 2 crosses inputs 0 and 1, publishes boundary 2, feeds input 2 from it, and must match the scratch replay and end idle on boundary 2's state. --- cartesi-rollups/node/tests/engine_machine.rs | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cartesi-rollups/node/tests/engine_machine.rs b/cartesi-rollups/node/tests/engine_machine.rs index ae0531d2..cb4b27ff 100644 --- a/cartesi-rollups/node/tests/engine_machine.rs +++ b/cartesi-rollups/node/tests/engine_machine.rs @@ -1699,6 +1699,31 @@ fn positioning_over_rejected_inputs_keeps_the_floor() { assert_eq!(staging_leftovers(state_dir.path()), Vec::::new()); } +/// A rejection fed at a positioned boundary above the epoch start reverts +/// to the directory the crossing published. Echo rejects input 2, which +/// runs about 151k mcycles, inside the three-level table's middle level +/// over window 2, so the level ends idle on the reverted state. +#[test] +#[ignore = "requires verified echo and yield machine images; run `just test-engine-machine`"] +fn a_rejection_at_a_crossed_boundary_reverts_to_it() { + let image = echo_image(); + let inputs = encode_inputs(&[&b"zero"[..], b"one", b"two", b"three"]); + let (state_dir, storage) = initialized_storage_with(&image, inputs.clone()); + let level = LevelCoords::new(0, Structure::PRODUCTION.window_start(2), 27, 17); + let mut source = DisputeSource::on_store(storage, 0).unwrap(); + let root = source.node(&level.root()).unwrap(); + assert_eq!(root, scratch_quartet_root(&image, &inputs, &level)); + + let mut storage = Storage::new(state_dir.path()).unwrap(); + let crossed = storage.snapshot_hash(0, 2).unwrap().unwrap(); + assert_ne!(storage.snapshot_hash(0, 0).unwrap(), Some(crossed)); + assert_eq!( + source.prove_last(&level).unwrap().node, + Digest::from(crossed), + "the level ends on the reverted boundary" + ); +} + /// Leaf commitments on a real machine, dense spans and reverts included, /// against the released CLI's answers (see leaf_cases). The CLI computes /// them through the collect API, as the node's bulk collector does, so the From 436fc1c9d6a1dc6c944ebc8ea70acb3e5a508754 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Mon, 19 Oct 2026 08:58:45 -0300 Subject: [PATCH 3/5] refactor(node): feed a dispute ruler only the window it was positioned at The dispute feeder committed every boundary it crossed into the store, fully storing any the store missed, and used that directory as the revert checkpoint. Since the torn-boundary fallback became a stop, no production ruler feeds past the window its boundary was loaded from: positioning crosses on clones and publishes the target, nested levels and level-0 quartets below window height lie inside one window, and the frontier serves the rest. An instrumented run saw 467 positioned rulers and no production feed beyond the positioned window. The feeder now keeps that window and the directory positioning loaded and verified, uses it as the revert checkpoint as the advance feeder uses the batch's, and asserts loudly if asked to feed any other window. Its constructor becomes private. snapshot_resumed_source_matches_template_replay fed two windows through the feeder directly; what it compared, a source resuming from a crossing-published boundary 1 against a template replay, stays pinned by restarted_source_resumes_a_half_built_level, and the window-1 tree shapes by dispute_source_matches_prototype_tree. Tests and docs that described a write-back now describe the crossing. --- .../node/src/engine/machine_stf.rs | 61 ++++++++------- cartesi-rollups/node/src/harness/mod.rs | 2 +- cartesi-rollups/node/tests/engine_machine.rs | 76 +++---------------- docs/node-architecture.md | 7 +- 4 files changed, 47 insertions(+), 99 deletions(-) diff --git a/cartesi-rollups/node/src/engine/machine_stf.rs b/cartesi-rollups/node/src/engine/machine_stf.rs index 250e514a..1903baa3 100644 --- a/cartesi-rollups/node/src/engine/machine_stf.rs +++ b/cartesi-rollups/node/src/engine/machine_stf.rs @@ -46,10 +46,9 @@ use std::path::{Path, PathBuf}; /// aspects of the one fused verb. Scratch carries explicit payload /// vectors and the directory its checkpoints go to (the storage-less /// harnesses: measure, the differential tests). Store is the dispute -/// path's mode: payloads come from the inputs table and the pre-feed -/// snapshot commits into the boundary store; positioning has usually -/// published it already (Positioner::cross), so the commit adopts it, -/// and the row insert is a cross-regime nondeterminism tripwire. +/// path's mode: it feeds only the window it was positioned at, its +/// payload from the inputs table, and the pre-feed snapshot is that +/// window's stored boundary, the directory positioning loaded. /// Advance is the mode of the machine runner and of positioning's /// crossed windows: one window, its payload handed in, and the /// pre-feed snapshot is the batch's closed checkpoint. It may be @@ -65,7 +64,8 @@ enum Feeder { Store { storage: Storage, epoch: u64, - next_input: u64, + window: u64, + boundary: PathBuf, }, Advance { window: u64, @@ -250,15 +250,22 @@ impl MachineStf { matches!(self.feeder, Feeder::Advance { reverted: true, .. }) } - /// Upgrades the feeder into the node's storage: this machine sits - /// at `next_input`'s boundary of `epoch`; every window it feeds - /// from here reads its payload from the inputs table and commits - /// the crossed boundary. - pub fn with_write_back(mut self, storage: Storage, epoch: u64, next_input: u64) -> Self { + /// The dispute path's feeder: this machine was loaded from + /// `boundary`, the stored boundary of `epoch` before input `window`. + /// It feeds that window alone, its payload from the inputs table, + /// and a rejection reloads `boundary`. + fn at_stored_boundary( + mut self, + storage: Storage, + epoch: u64, + window: u64, + boundary: PathBuf, + ) -> Self { self.feeder = Feeder::Store { storage, epoch, - next_input, + window, + boundary, }; self } @@ -375,13 +382,13 @@ impl Stf for MachineStf { Feeder::Store { storage, epoch, - next_input, + window: expected, + boundary, } => { assert_eq!( - window, *next_input, - "windows feed sequentially from the resume point" + window, *expected, + "the dispute stf feeds only the window it was positioned at" ); - *next_input += 1; let payload = storage .input(&InputId { epoch_number: *epoch, @@ -389,13 +396,9 @@ impl Stf for MachineStf { })? .expect("fed windows lie in the ingested contiguous prefix") .data; - // The write-back: this boundary joins the store - // (stored only where regime 1 has not already), and - // the committed directory is the revert point - - // never removed here, it is the store's. - let dir = - storage.commit_boundary_machine(*epoch, window, &root, &mut self.machine)?; - (dir, payload) + // Positioning verified the directory against its row, and + // nothing ran before this feed. + (boundary.clone(), payload) } Feeder::Advance { window: expected, @@ -750,7 +753,7 @@ impl RulerFactory for Positioner { fn ruler_at(&mut self, position: U256, hashing: Hashing) -> Result> { let target = self.structure.decompose(position).input; - let (boundary, stf) = loop { + let (boundary, path, stf) = loop { let (boundary, path) = self .store .nearest_boundary_at_or_before(self.epoch, target)?; @@ -768,9 +771,10 @@ impl RulerFactory for Positioner { // free - committed boundaries carry exact sidecars). // Publication verifies before its rename, so a mismatch is // corrupt local state, which stops the node as the runner's - // cursor does. Skipping it would heal only sidecar damage at - // an accepted input, at the price of full machine stores, - // since the write-back would re-adopt the torn directory. + // cursor does. Skipping it would mean feeding the windows + // above an earlier boundary, since a crossing re-adopts the + // torn directory by its name, and the dispute feeder feeds + // only the window it is positioned at. let expected = self .store .snapshot_hash(self.epoch, boundary.0)? @@ -790,7 +794,7 @@ impl RulerFactory for Positioner { self.cross(boundary.0, expected, to)?; continue; } - break (boundary, stf); + break (boundary, path, stf); }; // The seam's one boundary-to-position conversion. @@ -798,10 +802,11 @@ impl RulerFactory for Positioner { assert!(at <= position, "boundary store answered past the target"); // Left to cross: padding windows past the inputs. - let stf = stf.with_write_back( + let stf = stf.at_stored_boundary( Storage::new(self.store.state_dir())?, self.epoch, boundary.0, + path, ); let mut ruler = Ruler::new_at(stf, self.structure, self.fed_windows, at); ruler.advance(position)?; diff --git a/cartesi-rollups/node/src/harness/mod.rs b/cartesi-rollups/node/src/harness/mod.rs index 6bcbfc9e..1831cf4c 100644 --- a/cartesi-rollups/node/src/harness/mod.rs +++ b/cartesi-rollups/node/src/harness/mod.rs @@ -484,7 +484,7 @@ impl World { ) -> Result { // A private copy of the node's store, as an independent sybil would // have: the adversary reads the node's rolled material, but its - // event cache, quartet rows and write-backs stay out of the node's + // event cache, quartet rows and crossings stay out of the node's // store, so the node computes and harvests everything itself. Its // snapshots link the node's, which are immutable once published; // linked after the copy, so every copied row finds its directory. diff --git a/cartesi-rollups/node/tests/engine_machine.rs b/cartesi-rollups/node/tests/engine_machine.rs index cb4b27ff..fd55c64d 100644 --- a/cartesi-rollups/node/tests/engine_machine.rs +++ b/cartesi-rollups/node/tests/engine_machine.rs @@ -713,68 +713,11 @@ fn dispute_source_matches_prototype_tree() { } } -/// The increment-D differential: a source answering with a mid-epoch -/// snapshot must produce the same tree material as the template -/// replay, on the query shapes the Player sends. The snapshot is the -/// window-1 boundary, produced the production way: a write-back -/// ruler crosses it and commits it into the store the resumed -/// source reads. +/// Positioning into a later window publishes that window's boundary, +/// so the next ruler resumes there. #[test] #[ignore = "requires verified echo and yield machine images; run `just test-engine-machine`"] -fn snapshot_resumed_source_matches_template_replay() { - let image = echo_image(); - - let (_replayed_scratch, mut replayed) = machine_source(&image); - let (resumed_scratch, mut resumed) = machine_source(&image); - - // Cross boundary 1 with a write-back stf: feed(input 0) commits - // boundary 0 (absorbed - the epoch start), feed(input 1) commits - // boundary 1. The post-feed machine is discarded scratch. - { - let stf = MachineStf::load(&image, Hashing::Sampled).unwrap(); - let mut stf = stf.with_write_back(Storage::new(resumed_scratch.path()).unwrap(), 0, 0); - stf.feed(0).unwrap(); - while stf.run_big(u64::MAX).unwrap() > 0 {} - assert!(stf.yielded().unwrap()); - stf.feed(1).unwrap(); - } - let mut check = Storage::new(resumed_scratch.path()).unwrap(); - assert_eq!(check.nearest_boundary_at_or_before(0, 1).unwrap().0.0, 1); - - let level = LevelCoords::new(0, U256::from(1) << 68, 0, 20); - - assert_eq!( - resumed.node(&level.root()).unwrap(), - replayed.node(&level.root()).unwrap(), - "root" - ); - assert_eq!( - resumed.children(&level.root()).unwrap(), - replayed.children(&level.root()).unwrap(), - "children" - ); - let (a, b) = ( - resumed.prove_last(&level).unwrap(), - replayed.prove_last(&level).unwrap(), - ); - assert_eq!(a.node, b.node, "last leaf"); - assert_eq!(a.siblings, b.siblings, "last proof"); - let mid = U256::from(1) << 10; - let (a, b) = ( - resumed.prove_leaf(&level, mid).unwrap(), - replayed.prove_leaf(&level, mid).unwrap(), - ); - assert_eq!(a.node, b.node, "mid leaf"); - assert_eq!(a.siblings, b.siblings, "mid proof"); -} - -/// Positioning into a later window registers that window's boundary, -/// so the next ruler resumes there - and a boundary regime 1 already -/// recorded absorbs identically (the cross-regime tripwire staying -/// silent on agreement). -#[test] -#[ignore = "requires verified echo and yield machine images; run `just test-engine-machine`"] -fn positioning_writes_back_crossed_boundaries() { +fn positioning_publishes_the_target_boundary() { let image = echo_image(); let (guards, mut source) = machine_source(&image); @@ -784,12 +727,11 @@ fn positioning_writes_back_crossed_boundaries() { let (floor, _) = storage.nearest_boundary_at_or_before(0, 1).unwrap(); assert_eq!(floor.0, 0); - // A window-1 quartet: positioning replays across boundary 1. + // A window-1 quartet: positioning crosses input 0. let level = LevelCoords::new(0, U256::from(1) << 68, 0, 20); source.node(&level.root()).unwrap(); - // The replay fed input 0, so boundary 1 is now stored and the - // next positioning starts there. + // Boundary 1 is now stored, and the next positioning starts there. let (boundary, path) = storage.nearest_boundary_at_or_before(0, 1).unwrap(); assert_eq!(boundary.0, 1); assert!(path.join("config.json").exists()); @@ -2044,8 +1986,8 @@ fn node_proof_vectors_hold() { } /// Recovery from a partially built level: a source that stored part of a -/// level (rows commit per build, all or nothing) and wrote back the -/// boundaries its positioning crossed is dropped between operations; a +/// level (rows commit per build, all or nothing) and published the +/// boundary its positioning crossed to is dropped between operations; a /// restarted source over the same state directory must serve the level /// exactly like a fresh store. A build stopped midway is /// `a_stopped_build_resumes_after_its_stored_spans`; atomicity under @@ -2075,7 +2017,7 @@ fn restarted_source_resumes_a_half_built_level() { let mut check = Storage::new(state_dir.path()).unwrap(); assert!( check.snapshot_hash(0, 1).unwrap().is_some(), - "the killed process wrote back boundary 1" + "the killed process published boundary 1" ); let mut restarted = @@ -2096,7 +2038,7 @@ fn restarted_source_resumes_a_half_built_level() { } /// A stopped source keeps what it finished. Positioning stops before -/// crossing an input, so nothing is written back. A tall level root +/// crossing an input, so it publishes nothing. A tall level root /// (height 28, built span by span over its height-20 stratum) reuses the /// spans a descent already stored and stops at the first missing one, and /// a source over the same store then resumes there and serves the level diff --git a/docs/node-architecture.md b/docs/node-architecture.md index e4c3fef0..1f58b969 100644 --- a/docs/node-architecture.md +++ b/docs/node-architecture.md @@ -352,9 +352,10 @@ polling retry loop. Normal filesystem-first publication cannot create that state; it indicates a node bug, external mutation, or an underlying storage failure. Dispute positioning treats a boundary whose loaded root differs from its row -the same way, as corrupt local state to rebuild: skipping it would heal only -sidecar damage at an accepted input, at the price of full machine stores, -since the crossing would re-adopt the torn directory by its name. A boundary +the same way, as corrupt local state to rebuild. It cannot skip it: the +crossing would re-adopt the torn directory by its name, and a dispute ruler +cannot replay from an earlier boundary instead, since it feeds only the window +it was positioned at, whose stored boundary is its revert checkpoint. A boundary whose directory vanished is different: positioning falls back to an earlier one and crosses from there, publishing only the disputed input's boundary. From 42d571ea1759e60f471b7a71e32b97f68cb00e1a Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Mon, 19 Oct 2026 08:58:50 -0300 Subject: [PATCH 4/5] refactor(node): delete the dispute write-back's boundary store commit_boundary_machine and stage_machine_store lost their last caller with the single-window dispute feeder. Production no longer stores a machine in full: the runner and the crossing publish clones through commit_clone and publish_staged, which stay. Their storage tests go too: the write-once row panic stays pinned by insert_boundary_disagreement_panics, the vanished-row skip by nearest_boundary_answers_the_floor_and_self_heals, and republication by a_vanished_dispute_boundary_is_crossed_to_again. The startup sweep still removes .part-* leftovers from older versions. --- cartesi-rollups/node/src/storage/snapshots.rs | 188 +----------------- 1 file changed, 2 insertions(+), 186 deletions(-) diff --git a/cartesi-rollups/node/src/storage/snapshots.rs b/cartesi-rollups/node/src/storage/snapshots.rs index b35abf92..13fa0880 100644 --- a/cartesi-rollups/node/src/storage/snapshots.rs +++ b/cartesi-rollups/node/src/storage/snapshots.rs @@ -97,74 +97,6 @@ pub(super) fn assert_runner_boundary_root( } impl Storage { - /// The dispute write-back: commits a raw machine sitting at an - /// input boundary - stored only when the content-addressed - /// directory is absent, then registered. The row's write-once - /// cell makes the whole verb idempotent AND a cross-regime - /// nondeterminism tripwire: a replay that reaches a boundary - /// regime 1 recorded must produce the identical hash or fail - /// loudly. Returns the committed directory (the caller's revert - /// point). - pub fn commit_boundary_machine( - &mut self, - epoch_number: u64, - input_number: u64, - state_hash: &Hash, - machine: &mut Machine, - ) -> Result { - let actual_hash = machine.root_hash()?; - assert_eq!( - actual_hash, *state_hash, - "dispute boundary CAS key disagrees with the machine root" - ); - let dest = self.stage_machine_store(state_hash, |staging| machine.store(staging))?; - self.insert_boundary(epoch_number, input_number, state_hash)?; - Ok(dest) - } - - /// Serializes into a unique staging directory before durable CAS - /// publication. Staging names are never keyed by hash: the hero's - /// write-back and the runner's batch commit may publish an identical - /// state concurrently, and publish_staged settles the race. - fn stage_machine_store( - &self, - state_hash: &Hash, - store: impl FnOnce(&Path) -> cartesi_machine::error::MachineResult<()>, - ) -> anyhow::Result { - let snapshots = snapshots_path(self.state_dir()); - let dest = snapshot_path(self.state_dir(), state_hash); - - if !dest.exists() { - static SEQ: AtomicU64 = AtomicU64::new(0); - let staging = snapshots.join(format!( - ".part-{}-{}", - std::process::id(), - SEQ.fetch_add(1, Ordering::Relaxed) - )); - - if let Err(machine_err) = store(&staging) { - // cleanup partial store before returning error. - let fs_status = std::fs::remove_dir_all(&staging); - - if let Err(fs_err) = fs_status { - anyhow::bail!( - "Failed to cleanup partial store {fs_err}, caused by {machine_err}" - ); - } else { - return Err(machine_err.into()); - } - } - - return self.publish_staged(staging, state_hash); - } - - // A concurrent publisher may have made the directory visible - // before its own parent fsync completed. Adoption establishes - // durability independently before any caller registers a row. - Machine::sync_stored(&dest)?; - Ok(dest) - } - /// The one durable CAS publication boundary. The staged machine /// is root-verified, synced, and renamed without replacement. /// Only a destination that won the same CAS race makes a failed @@ -242,7 +174,8 @@ impl Storage { } /// Startup ritual: removes staging leftovers a crash orphaned - - /// partial stores (.part-*) and working clones (.work-*). Runs at + /// working clones (.work-*), and the partial stores (.part-*) of + /// versions that still stored dispute boundaries. Runs at /// open, under the directory lock and before seeding, so no clone is /// held yet; never touches committed directories. pub fn sweep_stale_staging(&self) -> Result<()> { @@ -590,86 +523,6 @@ mod tests { let _ = s.insert_boundary(7, 3, &[6u8; 32]); } - /// The dispute write-back verb: stores only when the CAS misses, - /// absorbs identical recommits, and fails loudly on a divergent - /// hash for a written boundary (the cross-regime tripwire). - #[test] - fn commit_boundary_machine_is_idempotent_and_loud() { - let (_handle, mut s) = setup_storage(); - let template = s.snapshot_dir(0, 0).unwrap().unwrap(); - let mut machine = cartesi_machine::machine::Machine::load( - &template, - &cartesi_machine::config::runtime::RuntimeConfig::quiet_console(), - ) - .unwrap(); - let hash = machine.root_hash().unwrap(); - - // The CAS already holds this state (it is the template), so - // the commit only registers the row. - let dest = s - .commit_boundary_machine(7, 3, &hash, &mut machine) - .unwrap(); - assert_eq!(dest, template); - assert_eq!(s.snapshot_dir(7, 3).unwrap().unwrap(), dest); - - // An identical recommit absorbs. - s.commit_boundary_machine(7, 3, &hash, &mut machine) - .unwrap(); - - // A boundary whose state the CAS misses gets stored under the - // root derived from the actual machine. - machine - .write_memory(cartesi_machine::constants::ar::RAM_START, &[0xA5]) - .unwrap(); - let other = machine.root_hash().unwrap(); - assert_ne!(other, hash); - let dest2 = s - .commit_boundary_machine(7, 4, &other, &mut machine) - .unwrap(); - assert_ne!(dest2, dest); - assert!(dest2.join("config.json").exists()); - } - - /// A divergent hash for a written boundary is nondeterminism (the - /// cross-regime tripwire): PANIC, never a retryable error. - #[test] - #[should_panic(expected = "disagrees with its stored row")] - fn commit_boundary_machine_divergence_panics() { - let (_handle, mut s) = setup_storage(); - let template = s.snapshot_dir(0, 0).unwrap().unwrap(); - let mut machine = cartesi_machine::machine::Machine::load( - &template, - &cartesi_machine::config::runtime::RuntimeConfig::quiet_console(), - ) - .unwrap(); - let hash = machine.root_hash().unwrap(); - s.commit_boundary_machine(7, 3, &hash, &mut machine) - .unwrap(); - - machine - .write_memory(cartesi_machine::constants::ar::RAM_START, &[0x5A]) - .unwrap(); - let divergent = machine.root_hash().unwrap(); - assert_ne!(divergent, hash); - let _ = s.commit_boundary_machine(7, 3, &divergent, &mut machine); - } - - #[test] - #[should_panic(expected = "CAS key disagrees")] - fn commit_boundary_machine_refuses_a_supplied_wrong_root() { - let (_handle, mut s) = setup_storage(); - let template = s.snapshot_dir(0, 0).unwrap().unwrap(); - let mut machine = cartesi_machine::machine::Machine::load( - &template, - &cartesi_machine::config::runtime::RuntimeConfig::quiet_console(), - ) - .unwrap(); - let mut wrong = machine.root_hash().unwrap(); - wrong[0] ^= 0xFF; - - let _ = s.commit_boundary_machine(7, 3, &wrong, &mut machine); - } - /// The floor query answers the nearest surviving boundary, /// skipping rows whose directories vanished. #[test] @@ -701,43 +554,6 @@ mod tests { assert!(s.nearest_boundary_at_or_before(6, 100).is_err()); } - /// GC never removes a directory a crossing can adopt (the argument is in - /// node-architecture.md); one that vanished anyway is skipped by - /// positioning and republished by the next crossing. - #[test] - fn a_vanished_dispute_boundary_is_republished() { - let (_handle, mut s) = setup_storage(); - let template = s.snapshot_dir(0, 0).unwrap().unwrap(); - let mut machine = cartesi_machine::machine::Machine::load( - &template, - &cartesi_machine::config::runtime::RuntimeConfig::quiet_console(), - ) - .unwrap(); - let start = machine.root_hash().unwrap(); - s.commit_boundary_machine(7, 0, &start, &mut machine) - .unwrap(); - machine - .write_memory(cartesi_machine::constants::ar::RAM_START, &[0xA5]) - .unwrap(); - let hash = machine.root_hash().unwrap(); - let dest = s - .commit_boundary_machine(7, 4, &hash, &mut machine) - .unwrap(); - - std::fs::remove_dir_all(&dest).unwrap(); - let (floor, _) = s.nearest_boundary_at_or_before(7, 4).unwrap(); - assert_eq!(floor, InputBoundary(0)); - - let republished = s - .commit_boundary_machine(7, 4, &hash, &mut machine) - .unwrap(); - assert_eq!(republished, dest); - assert!(dest.join("config.json").exists()); - assert_eq!(s.snapshot_hash(7, 4).unwrap(), Some(hash)); - let (floor, _) = s.nearest_boundary_at_or_before(7, 4).unwrap(); - assert_eq!(floor, InputBoundary(4)); - } - /// A release orphans exactly the states no remaining boundary names, /// once each: a state a later epoch shares survives. #[test] From 7becfb0c9a78b7f47f5308802c011e7cc6ee79a9 Mon Sep 17 00:00:00 2001 From: gcdepaula Date: Mon, 19 Oct 2026 12:35:24 -0300 Subject: [PATCH 5/5] docs(node): say which stores the dispute machine can serve unaided The frontier comment still promised that a store the runner never processed is served by full-epoch replay. With the single-window feeder the machine answers only quartets that feed at most one input; production never disputes such a store with inputs, since a hero starts only after the runner's roll, and the engine harnesses replay wider levels on the scratch feeder. --- cartesi-rollups/node/src/engine/dispute.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cartesi-rollups/node/src/engine/dispute.rs b/cartesi-rollups/node/src/engine/dispute.rs index 1904f039..cf547824 100644 --- a/cartesi-rollups/node/src/engine/dispute.rs +++ b/cartesi-rollups/node/src/engine/dispute.rs @@ -244,12 +244,15 @@ impl DisputeSource { // beyond the prefix - a dispute descent through a padding // window's root stores its fanout there - and counting those // once bricked reconstruction after the hero's own join. A - // store the runner never processed (the engine harnesses; a - // freshly initialized node) has an empty prefix and the machine - // serves everything - the pre-frontier full-replay behavior. - // A nonzero prefix must match the closed epoch's input count - // exactly; anything else is corruption. The padding value is - // the final boundary hash. + // store the runner never processed has an empty prefix, so the + // machine serves every quartet, which it can only where the + // quartet feeds at most one input: the dispute feeder feeds only + // its positioned window. Production never disputes such a store + // with inputs, since a hero starts only once the runner rolled + // the epoch; the engine harnesses replay wider levels on the + // scratch feeder. A nonzero prefix must match the closed epoch's + // input count exactly; anything else is corruption. The padding + // value is the final boundary hash. let interior_height = structure.log2_window_span() - log2_run_stride; let inputs = storage.input_count(epoch)?; let rows = storage.window_root_count(epoch, log2_run_stride, interior_height, inputs)?;