From ca50ddaff8edd4c7b2ae23d93d14008a07484da8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 12:26:58 +0000 Subject: [PATCH] D-GSO-8: refuse a reused event seq The probe's key precondition (each event's seq is globally unique) was stated but never checked: row coalescing hides a same-row reuse from first_tie, and a reuse across seals leaves each batch tie-free. apply now refuses any seq not greater than the last accepted one, and resume_after restores the highest persisted key. The order-binding test swaps values instead of events so its stream stays strictly increasing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DgxrCafsJuAahpBs7oC14R --- .claude/board/STATUS_BOARD.md | 2 +- .../examples/seal_boundary_probe.rs | 97 ++++++++++++++++++- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index 86eec9771..54af64e8e 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -34,7 +34,7 @@ Plan: `.claude/plans/2026-10-06-global-sudoku-replayable-orchestration-v1.md`. O | **D-GSO-5** | P5: first recipe quartet over existing primitives | Shipped (#1355) | `cognitive-shader-driver/examples/recipe_quartet_probe.rs`: four `match`-arm recipes over `Quorum::observe`, `Quad8::fold_product`, `Morton8x8` + `PalettePerturbation::hop`, `GadamerRevision::revise` (verdict left `NotRun`: removing the new roots is tautological, so the counterfactual moves to P7); a thread-local counting allocator shows zero allocations per recipe (no instruction vector); `ProbeRecipe` is its own type, not a shipped `recipes` ID; ordinal meanings not canonized; no Pearl projection (P7) and no selector (P6); 7 tests, 6 disable runs red | | **D-GSO-6** | P6: deterministic, versioned recipe selector | Shipped (#1358, #1362); `local_disagreement` wired + rootless-revision fix: In PR | `cognitive-shader-driver/examples/recipe_selector_probe.rs`: `SelectorPolicy::select(state)` is a pure `const fn` over a declared 4-fact `EpistemicState`; V1 = §11 order (fold, bound, local, revise), rests only on the settled state; V2 swaps two steps (differs on 2 of 16 states); a recorded `Selection` (policy + state) replays under its own policy; every cycle rests in one step per open condition and replays identically; the version covers selection only, not recipe implementations (§13 digest open); 5 tests, 5 disable runs red. Follow-up: `new_encounter` is derived from the world (revising would still change a horizon mask; equals `revise` changing it on a 2-bit universe; rootless revisions included) and the `Revision` recipe writes `GadamerRevision`'s `delta.resulting`; dropping that write never rests; `unresolved_tension` cannot drive `local_disagreement` (never cleared); the other three facts are still stand-ins; 14 tests, 10 disable runs red. Follow-up 2: `local_disagreement` = `unresolved_tension \ interrogated`, where `interrogated` is the coverage `MooreInterrogation` writes (tension itself never cleared); a revision adding a new contradiction reopens it; dropping the interrogation write never rests; `observations_pending` and `frontier_bounded` remain stand-ins; 17 tests, 15 disable runs red | | **D-GSO-7** | P7: reasoning-band earning and downgrade | Shipped (#1360) | `cognitive-shader-driver/examples/reasoning_band_probe.rs`: `ReasoningBand` (bits 61..63) read only via `band_reading::project_band`; earned one rung per executed passing proof under the shipped Pearl `CausalMask` (SO majority → Association, PO intervention trial passed → Causal, SPO removal attack passed → Counterfactual), outcomes executed from trial data and never supplied, no skipping, `NotRun` never raises; failed test, contradicting `Quorum` (minority caps at Association, majority drops to Surface) and `simpsons_paradox_risk` lower it; replay gives the same edge bits; unreadable provenance / undeclared / band-absent refuse; ladder and thresholds are policy pins; 7 tests, 10 disable runs red | -| **D-GSO-8** | P8: seal boundary | In PR | `lance-graph-planner/examples/seal_boundary_probe.rs`: internal events change only working rows; a declared `Boundary` event freezes the changed rows with the shipped `DetachedCycleBatch::freeze` into a local ledger (no `WalSink`); an unchanged boundary writes nothing; replaying the persisted events from each prior seal reproduces the next seal exactly (frame, landings, image, hash); wrong base, dropped event and swapped order each change the seal; tie limit handled by plan option 1: the key is the event's own durable unique `seq`, a tied batch is refused, and a test shows on the real `freeze` that tied keys make the seal arrival-dependent; 6 tests, 5 disable runs red | +| **D-GSO-8** | P8: seal boundary | Shipped (#1363) | `lance-graph-planner/examples/seal_boundary_probe.rs`: internal events change only working rows; a declared `Boundary` event freezes the changed rows with the shipped `DetachedCycleBatch::freeze` into a local ledger (no `WalSink`); an unchanged boundary writes nothing; replaying the persisted events from each prior seal reproduces the next seal exactly (frame, landings, image, hash); wrong base, dropped event and swapped order each change the seal; tie limit handled by plan option 1: the key is the event's own durable unique `seq`, a tied batch is refused, and a test shows on the real `freeze` that tied keys make the seal arrival-dependent; 6 tests, 5 disable runs red. Follow-up: `apply` refuses a `seq` not greater than the last (a reuse on one row or across seals escaped `first_tie`), `resume_after` restores the top persisted key; 7 tests, 2 more disable runs red | ## D-PLX — Population-law cross-check (2026-10-03) diff --git a/crates/lance-graph-planner/examples/seal_boundary_probe.rs b/crates/lance-graph-planner/examples/seal_boundary_probe.rs index d7195451a..9c2b08214 100644 --- a/crates/lance-graph-planner/examples/seal_boundary_probe.rs +++ b/crates/lance-graph-planner/examples/seal_boundary_probe.rs @@ -22,7 +22,9 @@ //! (`.claude/knowledge/seal-vs-temporal-ordering-information.md` §2). The plan //! offers three ways out; this probe takes the first: **the key is the //! event's own sequence number, globally unique and persisted with the -//! event.** `seal` refuses a batch with a tied key instead of sealing it, and +//! event.** `apply` enforces that by refusing any event whose `seq` is not +//! greater than the last one (`a_reused_seq_is_refused`). `seal` also refuses +//! a batch with a tied key instead of sealing it, and //! `tied_keys_make_the_seal_depend_on_arrival` shows on the real `freeze` //! why that refusal is needed. //! @@ -70,12 +72,16 @@ struct Seal { batch: DetachedCycleBatch, } -/// Why a boundary did not seal. +/// Why an event was refused or a boundary did not seal. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SealError { /// Two casts share a `stream_position`; their order would come from /// arrival, which is not durable. TiedKey(u64), + /// An event's `seq` is not greater than the last accepted one. Row + /// coalescing would hide a reused `seq` from `first_tie`, so the key's + /// uniqueness is checked here, per event, before anything folds. + StaleSeq(u64), } /// The in-memory working state plus the durable ledger. @@ -91,6 +97,8 @@ struct Engine { ledger: Vec, /// Internal operations applied since start (for the report). internal_ops: usize, + /// The last accepted `seq`. Sequence numbers must strictly increase. + last_seq: Option, } impl Engine { @@ -101,6 +109,7 @@ impl Engine { last_change: [None; ROWS], ledger: Vec::new(), internal_ops: 0, + last_seq: None, } } @@ -112,6 +121,13 @@ impl Engine { /// Apply one event. Internal events touch only working state; a boundary /// seals. fn apply(&mut self, event: Event) -> Result<(), SealError> { + let seq = match event { + Event::Internal { seq, .. } | Event::Boundary { seq } => seq, + }; + if self.last_seq.is_some_and(|last| seq <= last) { + return Err(SealError::StaleSeq(seq)); + } + self.last_seq = Some(seq); match event { Event::Internal { seq, row, value } => { let r = usize::from(row) % ROWS; @@ -167,13 +183,16 @@ impl Engine { } /// Rebuild the state a seal left behind: the previous sealed rows with the - /// seal's coalesced image written over them. + /// seal's coalesced image written over them. The highest persisted key + /// becomes `last_seq`, so a resumed engine still refuses a reused one. fn resume_after(prior: &[Seal]) -> Self { let mut e = Self::new(); for s in prior { for (&row, payload) in &s.batch.image { e.sealed_rows[row as usize] = payload[0]; } + let top = s.batch.landings.iter().map(|l| l.stream_position).max(); + e.last_seq = e.last_seq.max(top); e.ledger.push(s.clone()); } e.rows = e.sealed_rows; @@ -362,17 +381,29 @@ mod tests { reference.batch.batch_hash ); - // Two events on the same row, swapped (the fold does not commute). + // Two operations on the same row, applied in the other order (the + // fold does not commute). The values swap and the `seq`s stay put, so + // the stream is still strictly increasing and is not refused. let mut swapped: Vec = runs[2].to_vec(); let row_of = |e: &Event| match e { Event::Internal { row, .. } => Some(*row), Event::Boundary { .. } => None, }; + let value_of = |e: &Event| match e { + Event::Internal { value, .. } => *value, + Event::Boundary { .. } => unreachable!("only internal events swap"), + }; let i = 0; let j = (1..swapped.len()) .find(|&j| row_of(&swapped[j]) == row_of(&swapped[i])) .unwrap(); - swapped.swap(i, j); + let (vi, vj) = (value_of(&swapped[i]), value_of(&swapped[j])); + assert_ne!(vi, vj, "fixture: the two values must differ"); + for (k, v) in [(i, vj), (j, vi)] { + if let Event::Internal { value, .. } = &mut swapped[k] { + *value = v; + } + } let mut reordered = Engine::resume_after(&live.ledger[..2]); for ev in swapped { reordered.apply(ev).unwrap(); @@ -398,6 +429,62 @@ mod tests { ); } + /// FAILS IF: a reused `seq` is accepted. Both cases from review: a reuse + /// on the same row (row coalescing hides it from `first_tie`) and a reuse + /// across two seals (each batch alone has no tie). Also after a resume. + #[test] + fn a_reused_seq_is_refused() { + // Same row, same seq: the second event folds nothing. + let mut e = Engine::new(); + e.apply(Event::Internal { + seq: 5, + row: 2, + value: 1, + }) + .unwrap(); + assert_eq!( + e.apply(Event::Internal { + seq: 5, + row: 2, + value: 9 + }), + Err(SealError::StaleSeq(5)) + ); + assert_eq!(e.rows[2], fold(0, 1), "the refused event did not fold"); + assert_eq!(e.internal_ops, 1); + + // Across seals: seq 5 is sealed, then reused after the boundary. + e.apply(Event::Boundary { seq: 6 }).unwrap(); + assert_eq!(e.ledger.len(), 1); + assert_eq!( + e.apply(Event::Internal { + seq: 5, + row: 3, + value: 4 + }), + Err(SealError::StaleSeq(5)) + ); + + // After a resume from that seal, the persisted key is still known. + let mut resumed = Engine::resume_after(&e.ledger); + assert_eq!( + resumed.apply(Event::Internal { + seq: 5, + row: 3, + value: 4 + }), + Err(SealError::StaleSeq(5)) + ); + // Silence twin: a fresh, larger seq is accepted. + resumed + .apply(Event::Internal { + seq: 7, + row: 3, + value: 4, + }) + .unwrap(); + } + /// The known limit, measured on the shipped `freeze`. FAILS IF: tied keys /// stop making the seal depend on arrival order (then the refusal would be /// unnecessary), or the probe seals a tied batch instead of refusing it.