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)?; 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/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] diff --git a/cartesi-rollups/node/tests/engine_machine.rs b/cartesi-rollups/node/tests/engine_machine.rs index ca431d9d..fd55c64d 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 { @@ -711,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. -#[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). +/// 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 positioning_writes_back_crossed_boundaries() { +fn positioning_publishes_the_target_boundary() { let image = echo_image(); let (guards, mut source) = machine_source(&image); @@ -782,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()); @@ -1220,9 +1164,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 +1184,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 +1200,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 +1490,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() @@ -1694,6 +1641,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 @@ -2014,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 @@ -2045,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 = @@ -2066,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.