From 99304b24e84045f6ba0c89d00c85ce9f54860b95 Mon Sep 17 00:00:00 2001
From: "J. Kirby Ross" <james@flyingrobots.dev>
Date: Sun, 28 Dec 2025 22:30:45 -0800
Subject: [PATCH 1/2] warp-core: tick receipt blocking causality

Record which applied candidates block rejected rewrites (Paper II poset edges) without changing the decision digest format.

Refs #147
---
 crates/warp-core/src/engine_impl.rs          | 107 +++++++++---
 crates/warp-core/src/receipt.rs              |  37 +++-
 crates/warp-core/tests/tick_receipt_tests.rs | 170 ++++++++++++++++++-
 docs/aion-papers-bridge.md                   |   6 +-
 docs/decision-log.md                         |   1 +
 docs/execution-plan.md                       |   7 +
 docs/spec-merkle-commit.md                   |   5 +
 7 files changed, 304 insertions(+), 29 deletions(-)

diff --git a/crates/warp-core/src/engine_impl.rs b/crates/warp-core/src/engine_impl.rs
index 12207c96..40f8c62a 100644
--- a/crates/warp-core/src/engine_impl.rs
+++ b/crates/warp-core/src/engine_impl.rs
@@ -194,7 +194,8 @@ impl Engine {
     /// Executes all pending rewrites for the transaction, producing both a snapshot and a tick receipt.
     ///
     /// The receipt records (in canonical plan order) which candidates were accepted vs rejected.
-    /// This is the first step toward Paper II “tick receipts” (and later, the blocking poset).
+    /// For rejected candidates, it also records which earlier applied candidates blocked them
+    /// (a minimal blocking-causality witness / poset edge list, per Paper II).
     ///
     /// # Errors
     /// - Returns [`EngineError::UnknownTx`] if `tx` does not refer to a live transaction.
@@ -209,21 +210,34 @@ impl Engine {
         }
         // Drain pending to form the ready set and compute a plan digest over its canonical order.
         let drained = self.scheduler.drain_for_tx(tx);
-        let plan_digest = {
-            let mut hasher = blake3::Hasher::new();
-            hasher.update(&(drained.len() as u64).to_le_bytes());
-            for pr in &drained {
-                hasher.update(&pr.scope_hash);
-                hasher.update(&pr.rule_id);
-            }
-            hasher.finalize().into()
-        };
+        let plan_digest = compute_plan_digest(&drained);
 
         // Reserve phase: enforce independence against active frontier.
         let mut receipt_entries: Vec<TickReceiptEntry> = Vec::with_capacity(drained.len());
+        let mut blocked_by: Vec<Vec<u32>> = Vec::with_capacity(drained.len());
         let mut reserved: Vec<PendingRewrite> = Vec::new();
-        for mut rewrite in drained {
+        let mut reserved_entry_indices: Vec<u32> = Vec::new();
+        for (entry_idx, mut rewrite) in drained.into_iter().enumerate() {
+            let entry_idx_u32 = u32::try_from(entry_idx).map_err(|_| {
+                EngineError::InternalCorruption("too many receipt entries to index")
+            })?;
             let accepted = self.scheduler.reserve(tx, &mut rewrite);
+            let blockers = if accepted {
+                Vec::new()
+            } else {
+                let mut blockers: Vec<u32> = Vec::new();
+                for (k, prior) in reserved.iter().enumerate() {
+                    if footprints_conflict(&rewrite.footprint, &prior.footprint) {
+                        blockers.push(reserved_entry_indices[k]);
+                    }
+                }
+                if blockers.is_empty() {
+                    return Err(EngineError::InternalCorruption(
+                        "scheduler rejected rewrite but no blockers were found",
+                    ));
+                }
+                blockers
+            };
             receipt_entries.push(TickReceiptEntry {
                 rule_id: rewrite.rule_id,
                 scope_hash: rewrite.scope_hash,
@@ -238,21 +252,14 @@ impl Engine {
             });
             if accepted {
                 reserved.push(rewrite);
+                reserved_entry_indices.push(entry_idx_u32);
             }
+            blocked_by.push(blockers);
         }
-        let receipt = TickReceipt::new(tx, receipt_entries);
+        let receipt = TickReceipt::new(tx, receipt_entries, blocked_by);
 
         // Deterministic digest of the ordered rewrites we will apply.
-        let rewrites_digest = {
-            let mut hasher = blake3::Hasher::new();
-            hasher.update(&(reserved.len() as u64).to_le_bytes());
-            for r in &reserved {
-                hasher.update(&r.rule_id);
-                hasher.update(&r.scope_hash);
-                hasher.update(&(r.scope).0);
-            }
-            hasher.finalize().into()
-        };
+        let rewrites_digest = compute_rewrites_digest(&reserved);
 
         for rewrite in reserved {
             let id = rewrite.compact_rule;
@@ -353,6 +360,62 @@ impl Engine {
     }
 }
 
+fn footprints_conflict(a: &crate::footprint::Footprint, b: &crate::footprint::Footprint) -> bool {
+    // IMPORTANT: do not use `Footprint::independent` here yet.
+    //
+    // `Footprint::independent` includes a `factor_mask` fast-path that assumes
+    // masks are correctly populated as a conservative superset. Many current
+    // footprints in the engine spike use `factor_mask = 0` as a placeholder,
+    // which would incorrectly classify conflicting rewrites as independent.
+    //
+    // The scheduler’s conflict logic is defined by explicit overlap checks on
+    // nodes/edges/ports; this mirrors that behavior exactly and stays correct
+    // while factor masks are still being wired through.
+    if a.b_in.intersects(&b.b_in)
+        || a.b_in.intersects(&b.b_out)
+        || a.b_out.intersects(&b.b_in)
+        || a.b_out.intersects(&b.b_out)
+    {
+        return true;
+    }
+    if a.e_write.intersects(&b.e_write)
+        || a.e_write.intersects(&b.e_read)
+        || b.e_write.intersects(&a.e_read)
+    {
+        return true;
+    }
+    a.n_write.intersects(&b.n_write)
+        || a.n_write.intersects(&b.n_read)
+        || b.n_write.intersects(&a.n_read)
+}
+
+fn compute_plan_digest(plan: &[PendingRewrite]) -> Hash {
+    if plan.is_empty() {
+        return *crate::constants::DIGEST_LEN0_U64;
+    }
+    let mut hasher = Hasher::new();
+    hasher.update(&(plan.len() as u64).to_le_bytes());
+    for pr in plan {
+        hasher.update(&pr.scope_hash);
+        hasher.update(&pr.rule_id);
+    }
+    hasher.finalize().into()
+}
+
+fn compute_rewrites_digest(rewrites: &[PendingRewrite]) -> Hash {
+    if rewrites.is_empty() {
+        return *crate::constants::DIGEST_LEN0_U64;
+    }
+    let mut hasher = Hasher::new();
+    hasher.update(&(rewrites.len() as u64).to_le_bytes());
+    for r in rewrites {
+        hasher.update(&r.rule_id);
+        hasher.update(&r.scope_hash);
+        hasher.update(&(r.scope).0);
+    }
+    hasher.finalize().into()
+}
+
 impl Engine {
     fn rule_by_compact(&self, id: CompactRuleId) -> Option<&RewriteRule> {
         let name = self.rules_by_compact.get(&id)?;
diff --git a/crates/warp-core/src/receipt.rs b/crates/warp-core/src/receipt.rs
index f17cf311..4c2f8d37 100644
--- a/crates/warp-core/src/receipt.rs
+++ b/crates/warp-core/src/receipt.rs
@@ -24,15 +24,22 @@ use crate::tx::TxId;
 pub struct TickReceipt {
     tx: TxId,
     entries: Vec<TickReceiptEntry>,
+    blocked_by: Vec<Vec<u32>>,
     digest: Hash,
 }
 
 impl TickReceipt {
-    pub(crate) fn new(tx: TxId, entries: Vec<TickReceiptEntry>) -> Self {
+    pub(crate) fn new(tx: TxId, entries: Vec<TickReceiptEntry>, blocked_by: Vec<Vec<u32>>) -> Self {
+        debug_assert_eq!(
+            entries.len(),
+            blocked_by.len(),
+            "blocked_by must be parallel to entries"
+        );
         let digest = compute_tick_receipt_digest(&entries);
         Self {
             tx,
             entries,
+            blocked_by,
             digest,
         }
     }
@@ -49,6 +56,30 @@ impl TickReceipt {
         &self.entries
     }
 
+    /// Returns the indices of the candidates that blocked entry `idx`.
+    ///
+    /// This is the (currently minimal) *blocking causality poset* witness described
+    /// by Paper II: when a candidate is rejected due to a footprint conflict, the
+    /// receipt records which already-applied candidates blocked it.
+    ///
+    /// Semantics and invariants:
+    /// - Returned indices are indices into [`TickReceipt::entries`].
+    /// - The list is sorted in ascending order and contains no duplicates.
+    /// - Every returned index is strictly less than `idx`.
+    /// - For an [`TickReceiptDisposition::Applied`] entry, the list is empty.
+    /// - For a [`TickReceiptDisposition::Rejected`] entry, the list is expected to
+    ///   be non-empty for `Rejected(FootprintConflict)`.
+    ///
+    /// Note: these blocker indices are *not* included in [`TickReceipt::digest`]
+    /// today; the digest commits only to accepted vs rejected outcomes.
+    ///
+    /// # Panics
+    /// Panics if `idx` is out of bounds for [`TickReceipt::entries`].
+    #[must_use]
+    pub fn blocked_by(&self, idx: usize) -> &[u32] {
+        &self.blocked_by[idx]
+    }
+
     /// Canonical digest of the tick receipt entries.
     ///
     /// This digest is stable across architectures and depends only on:
@@ -56,6 +87,10 @@ impl TickReceipt {
     /// - the number of entries, and
     /// - the ordered per-entry content.
     ///
+    /// It intentionally does **not** include blocking attribution metadata
+    /// (see [`TickReceipt::blocked_by`]) so that commit hashes remain stable
+    /// across improvements to rejection explanations.
+    ///
     /// It intentionally does **not** include `tx` so that receipts can be
     /// compared across runs that use different transaction numbering.
     #[must_use]
diff --git a/crates/warp-core/tests/tick_receipt_tests.rs b/crates/warp-core/tests/tick_receipt_tests.rs
index 8be87963..4d986129 100644
--- a/crates/warp-core/tests/tick_receipt_tests.rs
+++ b/crates/warp-core/tests/tick_receipt_tests.rs
@@ -4,9 +4,9 @@
 #![allow(missing_docs)]
 
 use warp_core::{
-    encode_motion_payload, make_node_id, make_type_id, Engine, GraphStore, Hash, NodeRecord,
-    RewriteRule, TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection, TxId,
-    MOTION_RULE_NAME,
+    encode_motion_payload, make_node_id, make_type_id, ConflictPolicy, Engine, Footprint,
+    GraphStore, Hash, NodeId, NodeRecord, PatternGraph, RewriteRule, TickReceiptDisposition,
+    TickReceiptEntry, TickReceiptRejection, TxId, MOTION_RULE_NAME,
 };
 
 fn rule_id(name: &str) -> Hash {
@@ -16,6 +16,13 @@ fn rule_id(name: &str) -> Hash {
     hasher.finalize().into()
 }
 
+fn scope_hash(rule_id: &Hash, scope: &NodeId) -> Hash {
+    let mut hasher = blake3::Hasher::new();
+    hasher.update(rule_id);
+    hasher.update(&scope.0);
+    hasher.finalize().into()
+}
+
 fn compute_plan_digest(entries: &[TickReceiptEntry]) -> Hash {
     let mut hasher = blake3::Hasher::new();
     hasher.update(&(entries.len() as u64).to_le_bytes());
@@ -43,6 +50,69 @@ fn compute_rewrites_digest(entries: &[TickReceiptEntry]) -> Hash {
     hasher.finalize().into()
 }
 
+fn always_match(_: &GraphStore, _: &NodeId) -> bool {
+    true
+}
+
+fn exec_noop(_: &mut GraphStore, _: &NodeId) {}
+
+fn other_of(scope: &NodeId) -> NodeId {
+    NodeId(blake3::hash(&scope.0).into())
+}
+
+fn fp_write_scope(_: &GraphStore, scope: &NodeId) -> Footprint {
+    let mut fp = Footprint::default();
+    fp.n_write.insert_node(scope);
+    fp.factor_mask = 1;
+    fp
+}
+
+fn fp_write_scope_and_other(_: &GraphStore, scope: &NodeId) -> Footprint {
+    let mut fp = Footprint::default();
+    fp.n_write.insert_node(scope);
+    fp.n_write.insert_node(&other_of(scope));
+    fp.factor_mask = 1;
+    fp
+}
+
+fn pick_rule_ids_for_blocker_test(scope_a: &NodeId, scope_b: &NodeId) -> (Hash, Hash, Hash) {
+    // Pick three distinct synthetic ids such that:
+    // - the combined-write rule (C) sorts last by scope_hash
+    // - the two single-write rules (A, B) sort before it
+    //
+    // This ensures the first two candidates are accepted and the last candidate
+    // is rejected with *two* blockers, making the test stable even if rule IDs
+    // or scope hashing semantics evolve.
+    let mut candidates: Vec<(Hash, u8)> = (0u8..=255)
+        .map(|b| {
+            let id = [b; 32];
+            (scope_hash(&id, scope_a), b)
+        })
+        .collect();
+    candidates.sort_by(|(ha, ba), (hb, bb)| ha.cmp(hb).then(ba.cmp(bb)));
+
+    for (_, c_byte) in candidates.iter().rev() {
+        let c_id: Hash = [*c_byte; 32];
+        let h_c = scope_hash(&c_id, scope_a);
+
+        let Some((_, a_byte)) = candidates.iter().find(|(h, b)| *b != *c_byte && *h < h_c) else {
+            continue;
+        };
+        let a_id: Hash = [*a_byte; 32];
+
+        let b_byte = (0u8..=255)
+            .find(|b| *b != *c_byte && *b != *a_byte && scope_hash(&[*b; 32], scope_b) < h_c);
+        let Some(b_byte) = b_byte else {
+            continue;
+        };
+        let b_id: Hash = [b_byte; 32];
+
+        return (a_id, b_id, c_id);
+    }
+
+    panic!("failed to find stable test ids for blocker ordering");
+}
+
 #[test]
 fn commit_with_receipt_records_accept_reject_and_matches_snapshot_digests() {
     let entity = make_node_id("tick-receipt-entity");
@@ -102,6 +172,15 @@ fn commit_with_receipt_records_accept_reject_and_matches_snapshot_digests() {
         entries[1].disposition,
         TickReceiptDisposition::Rejected(TickReceiptRejection::FootprintConflict)
     );
+    assert!(
+        receipt.blocked_by(0).is_empty(),
+        "applied entries should not have blockers"
+    );
+    assert_eq!(
+        receipt.blocked_by(1),
+        &[0],
+        "the rejected candidate should be blocked by the applied candidate"
+    );
 
     assert_eq!(snapshot.plan_digest, compute_plan_digest(entries));
     assert_eq!(snapshot.rewrites_digest, compute_rewrites_digest(entries));
@@ -112,3 +191,88 @@ fn commit_with_receipt_records_accept_reject_and_matches_snapshot_digests() {
         "non-empty tick receipt should not use the canonical empty digest"
     );
 }
+
+#[test]
+fn commit_with_receipt_records_multi_blocker_causality() {
+    let scope_a = make_node_id("tick-receipt-poset-a");
+    let scope_b = other_of(&scope_a);
+    assert_ne!(scope_a, scope_b, "expected distinct nodes for the test");
+
+    let ty = make_type_id("entity");
+    let mut store = GraphStore::default();
+    store.insert_node(scope_a, NodeRecord { ty, payload: None });
+    store.insert_node(scope_b, NodeRecord { ty, payload: None });
+
+    let mut engine = Engine::new(store, scope_a);
+
+    let (id_a, id_b, id_c) = pick_rule_ids_for_blocker_test(&scope_a, &scope_b);
+
+    const RULE_A: &str = "test/write-scope-a";
+    const RULE_B: &str = "test/write-scope-b";
+    const RULE_C: &str = "test/write-scope-a-and-b";
+
+    engine
+        .register_rule(RewriteRule {
+            id: id_a,
+            name: RULE_A,
+            left: PatternGraph { nodes: vec![] },
+            matcher: always_match,
+            executor: exec_noop,
+            compute_footprint: fp_write_scope,
+            factor_mask: 1,
+            conflict_policy: ConflictPolicy::Abort,
+            join_fn: None,
+        })
+        .expect("rule A registers");
+    engine
+        .register_rule(RewriteRule {
+            id: id_b,
+            name: RULE_B,
+            left: PatternGraph { nodes: vec![] },
+            matcher: always_match,
+            executor: exec_noop,
+            compute_footprint: fp_write_scope,
+            factor_mask: 1,
+            conflict_policy: ConflictPolicy::Abort,
+            join_fn: None,
+        })
+        .expect("rule B registers");
+    engine
+        .register_rule(RewriteRule {
+            id: id_c,
+            name: RULE_C,
+            left: PatternGraph { nodes: vec![] },
+            matcher: always_match,
+            executor: exec_noop,
+            compute_footprint: fp_write_scope_and_other,
+            factor_mask: 1,
+            conflict_policy: ConflictPolicy::Abort,
+            join_fn: None,
+        })
+        .expect("rule C registers");
+
+    let tx = engine.begin();
+    engine.apply(tx, RULE_A, &scope_a).expect("apply A");
+    engine.apply(tx, RULE_B, &scope_b).expect("apply B");
+    engine.apply(tx, RULE_C, &scope_a).expect("apply C");
+
+    let (_snapshot, receipt) = engine.commit_with_receipt(tx).expect("commit_with_receipt");
+    let entries = receipt.entries();
+    assert_eq!(entries.len(), 3);
+
+    assert_eq!(entries[2].rule_id, id_c, "combined write should sort last");
+    assert_eq!(entries[0].disposition, TickReceiptDisposition::Applied);
+    assert_eq!(entries[1].disposition, TickReceiptDisposition::Applied);
+    assert_eq!(
+        entries[2].disposition,
+        TickReceiptDisposition::Rejected(TickReceiptRejection::FootprintConflict)
+    );
+
+    assert!(receipt.blocked_by(0).is_empty());
+    assert!(receipt.blocked_by(1).is_empty());
+    assert_eq!(
+        receipt.blocked_by(2),
+        &[0, 1],
+        "combined write should be blocked by both prior applied candidates"
+    );
+}
diff --git a/docs/aion-papers-bridge.md b/docs/aion-papers-bridge.md
index 9c77ffec..4e513d28 100644
--- a/docs/aion-papers-bridge.md
+++ b/docs/aion-papers-bridge.md
@@ -2,7 +2,7 @@
 <!-- © James Ross Ω FLYING•ROBOTS <https://github.com/flyingrobots> -->
 # AIΩN Foundations → Echo: Bridge
 
-Last reviewed: 2025-12-28.
+Last reviewed: 2025-12-29.
 
 This doc maps the **AIΩN Foundations series** (“WARP Graphs”, Papers I–VI) onto the **Echo** repository as it exists today.
 
@@ -60,7 +60,7 @@ These tables are intentionally “backlog-driving”: they identify what exists
 | Tick = atomic commit (all-or-nothing) | Implemented for the spike (`commit` finalizes tx) | `crates/warp-core/src/engine_impl.rs` | Make abort/stutter semantics explicit if/when partial failure exists (currently “reserve rejects conflicts”) | Echo currently models conflicts as “not reserved”; explicit abort receipts are a good future addition. |
 | Independence via footprints (delete/use; read/write sets) | Implemented (expanded to nodes/edges/ports + factor mask) | `crates/warp-core/src/footprint.rs`, `crates/warp-core/src/scheduler.rs` | Ensure footprint semantics remain “Paper II compatible” as optimizations land (bitmaps/SIMD, etc.) | Echo adds boundary ports + factor masks for engine practicality; document as an extension of the footprint idea. |
 | Deterministic scheduling via total key order (“left-most wins”) | Implemented (deterministic ordering + deterministic reserve filter) | `crates/warp-core/src/scheduler.rs` | Specify the canonical key format (what exactly is “scope”?); keep stable across releases | Echo’s key is currently (`scope_hash`, `rule_id`, `nonce`); may evolve, but must remain deterministic. |
-| Tick receipts (accepted vs rejected + blocking poset) | Implemented (minimal receipt; poset pending) | `crates/warp-core/src/receipt.rs`, `crates/warp-core/src/engine_impl.rs`, `docs/spec-merkle-commit.md` | Extend receipts with blocking attribution (poset) + richer rejection reasons once conflict policy/join semantics land | Current receipt captures accepted vs rejected in canonical plan order; only rejection reason today is footprint conflict. |
+| Tick receipts (accepted vs rejected + blocking poset) | Implemented (receipt + blocking attribution; richer reasons pending) | `crates/warp-core/src/receipt.rs`, `crates/warp-core/src/engine_impl.rs`, `docs/spec-merkle-commit.md` | Decide when/if to commit blocking edges into the hash and extend receipts with richer rejection reasons once conflict policy/join semantics land | Receipt captures accepted vs rejected in canonical plan order and records blockers (poset edges) for footprint conflicts; only rejection reason today is footprint conflict. |
 
 ### Paper III — Holography (payloads, BTRs, wormholes)
 
@@ -135,7 +135,7 @@ These tables are intentionally “backlog-driving”: they identify what exists
 
 **Notable gap (intentional/expected):**
 
-- Echo’s current engine exposes “plan_digest” / “rewrites_digest”, but does not yet expose a first-class “tick receipt poset” structure for debugging/provenance the way Paper II describes.
+- Echo’s current engine exposes “plan_digest” / “rewrites_digest”, and now exposes a minimal tick receipt blocking witness: for rejected candidates, the receipt lists which earlier applied candidates blocked it (indices in canonical plan order).
 
 ## Paper III — Computational holography: provenance payloads, BTRs, wormholes
 
diff --git a/docs/decision-log.md b/docs/decision-log.md
index 5ad6bd9a..94a5a569 100644
--- a/docs/decision-log.md
+++ b/docs/decision-log.md
@@ -6,6 +6,7 @@
 
 | Date | Context | Decision | Rationale | Consequence |
 | ---- | ------- | -------- | --------- | ----------- |
+| 2025-12-29 | Paper II tick receipts: blocking causality | Extend `TickReceipt` to record a blocking-causality witness for `Rejected(FootprintConflict)` entries (indices of the applied candidates that blocked it, in canonical plan order); keep `decision_digest` encoding stable (digest commits only to accept/reject outcomes, not blocker metadata). | Paper II’s receipts are meant to explain *why* candidates were rejected and support deterministic debugging/provenance. Blocker indices provide a minimal poset edge list without destabilizing commit hashes as explanations evolve. | Tools/tests can surface “blocked by …” explanations; receipts now carry poset edges for footprint conflicts; commit ids remain stable for identical accept/reject outcomes. |
 | 2025-12-28 | AIΩN bridge doc + Paper II tick receipts | Promote the AIΩN Foundations bridge from `docs/notes/` into a canonical doc (`docs/aion-papers-bridge.md`, keeping a stub for historical links); implement `TickReceipt` + `Engine::commit_with_receipt` in `warp-core` and commit the receipt digest via `Snapshot.decision_digest` (encoding defined in `docs/spec-merkle-commit.md`). | The bridge is a long-lived architectural dependency and should not be buried in dated notes; Paper II tick receipts are required for deterministic debugging and provenance. Reusing `decision_digest` commits to accepted/rejected outcomes without changing the commit header shape. | A stable bridge doc is indexed in `docs/docs-index.md`; tools/tests can obtain receipts from `commit_with_receipt`. Commit ids now incorporate receipt outcomes whenever the candidate set is non-empty (empty receipts still use the canonical empty digest). |
 | 2025-12-28 | Brand asset variants | Add `assets/echo-white-radial.svg`: an `echo-white.svg`-derived logo variant with a shared radial gradient fill (`#F5E6AD` center → `#F13C77` edges) and an interior stroke emulated via clipped stroke paths for broad SVG renderer support. | Provide a ready-to-use, style-forward logo asset while keeping the original `echo-white.svg` unchanged; prefer a clip-path “inner stroke” technique over `stroke-alignment` to avoid uneven renderer support. | Consumers can pick the original flat-white logo or the gradient+inner-stroke variant without changing code; the new SVG renders consistently in common SVG engines (rsvg/InkScape). |
 | 2025-12-28 | Repo tour + onboarding refresh | Replace the root README with a reality-aligned overview, link Echo to the AIΩN Framework + the Foundations series papers, and add durable tour/bridge notes; fix `spec-merkle-commit` empty-digest semantics to match `warp-core`. | Keep onboarding and determinism specs consistent with the implemented engine contracts and the motivating research lineage; reduce future churn caused by doc drift. | New `README.md`, `docs/notes/project-tour-2025-12-28.md`, and `docs/notes/aion-papers-bridge.md` provide a single entry path; the commit digest spec now matches code behavior. |
diff --git a/docs/execution-plan.md b/docs/execution-plan.md
index 95403f83..24ef1734 100644
--- a/docs/execution-plan.md
+++ b/docs/execution-plan.md
@@ -35,6 +35,13 @@ This is Codex’s working map for building Echo. Update it relentlessly—each s
 
 ## Today’s Intent
 
+> 2025-12-29 — Tick receipts: blocking causality (COMPLETED)
+
+- Goal: finish the Paper II tick receipt slice by recording *blocking causality* for rejected candidates (a poset edge list) in `warp-core`.
+- Scope: extend `TickReceipt` to expose the applied candidates that blocked a `Rejected(FootprintConflict)` entry; keep `decision_digest` stable (digest commits only to accept/reject outcomes, not blocker metadata).
+- Exit criteria: new tests cover multi-blocker cases; `cargo test --workspace` and `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs` are green; bridge docs updated to match implementation; decision log entry recorded.
+- Evidence: implemented `TickReceipt::blocked_by` and blocker attribution in `Engine::commit_with_receipt`; added multi-blocker tests; updated `docs/aion-papers-bridge.md` and `docs/spec-merkle-commit.md`; validated via `cargo test --workspace` + `cargo clippy --workspace --all-targets -- -D warnings -D missing_docs`.
+
 > 2025-12-28 — Promote AIΩN bridge doc + add tick receipts (COMPLETED)
 
 - Goal: promote the AIΩN Foundations ↔ Echo bridge from a dated note into a canonical doc, then implement Paper II “tick receipts” in `warp-core`.
diff --git a/docs/spec-merkle-commit.md b/docs/spec-merkle-commit.md
index 2307cb2a..b89fb7f0 100644
--- a/docs/spec-merkle-commit.md
+++ b/docs/spec-merkle-commit.md
@@ -64,6 +64,11 @@ Canonical encoding (v1) for the tick receipt digest:
       - `1` = Applied
       - `2` = Rejected(FootprintConflict)
 
+Note: `TickReceipt` may expose additional debugging/provenance metadata (e.g. a
+blocking-causality witness for rejections). `decision_digest` v1 intentionally
+commits only to accepted vs rejected outcomes (and the coarse rejection code),
+not to the blocker metadata.
+
 ## 3. Invariants and Notes
 
 - Any change to ordering, lengths, or endianness breaks all prior hashes.

From 28bc3a8e4c5cae1232712934fc69e9f6a5fcfbf8 Mon Sep 17 00:00:00 2001
From: "J. Kirby Ross" <james@flyingrobots.dev>
Date: Mon, 29 Dec 2025 04:08:31 -0800
Subject: [PATCH 2/2] warp-core: tighten receipt blocker invariants

Address CodeRabbit review: enforce receipt invariants in release builds, document scheduler reserve rejection contract, and clarify blocker attribution assumptions.

Refs #147
---
 crates/warp-core/src/engine_impl.rs          | 14 ++++++-
 crates/warp-core/src/receipt.rs              |  7 +++-
 crates/warp-core/src/scheduler.rs            | 39 ++++++++++++++++++++
 crates/warp-core/tests/tick_receipt_tests.rs |  8 ++++
 4 files changed, 66 insertions(+), 2 deletions(-)

diff --git a/crates/warp-core/src/engine_impl.rs b/crates/warp-core/src/engine_impl.rs
index 40f8c62a..d13474ee 100644
--- a/crates/warp-core/src/engine_impl.rs
+++ b/crates/warp-core/src/engine_impl.rs
@@ -225,6 +225,8 @@ impl Engine {
             let blockers = if accepted {
                 Vec::new()
             } else {
+                // O(n) scan over reserved rewrites. Acceptable for typical tick sizes;
+                // consider spatial indexing if tick candidate counts grow large.
                 let mut blockers: Vec<u32> = Vec::new();
                 for (k, prior) in reserved.iter().enumerate() {
                     if footprints_conflict(&rewrite.footprint, &prior.footprint) {
@@ -232,6 +234,10 @@ impl Engine {
                     }
                 }
                 if blockers.is_empty() {
+                    // `reserve()` currently returns `false` exclusively on footprint
+                    // conflicts (see scheduler reserve rustdoc). If additional rejection
+                    // reasons are added, update the scheduler contract and this attribution
+                    // logic accordingly.
                     return Err(EngineError::InternalCorruption(
                         "scheduler rejected rewrite but no blockers were found",
                     ));
@@ -245,7 +251,8 @@ impl Engine {
                 disposition: if accepted {
                     TickReceiptDisposition::Applied
                 } else {
-                    // NOTE: reserve() currently only rejects for footprint conflicts.
+                    // NOTE: reserve() currently returns `false` exclusively on
+                    // footprint conflicts (see scheduler reserve rustdoc).
                     // If additional rejection reasons are added, update this mapping.
                     TickReceiptDisposition::Rejected(TickReceiptRejection::FootprintConflict)
                 },
@@ -363,6 +370,11 @@ impl Engine {
 fn footprints_conflict(a: &crate::footprint::Footprint, b: &crate::footprint::Footprint) -> bool {
     // IMPORTANT: do not use `Footprint::independent` here yet.
     //
+    // This logic MUST remain consistent with the scheduler’s footprint conflict
+    // predicate (`RadixScheduler::has_conflict` in `scheduler.rs`). If one
+    // changes, the other must change too, or receipts will attribute blockers
+    // differently than the scheduler rejects candidates.
+    //
     // `Footprint::independent` includes a `factor_mask` fast-path that assumes
     // masks are correctly populated as a conservative superset. Many current
     // footprints in the engine spike use `factor_mask = 0` as a placeholder,
diff --git a/crates/warp-core/src/receipt.rs b/crates/warp-core/src/receipt.rs
index 4c2f8d37..26f1f957 100644
--- a/crates/warp-core/src/receipt.rs
+++ b/crates/warp-core/src/receipt.rs
@@ -30,7 +30,7 @@ pub struct TickReceipt {
 
 impl TickReceipt {
     pub(crate) fn new(tx: TxId, entries: Vec<TickReceiptEntry>, blocked_by: Vec<Vec<u32>>) -> Self {
-        debug_assert_eq!(
+        assert_eq!(
             entries.len(),
             blocked_by.len(),
             "blocked_by must be parallel to entries"
@@ -77,6 +77,11 @@ impl TickReceipt {
     /// Panics if `idx` is out of bounds for [`TickReceipt::entries`].
     #[must_use]
     pub fn blocked_by(&self, idx: usize) -> &[u32] {
+        assert!(
+            idx < self.blocked_by.len(),
+            "blocked_by index {idx} out of bounds for {} entries",
+            self.blocked_by.len()
+        );
         &self.blocked_by[idx]
     }
 
diff --git a/crates/warp-core/src/scheduler.rs b/crates/warp-core/src/scheduler.rs
index f2062494..d3c5de0a 100644
--- a/crates/warp-core/src/scheduler.rs
+++ b/crates/warp-core/src/scheduler.rs
@@ -117,6 +117,16 @@ impl RadixScheduler {
     ///
     /// On success, marks all resources in the active `GenSets` and transitions
     /// the phase to `Reserved`.
+    ///
+    /// Return value contract (engine spike):
+    /// - Returns `true` when the rewrite is reserved and will be applied.
+    /// - Returns `false` exclusively when the rewrite footprint conflicts with
+    ///   the already-reserved frontier for this tick. In this case the rewrite
+    ///   phase is transitioned to `Aborted`.
+    ///
+    /// If additional rejection reasons are introduced in the future (beyond
+    /// footprint conflicts), upgrade the return type to an explicit reason enum
+    /// so callers can distinguish between them.
     pub(crate) fn reserve(&mut self, tx: TxId, pr: &mut PendingRewrite) -> bool {
         let active = self.active.entry(tx).or_insert_with(ActiveFootprints::new);
 
@@ -543,6 +553,22 @@ impl LegacyScheduler {
             .unwrap_or_default()
     }
 
+    /// Attempts to reserve a rewrite by checking full footprint independence
+    /// against the currently reserved frontier.
+    ///
+    /// This legacy implementation performs an O(k) scan over the reserved
+    /// footprints for the tick, using [`Footprint::independent`] to detect
+    /// conflicts.
+    ///
+    /// Return value contract (engine spike):
+    /// - Returns `true` when the rewrite is reserved and will be applied.
+    /// - Returns `false` exclusively when the rewrite footprint conflicts with
+    ///   the already-reserved frontier for this tick. In this case the rewrite
+    ///   phase is transitioned to `Aborted`.
+    ///
+    /// If additional rejection reasons are introduced in the future (beyond
+    /// footprint conflicts), upgrade the return type to an explicit reason enum
+    /// so callers can distinguish between them.
     pub(crate) fn reserve(&mut self, tx: TxId, pr: &mut PendingRewrite) -> bool {
         let frontier = self.active.entry(tx).or_default();
         for fp in frontier.iter() {
@@ -633,6 +659,19 @@ impl DeterministicScheduler {
         }
     }
 
+    /// Attempts to reserve `pr` in the scheduler for `tx`.
+    ///
+    /// This forwards to the selected scheduler implementation (radix vs legacy).
+    ///
+    /// Return value contract (engine spike):
+    /// - Returns `true` when the rewrite is reserved and will be applied.
+    /// - Returns `false` exclusively when the rewrite footprint conflicts with
+    ///   the already-reserved frontier for this tick. In this case the rewrite
+    ///   phase is transitioned to `Aborted`.
+    ///
+    /// If additional rejection reasons are introduced in the future (beyond
+    /// footprint conflicts), upgrade the return type to an explicit reason enum
+    /// so callers can distinguish between them.
     pub(crate) fn reserve(&mut self, tx: TxId, pr: &mut PendingRewrite) -> bool {
         match &mut self.inner {
             SchedulerImpl::Radix(s) => s.reserve(tx, pr),
diff --git a/crates/warp-core/tests/tick_receipt_tests.rs b/crates/warp-core/tests/tick_receipt_tests.rs
index 4d986129..758fa75c 100644
--- a/crates/warp-core/tests/tick_receipt_tests.rs
+++ b/crates/warp-core/tests/tick_receipt_tests.rs
@@ -16,6 +16,8 @@ fn rule_id(name: &str) -> Hash {
     hasher.finalize().into()
 }
 
+// Mirrors the engine implementation in `crates/warp-core/src/engine_impl.rs`.
+// If the engine's scope hash semantics change, this helper must be updated to match.
 fn scope_hash(rule_id: &Hash, scope: &NodeId) -> Hash {
     let mut hasher = blake3::Hasher::new();
     hasher.update(rule_id);
@@ -75,6 +77,12 @@ fn fp_write_scope_and_other(_: &GraphStore, scope: &NodeId) -> Footprint {
     fp
 }
 
+/// Finds three synthetic rule ids (A, B, C) such that when applied to
+/// `(scope_a, scope_b, scope_a)` the deterministic scheduler order is `[A, B, C]`
+/// with `C` last.
+///
+/// This ensures the first two candidates are accepted and the last candidate is
+/// rejected with two blockers for stable multi-blocker assertions.
 fn pick_rule_ids_for_blocker_test(scope_a: &NodeId, scope_b: &NodeId) -> (Hash, Hash, Hash) {
     // Pick three distinct synthetic ids such that:
     // - the combined-write rule (C) sorts last by scope_hash
