From a8b4105a9094a55afd179fe35a946f6972ddd78e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:05:26 +0000 Subject: [PATCH 1/3] thought-git-probe: immutable sealed landings, write-path commit witness, typed refusals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- crates/thought-git-probe/src/receipt.rs | 285 ++++++++++++++++++- crates/thought-git-probe/tests/receipt.rs | 320 +++++++++++++++++++++- 2 files changed, 582 insertions(+), 23 deletions(-) diff --git a/crates/thought-git-probe/src/receipt.rs b/crates/thought-git-probe/src/receipt.rs index 94d000006..36f8061b4 100644 --- a/crates/thought-git-probe/src/receipt.rs +++ b/crates/thought-git-probe/src/receipt.rs @@ -15,16 +15,39 @@ //! (dataset, data version, thought id), separate from the data version it //! describes. Data and metadata are two writes, so the table distinguishes a //! staged row from a sealed one. Replay accepts only a sealed row whose seal -//! still matches its contents, and only if the data version it names exists. +//! still matches its contents. //! -//! Lab only. [`BaseRef`] stands in for D-CPG's `DurableRef` (dataset, branch, -//! version), which has no contract type yet; [`VersionStore`] stands in for -//! Lance. Nothing here touches `cycle_driver`, `LanceCycleWriter` or CE64. +//! The data version must also be proven to contain the thought. The write +//! path ([`VersionStore::commit_cycle`]) seals a data version together with a +//! [`CommitWitness`]: the supervisor cycle and the cohort of thoughts that +//! were coalesced into that one version (EXO-55: several thoughts per cycle, +//! one version), each with its output fold and outcome. A landing is accepted +//! only when the witness exists, is intact, names the row's cycle, and lists +//! the receipt's thought with the receipt's output and outcome. A version that +//! merely exists proves nothing. The witness is a fold, not a cryptographic +//! proof, and data and metadata are still two writes: a crash between them +//! leaves a committed thought without a sealed row, which +//! [`MetadataTable::incomplete`] reports. No atomicity across the two is +//! claimed. +//! +//! A sealed row is immutable: restaging under a sealed key is refused unless +//! it is the same receipt (same identity and same live slot), and a row that +//! no longer matches its seal cannot be resealed. An unsealed row (a crash +//! after staging, before sealing) may be restaged only with the same +//! receipt. +//! +//! Lab only, not production-admitted. [`BaseRef`] stands in for D-CPG's +//! `DurableRef` (dataset, branch, version), which has no contract type yet; +//! [`VersionStore`] stands in for Lance, and [`VersionStore::commit_cycle`] +//! for the coalesced per-cycle commit of `LanceCycleWriter` (EXO-55). Nothing +//! here touches `cycle_driver`, `LanceCycleWriter` or CE64; wiring the witness +//! into the real writer, and moving any of these types into the contract, is +//! D-TGB-7's admission decision. use std::collections::BTreeMap; use crate::fixtures::{Cell, Outcome, Thought}; -use crate::oracle::{Givens, Version}; +use crate::oracle::{Givens, Version, next_version}; use crate::slots::{Slot, SlotPool, Stale}; /// A sealed version a thought reads from. Probe stand-in for D-CPG's @@ -41,6 +64,47 @@ pub struct BaseRef { pub struct VersionStore { pub givens: Givens, versions: BTreeMap, + witnesses: BTreeMap, +} + +/// One thought's part of a committed batch, as the write path saw it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Contribution { + /// Fold of the thought's writes as handed to the writer. + pub output: Fold, + pub outcome: Outcome, +} + +/// What the write path records when it seals a data version: the supervisor +/// cycle, every thought coalesced into the version, and a fold over that +/// cohort. Produced only by [`VersionStore::commit_cycle`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitWitness { + pub data: BaseRef, + pub cycle: u64, + pub cohort: BTreeMap, + /// Fold over `data`, `cycle` and every cohort entry. + pub batch: Fold, +} + +fn batch_fold(data: BaseRef, cycle: u64, cohort: &BTreeMap) -> Fold { + let mut f = Fold::default(); + f.add(data.dataset, data.version); + f.add(u64::from(data.branch), cycle); + for (&thought, c) in cohort { + f.add(u64::from(thought), c.output.count); + f.add(c.output.fold, outcome_code(c.outcome)); + } + f +} + +/// Why the write path did not commit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommitError { + /// `at` already holds different contents. + AlreadySealed(BaseRef), + /// One thought appears twice in the batch. + DuplicateThought(u32), } impl VersionStore { @@ -49,6 +113,7 @@ impl VersionStore { Self { givens, versions: BTreeMap::new(), + witnesses: BTreeMap::new(), } } @@ -73,6 +138,58 @@ impl VersionStore { self.versions.get(&at) } + /// The write path: compute the next version from `prior` and the batch + /// coalesced in supervisor cycle `cycle`, seal it under `at`, and record + /// the commit witness for it. Every thought in `batch` is in the cohort, + /// whatever its outcome; only `Commit` thoughts change the cells + /// ([`next_version`]). + /// + /// # Errors + /// [`CommitError::DuplicateThought`]; [`CommitError::AlreadySealed`] when + /// `at` already holds a different version or a different witness. + pub fn commit_cycle( + &mut self, + at: BaseRef, + prior: &Version, + cycle: u64, + batch: &[Thought], + ) -> Result { + let mut cohort = BTreeMap::new(); + for t in batch { + let c = Contribution { + output: fold_writes(&t.writes), + outcome: t.outcome, + }; + if cohort.insert(t.id, c).is_some() { + return Err(CommitError::DuplicateThought(t.id)); + } + } + let witness = CommitWitness { + data: at, + cycle, + batch: batch_fold(at, cycle, &cohort), + cohort, + }; + if matches!(self.witnesses.get(&at), Some(w) if *w != witness) { + return Err(CommitError::AlreadySealed(at)); + } + let version = next_version(&self.givens, prior, batch); + self.seal(at, version) + .map_err(|AlreadySealed(at)| CommitError::AlreadySealed(at))?; + self.witnesses.insert(at, witness.clone()); + Ok(witness) + } + + #[must_use] + pub fn witness(&self, at: BaseRef) -> Option<&CommitWitness> { + self.witnesses.get(&at) + } + + /// Test seam: change a recorded witness behind the reader's back. + pub fn witness_mut(&mut self, at: BaseRef) -> Option<&mut CommitWitness> { + self.witnesses.get_mut(&at) + } + /// Test seam: replace the contents under `at` as if the sealed source had /// been changed behind the receipt's back. Replay must notice. pub fn tamper(&mut self, at: BaseRef, version: Version) { @@ -107,6 +224,28 @@ pub struct ProgramInputs { } impl ProgramInputs { + /// Checked constructor. + /// + /// # Errors + /// [`Refusal::MalformedInputs`] when the lanes differ in length. + pub fn try_new(sources: Vec, targets: Vec) -> Result { + let inputs = Self { sources, targets }; + inputs.check()?; + Ok(inputs) + } + + /// The fields are public, so every consumer re-checks before indexing. + fn check(&self) -> Result<(), Refusal> { + if self.sources.len() == self.targets.len() { + Ok(()) + } else { + Err(Refusal::MalformedInputs { + sources: self.sources.len(), + targets: self.targets.len(), + }) + } + } + /// # Panics /// When the lanes differ in length; a receipt with unequal lanes has no /// defined program. @@ -127,6 +266,18 @@ pub struct ExecConfig { pub modulus: u64, } +impl ExecConfig { + /// # Errors + /// [`Refusal::ZeroModulus`]: the jitter would divide by zero. + fn check(self) -> Result<(), Refusal> { + if self.modulus == 0 { + Err(Refusal::ZeroModulus) + } else { + Ok(()) + } + } +} + pub const EXEC_DEFAULT: ExecConfig = ExecConfig { rng_revision: 1, modulus: 1000, @@ -198,6 +349,8 @@ fn run( if program != DERIVE { return Err(Refusal::UnknownProgram(program)); } + inputs.check()?; + cfg.check()?; Ok((0..inputs.targets.len()) .map(|i| { let (src, dst) = (inputs.sources[i], inputs.targets[i]); @@ -301,6 +454,27 @@ pub enum Refusal { DataMissing(MetaKey), /// The landing names a different thought than the receipt. KeyMismatch(MetaKey), + /// `sources` and `targets` differ in length. + MalformedInputs { + sources: usize, + targets: usize, + }, + /// `ExecConfig::modulus` is 0. + ZeroModulus, + /// A sealed row already holds a different receipt under this key. + AlreadyLanded(MetaKey), + /// An unsealed row already holds a different receipt under this key. + StagedConflict(MetaKey), + /// The data version was not sealed by the write path, so nothing proves + /// which thoughts it contains. + NoCommitWitness(MetaKey), + /// The commit witness no longer matches its own batch fold. + WitnessTorn(MetaKey), + /// The thought is not in the cohort committed into that version. + NotInCohort(MetaKey), + /// The thought is in the cohort, but with another output or outcome, or + /// the row names another cycle. + ContributionMismatch(MetaKey), } impl From for Refusal { @@ -446,10 +620,14 @@ pub struct MetaKey { pub thought: u32, } -/// One metadata row. `seal` is written last and covers every other field. +/// One metadata row. `seal` is written last and covers every other field, +/// including `captured_in` (provenance) and `cycle`, though neither is part of +/// the receipt's identity. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MetaRow { pub receipt: Receipt, + /// The supervisor cycle of the commit witness the row was staged against. + pub cycle: u64, pub seal: Option, } @@ -462,9 +640,12 @@ const fn outcome_code(o: Outcome) -> u64 { } } -fn seal_of(key: MetaKey, r: &Receipt) -> u64 { +/// Call only after `r.inputs.check()`; the loop below indexes both lanes. +fn seal_of(key: MetaKey, cycle: u64, r: &Receipt) -> u64 { let mut f = Fold::default(); let ident = r.identity(); + f.add(cycle, u64::from(r.captured_in.index)); + f.add(u64::from(r.captured_in.generation), 0); f.add(key.data.dataset, key.data.version); f.add(u64::from(key.data.branch), u64::from(key.thought)); f.add(ident.base.dataset, ident.base.version); @@ -489,6 +670,25 @@ fn seal_of(key: MetaKey, r: &Receipt) -> u64 { f.fold ^ f.count } +/// The receipt's contribution, checked against the write path's witness for +/// `key.data`. Returns the witness's cycle. +fn contribution_check(store: &VersionStore, key: MetaKey, r: &Receipt) -> Result { + let w = store + .witness(key.data) + .ok_or(Refusal::NoCommitWitness(key))?; + if w.data != key.data || w.batch != batch_fold(w.data, w.cycle, &w.cohort) { + return Err(Refusal::WitnessTorn(key)); + } + let c = w + .cohort + .get(&key.thought) + .ok_or(Refusal::NotInCohort(key))?; + if c.output != r.output || c.outcome != r.outcome { + return Err(Refusal::ContributionMismatch(key)); + } + Ok(w.cycle) +} + /// The separate, version-linked relation holding landed receipts. #[derive(Debug, Clone, Default)] pub struct MetadataTable { @@ -520,30 +720,61 @@ impl MetadataTable { if key.thought != receipt.thought { return Err(Refusal::KeyMismatch(key)); } + receipt.inputs.check()?; + receipt.config.check()?; if store.get(key.data).is_none() { return Err(Refusal::DataMissing(key)); } + let cycle = contribution_check(store, key, &receipt)?; + if let Some(existing) = self.rows.get(&key) { + let same = existing.receipt.identity() == receipt.identity() + && existing.receipt.captured_in == receipt.captured_in + && existing.cycle == cycle; + return match (existing.seal, same) { + // Restaging the landed receipt itself is a no-op; the seal stays. + (Some(_), true) => Ok(()), + (Some(_), false) => Err(Refusal::AlreadyLanded(key)), + // Crash recovery: the same receipt may be restaged unsealed. + (None, true) => Ok(()), + (None, false) => Err(Refusal::StagedConflict(key)), + }; + } self.rows.insert( key, MetaRow { receipt, + cycle, seal: None, }, ); Ok(()) } - /// Step 2: seal the staged row. Only a sealed row is a landing. + /// Step 2: seal the staged row. Only a sealed row is a landing. Sealing a + /// sealed row again is a no-op if it still matches its seal; a row changed + /// after its seal cannot be resealed. /// /// # Errors - /// [`Refusal::MissingReceipt`] when nothing was staged under `key`. + /// [`Refusal::MissingReceipt`] when nothing was staged under `key`; + /// [`Refusal::MalformedInputs`] / [`Refusal::ZeroModulus`] for a row + /// changed after staging; [`Refusal::Torn`] for a sealed row that no + /// longer matches its seal. pub fn seal(&mut self, key: MetaKey) -> Result<(), Refusal> { let row = self .rows .get_mut(&key) .ok_or(Refusal::MissingReceipt(key))?; - row.seal = Some(seal_of(key, &row.receipt)); - Ok(()) + row.receipt.inputs.check()?; + row.receipt.config.check()?; + let now = seal_of(key, row.cycle, &row.receipt); + match row.seal { + Some(s) if s == now => Ok(()), + Some(_) => Err(Refusal::Torn(key)), + None => { + row.seal = Some(now); + Ok(()) + } + } } #[must_use] @@ -557,23 +788,47 @@ impl MetadataTable { } /// The sealed receipt under `key`, checked against its seal and against - /// the data version it names. + /// the commit witness of the data version it names. /// /// # Errors - /// [`Refusal::MissingReceipt`], [`Refusal::Unsealed`], [`Refusal::Torn`], - /// [`Refusal::DataMissing`]. + /// [`Refusal::MissingReceipt`], [`Refusal::Unsealed`], + /// [`Refusal::MalformedInputs`], [`Refusal::ZeroModulus`], + /// [`Refusal::Torn`], [`Refusal::DataMissing`], and the witness refusals + /// of [`MetadataTable::stage`]. pub fn landed(&self, store: &VersionStore, key: MetaKey) -> Result<&Receipt, Refusal> { let row = self.rows.get(&key).ok_or(Refusal::MissingReceipt(key))?; let seal = row.seal.ok_or(Refusal::Unsealed(key))?; - if seal != seal_of(key, &row.receipt) { + row.receipt.inputs.check()?; + row.receipt.config.check()?; + if seal != seal_of(key, row.cycle, &row.receipt) { return Err(Refusal::Torn(key)); } if store.get(key.data).is_none() { return Err(Refusal::DataMissing(key)); } + if contribution_check(store, key, &row.receipt)? != row.cycle { + return Err(Refusal::ContributionMismatch(key)); + } Ok(&row.receipt) } + /// Thoughts the write path committed into `data` that have no sealed + /// metadata row: a landing whose data write happened and whose metadata + /// write did not. Empty when `data` has no commit witness. + #[must_use] + pub fn incomplete(&self, store: &VersionStore, data: BaseRef) -> Vec { + store.witness(data).map_or_else(Vec::new, |w| { + w.cohort + .keys() + .copied() + .filter(|&thought| { + let key = MetaKey { data, thought }; + !matches!(self.rows.get(&key), Some(r) if r.seal.is_some()) + }) + .collect() + }) + } + /// Replay a landed thought from its sealed row. Needs no slot pool. /// /// # Errors diff --git a/crates/thought-git-probe/tests/receipt.rs b/crates/thought-git-probe/tests/receipt.rs index bc2588464..0280ad642 100644 --- a/crates/thought-git-probe/tests/receipt.rs +++ b/crates/thought-git-probe/tests/receipt.rs @@ -11,8 +11,8 @@ use lance_graph_contract::revision::{ use thought_git_probe::fixtures::{self, Cell, Outcome, Thought, cell, main_version}; use thought_git_probe::oracle::{Version, next_version}; use thought_git_probe::receipt::{ - self, BaseRef, DERIVE, EXEC_DEFAULT, ExecConfig, MetaKey, MetadataTable, Plan, ProgramInputs, - Receipt, Refusal, Replayed, VersionStore, + self, BaseRef, CommitError, DERIVE, EXEC_DEFAULT, ExecConfig, MetaKey, MetadataTable, Plan, + ProgramInputs, Receipt, Refusal, Replayed, VersionStore, }; use thought_git_probe::slots::{Eviction, Slot, SlotPool}; @@ -379,11 +379,6 @@ fn a_partial_metadata_write_cannot_pass_as_a_landing() { let (mut store, base) = setup(); let mut pool = SlotPool::new(Eviction::Fifo); let (slot, original, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); - let landed = next_version( - &store.givens.clone(), - &main_version(), - std::slice::from_ref(&original), - ); let data = BaseRef { dataset: 7, branch: 0, @@ -397,7 +392,9 @@ fn a_partial_metadata_write_cannot_pass_as_a_landing() { table.stage(&pool, &store, key, r.clone()), Err(Refusal::DataMissing(_)) )); - store.seal(data, landed).expect("seal data"); + store + .commit_cycle(data, &main_version(), 1, std::slice::from_ref(&original)) + .expect("commit"); // A key naming another thought than the receipt's. let bad = MetaKey { data, thought: 11 }; @@ -488,3 +485,310 @@ fn identity_ignores_the_slot_but_not_the_seed() { let (_, _, rc) = run(&store, &mut pool_a, Plan { seed: 6, ..p }); assert_ne!(ra.identity(), rc.identity()); } + +fn data_ref(version: u64) -> BaseRef { + BaseRef { + dataset: 7, + branch: 0, + version, + } +} + +/// 11. A sealed landing is immutable. Disables: (a) `stage` inserts over an +/// existing row unconditionally; (b) `seal` overwrites an existing seal. +#[test] +fn a_sealed_landing_cannot_be_replaced_or_resealed() { + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, original, r) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let data = data_ref(2); + store + .commit_cycle(data, &main_version(), 1, std::slice::from_ref(&original)) + .expect("commit"); + let key = MetaKey { data, thought: 10 }; + let mut table = MetadataTable::default(); + + // Crash recovery before the seal: the same receipt may be restaged; a + // different one (same output and outcome, other roots) may not. + table.stage(&pool, &store, key, r.clone()).expect("stage"); + table + .stage(&pool, &store, key, r.clone()) + .expect("restage same"); + let other = Receipt { + roots: r.roots ^ 0b0100, + ..r.clone() + }; + assert_ne!(other.identity(), r.identity(), "anti-vacuity"); + assert_eq!( + table.stage(&pool, &store, key, other.clone()), + Err(Refusal::StagedConflict(key)) + ); + + table.seal(key).expect("seal"); + let sealed = table.row(key).expect("row").clone(); + + // Under a sealed key: the same receipt is a no-op, anything else refuses. + table + .stage(&pool, &store, key, r.clone()) + .expect("restage landed"); + assert_eq!(table.row(key), Some(&sealed), "restage changed the row"); + assert_eq!( + table.stage(&pool, &store, key, other), + Err(Refusal::AlreadyLanded(key)) + ); + assert_eq!( + table.row(key), + Some(&sealed), + "a refused stage changed the row" + ); + + // A row changed after its seal can neither land nor be resealed. + table.row_mut(key).expect("row").receipt.roots ^= 0b0100; + assert_eq!(table.seal(key), Err(Refusal::Torn(key))); + assert!(matches!( + table.replay_landed(&store, key, EXEC_DEFAULT), + Err(Refusal::Torn(_)) + )); + table.row_mut(key).expect("row").receipt.roots ^= 0b0100; + table.seal(key).expect("reseal of an intact row is a no-op"); + assert!(table.replay_landed(&store, key, EXEC_DEFAULT).is_ok()); + + // `captured_in` is outside identity but inside the seal. + table + .row_mut(key) + .expect("row") + .receipt + .captured_in + .generation ^= 1; + assert!(matches!( + table.replay_landed(&store, key, EXEC_DEFAULT), + Err(Refusal::Torn(_)) + )); +} + +/// 12. A landing must be in the cohort the write path committed into that +/// version. Disable: drop the `contribution_check` call from `landed` / +/// `stage` (an existing version then certifies any receipt). +#[test] +fn a_landing_needs_the_write_paths_commit_witness() { + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (slot_a, ta, ra) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let (_, tb, rb) = run(&store, &mut pool, plan(base, 11, Outcome::Commit, 9)); + let (_, tc, _) = run(&store, &mut pool, plan(base, 12, Outcome::Commit, 13)); + + // Two thoughts coalesced into one version (EXO-55): both land. + let v2 = data_ref(2); + let w = store + .commit_cycle(v2, &main_version(), 41, &[ta.clone(), tb.clone()]) + .expect("commit"); + assert_eq!(w.cohort.len(), 2); + let mut table = MetadataTable::default(); + let (ka, kb) = ( + MetaKey { + data: v2, + thought: 10, + }, + MetaKey { + data: v2, + thought: 11, + }, + ); + for (k, r) in [(ka, &ra), (kb, &rb)] { + table.stage(&pool, &store, k, r.clone()).expect("stage"); + table.seal(k).expect("seal"); + assert!(table.landed(&store, k).is_ok(), "{k:?} should land"); + } + + // 1/3. A valid receipt under an unrelated existing version. + let v3 = data_ref(3); + store + .commit_cycle(v3, &main_version(), 42, std::slice::from_ref(&tc)) + .expect("commit v3"); + let k3 = MetaKey { + data: v3, + thought: 10, + }; + assert_eq!( + table.stage(&pool, &store, k3, ra.clone()), + Err(Refusal::NotInCohort(k3)) + ); + // A version sealed outside the write path proves nothing. + let v4 = data_ref(4); + let landed = next_version( + &store.givens.clone(), + &main_version(), + std::slice::from_ref(&ta), + ); + store.seal(v4, landed).expect("plain seal"); + let k4 = MetaKey { + data: v4, + thought: 10, + }; + assert_eq!( + table.stage(&pool, &store, k4, ra.clone()), + Err(Refusal::NoCommitWitness(k4)) + ); + // In the cohort, but with another output: a different run of thought 10. + let (_, _, ra2) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 6)); + assert_ne!(ra2.output, ra.output, "anti-vacuity"); + let mut fresh = MetadataTable::default(); + assert_eq!( + fresh.stage(&pool, &store, ka, ra2), + Err(Refusal::ContributionMismatch(ka)) + ); + // Same thought and output, but committed with another outcome. + let v5 = data_ref(5); + let pruned = Thought::new(10, ta.writes.clone(), Outcome::Prune); + store + .commit_cycle(v5, &main_version(), 43, std::slice::from_ref(&pruned)) + .expect("commit v5"); + let k5 = MetaKey { + data: v5, + thought: 10, + }; + assert_eq!( + fresh.stage(&pool, &store, k5, ra.clone()), + Err(Refusal::ContributionMismatch(k5)) + ); + + // 4. After the live slot is released, replay needs a valid witness. + pool.free(slot_a).expect("free"); + let rep = table + .replay_landed(&store, ka, EXEC_DEFAULT) + .expect("replay"); + assert_same_by_folds(&ta, &rep, &ra); + store.witness_mut(v2).expect("witness").cohort.remove(&11); + assert_eq!( + table.replay_landed(&store, ka, EXEC_DEFAULT), + Err(Refusal::WitnessTorn(ka)) + ); + + // The write path refuses one thought twice, and a second batch under a + // sealed version. + let v6 = data_ref(6); + assert_eq!( + store.commit_cycle(v6, &main_version(), 44, &[ta.clone(), ta.clone()]), + Err(CommitError::DuplicateThought(10)) + ); + assert_eq!( + store.commit_cycle(v3, &main_version(), 45, std::slice::from_ref(&tc)), + Err(CommitError::AlreadySealed(v3)) + ); +} + +/// 13. Missing metadata after the data write is reported, not certified. +/// Disable: `incomplete` returns an empty list. +#[test] +fn a_committed_thought_without_a_sealed_row_is_incomplete() { + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, ta, ra) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let (_, tb, rb) = run(&store, &mut pool, plan(base, 11, Outcome::Commit, 9)); + let v2 = data_ref(2); + store + .commit_cycle(v2, &main_version(), 7, &[ta, tb]) + .expect("commit"); + let mut table = MetadataTable::default(); + assert_eq!(table.incomplete(&store, v2), vec![10, 11]); + let (ka, kb) = ( + MetaKey { + data: v2, + thought: 10, + }, + MetaKey { + data: v2, + thought: 11, + }, + ); + table.stage(&pool, &store, ka, ra).expect("stage a"); + table.seal(ka).expect("seal a"); + // Thought 11 crashed between its data write and its metadata write. + table.stage(&pool, &store, kb, rb).expect("stage b"); + assert_eq!(table.incomplete(&store, v2), vec![11]); + assert_eq!(table.landed(&store, kb), Err(Refusal::Unsealed(kb))); + table.seal(kb).expect("seal b"); + assert!(table.incomplete(&store, v2).is_empty()); +} + +/// 14. Malformed inputs are typed refusals, never panics. Disables: drop +/// `inputs.check()` / `cfg.check()` from `run`, or from `stage`. +#[test] +fn malformed_inputs_refuse_without_panicking() { + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + + assert_eq!( + ProgramInputs::try_new(vec![cell(0, 0)], vec![]), + Err(Refusal::MalformedInputs { + sources: 1, + targets: 0 + }) + ); + + // Public fields bypass the constructor: capture must refuse. + let mut short = plan(base, 10, Outcome::Commit, 5); + short.inputs.sources.pop(); + let (slot, _) = pool.branch(10, None).expect("branch"); + assert_eq!( + receipt::capture(&pool, slot, &store, short).map(|_| ()), + Err(Refusal::MalformedInputs { + sources: 2, + targets: 3 + }) + ); + let mut zero = plan(base, 10, Outcome::Commit, 5); + zero.config.modulus = 0; + assert_eq!( + receipt::capture(&pool, slot, &store, zero).map(|_| ()), + Err(Refusal::ZeroModulus) + ); + + // A receipt mutated after capture: replay, stage and seal refuse. + let (t, r) = + receipt::capture(&pool, slot, &store, plan(base, 10, Outcome::Commit, 5)).expect("capture"); + let mut bad = r.clone(); + bad.inputs.targets.push(cell(7, 2)); + assert!(matches!( + receipt::replay(&bad, &store, EXEC_DEFAULT), + Err(Refusal::MalformedInputs { .. }) + )); + let mut zero_cfg = r.clone(); + zero_cfg.config.modulus = 0; + assert_eq!( + receipt::replay(&zero_cfg, &store, zero_cfg.config).map(|_| ()), + Err(Refusal::ZeroModulus) + ); + + let data = data_ref(2); + store + .commit_cycle(data, &main_version(), 1, std::slice::from_ref(&t)) + .expect("commit"); + let key = MetaKey { data, thought: 10 }; + let mut table = MetadataTable::default(); + assert!(matches!( + table.stage(&pool, &store, key, bad), + Err(Refusal::MalformedInputs { .. }) + )); + assert_eq!( + table.stage(&pool, &store, key, zero_cfg), + Err(Refusal::ZeroModulus) + ); + // A staged row truncated before its seal cannot become a landing. + table.stage(&pool, &store, key, r).expect("stage"); + table + .row_mut(key) + .expect("row") + .receipt + .inputs + .sources + .clear(); + assert!(matches!( + table.seal(key), + Err(Refusal::MalformedInputs { .. }) + )); + assert!(matches!( + table.landed(&store, key), + Err(Refusal::Unsealed(_)) + )); +} From 28faacb858c17bb0d1bf4edb467c519c925d1271 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:06:26 +0000 Subject: [PATCH 2/3] board: D-TGB-4 entry records the landing corrections Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- .../entries/2026-10-21-d-tgb-4-receipt-replay.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md b/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md index 388531213..8c7e1e25d 100644 --- a/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md +++ b/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md @@ -33,3 +33,17 @@ - Fold collisions: the folds are 64-bit witnesses, not proofs. OPEN: `BaseRef` stands in for D-CPG's `DurableRef`, which has no contract type; the plan places receipt, slot and `DurableRef` in `lance-graph-contract` at D-TGB-7. D-RFT-5 can now reference this receipt shape. + +## Correction (post-merge review, `GPT:gpt-ada-01/20261021-pr1674-1682-consolidated-review-01`) + +Written by `session_013z2e1YXkm6o6HNrDn5BPWA` on the operator's instruction (EXO-50 `/038`). Three defects in the merged probe, each now refused, each with a test and a disable that turns it red: + +| defect on main | correction | test | disable | +|---|---|---|---| +| `stage` replaced any row, a sealed landing included; `seal` overwrote an existing seal, so a changed row could be resealed | a sealed key accepts only the same receipt (same identity, same live slot) as a no-op; a different one is `AlreadyLanded`; an unsealed row (crash before the seal) accepts only the same receipt, else `StagedConflict`; `seal` on a sealed row that no longer matches is `Torn` | `a_sealed_landing_cannot_be_replaced_or_resealed` | stage overwrites; seal overwrites | +| `landed` checked only that the named data version exists | the write path, `VersionStore::commit_cycle` (stand-in for the coalesced per-cycle `LanceCycleWriter` commit, EXO-55), seals the version with a `CommitWitness`: cycle, the cohort of every thought coalesced into it with output fold and outcome, and a batch fold. `stage` and `landed` require the witness, an intact batch fold, the row's cycle, and the receipt's thought in the cohort with the receipt's output and outcome (`NoCommitWitness`, `WitnessTorn`, `NotInCohort`, `ContributionMismatch`). Two thoughts in one version both land. `MetadataTable::incomplete` lists committed thoughts with no sealed row | `a_landing_needs_the_write_paths_commit_witness`, `a_committed_thought_without_a_sealed_row_is_incomplete` | drop the cohort check; `incomplete` returns nothing | +| unequal `sources`/`targets` (public fields) panicked; `modulus == 0` divided by zero | `ProgramInputs::try_new`, and checks in `run`, `stage`, `seal` and `landed`: `MalformedInputs`, `ZeroModulus`; a malformed staged row cannot be sealed | `malformed_inputs_refuse_without_panicking` | drop the checks in `run`; drop them in `stage` | + +`captured_in` is now covered by the seal (provenance integrity) and stays outside `identity` (test 10 unchanged); disable: drop it from the seal. + +Still not claimed: atomicity between the data and metadata writes (a crash between them is detected by `incomplete`, not prevented); wiring the witness into the real `LanceCycleWriter`; moving any type into `lance-graph-contract` (D-TGB-7 admission). The folds remain witnesses, not cryptographic proofs. EXO-58 stays Done. From 5b62bc9aec1c2af3af91aeda87cef97ae2659a5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:31:35 +0000 Subject: [PATCH 3/3] thought-git-probe: bind each cohort contribution to its thought in the batch fold The fold sums terms order-independently, and the thought id and its output/outcome were separate terms, so swapping two equal-count contributions left the batch fold unchanged and a receipt for thought 10 carrying thought 11's output landed. Each entry is now one chained term. Test 15 reproduces the bypass (red before the fix: the forged receipt staged Ok). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- .../2026-10-21-d-tgb-4-receipt-replay.md | 2 +- crates/thought-git-probe/src/receipt.rs | 7 +++- crates/thought-git-probe/tests/receipt.rs | 41 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md b/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md index 8c7e1e25d..93e0e35c0 100644 --- a/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md +++ b/.claude/board/entries/2026-10-21-d-tgb-4-receipt-replay.md @@ -41,7 +41,7 @@ Written by `session_013z2e1YXkm6o6HNrDn5BPWA` on the operator's instruction (EXO | defect on main | correction | test | disable | |---|---|---|---| | `stage` replaced any row, a sealed landing included; `seal` overwrote an existing seal, so a changed row could be resealed | a sealed key accepts only the same receipt (same identity, same live slot) as a no-op; a different one is `AlreadyLanded`; an unsealed row (crash before the seal) accepts only the same receipt, else `StagedConflict`; `seal` on a sealed row that no longer matches is `Torn` | `a_sealed_landing_cannot_be_replaced_or_resealed` | stage overwrites; seal overwrites | -| `landed` checked only that the named data version exists | the write path, `VersionStore::commit_cycle` (stand-in for the coalesced per-cycle `LanceCycleWriter` commit, EXO-55), seals the version with a `CommitWitness`: cycle, the cohort of every thought coalesced into it with output fold and outcome, and a batch fold. `stage` and `landed` require the witness, an intact batch fold, the row's cycle, and the receipt's thought in the cohort with the receipt's output and outcome (`NoCommitWitness`, `WitnessTorn`, `NotInCohort`, `ContributionMismatch`). Two thoughts in one version both land. `MetadataTable::incomplete` lists committed thoughts with no sealed row | `a_landing_needs_the_write_paths_commit_witness`, `a_committed_thought_without_a_sealed_row_is_incomplete` | drop the cohort check; `incomplete` returns nothing | +| `landed` checked only that the named data version exists | the write path, `VersionStore::commit_cycle` (stand-in for the coalesced per-cycle `LanceCycleWriter` commit, EXO-55), seals the version with a `CommitWitness`: cycle, the cohort of every thought coalesced into it with output fold and outcome, and a batch fold. `stage` and `landed` require the witness, an intact batch fold, the row's cycle, and the receipt's thought in the cohort with the receipt's output and outcome (`NoCommitWitness`, `WitnessTorn`, `NotInCohort`, `ContributionMismatch`). Two thoughts in one version both land. The batch fold hashes each cohort entry (thought, output fold, outcome) as one term, so swapping two equal-count contributions tears the witness (Codex on #1684). `MetadataTable::incomplete` lists committed thoughts with no sealed row | `a_landing_needs_the_write_paths_commit_witness`, `a_committed_thought_without_a_sealed_row_is_incomplete`, `a_witness_with_swapped_contributions_is_torn` | drop the cohort check; `incomplete` returns nothing; fold the thought and its contribution as separate terms | | unequal `sources`/`targets` (public fields) panicked; `modulus == 0` divided by zero | `ProgramInputs::try_new`, and checks in `run`, `stage`, `seal` and `landed`: `MalformedInputs`, `ZeroModulus`; a malformed staged row cannot be sealed | `malformed_inputs_refuse_without_panicking` | drop the checks in `run`; drop them in `stage` | `captured_in` is now covered by the seal (provenance integrity) and stays outside `identity` (test 10 unchanged); disable: drop it from the seal. diff --git a/crates/thought-git-probe/src/receipt.rs b/crates/thought-git-probe/src/receipt.rs index 36f8061b4..57a7c9e0f 100644 --- a/crates/thought-git-probe/src/receipt.rs +++ b/crates/thought-git-probe/src/receipt.rs @@ -91,9 +91,12 @@ fn batch_fold(data: BaseRef, cycle: u64, cohort: &BTreeMap) - let mut f = Fold::default(); f.add(data.dataset, data.version); f.add(u64::from(data.branch), cycle); + // One term per entry, chained through `mix`: the sum is order-independent, + // so a thought and its contribution folded as separate terms could be + // swapped between two equal-count entries without moving the fold. for (&thought, c) in cohort { - f.add(u64::from(thought), c.output.count); - f.add(c.output.fold, outcome_code(c.outcome)); + let entry = mix(mix(mix(u64::from(thought)) ^ c.output.count) ^ c.output.fold); + f.add(entry, outcome_code(c.outcome)); } f } diff --git a/crates/thought-git-probe/tests/receipt.rs b/crates/thought-git-probe/tests/receipt.rs index 0280ad642..3ab667535 100644 --- a/crates/thought-git-probe/tests/receipt.rs +++ b/crates/thought-git-probe/tests/receipt.rs @@ -792,3 +792,44 @@ fn malformed_inputs_refuse_without_panicking() { Err(Refusal::Unsealed(_)) )); } + +/// 15. The batch fold binds each contribution to its thought: swapping two +/// equal-count contributions inside a witness tears it, so a receipt for +/// thought 10 carrying thought 11's output cannot land (Codex, #1684). +/// Disable: fold the thought id and the contribution as separate terms. +#[test] +fn a_witness_with_swapped_contributions_is_torn() { + let (mut store, base) = setup(); + let mut pool = SlotPool::new(Eviction::Fifo); + let (_, ta, ra) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 5)); + let (_, tb, _) = run(&store, &mut pool, plan(base, 11, Outcome::Commit, 9)); + let v2 = data_ref(2); + store + .commit_cycle(v2, &main_version(), 41, &[ta, tb]) + .expect("commit"); + let ka = MetaKey { + data: v2, + thought: 10, + }; + + // Thought 10 run with thought 11's seed: its output is 11's contribution. + let (_, _, forged) = run(&store, &mut pool, plan(base, 10, Outcome::Commit, 9)); + let w = store.witness_mut(v2).expect("witness"); + let (c10, c11) = (w.cohort[&10], w.cohort[&11]); + assert_eq!( + c10.output.count, c11.output.count, + "anti-vacuity: equal counts" + ); + assert_ne!(c10, c11, "anti-vacuity: distinct contributions"); + assert_eq!(forged.output, c11.output, "anti-vacuity: forged output"); + w.cohort.insert(10, c11); + w.cohort.insert(11, c10); + + for r in [forged, ra] { + let mut table = MetadataTable::default(); + assert_eq!( + table.stage(&pool, &store, ka, r), + Err(Refusal::WitnessTorn(ka)) + ); + } +}