diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f16af866..2a2bb2837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,72 @@ ### Added +- `ActualFootprint` records the graph resources an execution actually touched + and compares them against a declared `Footprint`, returning the existing + `ViolationKind` vocabulary in a deterministic axis order. Footprint + enforcement previously answered "was this access declared?" and forgot, so + the soundness relation `Actual ⊆ Declared` could only be observed as a panic + and never evaluated as a value. `ActualFootprint::from_ops` derives the write + axis from an emitted op sequence using the same extraction as enforcement, so + a recorded write set and an enforced write check cannot disagree. Cross-warp + and instance-level concerns are deliberately excluded: they are scope and + authority questions the guard already reports, not footprint-subset + questions. The read axis is recorded as accesses happen through + `ExecutionGraphView`; it cannot be reconstructed from emitted ops. +- `ExecutionGraphView` is the executor-only capability that records what an + execution actually read. `GraphView` could not grow a recorder: its accessors + take `&self`, so mutating a borrowed accumulator would need interior + mutability, which its contract forbids and which would cost it `Sync` — and + `WorkUnit: Sync` is required for workers to borrow the shared unit slice. + Moving the mutable execution frame rather than the declared guard into + exclusive worker ownership makes `&mut self` sufficient, so no lock, no + `UnsafeCell`, and no manual `Sync` are involved, and `GraphView` and + `WorkUnit` are untouched. Accessors record before consulting the guard, so + the access that trips enforcement is already in the transcript when the panic + unwinds; recording afterwards would retain an actual footprint missing its own + counterexample. The recorded axis mirrors enforcement exactly — `edges_from` + records a node read, because declaring a node grants its outbound adjacency — + and an absent resource is still a recorded coordinate, so a rule cannot probe + undeclared coordinates for free by picking empty ones. +- `ActualFootprintPosture` states what a footprint record is entitled to claim. + An empty violation set means "the declaration covered the execution" only when + the lane both recorded and enforced; from an unobserved lane it means only + that nothing was compared. `read_axis_is_complete` keeps that difference + legible, so an empty read axis from an unobserved lane reads as _unknown_ + rather than as _this execution read nothing_, and only `RecordedAndEnforced` + may ground an admitted falsification witness. `build_footprint_posture` caps + every lane by the enforcement the binary actually compiled. +- `ObservedExecuteFn` and `RuleExecutor` route native scheduler execution through + one worker-local evidence core. Native and generated contract-host executors + receive `ExecutionGraphView`; the frozen provider-v1 callback remains an + explicit legacy `GraphView` ABI and can never manufacture a complete read + axis. `ExecutionFootprintEvidence` retains one canonical record per executed + Action on `Engine` and `WorldlineState`, including the emitted write set when + the executor or read guard unwinds. Records are keyed before worker dispatch + and sorted independently of worker claim or completion order. Successful, + panicking, legacy, generated-consumer, provider-v1, and worker-count-invariant + witnesses complete stage 2 of the falsification roadmap without exporting + `FootprintGuard` or weakening its ordinary panic path. +- Differential tests run the recorded write set and the enforced write check + against the same ops and the same declaration, closing an assumption the + design had only asserted. They also pin the two deliberate disagreements: + cross-warp emission and unauthorized instance ops are scope and authority + failures that the recorder declines to report as footprint-subset failures, + so a reducer cannot hop between bug classes by conflating them. +- ADR 0027 proposes first-class falsification witnesses, and + `docs/topics/FalsificationWitnesses.md` carries the design and delivery + roadmap. Anyone may propose a counterexample; only Echo may admit that it + falsifies an exact property instance. Discovery stays outside the admission + trust boundary, property evaluation returns a closed outcome sum, admission + requires fresh-host exact replay, reduction must preserve a typed violation + class under a declared equivalence policy, minimality is always qualified, + semantic counterexample identity is separate from the evidence-envelope + identity, and the target worldline is never rewritten—witnesses append to a + separate evidence worldline under the existing admission-kernel append + authority. The first vertical is footprint honesty, whose blocking gap is now + documented: the footprint guard compares each access against the declared set + and panics, accumulating nothing, so no actual per-Action footprint exists for + a property to compare against. - Strict filesystem WAL stores now persist a checksummed writer-epoch ledger containing the active epoch, its exact latest closed predecessor, and final LSN and commit-digest evidence. Bounded retention keeps ledger writes and @@ -1628,6 +1694,25 @@ Applied, Rejected, Obstructed}` with receipt evidence and typed contract ### Fixed +- A writer epoch that commits nothing no longer consumes an LSN. An LSN names a + WAL frame; acquiring an epoch persists ledger evidence and emits no frame, so + an epoch's start LSN is the next unallocated frame coordinate and is + non-regressing rather than universally strictly increasing. After a + predecessor with committed frames the successor still starts at + `final_lsn + 1`, but after an empty predecessor it resumes at the + predecessor's own start. Previously every barren open-and-close minted a + phantom coordinate, so a host that reopened a filesystem WAL only to inspect + it left a permanent hole; the next writer's frames landed past the gap and + every later recovery failed closed with `LsnContinuityMismatch`. Epoch-chain + advancement remains strict and is carried by epoch identity, ordinal, fencing + token, and lease evidence, none of which changed. +- `cargo test -p warp-core` no longer reports a green result for eight test + files it never ran. Each sits behind an inner `#![cfg(feature = ...)]`, so + without the feature cargo compiled an empty crate and printed + "running 0 tests ... test result: ok". They now declare `required-features`, + matching the convention the manifest already used for five other targets, so + cargo skips the target with an explicit message instead of manufacturing + coverage. Under `--workspace`, feature unification runs them as before. - Generic executable-operation lowering and independent verification now resolve source-local obstruction constructor aliases through the exact digest-locked lawpack import before encoding or comparing the package. diff --git a/crates/echo-dind-tests/src/rules.rs b/crates/echo-dind-tests/src/rules.rs index ddd039162..7d6c678a9 100644 --- a/crates/echo-dind-tests/src/rules.rs +++ b/crates/echo-dind-tests/src/rules.rs @@ -13,9 +13,9 @@ use crate::type_ids::{ use echo_wasm_abi::unpack_intent_v1; use warp_core::{ make_edge_id, make_node_id, make_type_id, AtomPayload, AtomView, AttachmentKey, AttachmentSet, - AttachmentValue, ConflictPolicy, EdgeId, EdgeRecord, EdgeSet, Footprint, GraphStore, GraphView, - Hash, NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, RewriteRule, TickDelta, TypeId, - WarpId, WarpOp, + AttachmentValue, ConflictPolicy, EdgeId, EdgeRecord, EdgeSet, ExecutionGraphView, Footprint, + GraphStore, GraphView, Hash, NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, RewriteRule, + RuleExecutor, TickDelta, TypeId, WarpId, WarpOp, }; const TYPE_VIEW_OP: &str = "sys/view/op"; @@ -61,13 +61,15 @@ pub fn route_push_rule() -> RewriteRule { name: ROUTE_PUSH_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: |s, scope| matcher_for_op(s, scope, ops::route_push::OP_ID), - executor: |s, scope, delta| { - if let Some(args) = - decode_op_args::(s, scope, ops::route_push::decode_vars) - { + executor: RuleExecutor::observed(|s, scope, delta| { + if let Some(args) = decode_observed_op_args::( + s, + scope, + ops::route_push::decode_vars, + ) { emit_route_push(s.warp_id(), delta, args.path); } - }, + }), compute_footprint: |s, scope| { // Only declare full footprint if args decode succeeds (mirrors executor). if decode_op_args::(s, scope, ops::route_push::decode_vars) @@ -92,13 +94,15 @@ pub fn set_theme_rule() -> RewriteRule { name: SET_THEME_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: |s, scope| matcher_for_op(s, scope, ops::set_theme::OP_ID), - executor: |s, scope, delta| { - if let Some(args) = - decode_op_args::(s, scope, ops::set_theme::decode_vars) - { + executor: RuleExecutor::observed(|s, scope, delta| { + if let Some(args) = decode_observed_op_args::( + s, + scope, + ops::set_theme::decode_vars, + ) { emit_set_theme(s.warp_id(), delta, args.mode); } - }, + }), compute_footprint: |s, scope| { // Only declare full footprint if args decode succeeds (mirrors executor). if decode_op_args::(s, scope, ops::set_theme::decode_vars) @@ -123,9 +127,9 @@ pub fn toggle_nav_rule() -> RewriteRule { name: TOGGLE_NAV_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: |s, scope| matcher_for_op(s, scope, ops::toggle_nav::OP_ID), - executor: |s, _scope, delta| { + executor: RuleExecutor::observed(|s, _scope, delta| { emit_toggle_nav(s, delta); - }, + }), compute_footprint: |s, scope| footprint_for_state_node(s, scope, "sim/state/navOpen"), factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -142,9 +146,9 @@ pub fn toast_rule() -> RewriteRule { name: TOAST_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: |s, scope| matcher_for_op(s, scope, ops::toast::OP_ID), - executor: |s, scope, delta| { + executor: RuleExecutor::observed(|s, scope, delta| { if let Some(args) = - decode_op_args::(s, scope, ops::toast::decode_vars) + decode_observed_op_args::(s, scope, ops::toast::decode_vars) { // Use intent scope (NodeId) for deterministic view op sequencing. // This ensures the same intent always produces the same view op ID, @@ -157,7 +161,7 @@ pub fn toast_rule() -> RewriteRule { scope, ); } - }, + }), compute_footprint: |s, scope| { // Only declare full footprint if args decode succeeds (mirrors executor). if decode_op_args::(s, scope, ops::toast::decode_vars).is_none() { @@ -190,7 +194,7 @@ pub fn drop_ball_rule() -> RewriteRule { name: DROP_BALL_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: |s, scope| matcher_for_op(s, scope, ops::drop_ball::OP_ID), - executor: |view, _scope, delta| { + executor: RuleExecutor::observed(|view, _scope, delta| { let warp_id = view.warp_id(); let ball_id = make_node_id("ball"); // Q32.32 fixed-point: 1 unit = 1 << 32 @@ -215,7 +219,7 @@ pub fn drop_ball_rule() -> RewriteRule { }), value: Some(AttachmentValue::Atom(atom)), }); - }, + }), compute_footprint: |s, _scope| { // Minimal footprint: executor only creates the ball node and its attachment. // No sim/state hierarchy or edges are created by this rule. @@ -249,8 +253,8 @@ pub fn ball_physics_rule() -> RewriteRule { } false }, - executor: |view, scope, delta| { - if let Some(m) = MotionV2View::try_from_node(&view, scope) { + executor: RuleExecutor::observed(|view, scope, delta| { + if let Some(m) = MotionV2View::try_from_execution_node(view, scope) { let mut pos = m.pos_raw(); let mut vel = m.vel_raw(); @@ -276,7 +280,7 @@ pub fn ball_physics_rule() -> RewriteRule { ))), }); } - }, + }), compute_footprint: |s, scope| { echo_dry_tests::FootprintBuilder::from_view(s) .reads_writes_node_alpha(*scope) @@ -301,13 +305,13 @@ pub fn put_kv_rule() -> RewriteRule { name: PUT_KV_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: |s, scope| matcher_for_op(s, scope, ops::put_kv::OP_ID), - executor: |s, scope, delta| { + executor: RuleExecutor::observed(|s, scope, delta| { if let Some(args) = - decode_op_args::(s, scope, ops::put_kv::decode_vars) + decode_observed_op_args::(s, scope, ops::put_kv::decode_vars) { emit_put_kv(s.warp_id(), delta, args.key, args.value); } - }, + }), compute_footprint: |s, scope| { if let Some(args) = decode_op_args::(s, scope, ops::put_kv::decode_vars) @@ -347,6 +351,18 @@ fn decode_op_args( decode_fn(vars) } +fn decode_observed_op_args( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, + decode_fn: fn(&[u8]) -> Option, +) -> Option { + let AttachmentValue::Atom(a) = view.node_attachment(scope)? else { + return None; + }; + let (_, vars) = unpack_intent_v1(&a.bytes).ok()?; + decode_fn(vars) +} + impl<'a> MotionV2View<'a> { /// Attempt to construct a motion v2 view from a node's attachment. pub fn try_from_node(view: &'a GraphView<'a>, node: &NodeId) -> Option { @@ -355,6 +371,17 @@ impl<'a> MotionV2View<'a> { }; Self::try_from_payload(p) } + + /// Attempt to construct a motion v2 view through an observed executor view. + pub fn try_from_execution_node<'store>( + view: &mut ExecutionGraphView<'store, '_>, + node: &NodeId, + ) -> Option> { + let AttachmentValue::Atom(p) = view.node_attachment(node)? else { + return None; + }; + MotionV2View::try_from_payload(p) + } } /// Returns a minimal footprint for decode-only access. @@ -533,7 +560,7 @@ fn emit_set_theme(warp_id: WarpId, delta: &mut TickDelta, mode: crate::codecs::T } /// Emit ops for a toggle nav operation. -fn emit_toggle_nav(view: GraphView<'_>, delta: &mut TickDelta) { +fn emit_toggle_nav(view: &mut ExecutionGraphView<'_, '_>, delta: &mut TickDelta) { let warp_id = view.warp_id(); let (_, sim_state_id) = emit_state_base(warp_id, delta); let id = make_node_id("sim/state/navOpen"); diff --git a/crates/echo-dry-tests/src/demo_rules.rs b/crates/echo-dry-tests/src/demo_rules.rs index 58df57ec4..f1b77de20 100644 --- a/crates/echo-dry-tests/src/demo_rules.rs +++ b/crates/echo-dry-tests/src/demo_rules.rs @@ -8,11 +8,14 @@ use warp_core::{ decode_motion_atom_payload_q32_32, decode_motion_payload, encode_motion_atom_payload, encode_motion_payload, encode_motion_payload_q32_32, make_node_id, make_type_id, motion_payload_type_id, pack_port_key, AtomPayload, AttachmentKey, AttachmentSet, - AttachmentValue, ConflictPolicy, EdgeSet, Engine, Footprint, GraphStore, GraphView, Hash, - NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, PortSet, RewriteRule, TickDelta, WarpId, - WarpOp, + AttachmentValue, ConflictPolicy, EdgeSet, Engine, ExecutionGraphView, Footprint, GraphStore, + GraphView, Hash, NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, PortSet, RewriteRule, + RuleExecutor, TickDelta, WarpId, WarpOp, }; +#[cfg(test)] +use warp_core::ActualFootprint; + // ============================================================================= // Motion Rule // ============================================================================= @@ -54,7 +57,7 @@ mod motion_scalar_backend { use motion_scalar_backend::{scalar_from_raw, scalar_to_raw}; -fn motion_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { +fn motion_executor(view: &mut ExecutionGraphView<'_, '_>, scope: &NodeId, delta: &mut TickDelta) { if view.node(scope).is_none() { return; } @@ -187,7 +190,7 @@ pub fn motion_rule() -> RewriteRule { name: MOTION_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: motion_matcher, - executor: motion_executor, + executor: RuleExecutor::observed(motion_executor), compute_footprint: compute_motion_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -222,7 +225,7 @@ fn port_matcher(_: GraphView<'_>, _: &NodeId) -> bool { true } -fn port_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { +fn port_executor(view: &mut ExecutionGraphView<'_, '_>, scope: &NodeId, delta: &mut TickDelta) { if view.node(scope).is_none() { return; } @@ -306,7 +309,7 @@ pub fn port_rule() -> RewriteRule { name: PORT_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: port_matcher, - executor: port_executor, + executor: RuleExecutor::observed(port_executor), compute_footprint: compute_port_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -353,9 +356,10 @@ mod tests { Some(AttachmentValue::Atom(encode_motion_atom_payload(pos, vel))), ); - let view = GraphView::new(&store); + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new(&store, &mut actual); let mut delta = TickDelta::new(); - port_executor(view, &node_id, &mut delta); + port_executor(&mut view, &node_id, &mut delta); assert!(delta.is_empty(), "no-op update should not emit a delta op"); } diff --git a/crates/echo-dry-tests/src/rules.rs b/crates/echo-dry-tests/src/rules.rs index f76071e21..adc44e5ea 100644 --- a/crates/echo-dry-tests/src/rules.rs +++ b/crates/echo-dry-tests/src/rules.rs @@ -9,7 +9,8 @@ use crate::hashes::make_rule_id; #[cfg(test)] use warp_core::GraphStore; use warp_core::{ - ConflictPolicy, Footprint, GraphView, Hash, NodeId, PatternGraph, RewriteRule, TickDelta, + ConflictPolicy, ExecutionGraphView, Footprint, GraphView, Hash, NodeId, ObservedExecuteFn, + PatternGraph, RewriteRule, RuleExecutor, TickDelta, }; /// Type alias for join functions matching warp-core's `JoinFn`. @@ -35,7 +36,7 @@ pub fn scope_exists(view: GraphView<'_>, scope: &NodeId) -> bool { // --- Executor Functions --- /// Executor that does nothing. -pub fn noop_exec(_: GraphView<'_>, _: &NodeId, _: &mut TickDelta) {} +pub fn noop_exec(_: &mut ExecutionGraphView<'_, '_>, _: &NodeId, _: &mut TickDelta) {} // --- Footprint Functions --- @@ -113,7 +114,7 @@ impl NoOpRule { pub type MatcherFn = for<'a> fn(GraphView<'a>, &NodeId) -> bool; /// Type alias for Phase 5 parallel execution executor functions. -pub type ExecutorFn = for<'a> fn(GraphView<'a>, &NodeId, &mut TickDelta); +pub type ExecutorFn = ObservedExecuteFn; /// Type alias for Phase 5 parallel execution footprint functions. pub type FootprintFn = for<'a> fn(GraphView<'a>, &NodeId) -> Footprint; @@ -233,7 +234,7 @@ impl SyntheticRuleBuilder { name: self.name, left: PatternGraph { nodes: vec![] }, matcher: self.matcher, - executor: self.executor, + executor: RuleExecutor::observed(self.executor), compute_footprint: self.footprint, factor_mask: self.factor_mask, conflict_policy: self.conflict_policy, diff --git a/crates/echo-wesley-gen/src/main.rs b/crates/echo-wesley-gen/src/main.rs index 83ffbc729..f9fd3def8 100644 --- a/crates/echo-wesley-gen/src/main.rs +++ b/crates/echo-wesley-gen/src/main.rs @@ -651,8 +651,8 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { if args.contract_host && has_mutation_ops { helper_prelude.extend(quote! { use warp_core::{ - ConflictPolicy, Footprint, GraphView, NodeId, PatternGraph, RewriteRule, - TickDelta, + ConflictPolicy, ExecutionGraphView, Footprint, GraphView, NodeId, + ObservedExecuteFn, PatternGraph, RewriteRule, RuleExecutor, }; }); } @@ -838,8 +838,15 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { /// Decode this mutation's generated vars from a scheduler-materialized /// EINT runtime ingress event. - pub fn #contract_vars_fn_name(view: GraphView<'_>, scope: &NodeId) -> Option<#vars_name> { - let vars = warp_core::eint_vars_for_op(view, scope, super::#const_name)?; + pub fn #contract_vars_fn_name( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, + ) -> Option<#vars_name> { + let vars = warp_core::observed_eint_vars_for_op( + view, + scope, + super::#const_name, + )?; echo_wasm_abi::codec::decode_from_bytes(vars).ok() } @@ -854,7 +861,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { /// Build a `warp-core` command rule for this generated contract /// mutation using a host-supplied executor and footprint function. pub fn #contract_rule_fn_name( - executor: for<'a> fn(GraphView<'a>, &NodeId, &mut TickDelta), + executor: ObservedExecuteFn, compute_footprint: for<'a> fn(GraphView<'a>, &NodeId) -> Footprint, ) -> RewriteRule { RewriteRule { @@ -862,7 +869,7 @@ fn generate_rust(ir: &WesleyIR, args: &Args) -> Result { name: #contract_rule_name_const, left: PatternGraph { nodes: Vec::new() }, matcher: #contract_match_fn_name, - executor, + executor: RuleExecutor::observed(executor), compute_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/echo-wesley-gen/tests/generation.rs b/crates/echo-wesley-gen/tests/generation.rs index 9675a2f95..d1bbf0831 100644 --- a/crates/echo-wesley-gen/tests/generation.rs +++ b/crates/echo-wesley-gen/tests/generation.rs @@ -764,8 +764,8 @@ mod tests { use warp_core::{ make_edge_id, make_head_id, make_intent_kind, make_node_id, make_type_id, AttachmentKey, AttachmentValue, AtomPayload, ContractQueryObserverContext, ContractQueryObserverError, - ContractQueryObserverResult, EdgeRecord, EngineBuilder, GraphStore, GraphView, - InboxPolicy, IngressEnvelope, IngressTarget, NodeId, NodeKey, NodeRecord, + ContractQueryObserverResult, EdgeRecord, EngineBuilder, ExecutionGraphView, GraphStore, + GraphView, InboxPolicy, IngressEnvelope, IngressTarget, NodeId, NodeKey, NodeRecord, ObservationAt, ObservationCoordinate, ObservationFrame, ObservationPayload, ObservationProjection, ObservationService, PlaybackMode, ProvenanceService, ReadingObserverBasis, ReadingObserverPlan, ReadingResidualPosture, SchedulerCoordinator, @@ -784,7 +784,11 @@ mod tests { make_edge_id("generated-contract-host/result-edge") } - fn increment_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { + fn increment_executor( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, + ) { let Some(vars) = increment_contract_vars(view, scope) else { return; }; @@ -1885,6 +1889,7 @@ fn test_toy_contract_generated_contract_host_query_observer_compiles_in_consumer let generated = String::from_utf8_lossy(&output.stdout); assert!(generated.contains("pub fn increment_contract_rule")); assert!(generated.contains("pub fn increment_contract_vars")); + assert!(generated.contains("executor: RuleExecutor::observed(executor)")); assert!(generated.contains("pub fn increment_contract_runtime_ingress_footprint")); assert!(generated.contains("pub fn counter_value_query_observer")); assert!(generated.contains("pub fn counter_value_observer_vars")); diff --git a/crates/warp-benches/benches/scheduler_drain.rs b/crates/warp-benches/benches/scheduler_drain.rs index 1b5aa44ee..c0c99d2b6 100644 --- a/crates/warp-benches/benches/scheduler_drain.rs +++ b/crates/warp-benches/benches/scheduler_drain.rs @@ -22,8 +22,8 @@ use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criteri use echo_dry_tests::build_motion_demo_engine; use std::time::Duration; use warp_core::{ - make_node_id, make_type_id, ApplyResult, ConflictPolicy, Engine, Footprint, GraphView, Hash, - NodeId, NodeRecord, PatternGraph, RewriteRule, TickDelta, + make_node_id, make_type_id, ApplyResult, ConflictPolicy, Engine, ExecutionGraphView, Footprint, + GraphView, Hash, NodeId, NodeRecord, PatternGraph, RewriteRule, RuleExecutor, TickDelta, }; // Bench constants to avoid magic strings. @@ -43,7 +43,7 @@ fn bench_noop_rule() -> RewriteRule { fn matcher(_view: GraphView<'_>, _n: &NodeId) -> bool { true } - fn executor(_view: GraphView<'_>, _n: &NodeId, _delta: &mut TickDelta) {} + fn executor(_view: &mut ExecutionGraphView<'_, '_>, _n: &NodeId, _delta: &mut TickDelta) {} fn footprint(_view: GraphView<'_>, _n: &NodeId) -> Footprint { Footprint::default() } @@ -52,7 +52,7 @@ fn bench_noop_rule() -> RewriteRule { name: BENCH_NOOP_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher, - executor, + executor: RuleExecutor::observed(executor), compute_footprint: footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/warp-cli/tests/support/runtime_wal_fixture.rs b/crates/warp-cli/tests/support/runtime_wal_fixture.rs index 44f0b547a..2e4d25d52 100644 --- a/crates/warp-cli/tests/support/runtime_wal_fixture.rs +++ b/crates/warp-cli/tests/support/runtime_wal_fixture.rs @@ -215,7 +215,7 @@ fn contract_rule() -> warp_core::RewriteRule { name: RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: contract_matches, - executor: contract_execute, + executor: warp_core::RuleExecutor::legacy(contract_execute), compute_footprint: contract_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, diff --git a/crates/warp-core/Cargo.toml b/crates/warp-core/Cargo.toml index 49fc5fa88..454f9fc26 100644 --- a/crates/warp-core/Cargo.toml +++ b/crates/warp-core/Cargo.toml @@ -102,6 +102,46 @@ required-features = ["native_rule_bootstrap", "trusted_runtime"] name = "executable_operation_pipeline_tests" required-features = ["native_rule_bootstrap", "trusted_runtime"] +# A test file whose contents sit behind an inner `#![cfg(feature = ...)]` MUST +# also declare `required-features` here. Without it, `cargo test -p warp-core` +# compiles the target with the feature off, finds an empty crate, and reports +# "running 0 tests ... test result: ok" — a green that means nothing was run. +# With it, cargo skips the target outright, so a per-crate run cannot be +# mistaken for coverage. Under `--workspace`, feature unification turns these +# on and the tests actually execute. + +[[test]] +name = "causal_anchor_external_consumer_tests" +required-features = ["native_rule_bootstrap", "trusted_runtime"] + +[[test]] +name = "external_consumer_contract_fixture_tests" +required-features = ["native_rule_bootstrap", "trusted_runtime"] + +[[test]] +name = "trusted_runtime_host_loop_tests" +required-features = ["native_rule_bootstrap", "trusted_runtime"] + +[[test]] +name = "parallel_engine_integration_multiwarp" +required-features = ["delta_validate"] + +[[test]] +name = "parallel_merge_tripwire" +required-features = ["delta_validate"] + +[[test]] +name = "parallel_merge_warpopkey" +required-features = ["delta_validate"] + +[[test]] +name = "parallel_openportal_rules" +required-features = ["delta_validate"] + +[[test]] +name = "parallel_stress_multiwarp" +required-features = ["delta_validate"] + [build-dependencies] blake3 = "1.0" diff --git a/crates/warp-core/src/actual_footprint.rs b/crates/warp-core/src/actual_footprint.rs new file mode 100644 index 000000000..fdd2bdba4 --- /dev/null +++ b/crates/warp-core/src/actual_footprint.rs @@ -0,0 +1,903 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Actual graph footprints observed during a guarded execution. +//! +//! A [`Footprint`] is a *claim*: it says what a rule intends to read and write. +//! [`FootprintGuard`](crate::footprint_guard::FootprintGuard) checks each access +//! against that claim and panics on a miss, but it accumulates nothing — it +//! answers "was this access declared?" and immediately forgets. +//! +//! [`ActualFootprint`] is the other half: an accumulated record of what an +//! execution *actually* touched. Holding both sides makes the soundness relation +//! +//! ```text +//! ActualRead(a) ⊆ DeclaredRead(a) +//! ActualWrite(a) ⊆ DeclaredWrite(a) +//! ``` +//! +//! evaluable as a value rather than only observable as a panic. That is what a +//! read-only property evaluator needs in order to refute a footprint claim +//! without depending on unwind. +//! +//! # Scope +//! +//! Local ids within a single warp, matching the guard's own pre-filtering. An +//! `ActualFootprint` records accesses for exactly one [`WarpId`]; accesses +//! belonging to another warp are a cross-warp concern and are reported by the +//! guard as [`ViolationKind::CrossWarpEmission`], not by this type. +//! +//! # Determinism +//! +//! All sets are [`BTreeSet`]s and [`ActualFootprint::soundness_violations`] +//! emits violations in a fixed axis order, so the same execution yields the +//! same violation sequence on every host. + +use std::collections::BTreeSet; + +use crate::attachment::{AttachmentKey, AttachmentOwner}; +use crate::footprint::Footprint; +use crate::footprint_guard::{op_write_targets, ViolationKind}; +use crate::ident::{EdgeId, NodeId, WarpId}; +use crate::tick_patch::WarpOp; + +/// What an [`ActualFootprint`] is entitled to claim about the execution it came +/// from. +/// +/// An empty violation set means two very different things depending on how the +/// record was produced. If every access passed through a recording, enforcing +/// capability, it means the declaration covered the execution. If the lane +/// recorded nothing — because the executor read through an unobserved +/// [`GraphView`](crate::GraphView), or the build compiled enforcement out — it +/// means only that nothing was compared. Reporting the second as the first is +/// the false negative that footprint honesty exists to prevent, so posture +/// travels with the evidence instead of being inferred from it. +/// +/// Only [`RecordedAndEnforced`](Self::RecordedAndEnforced) may support an +/// admitted falsification witness against a footprint property. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ActualFootprintPosture { + /// Every access was recorded and checked against the declared footprint. + /// + /// The record is a complete transcript and the declared/actual comparison + /// is authoritative. + RecordedAndEnforced, + /// Every access was recorded, but nothing was checked. + /// + /// The transcript is complete and `Actual ⊆ Declared` is still evaluable + /// from it, but no guard was active, so the run cannot corroborate that + /// enforcement would have caught the violation at the point of access. + RecordedWithoutEnforcement, + /// The executor read through an unobserved capability. + /// + /// Reads bypassed recording entirely. The write axis may still be derived + /// from emitted ops, but the read axis is unknown — not empty. + UnavailableLegacyExecutor, + /// The build compiled footprint enforcement out. + /// + /// Either `unsafe_graph` is enabled, or this is a release build without + /// `footprint_enforce_release`. No lane in this binary can produce + /// enforced evidence. + UnavailableBuildProfile, +} + +impl ActualFootprintPosture { + /// Returns `true` when evidence under this posture may ground an admitted + /// falsification witness against a footprint property. + #[must_use] + pub fn is_authoritative(self) -> bool { + matches!(self, Self::RecordedAndEnforced) + } + + /// Returns `true` when the read axis of the record is a complete + /// transcript rather than an unknown. + /// + /// A record whose read axis is unknown must never be read as "this + /// execution read nothing." + #[must_use] + pub fn read_axis_is_complete(self) -> bool { + matches!( + self, + Self::RecordedAndEnforced | Self::RecordedWithoutEnforcement + ) + } +} + +/// The strongest posture this build can produce, regardless of lane. +/// +/// Enforcement is compiled out unless `debug_assertions` or +/// `footprint_enforce_release` is active, and it is additionally excluded by +/// `unsafe_graph`. A lane cannot claim enforcement the binary does not contain, +/// so every posture is capped by this value. +#[must_use] +pub const fn build_footprint_posture() -> ActualFootprintPosture { + #[cfg(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") + ))] + { + ActualFootprintPosture::RecordedAndEnforced + } + #[cfg(not(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") + )))] + { + ActualFootprintPosture::UnavailableBuildProfile + } +} + +/// Graph resources an execution actually touched, as local ids within one warp. +/// +/// Construct with [`ActualFootprint::new`], accumulate with the `record_*` +/// methods, then compare against a declared [`Footprint`] with +/// [`ActualFootprint::soundness_violations`]. +/// +/// Recording is additive and never panics. It does not replace footprint +/// enforcement: the guard's panic remains the correct response to an undeclared +/// access during ordinary execution. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ActualFootprint { + nodes_read: BTreeSet, + edges_read: BTreeSet, + attachments_read: BTreeSet, + nodes_write: BTreeSet, + edges_write: BTreeSet, + attachments_write: BTreeSet, +} + +impl ActualFootprint { + /// Creates an empty record. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns `true` when nothing has been recorded. + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes_read.is_empty() + && self.edges_read.is_empty() + && self.attachments_read.is_empty() + && self.nodes_write.is_empty() + && self.edges_write.is_empty() + && self.attachments_write.is_empty() + } + + /// Records an observed node read. + pub fn record_node_read(&mut self, id: NodeId) { + self.nodes_read.insert(id); + } + + /// Records an observed edge read. + pub fn record_edge_read(&mut self, id: EdgeId) { + self.edges_read.insert(id); + } + + /// Records an observed attachment read. + pub fn record_attachment_read(&mut self, key: AttachmentKey) { + self.attachments_read.insert(key); + } + + /// Records an observed node write. + pub fn record_node_write(&mut self, id: NodeId) { + self.nodes_write.insert(id); + } + + /// Records an observed edge write. + pub fn record_edge_write(&mut self, id: EdgeId) { + self.edges_write.insert(id); + } + + /// Records an observed attachment write. + pub fn record_attachment_write(&mut self, key: AttachmentKey) { + self.attachments_write.insert(key); + } + + /// Returns the recorded node reads in canonical order. + pub fn nodes_read(&self) -> impl Iterator { + self.nodes_read.iter() + } + + /// Returns the recorded edge reads in canonical order. + pub fn edges_read(&self) -> impl Iterator { + self.edges_read.iter() + } + + /// Returns the recorded attachment reads in canonical order. + pub fn attachments_read(&self) -> impl Iterator { + self.attachments_read.iter() + } + + /// Returns the recorded node writes in canonical order. + pub fn nodes_write(&self) -> impl Iterator { + self.nodes_write.iter() + } + + /// Returns the recorded edge writes in canonical order. + pub fn edges_write(&self) -> impl Iterator { + self.edges_write.iter() + } + + /// Returns the recorded attachment writes in canonical order. + pub fn attachments_write(&self) -> impl Iterator { + self.attachments_write.iter() + } + + /// Records every write target of one emitted op. + /// + /// Uses the same extraction as footprint enforcement, so a recorded write + /// set and an enforced write check can never disagree about what an op + /// mutates. Instance-level and cross-warp concerns are deliberately not + /// recorded here: they are authority and scope questions that the guard + /// reports as [`ViolationKind::UnauthorizedInstanceOp`] and + /// [`ViolationKind::CrossWarpEmission`], not footprint-subset questions. + /// + /// Targets belonging to another warp are skipped, matching the guard's + /// pre-filtering. + pub fn record_op(&mut self, op: &WarpOp, warp_id: WarpId) { + let targets = op_write_targets(op); + + if targets.op_warp.is_some_and(|op_warp| op_warp != warp_id) { + return; + } + + for node in targets.nodes { + self.record_node_write(node); + } + for edge in targets.edges { + self.record_edge_write(edge); + } + for attachment in targets.attachments { + self.record_attachment_write(attachment); + } + } + + /// Accumulates the actual write footprint of an emitted op sequence. + /// + /// This is the write axis of footprint soundness, computable entirely from + /// material the executor already produced. The read axis requires observing + /// accesses as they happen and is not derivable from ops. + #[must_use] + pub fn from_ops<'a>(ops: impl IntoIterator, warp_id: WarpId) -> Self { + let mut actual = Self::new(); + for op in ops { + actual.record_op(op, warp_id); + } + actual + } + + /// Returns every recorded access that the declared footprint does not cover. + /// + /// An empty result means `Actual ⊆ Declared` on both axes for `warp_id`: + /// the declaration is sound with respect to what this execution did. A + /// declaration that covers *more* than the execution touched is sound; this + /// relation does not demand footprint minimality. + /// + /// Violations are emitted in a fixed axis order — node reads, edge reads, + /// attachment reads, node writes, edge writes, attachment writes — and + /// canonically within each axis. + #[must_use] + pub fn soundness_violations( + &self, + declared: &Footprint, + warp_id: WarpId, + ) -> Vec { + let mut violations = Vec::new(); + + let declared_nodes_read = declared_nodes(declared.n_read.iter(), warp_id); + let declared_nodes_write = declared_nodes(declared.n_write.iter(), warp_id); + let declared_edges_read = declared_edges(declared.e_read.iter(), warp_id); + let declared_edges_write = declared_edges(declared.e_write.iter(), warp_id); + let declared_attachments_read = declared_attachments(declared.a_read.iter(), warp_id); + let declared_attachments_write = declared_attachments(declared.a_write.iter(), warp_id); + + for id in &self.nodes_read { + if !declared_nodes_read.contains(id) { + violations.push(ViolationKind::NodeReadNotDeclared(*id)); + } + } + for id in &self.edges_read { + if !declared_edges_read.contains(id) { + violations.push(ViolationKind::EdgeReadNotDeclared(*id)); + } + } + for key in &self.attachments_read { + if !declared_attachments_read.contains(key) { + violations.push(ViolationKind::AttachmentReadNotDeclared(*key)); + } + } + for id in &self.nodes_write { + if !declared_nodes_write.contains(id) { + violations.push(ViolationKind::NodeWriteNotDeclared(*id)); + } + } + for id in &self.edges_write { + if !declared_edges_write.contains(id) { + violations.push(ViolationKind::EdgeWriteNotDeclared(*id)); + } + } + for key in &self.attachments_write { + if !declared_attachments_write.contains(key) { + violations.push(ViolationKind::AttachmentWriteNotDeclared(*key)); + } + } + + violations + } + + /// Returns `true` when the declared footprint covers every recorded access. + #[must_use] + pub fn is_sound_under(&self, declared: &Footprint, warp_id: WarpId) -> bool { + self.soundness_violations(declared, warp_id).is_empty() + } +} + +fn declared_nodes<'a>( + keys: impl Iterator, + warp_id: WarpId, +) -> BTreeSet { + keys.filter(|key| key.warp_id == warp_id) + .map(|key| key.local_id) + .collect() +} + +fn declared_edges<'a>( + keys: impl Iterator, + warp_id: WarpId, +) -> BTreeSet { + keys.filter(|key| key.warp_id == warp_id) + .map(|key| key.local_id) + .collect() +} + +fn declared_attachments<'a>( + keys: impl Iterator, + warp_id: WarpId, +) -> BTreeSet { + // Matched directly rather than via `AttachmentOwner::warp_id`, which is only + // compiled under enforcement. Soundness comparison must remain available in + // every build so retained evidence stays inspectable. + keys.filter(|key| attachment_warp_id(**key) == warp_id) + .copied() + .collect() +} + +fn attachment_warp_id(key: AttachmentKey) -> WarpId { + match key.owner { + AttachmentOwner::Node(node) => node.warp_id, + AttachmentOwner::Edge(edge) => edge.warp_id, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::ident::{make_edge_id, make_node_id, make_warp_id, NodeKey}; + + fn warp() -> WarpId { + make_warp_id("actual-footprint-tests") + } + + fn other_warp() -> WarpId { + make_warp_id("actual-footprint-tests-other") + } + + fn node_attachment(node: NodeId) -> AttachmentKey { + AttachmentKey::node_alpha(NodeKey { + warp_id: warp(), + local_id: node, + }) + } + + mod posture { + use super::*; + + #[test] + fn only_recorded_and_enforced_is_authoritative() { + assert!(ActualFootprintPosture::RecordedAndEnforced.is_authoritative()); + for posture in [ + ActualFootprintPosture::RecordedWithoutEnforcement, + ActualFootprintPosture::UnavailableLegacyExecutor, + ActualFootprintPosture::UnavailableBuildProfile, + ] { + assert!( + !posture.is_authoritative(), + "{posture:?} must not ground an admitted witness" + ); + } + } + + #[test] + fn an_unavailable_posture_does_not_claim_a_complete_read_axis() { + // This is the false negative the vertical exists to prevent: an + // empty read axis from an unobserved lane must read as "unknown", + // never as "this execution read nothing". + assert!(ActualFootprintPosture::RecordedAndEnforced.read_axis_is_complete()); + assert!(ActualFootprintPosture::RecordedWithoutEnforcement.read_axis_is_complete()); + assert!(!ActualFootprintPosture::UnavailableLegacyExecutor.read_axis_is_complete()); + assert!(!ActualFootprintPosture::UnavailableBuildProfile.read_axis_is_complete()); + } + + #[test] + fn the_build_posture_matches_the_compiled_enforcement_gate() { + // `build_footprint_posture` must track the same cfg the guard is + // gated on, or a binary without enforcement could claim evidence + // it cannot produce. + let expected = if cfg!(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") + )) { + ActualFootprintPosture::RecordedAndEnforced + } else { + ActualFootprintPosture::UnavailableBuildProfile + }; + assert_eq!(build_footprint_posture(), expected); + } + } + + #[test] + fn empty_record_is_sound_under_any_declaration() { + let actual = ActualFootprint::new(); + assert!(actual.is_empty()); + assert!(actual.is_sound_under(&Footprint::default(), warp())); + } + + #[test] + fn declared_access_produces_no_violation() { + let node = make_node_id("a"); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: warp(), + local_id: node, + }); + + let mut actual = ActualFootprint::new(); + actual.record_node_read(node); + + assert_eq!(actual.soundness_violations(&declared, warp()), Vec::new()); + } + + #[test] + fn undeclared_node_read_is_reported() { + let declared_node = make_node_id("a"); + let undeclared_node = make_node_id("b"); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: warp(), + local_id: declared_node, + }); + + let mut actual = ActualFootprint::new(); + actual.record_node_read(declared_node); + actual.record_node_read(undeclared_node); + + assert_eq!( + actual.soundness_violations(&declared, warp()), + vec![ViolationKind::NodeReadNotDeclared(undeclared_node)] + ); + } + + #[test] + fn superset_declaration_is_sound() { + // The relation is Actual ⊆ Declared. Declaring more than was touched is + // sound; this type does not demand footprint minimality. + let touched = make_node_id("a"); + let untouched = make_node_id("b"); + let mut declared = Footprint::default(); + for node in [touched, untouched] { + declared.n_read.insert(NodeKey { + warp_id: warp(), + local_id: node, + }); + } + + let mut actual = ActualFootprint::new(); + actual.record_node_read(touched); + + assert!(actual.is_sound_under(&declared, warp())); + } + + #[test] + fn read_declaration_does_not_authorize_a_write() { + let node = make_node_id("a"); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: warp(), + local_id: node, + }); + + let mut actual = ActualFootprint::new(); + actual.record_node_write(node); + + assert_eq!( + actual.soundness_violations(&declared, warp()), + vec![ViolationKind::NodeWriteNotDeclared(node)] + ); + } + + #[test] + fn declaration_in_another_warp_does_not_cover_this_warp() { + let node = make_node_id("a"); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: other_warp(), + local_id: node, + }); + + let mut actual = ActualFootprint::new(); + actual.record_node_read(node); + + assert_eq!( + actual.soundness_violations(&declared, warp()), + vec![ViolationKind::NodeReadNotDeclared(node)] + ); + } + + #[test] + fn violations_are_ordered_by_axis_then_canonically() { + let node_b = make_node_id("b"); + let node_a = make_node_id("a"); + let edge = make_edge_id("e"); + let attachment = node_attachment(node_a); + + let mut actual = ActualFootprint::new(); + actual.record_node_read(node_b); + actual.record_node_read(node_a); + actual.record_edge_read(edge); + actual.record_attachment_read(attachment); + actual.record_node_write(node_a); + + let violations = actual.soundness_violations(&Footprint::default(), warp()); + let mut expected_nodes = [node_a, node_b]; + expected_nodes.sort_unstable(); + + assert_eq!( + violations, + vec![ + ViolationKind::NodeReadNotDeclared(expected_nodes[0]), + ViolationKind::NodeReadNotDeclared(expected_nodes[1]), + ViolationKind::EdgeReadNotDeclared(edge), + ViolationKind::AttachmentReadNotDeclared(attachment), + ViolationKind::NodeWriteNotDeclared(node_a), + ] + ); + } + + #[test] + fn recording_is_idempotent() { + let node = make_node_id("a"); + + let mut once = ActualFootprint::new(); + once.record_node_read(node); + + let mut twice = ActualFootprint::new(); + twice.record_node_read(node); + twice.record_node_read(node); + + assert_eq!(once, twice); + } + + #[test] + fn recording_order_does_not_affect_the_record() { + let node_a = make_node_id("a"); + let node_b = make_node_id("b"); + + let mut forward = ActualFootprint::new(); + forward.record_node_read(node_a); + forward.record_node_read(node_b); + + let mut reverse = ActualFootprint::new(); + reverse.record_node_read(node_b); + reverse.record_node_read(node_a); + + assert_eq!(forward, reverse); + assert_eq!( + forward.soundness_violations(&Footprint::default(), warp()), + reverse.soundness_violations(&Footprint::default(), warp()) + ); + } + + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + mod from_ops { + use super::*; + use crate::record::NodeRecord; + use crate::tick_patch::WarpOp; + + fn upsert(node: NodeId) -> WarpOp { + WarpOp::UpsertNode { + node: NodeKey { + warp_id: warp(), + local_id: node, + }, + record: NodeRecord { + ty: crate::ident::make_type_id("actual-footprint-test"), + }, + } + } + + #[test] + fn ops_populate_the_write_axis_and_leave_reads_empty() { + let node = make_node_id("a"); + let actual = ActualFootprint::from_ops([&upsert(node)], warp()); + + assert_eq!( + actual.nodes_write().copied().collect::>(), + vec![node] + ); + assert_eq!(actual.nodes_read().count(), 0); + } + + #[test] + fn undeclared_op_write_is_reported() { + let node = make_node_id("a"); + let actual = ActualFootprint::from_ops([&upsert(node)], warp()); + + assert_eq!( + actual.soundness_violations(&Footprint::default(), warp()), + vec![ViolationKind::NodeWriteNotDeclared(node)] + ); + } + + #[test] + fn declared_op_write_is_sound() { + let node = make_node_id("a"); + let mut declared = Footprint::default(); + declared.n_write.insert(NodeKey { + warp_id: warp(), + local_id: node, + }); + + let actual = ActualFootprint::from_ops([&upsert(node)], warp()); + + assert!(actual.is_sound_under(&declared, warp())); + } + + #[test] + fn cross_warp_op_targets_are_not_recorded() { + // Cross-warp emission is a scope violation the guard reports as + // CrossWarpEmission. Recording it as a local write would misreport + // it as a footprint-subset failure. + let node = make_node_id("a"); + let op = WarpOp::UpsertNode { + node: NodeKey { + warp_id: other_warp(), + local_id: node, + }, + record: NodeRecord { + ty: crate::ident::make_type_id("actual-footprint-test"), + }, + }; + + let actual = ActualFootprint::from_ops([&op], warp()); + + assert!(actual.is_empty()); + } + + #[test] + fn edge_write_records_both_edge_and_from_node() { + // op_write_targets treats an edge mutation as writing the edge and + // its `from` node. The recorded footprint must agree, or a sound + // declaration would look unsound. + let from = make_node_id("from"); + let to = make_node_id("to"); + let edge = make_edge_id("e"); + let op = WarpOp::UpsertEdge { + warp_id: warp(), + record: crate::record::EdgeRecord { + id: edge, + from, + to, + ty: crate::ident::make_type_id("actual-footprint-test-edge"), + }, + }; + + let actual = ActualFootprint::from_ops([&op], warp()); + + assert_eq!( + actual.edges_write().copied().collect::>(), + vec![edge] + ); + assert_eq!( + actual.nodes_write().copied().collect::>(), + vec![from] + ); + } + + /// The recorded write set and the enforced write check must agree. + /// + /// `from_ops` and `check_op` both derive their targets from + /// `op_write_targets`, so they *should* be two readings of one + /// extraction. "Should" is not evidence. These tests run both against + /// the same ops and the same declaration and compare the verdicts, + /// because a silent divergence would let a witness claim a violation + /// enforcement never saw — or, worse, report soundness for an + /// execution enforcement would have stopped. + mod differential { + use super::*; + use crate::footprint_guard::FootprintGuard; + + /// Runs the enforced check over `ops`, returning the first typed + /// violation, exactly as post-hoc write enforcement does. + fn enforced_verdict( + declared: &Footprint, + ops: &[WarpOp], + is_system: bool, + ) -> Option { + let guard = FootprintGuard::new(declared, warp(), "differential", is_system); + for op in ops { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + guard.check_op(op); + })); + if let Err(panic) = outcome { + let violation = panic + .downcast_ref::() + .expect("the guard panics with a typed payload"); + return Some(violation.kind.clone()); + } + } + None + } + + fn recorded_verdicts(declared: &Footprint, ops: &[WarpOp]) -> Vec { + ActualFootprint::from_ops(ops, warp()).soundness_violations(declared, warp()) + } + + fn declaring(nodes: &[NodeId]) -> Footprint { + let mut declared = Footprint::default(); + for node in nodes { + declared.n_write.insert(NodeKey { + warp_id: warp(), + local_id: *node, + }); + } + declared + } + + #[test] + fn an_undeclared_write_is_reported_identically_by_both_readings() { + let declared_node = make_node_id("declared"); + let undeclared_node = make_node_id("undeclared"); + let declared = declaring(&[declared_node]); + let ops = [upsert(declared_node), upsert(undeclared_node)]; + + let enforced = enforced_verdict(&declared, &ops, false); + let recorded = recorded_verdicts(&declared, &ops); + + assert_eq!( + enforced, + Some(ViolationKind::NodeWriteNotDeclared(undeclared_node)) + ); + assert_eq!( + recorded, + vec![ViolationKind::NodeWriteNotDeclared(undeclared_node)] + ); + assert_eq!(recorded.first().cloned(), enforced); + } + + #[test] + fn a_sound_declaration_produces_no_verdict_from_either_reading() { + let node = make_node_id("declared"); + let declared = declaring(&[node]); + let ops = [upsert(node)]; + + assert_eq!(enforced_verdict(&declared, &ops, false), None); + assert_eq!(recorded_verdicts(&declared, &ops), Vec::new()); + } + + #[test] + fn a_superset_declaration_produces_no_verdict_from_either_reading() { + let touched = make_node_id("touched"); + let untouched = make_node_id("untouched"); + let declared = declaring(&[touched, untouched]); + let ops = [upsert(touched)]; + + assert_eq!(enforced_verdict(&declared, &ops, false), None); + assert_eq!(recorded_verdicts(&declared, &ops), Vec::new()); + } + + #[test] + fn an_edge_write_agrees_on_the_implied_from_node() { + // `op_write_targets` treats an edge mutation as writing the + // edge and its `from` node. If the recorder and the guard + // disagreed about that implication, a rule declaring both + // would look unsound to one of them. + let from = make_node_id("from"); + let to = make_node_id("to"); + let edge = make_edge_id("e"); + let op = WarpOp::UpsertEdge { + warp_id: warp(), + record: crate::record::EdgeRecord { + id: edge, + from, + to, + ty: crate::ident::make_type_id("differential-edge"), + }, + }; + + // Declaring only the edge is unsound: the `from` node is + // written too, and both readings must say so. + let mut edge_only = Footprint::default(); + edge_only.e_write.insert(crate::ident::EdgeKey { + warp_id: warp(), + local_id: edge, + }); + assert_eq!( + enforced_verdict(&edge_only, std::slice::from_ref(&op), false), + Some(ViolationKind::NodeWriteNotDeclared(from)) + ); + assert_eq!( + recorded_verdicts(&edge_only, std::slice::from_ref(&op)), + vec![ViolationKind::NodeWriteNotDeclared(from)] + ); + + // Declaring both is sound to both readings. + let mut both = edge_only; + both.n_write.insert(NodeKey { + warp_id: warp(), + local_id: from, + }); + assert_eq!( + enforced_verdict(&both, std::slice::from_ref(&op), false), + None + ); + assert_eq!( + recorded_verdicts(&both, std::slice::from_ref(&op)), + Vec::new() + ); + } + + #[test] + fn a_cross_warp_emission_is_a_scope_verdict_the_recorder_declines() { + // The two readings deliberately disagree here, and the + // disagreement is the contract. `CrossWarpEmission` is a scope + // failure, not a footprint-subset failure; recording the + // foreign target as a local write would misreport it as one + // and let a reducer hop between two different bug classes. + let node = make_node_id("elsewhere"); + let op = WarpOp::UpsertNode { + node: NodeKey { + warp_id: other_warp(), + local_id: node, + }, + record: NodeRecord { + ty: crate::ident::make_type_id("differential-cross-warp"), + }, + }; + + assert_eq!( + enforced_verdict(&Footprint::default(), std::slice::from_ref(&op), false), + Some(ViolationKind::CrossWarpEmission { + op_warp: other_warp() + }) + ); + assert_eq!( + recorded_verdicts(&Footprint::default(), std::slice::from_ref(&op)), + Vec::new() + ); + } + + #[test] + fn an_unauthorized_instance_op_is_an_authority_verdict_the_recorder_declines() { + // Same contract as cross-warp: authority is a distinct class. + // A system rule emitting the same op is lawful, and neither + // reading reports a footprint violation for it. + let op = WarpOp::DeleteWarpInstance { warp_id: warp() }; + + assert_eq!( + enforced_verdict(&Footprint::default(), std::slice::from_ref(&op), false), + Some(ViolationKind::UnauthorizedInstanceOp) + ); + assert_eq!( + enforced_verdict(&Footprint::default(), std::slice::from_ref(&op), true), + None + ); + assert_eq!( + recorded_verdicts(&Footprint::default(), std::slice::from_ref(&op)), + Vec::new() + ); + } + } + } +} diff --git a/crates/warp-core/src/attachment.rs b/crates/warp-core/src/attachment.rs index 8f4f570b3..f202522e0 100644 --- a/crates/warp-core/src/attachment.rs +++ b/crates/warp-core/src/attachment.rs @@ -68,8 +68,6 @@ impl AttachmentOwner { } /// Returns the [`WarpId`] of the owner (node or edge). - #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] - #[cfg(not(feature = "unsafe_graph"))] pub(crate) fn warp_id(self) -> WarpId { match self { Self::Node(nk) => nk.warp_id, diff --git a/crates/warp-core/src/causal_wal.rs b/crates/warp-core/src/causal_wal.rs index 0c574e9be..03ec8b86c 100644 --- a/crates/warp-core/src/causal_wal.rs +++ b/crates/warp-core/src/causal_wal.rs @@ -1586,7 +1586,14 @@ fn validate_writer_epoch_request( if request.started_at_lsn <= final_lsn { return Err(WalStoreError::WriterEpochLsnRegression); } - } else if request.started_at_lsn <= previous_epoch.started_at_lsn { + } else if request.started_at_lsn < previous_epoch.started_at_lsn { + // The predecessor committed nothing, so it consumed no LSN. The + // successor may resume at the same coordinate; only moving + // backwards is a regression. Requiring a strict advance here + // would mint a hole in the frame sequence for every barren + // epoch, which recovery rejects as `LsnContinuityMismatch`. + // Epoch distinctness is carried by the epoch id, ordinal, + // fencing token, and lease evidence — not by the start LSN. return Err(WalStoreError::WriterEpochLsnRegression); } if request.storage_fencing_token == previous_epoch.storage_fencing_token @@ -5761,11 +5768,20 @@ impl FilesystemWalStore { .and_then(|epoch| self.epoch_closures.get(&epoch.epoch_id)) .copied() .unwrap_or_default(); - let required_started_at_lsn = previous_closure - .final_lsn - .or_else(|| previous_epoch.map(|epoch| epoch.started_at_lsn)) - .and_then(Lsn::checked_next) - .unwrap_or(minimum_started_at_lsn); + // An epoch's start LSN is the next unallocated *frame* coordinate. An + // LSN is assigned to a WAL frame; acquiring an epoch persists ledger + // evidence but emits no frame, so an epoch that committed nothing spent + // nothing and its successor resumes at the same coordinate. Advancing + // past it would invent a phantom coordinate that + // `validate_recovery_frame_order` reports as a gap to every later + // reader. Epoch-chain advancement is carried by epoch identity, fencing + // evidence, and predecessor linkage — not by the start LSN. + let required_started_at_lsn = match previous_closure.final_lsn { + Some(final_lsn) => final_lsn + .checked_next() + .ok_or(WalStoreError::WriterEpochChainGap)?, + None => previous_epoch.map_or(minimum_started_at_lsn, |epoch| epoch.started_at_lsn), + }; let started_at_lsn = minimum_started_at_lsn.max(required_started_at_lsn); let ordinal = u64::try_from(self.closed_epochs.len()) .map_err(|_| WalStoreError::WriterEpochChainGap)? diff --git a/crates/warp-core/src/cmd.rs b/crates/warp-core/src/cmd.rs index bcec54b52..df4f82fe5 100644 --- a/crates/warp-core/src/cmd.rs +++ b/crates/warp-core/src/cmd.rs @@ -12,11 +12,12 @@ use echo_wasm_abi::kernel_port as abi; use echo_wasm_abi::{encode_cbor, unpack_import_suffix_intent_v1}; use crate::attachment::{AtomPayload, AttachmentKey, AttachmentValue}; +use crate::execution_graph_view::ExecutionGraphView; use crate::footprint::{AttachmentSet, EdgeSet, Footprint, NodeSet, PortSet}; use crate::ident::{make_type_id, EdgeId, NodeId, NodeKey}; use crate::inbox::INTENT_ATTACHMENT_TYPE; use crate::record::{EdgeRecord, NodeRecord}; -use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule}; +use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule, RuleExecutor}; use crate::tick_patch::WarpOp; use crate::TickDelta; @@ -45,7 +46,7 @@ pub fn import_suffix_intent_rule() -> RewriteRule { name: IMPORT_SUFFIX_INTENT_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: import_suffix_intent_matches, - executor: import_suffix_intent_executor, + executor: RuleExecutor::observed(import_suffix_intent_executor), compute_footprint: import_suffix_intent_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -77,11 +78,11 @@ fn import_suffix_intent_matches(view: crate::GraphView<'_>, scope: &NodeId) -> b } fn import_suffix_intent_executor( - view: crate::GraphView<'_>, + view: &mut ExecutionGraphView<'_, '_>, scope: &NodeId, delta: &mut TickDelta, ) { - let Some(request) = import_suffix_request_from_scope(view, scope) else { + let Some(request) = import_suffix_request_from_execution_view(view, scope) else { return; }; let result = staged_import_suffix_result(&request); @@ -124,6 +125,19 @@ fn import_suffix_intent_executor( }); } +fn import_suffix_request_from_execution_view( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, +) -> Option { + let Some(AttachmentValue::Atom(atom)) = view.node_attachment(scope) else { + return None; + }; + if atom.type_id != make_type_id(INTENT_ATTACHMENT_TYPE) { + return None; + } + unpack_import_suffix_intent_v1(atom.bytes.as_ref()).ok() +} + fn import_suffix_intent_footprint(view: crate::GraphView<'_>, scope: &NodeId) -> Footprint { let warp_id = view.warp_id(); let result_id = import_suffix_result_node_id(scope); diff --git a/crates/warp-core/src/contract_host.rs b/crates/warp-core/src/contract_host.rs index 447947b67..b4e23c073 100644 --- a/crates/warp-core/src/contract_host.rs +++ b/crates/warp-core/src/contract_host.rs @@ -10,6 +10,7 @@ //! to recognize their operation and decode their own generated vars. use crate::attachment::{AttachmentKey, AttachmentValue}; +use crate::execution_graph_view::ExecutionGraphView; use crate::footprint::{AttachmentSet, EdgeSet, Footprint, NodeSet, PortSet}; use crate::graph_view::GraphView; use crate::ident::{make_type_id, NodeId, NodeKey}; @@ -59,6 +60,27 @@ pub fn eint_vars_for_op<'a>( (op_id == expected_op_id).then_some(vars) } +/// Returns canonical EINT vars through the observed executor capability. +/// +/// Native executors use this form so reading the runtime ingress attachment is +/// retained in their actual footprint. Matchers and footprint computation keep +/// using [`eint_vars_for_op`] through the immutable legacy view. +#[must_use] +pub fn observed_eint_vars_for_op<'store>( + view: &mut ExecutionGraphView<'store, '_>, + scope: &NodeId, + expected_op_id: u32, +) -> Option<&'store [u8]> { + let Some(AttachmentValue::Atom(atom)) = view.node_attachment(scope) else { + return None; + }; + if atom.type_id != make_type_id(INTENT_ATTACHMENT_TYPE) { + return None; + } + let (op_id, vars) = decode_canonical_eint(atom.bytes.as_ref())?; + (op_id == expected_op_id).then_some(vars) +} + /// Returns the standard read footprint for a handler that inspects the EINT /// attached to a runtime ingress event. /// diff --git a/crates/warp-core/src/coordinator.rs b/crates/warp-core/src/coordinator.rs index 27054cc4b..50a38481e 100644 --- a/crates/warp-core/src/coordinator.rs +++ b/crates/warp-core/src/coordinator.rs @@ -4938,8 +4938,13 @@ mod tests { == Some(TOY_INCREMENT_VARS) } - fn toy_increment_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut crate::TickDelta) { - let Some(vars) = crate::contract_host::eint_vars_for_op(view, scope, TOY_INCREMENT_OP_ID) + fn toy_increment_executor( + view: &mut crate::ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut crate::TickDelta, + ) { + let Some(vars) = + crate::contract_host::observed_eint_vars_for_op(view, scope, TOY_INCREMENT_OP_ID) else { return; }; @@ -5003,7 +5008,7 @@ mod tests { name: "cmd/contract/toy-counter/increment", left: PatternGraph { nodes: vec![] }, matcher: toy_increment_matches, - executor: toy_increment_executor, + executor: crate::RuleExecutor::observed(toy_increment_executor), compute_footprint: toy_increment_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -5534,7 +5539,7 @@ mod tests { name: rule_name, left: PatternGraph { nodes: vec![] }, matcher: runtime_marker_matches, - executor: |_view, _scope, _delta| {}, + executor: crate::RuleExecutor::observed(|_view, _scope, _delta| {}), compute_footprint: |_view, _scope| crate::Footprint::default(), factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -5555,7 +5560,7 @@ mod tests { name: rule_name, left: PatternGraph { nodes: vec![] }, matcher: |_view, _scope| true, - executor: |_view, _scope, _delta| {}, + executor: crate::RuleExecutor::observed(|_view, _scope, _delta| {}), compute_footprint: |view, _scope| { let mut footprint = crate::Footprint::default(); footprint @@ -5577,7 +5582,9 @@ mod tests { name: rule_name, left: PatternGraph { nodes: vec![] }, matcher: runtime_panic_matches, - executor: |_view, _scope, _delta| std::panic::panic_any("runtime-commit-panic"), + executor: crate::RuleExecutor::observed(|_view, _scope, _delta| { + std::panic::panic_any("runtime-commit-panic") + }), compute_footprint: |_view, _scope| crate::Footprint::default(), factor_mask: 0, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/warp-core/src/engine_impl.rs b/crates/warp-core/src/engine_impl.rs index fa382e883..c8815d053 100644 --- a/crates/warp-core/src/engine_impl.rs +++ b/crates/warp-core/src/engine_impl.rs @@ -17,6 +17,7 @@ use crate::echo_operation::{ install_recovered_v1, EchoOperationInstallationErrorV1, EchoOperationPackageIdV1, InstalledEchoOperationV1, }; +use crate::execution_evidence::ExecutionFootprintEvidence; use crate::graph::GraphStore; use crate::graph_view::GraphView; use crate::head_inbox::{IngressEnvelope, IngressPayload, IntentKind}; @@ -37,7 +38,7 @@ use crate::provider_contract::{ }; use crate::receipt::{TickReceipt, TickReceiptDisposition, TickReceiptEntry, TickReceiptRejection}; use crate::record::NodeRecord; -use crate::rule::{ConflictPolicy, RewriteRule}; +use crate::rule::{ConflictPolicy, RewriteRule, RuleExecutor}; use crate::scheduler::{DeterministicScheduler, PendingRewrite, RewritePhase, SchedulerKind}; use crate::snapshot::{compute_commit_hash_v2, compute_state_root, Snapshot}; use crate::telemetry::{NullTelemetrySink, TelemetrySink}; @@ -481,6 +482,8 @@ pub struct Engine { last_snapshot: Option, /// Sequential history of all committed ticks (Snapshot, Receipt, Patch). tick_history: Vec<(Snapshot, TickReceipt, WarpTickPatchV1)>, + /// Canonically ordered per-Action footprints from the latest execution. + last_execution_footprints: Vec, intent_log: Vec<(u64, crate::attachment::AtomPayload)>, /// Initial state (U0) snapshot preserved for replay via `jump_to_tick`. initial_state: WarpState, @@ -542,6 +545,7 @@ struct RuntimeCommitStateGuard<'a> { saved_initial_state: SavedField, saved_last_snapshot: SavedField>, saved_tick_history: SavedField>, + saved_last_execution_footprints: SavedField>, saved_last_materialization: SavedField>, saved_last_materialization_errors: SavedField>, committed_ingress: SavedField>, @@ -570,6 +574,10 @@ impl<'a> RuntimeCommitStateGuard<'a> { &mut engine.tick_history, std::mem::take(&mut state.tick_history), ); + let saved_last_execution_footprints = std::mem::replace( + &mut engine.last_execution_footprints, + std::mem::take(&mut state.last_execution_footprints), + ); let saved_last_materialization = std::mem::replace( &mut engine.last_materialization, std::mem::take(&mut state.last_materialization), @@ -597,6 +605,7 @@ impl<'a> RuntimeCommitStateGuard<'a> { saved_initial_state: SavedField::new(saved_initial_state), saved_last_snapshot: SavedField::new(saved_last_snapshot), saved_tick_history: SavedField::new(saved_tick_history), + saved_last_execution_footprints: SavedField::new(saved_last_execution_footprints), saved_last_materialization: SavedField::new(saved_last_materialization), saved_last_materialization_errors: SavedField::new(saved_last_materialization_errors), committed_ingress: SavedField::new(committed_ingress), @@ -636,6 +645,11 @@ impl<'a> RuntimeCommitStateGuard<'a> { self.saved_tick_history .take("runtime commit guard missing saved tick history"), ), + last_execution_footprints: std::mem::replace( + &mut self.engine.last_execution_footprints, + self.saved_last_execution_footprints + .take("runtime commit guard missing saved execution footprints"), + ), last_materialization: std::mem::replace( &mut self.engine.last_materialization, self.saved_last_materialization @@ -688,6 +702,11 @@ impl<'a> RuntimeCommitStateGuard<'a> { self.saved_tick_history .take("runtime commit guard missing saved tick history"), ), + last_execution_footprints: std::mem::replace( + &mut self.engine.last_execution_footprints, + self.saved_last_execution_footprints + .take("runtime commit guard missing saved execution footprints"), + ), last_materialization: std::mem::replace( &mut self.engine.last_materialization, self.saved_last_materialization @@ -913,6 +932,7 @@ impl Engine { }, last_snapshot: None, tick_history: Vec::new(), + last_execution_footprints: Vec::new(), intent_log: Vec::new(), initial_state, bus, @@ -1112,6 +1132,7 @@ impl Engine { current_root: root, last_snapshot: None, tick_history: Vec::new(), + last_execution_footprints: Vec::new(), intent_log: Vec::new(), initial_state, bus, @@ -1878,6 +1899,7 @@ impl Engine { if tx.value() == 0 || !self.live_txs.contains(&tx.value()) { return Err(EngineError::UnknownTx); } + self.last_execution_footprints.clear(); let policy_id = self.policy_id; let rule_pack_id = self.compute_rule_pack_id(); // Drain pending to form the ready set and compute a plan digest over its canonical order. @@ -2001,6 +2023,7 @@ impl Engine { self.live_txs.remove(&tx.value()); self.scheduler.finalize_tx(tx); self.bus.clear(); + self.last_execution_footprints.clear(); self.last_materialization.clear(); self.last_materialization_errors.clear(); } @@ -2093,11 +2116,12 @@ impl Engine { // BTreeMap ensures deterministic iteration order (WarpId: Ord from [u8; 32]). // 1. Pre-validate all rewrites and group by warp_id - let mut by_warp: BTreeMap< - WarpId, - Vec<(PendingRewrite, crate::rule::ExecuteFn, &'static str)>, - > = BTreeMap::new(); - for rewrite in rewrites { + let mut by_warp: BTreeMap> = + BTreeMap::new(); + for (evidence_sequence, rewrite) in rewrites.into_iter().enumerate() { + let evidence_sequence = u32::try_from(evidence_sequence).map_err(|_| { + EngineError::InternalCorruption("too many execution records to index") + })?; let id = rewrite.compact_rule; let (executor, rule_name) = { let Some(rule) = self.rule_by_compact(id) else { @@ -2117,10 +2141,12 @@ impl Engine { ); return Err(EngineError::UnknownWarp(rewrite.scope.warp_id)); } - by_warp - .entry(rewrite.scope.warp_id) - .or_default() - .push((rewrite, executor, rule_name)); + by_warp.entry(rewrite.scope.warp_id).or_default().push(( + rewrite, + executor, + rule_name, + evidence_sequence, + )); } // Collect per-item guard metadata (cfg-gated) for post-shard guard construction @@ -2132,7 +2158,7 @@ impl Engine { let items_by_warp = by_warp.into_iter().map(|(warp_id, warp_rewrites)| { let items: Vec = warp_rewrites .into_iter() - .map(|(rw, exec, name)| { + .map(|(rw, exec, name, evidence_sequence)| { #[cfg(all( any(debug_assertions, feature = "footprint_enforce_release"), not(feature = "unsafe_graph") @@ -2145,8 +2171,10 @@ impl Engine { ); if is_system { ExecItem::new_system(exec, rw.scope.local_id, rw.origin) + .with_evidence_sequence(evidence_sequence) } else { - ExecItem::new(exec, rw.scope.local_id, rw.origin) + ExecItem::from_rule_executor(exec, rw.scope.local_id, rw.origin) + .with_evidence_sequence(evidence_sequence) } } #[cfg(any( @@ -2155,7 +2183,8 @@ impl Engine { ))] { let _ = name; - ExecItem::new(exec, rw.scope.local_id, rw.origin) + ExecItem::from_rule_executor(exec, rw.scope.local_id, rw.origin) + .with_evidence_sequence(evidence_sequence) } }) .collect(); @@ -2181,7 +2210,7 @@ impl Engine { execute_work_queue(&units, capped_workers, |warp_id| self.state.store(warp_id)); // 3. Merge deltas into canonical op sequence - let ops = merge_parallel_deltas(worker_results)?; + let ops = merge_parallel_deltas(worker_results, &mut self.last_execution_footprints)?; // 4. Apply the merged ops to the state let patch = WarpTickPatchV1::new( @@ -2522,6 +2551,18 @@ impl Engine { &self.tick_history } + /// Returns canonically ordered per-Action footprints from the latest + /// scheduler execution attempt. + /// + /// This record is populated before a retained executor or enforcement + /// panic is resumed, so callers that deliberately catch the ordinary panic + /// can inspect the exact access that caused it. Posture remains part of + /// every record and decides whether it may ground a falsification witness. + #[must_use] + pub fn last_execution_footprints(&self) -> &[ExecutionFootprintEvidence] { + &self.last_execution_footprints + } + /// Resets the engine state to the beginning of time (U0) and re-applies all patches /// up to and including the specified tick index. /// @@ -2567,6 +2608,7 @@ impl Engine { pub fn is_fresh_runtime_state(&self) -> bool { self.last_snapshot.is_none() && self.tick_history.is_empty() + && self.last_execution_footprints.is_empty() && self.last_materialization.is_empty() && self.last_materialization_errors.is_empty() && self.live_txs.is_empty() @@ -2851,18 +2893,35 @@ impl Engine { /// # Panics /// /// Panics (via `resume_unwind`) if any delta was poisoned by an executor or enforcement panic. -fn merge_parallel_deltas(worker_results: Vec) -> Result, EngineError> { +fn merge_parallel_deltas( + worker_results: Vec, + retained_evidence: &mut Vec, +) -> Result, EngineError> { // Convert WorkerResult to the format expected by merge paths - let all_deltas: Result>, _> = - worker_results - .into_iter() - .map(|result| match result { - WorkerResult::Success(delta) => Ok(Ok(delta)), - WorkerResult::Poisoned(poisoned) => Ok(Err(poisoned)), - WorkerResult::MissingStore(warp_id) => Err(EngineError::UnknownWarp(warp_id)), - }) - .collect(); - let all_deltas = all_deltas?; + let mut all_deltas = Vec::with_capacity(worker_results.len()); + let mut evidence = Vec::new(); + let mut missing_store = None; + for result in worker_results { + match result { + WorkerResult::Success(execution) => { + let (delta, mut worker_evidence) = execution.into_parts(); + evidence.append(&mut worker_evidence); + all_deltas.push(Ok(delta)); + } + WorkerResult::Poisoned(poisoned) => { + evidence.extend(poisoned.evidence().iter().cloned()); + all_deltas.push(Err(poisoned)); + } + WorkerResult::MissingStore(warp_id) => { + missing_store.get_or_insert(warp_id); + } + } + } + evidence.sort_by_key(ExecutionFootprintEvidence::key); + *retained_evidence = evidence; + if let Some(warp_id) = missing_store { + return Err(EngineError::UnknownWarp(warp_id)); + } #[cfg(any(test, feature = "delta_validate"))] { @@ -2919,7 +2978,7 @@ fn merge_parallel_deltas(worker_results: Vec) -> Result>; +type RewritesByWarp = BTreeMap>; /// Collects guard metadata from grouped rewrites for footprint enforcement. #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] @@ -2930,7 +2989,7 @@ fn collect_guard_metadata( by_warp .values() .flatten() - .map(|(rw, _exec, name)| { + .map(|(rw, _exec, name, _evidence_sequence)| { ( ( rw.origin, @@ -3197,44 +3256,47 @@ mod tests { Some(AttachmentValue::Atom(payload)) if crate::payload::decode_motion_atom_payload(payload).is_some() ) }, - executor: |view: GraphView<'_>, scope, delta| { - // Phase 5: read from view, emit ops to delta (no direct mutation). - let warp_id = view.warp_id(); - - let Some(AttachmentValue::Atom(payload)) = view.node_attachment(scope) else { - return; - }; - let Some((pos_raw, vel_raw)) = - crate::payload::decode_motion_atom_payload_q32_32(payload) - else { - return; - }; - - // Compute the new position - let new_pos_raw = [ - pos_raw[0].saturating_add(vel_raw[0]), - pos_raw[1].saturating_add(vel_raw[1]), - pos_raw[2].saturating_add(vel_raw[2]), - ]; - - // Build new bytes - let new_bytes = crate::payload::encode_motion_payload_q32_32(new_pos_raw, vel_raw); - - // Only emit if bytes actually changed - if payload.bytes != new_bytes { - let key = AttachmentKey::node_alpha(NodeKey { - warp_id, - local_id: *scope, - }); - delta.push(WarpOp::SetAttachment { - key, - value: Some(AttachmentValue::Atom(AtomPayload { - type_id: crate::payload::motion_payload_type_id(), - bytes: new_bytes, - })), - }); - } - }, + executor: RuleExecutor::observed( + |view: &mut crate::ExecutionGraphView<'_, '_>, scope, delta| { + // Phase 5: read from view, emit ops to delta (no direct mutation). + let warp_id = view.warp_id(); + + let Some(AttachmentValue::Atom(payload)) = view.node_attachment(scope) else { + return; + }; + let Some((pos_raw, vel_raw)) = + crate::payload::decode_motion_atom_payload_q32_32(payload) + else { + return; + }; + + // Compute the new position + let new_pos_raw = [ + pos_raw[0].saturating_add(vel_raw[0]), + pos_raw[1].saturating_add(vel_raw[1]), + pos_raw[2].saturating_add(vel_raw[2]), + ]; + + // Build new bytes + let new_bytes = + crate::payload::encode_motion_payload_q32_32(new_pos_raw, vel_raw); + + // Only emit if bytes actually changed + if payload.bytes != new_bytes { + let key = AttachmentKey::node_alpha(NodeKey { + warp_id, + local_id: *scope, + }); + delta.push(WarpOp::SetAttachment { + key, + value: Some(AttachmentValue::Atom(AtomPayload { + type_id: crate::payload::motion_payload_type_id(), + bytes: new_bytes, + })), + }); + } + }, + ), compute_footprint: |view: GraphView<'_>, scope| { let mut a_read = crate::AttachmentSet::default(); let mut a_write = crate::AttachmentSet::default(); @@ -3307,7 +3369,7 @@ mod tests { name: rule_name, left: crate::rule::PatternGraph { nodes: vec![] }, matcher: runtime_event_matches, - executor: |view, scope, delta| { + executor: RuleExecutor::observed(|view, scope, delta| { let key = AttachmentKey::node_alpha(NodeKey { warp_id: view.warp_id(), local_id: *scope, @@ -3319,7 +3381,7 @@ mod tests { bytes::Bytes::from_static(b"marker"), ))), }); - }, + }), compute_footprint: runtime_event_attachment_footprint, factor_mask: 0, conflict_policy: crate::rule::ConflictPolicy::Abort, @@ -3334,7 +3396,9 @@ mod tests { name: rule_name, left: crate::rule::PatternGraph { nodes: vec![] }, matcher: runtime_event_matches, - executor: |_view, _scope, _delta| std::panic::panic_any("runtime-commit-panic"), + executor: RuleExecutor::observed(|_view, _scope, _delta| { + std::panic::panic_any("runtime-commit-panic") + }), compute_footprint: runtime_event_attachment_footprint, factor_mask: 0, conflict_policy: crate::rule::ConflictPolicy::Abort, @@ -3357,7 +3421,7 @@ mod tests { name: rule_name, left: crate::rule::PatternGraph { nodes: vec![] }, matcher: |_view, _scope| true, - executor: |view, scope, delta| { + executor: RuleExecutor::observed(|view, scope, delta| { let _ = view.node(scope); let key = AttachmentKey::node_alpha(NodeKey { warp_id: view.warp_id(), @@ -3370,7 +3434,7 @@ mod tests { bytes::Bytes::from_static(b"guard-meta"), ))), }); - }, + }), compute_footprint: |view, scope| { let warp_id = view.warp_id(); let mut n_read = crate::NodeSet::default(); @@ -3468,7 +3532,7 @@ mod tests { name: "bad/join", left: crate::rule::PatternGraph { nodes: vec![] }, matcher: |_s: GraphView<'_>, _n| true, - executor: |_s: GraphView<'_>, _n, _delta| {}, + executor: RuleExecutor::observed(|_view, _scope, _delta| {}), compute_footprint: |_s: GraphView<'_>, _n| crate::footprint::Footprint::default(), factor_mask: 0, conflict_policy: crate::rule::ConflictPolicy::Join, @@ -3490,7 +3554,7 @@ mod tests { name, left: crate::rule::PatternGraph { nodes: vec![] }, matcher: |_s: GraphView<'_>, _n| true, - executor: |_s: GraphView<'_>, _n, _delta| {}, + executor: RuleExecutor::observed(|_view, _scope, _delta| {}), compute_footprint: |_s: GraphView<'_>, _n| crate::footprint::Footprint::default(), factor_mask: 0, conflict_policy: crate::rule::ConflictPolicy::Abort, diff --git a/crates/warp-core/src/execution_evidence.rs b/crates/warp-core/src/execution_evidence.rs new file mode 100644 index 000000000..155843a44 --- /dev/null +++ b/crates/warp-core/src/execution_evidence.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Per-Action evidence retained from scheduler-owned execution. +//! +//! Worker completion order is not semantic: workers race to claim work units. +//! [`ExecutionEvidenceKey`] therefore binds each record to identity available +//! before execution and supplies the canonical order used when worker-local +//! records are joined. + +use crate::actual_footprint::{ActualFootprint, ActualFootprintPosture}; +use crate::ident::{NodeId, WarpId}; +use crate::tick_delta::OpOrigin; + +/// Stable pre-execution identity for one rule execution. +/// +/// `OpOrigin` supplies intent, compact rule, match, and operation coordinates. +/// Warp and scope complete the identity without consulting worker assignment or +/// completion order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExecutionEvidenceKey { + sequence: u32, + warp_id: WarpId, + scope: NodeId, + origin: OpOrigin, +} + +impl ExecutionEvidenceKey { + /// Binds one execution record to its warp, scope, and scheduler origin. + #[must_use] + pub const fn new(sequence: u32, warp_id: WarpId, scope: NodeId, origin: OpOrigin) -> Self { + Self { + sequence, + warp_id, + scope, + origin, + } + } + + /// Returns the canonical position assigned before worker dispatch. + #[must_use] + pub const fn sequence(&self) -> u32 { + self.sequence + } + + /// Returns the warp whose graph state the executor observed. + #[must_use] + pub const fn warp_id(&self) -> WarpId { + self.warp_id + } + + /// Returns the scope node supplied to the executor. + #[must_use] + pub const fn scope(&self) -> NodeId { + self.scope + } + + /// Returns the scheduler-owned origin assigned before execution. + #[must_use] + pub const fn origin(&self) -> OpOrigin { + self.origin + } +} + +/// Actual read/write footprint and evidence posture for one Action execution. +/// +/// The record is useful only under the entitlement expressed by `posture`. +/// In particular, a legacy executor may contribute a known write axis while +/// its read axis remains unknown; callers must not reinterpret that partial +/// record as a complete empty read set. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExecutionFootprintEvidence { + key: ExecutionEvidenceKey, + actual: ActualFootprint, + posture: ActualFootprintPosture, +} + +impl ExecutionFootprintEvidence { + /// Creates one per-Action footprint-evidence record. + #[must_use] + pub const fn new( + key: ExecutionEvidenceKey, + actual: ActualFootprint, + posture: ActualFootprintPosture, + ) -> Self { + Self { + key, + actual, + posture, + } + } + + /// Returns the stable pre-execution identity of this record. + #[must_use] + pub const fn key(&self) -> ExecutionEvidenceKey { + self.key + } + + /// Returns the canonical position assigned before worker dispatch. + #[must_use] + pub const fn sequence(&self) -> u32 { + self.key.sequence() + } + + /// Returns the warp whose graph state the executor observed. + #[must_use] + pub const fn warp_id(&self) -> WarpId { + self.key.warp_id() + } + + /// Returns the scope node supplied to the executor. + #[must_use] + pub const fn scope(&self) -> NodeId { + self.key.scope() + } + + /// Returns the scheduler-owned origin assigned before execution. + #[must_use] + pub const fn origin(&self) -> OpOrigin { + self.key.origin() + } + + /// Returns the actual resources recorded for this execution. + #[must_use] + pub const fn actual(&self) -> &ActualFootprint { + &self.actual + } + + /// Returns the entitlement carried by the actual-footprint record. + #[must_use] + pub const fn posture(&self) -> ActualFootprintPosture { + self.posture + } +} diff --git a/crates/warp-core/src/execution_graph_view.rs b/crates/warp-core/src/execution_graph_view.rs new file mode 100644 index 000000000..45e390f04 --- /dev/null +++ b/crates/warp-core/src/execution_graph_view.rs @@ -0,0 +1,497 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Observed read capability for one item's execution frame. +//! +//! [`GraphView`](crate::GraphView) is the immutable read capability used by +//! matchers, footprint computation, and frozen legacy executors. It is `Copy`, +//! and its accessors take `&self`. That shape is why it cannot record. Recording +//! an access mutates the execution transcript, and a `&self` accessor cannot +//! mutate anything it does not own without interior mutability — which +//! `GraphView` explicitly forbids and which would cost it `Sync`. +//! +//! [`ExecutionGraphView`] is the narrower capability that executors use when +//! Echo needs evidence of what an execution actually read. It borrows the +//! immutable declared [`FootprintGuard`] and *exclusively* borrows the +//! worker-local [`ActualFootprint`], so its accessors take `&mut self`. That is +//! ordinary inherited mutability, not interior mutability: it matches the +//! scheduler's existing one-item/one-worker discipline exactly, needs no lock, +//! and leaves `GraphView` and `WorkUnit` untouched. +//! +//! ```text +//! FootprintGuard prepared declared-access contract shared immutable +//! ActualFootprint observed access transcript worker mutable, exclusive +//! GraphStore basis state being observed shared immutable +//! ``` +//! +//! # Record before check +//! +//! Every accessor records the attempted access *before* consulting the guard. +//! The guard panics on an undeclared access, so checking first would unwind +//! before the violating coordinate entered the record — leaving Echo holding a +//! purported actual footprint that omits its own counterexample. +//! +//! The order is: +//! +//! ```text +//! canonicalize access key +//! record attempted access +//! check declared authorization +//! perform graph lookup +//! ``` +//! +//! # What counts as an access +//! +//! A graph resource coordinate presented to this capability, whether or not the +//! resource exists. Asking whether absent node `N` exists is still an +//! observation of coordinate `N`; defining it otherwise would let a rule probe +//! undeclared coordinates for free whenever the resource happened to be absent. +//! +//! # Axis mapping +//! +//! The recorded axis mirrors the guard's enforcement mapping exactly. It does +//! not invent a finer one, because a recorded access the declaration cannot +//! express would manufacture a false mismatch. +//! +//! | Accessor | Recorded as | +//! | --------------------- | ------------------------------------ | +//! | `node` | node read | +//! | `edges_from` | node read (adjacency is node-granted) | +//! | `has_edge` | edge read | +//! | `node_attachment` | canonical node-alpha attachment read | +//! | `edge_attachment` | canonical edge-beta attachment read | +//! +//! In particular `edges_from` records a *node* read. Today a rule that declares +//! a node in `n_read` is thereby granted that node's outbound edge list, so +//! recording each returned edge as an edge read would report violations against +//! a declaration that is sound under the enforced contract. + +use crate::actual_footprint::ActualFootprint; +use crate::attachment::{AttachmentKey, AttachmentValue}; +use crate::graph::GraphStore; +use crate::ident::{EdgeId, EdgeKey, NodeId, NodeKey, WarpId}; +use crate::record::{EdgeRecord, NodeRecord}; + +#[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] +#[cfg(not(feature = "unsafe_graph"))] +use crate::footprint_guard::FootprintGuard; + +/// Read capability that records what an execution actually touched. +/// +/// Deliberately **not** `Copy` and **not** `Clone`. An observation session is +/// owned by exactly one executor for exactly one item; duplicating the +/// capability would imply two writers to one transcript, which is precisely the +/// thing the exclusive borrow is expressing. +/// +/// Construct with [`ExecutionGraphView::new_guarded`] when footprint +/// enforcement is active and [`ExecutionGraphView::new`] otherwise. The two +/// constructors correspond to distinct evidence postures: only a guarded view +/// can support a claim that a footprint property was *tested under +/// enforcement*. +#[derive(Debug)] +pub struct ExecutionGraphView<'store, 'frame> { + store: &'store GraphStore, + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + declared: Option<&'frame FootprintGuard>, + actual: &'frame mut ActualFootprint, +} + +impl<'store, 'frame> ExecutionGraphView<'store, 'frame> { + /// Creates a recording view with no declared-footprint enforcement. + /// + /// Reads are recorded but nothing is checked. Evidence produced through + /// this constructor cannot support a footprint-soundness claim, because an + /// empty violation set proves only that nothing was compared. + pub fn new(store: &'store GraphStore, actual: &'frame mut ActualFootprint) -> Self { + Self { + store, + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + declared: None, + actual, + } + } + + /// Creates a recording view that also enforces the declared footprint. + /// + /// Each accessor records the attempted coordinate and then applies the + /// guard, which panics with a typed + /// [`FootprintViolation`](crate::footprint_guard::FootprintViolation) on an + /// undeclared access. The record survives that unwind because it lives on + /// the caller's frame, not inside this view. + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + // Kept `pub(crate)` because `FootprintGuard` is crate private and exporting + // the checker would leak an enforcement detail. + pub(crate) fn new_guarded( + store: &'store GraphStore, + declared: &'frame FootprintGuard, + actual: &'frame mut ActualFootprint, + ) -> Self { + Self { + store, + declared: Some(declared), + actual, + } + } + + /// Returns the warp instance identifier for this store. + /// + /// Not an access: this names the observation scope rather than a resource + /// within it, and the guard does not check it. + #[must_use] + pub fn warp_id(&self) -> WarpId { + self.store.warp_id() + } + + /// Returns a shared reference to a node when it exists. + /// + /// Records a node read for `id` whether or not the node exists. + pub fn node(&mut self, id: &NodeId) -> Option<&'store NodeRecord> { + self.actual.record_node_read(*id); + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + if let Some(declared) = self.declared { + declared.check_node_read(id); + } + self.store.node(id) + } + + /// Returns an iterator over edges that originate from the provided node. + /// + /// Records a **node** read, matching the enforced contract: declaring a + /// node in `n_read` grants its outbound adjacency. + pub fn edges_from(&mut self, id: &NodeId) -> impl Iterator { + self.actual.record_node_read(*id); + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + if let Some(declared) = self.declared { + declared.check_node_read(id); + } + self.store.edges_from(id) + } + + /// Returns `true` if an edge with `id` exists in the store. + /// + /// Records an edge read for `id` whether or not the edge exists. + pub fn has_edge(&mut self, id: &EdgeId) -> bool { + self.actual.record_edge_read(*id); + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + if let Some(declared) = self.declared { + declared.check_edge_read(id); + } + self.store.has_edge(id) + } + + /// Returns the node's attachment value (if any). + /// + /// Records a read of the canonical node-alpha attachment key, the same key + /// the guard derives. + pub fn node_attachment(&mut self, id: &NodeId) -> Option<&'store AttachmentValue> { + let key = AttachmentKey::node_alpha(NodeKey { + warp_id: self.store.warp_id(), + local_id: *id, + }); + self.actual.record_attachment_read(key); + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + if let Some(declared) = self.declared { + declared.check_attachment_read(&key); + } + self.store.node_attachment(id) + } + + /// Returns the edge's attachment value (if any). + /// + /// Records a read of the canonical edge-beta attachment key, the same key + /// the guard derives. + pub fn edge_attachment(&mut self, id: &EdgeId) -> Option<&'store AttachmentValue> { + let key = AttachmentKey::edge_beta(EdgeKey { + warp_id: self.store.warp_id(), + local_id: *id, + }); + self.actual.record_attachment_read(key); + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + if let Some(declared) = self.declared { + declared.check_attachment_read(&key); + } + self.store.edge_attachment(id) + } +} + +#[cfg(all( + test, + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") +))] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::attachment::{AtomPayload, AttachmentValue}; + use crate::footprint::Footprint; + use crate::ident::{make_edge_id, make_node_id, make_type_id}; + use crate::record::{EdgeRecord, NodeRecord}; + + fn store_with_a_and_b() -> (GraphStore, NodeId, NodeId, EdgeId) { + let mut store = GraphStore::default(); + let node_ty = make_type_id("execution-view-node"); + let edge_ty = make_type_id("execution-view-edge"); + let a = make_node_id("a"); + let b = make_node_id("b"); + store.insert_node(a, NodeRecord { ty: node_ty }); + store.insert_node(b, NodeRecord { ty: node_ty }); + + let edge = make_edge_id("a->b"); + store.insert_edge( + a, + EdgeRecord { + id: edge, + from: a, + to: b, + ty: edge_ty, + }, + ); + let attachment = AttachmentValue::Atom(AtomPayload { + type_id: make_type_id("execution-view-payload"), + bytes: vec![7].into(), + }); + store.set_node_attachment(a, Some(attachment.clone())); + store.set_edge_attachment(edge, Some(attachment)); + + (store, a, b, edge) + } + + fn nodes_read(actual: &ActualFootprint) -> Vec { + actual.nodes_read().copied().collect() + } + + #[test] + fn a_declared_node_read_is_recorded_once_and_succeeds() { + let (store, a, _b, _edge) = store_with_a_and_b(); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: store.warp_id(), + local_id: a, + }); + let guard = FootprintGuard::new(&declared, store.warp_id(), "declared-read", false); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert!(view.node(&a).is_some()); + assert!(view.node(&a).is_some()); + + assert_eq!(nodes_read(&actual), vec![a]); + assert!(actual.is_sound_under(&declared, store.warp_id())); + } + + #[test] + fn an_absent_node_is_still_an_observed_coordinate() { + // Asking whether an absent node exists is an observation of that + // coordinate. Recording only present resources would let a rule probe + // undeclared coordinates for free whenever they happened to be empty. + let (store, a, _b, _edge) = store_with_a_and_b(); + let missing = make_node_id("missing"); + let mut declared = Footprint::default(); + for node in [a, missing] { + declared.n_read.insert(NodeKey { + warp_id: store.warp_id(), + local_id: node, + }); + } + let guard = FootprintGuard::new(&declared, store.warp_id(), "absent-read", false); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert!(view.node(&missing).is_none()); + + assert_eq!(nodes_read(&actual), vec![missing]); + } + + #[test] + fn an_undeclared_read_is_recorded_before_the_guard_panics() { + // This is the load-bearing ordering. The access that falsifies + // footprint soundness is exactly the one that unwinds; recording after + // the check would leave Echo holding an actual footprint missing its + // own counterexample. + let (store, a, b, _edge) = store_with_a_and_b(); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: store.warp_id(), + local_id: a, + }); + let guard = FootprintGuard::new(&declared, store.warp_id(), "undeclared-read", false); + + let mut actual = ActualFootprint::new(); + let violation = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + let _ = view.node(&a); + let _ = view.node(&b); + unreachable!("the undeclared read must panic"); + })) + .expect_err("an undeclared read must trip the guard"); + + let violation = violation + .downcast_ref::() + .expect("the guard panics with a typed payload"); + assert_eq!( + violation.kind, + crate::footprint_guard::ViolationKind::NodeReadNotDeclared(b) + ); + + // Both the lawful read and the violating one survive the unwind. + let mut expected = vec![a, b]; + expected.sort_unstable(); + assert_eq!(nodes_read(&actual), expected); + assert_eq!( + actual.soundness_violations(&declared, store.warp_id()), + vec![crate::footprint_guard::ViolationKind::NodeReadNotDeclared( + b + )] + ); + } + + #[test] + fn the_first_access_violating_is_still_retained() { + let (store, _a, b, _edge) = store_with_a_and_b(); + let guard = FootprintGuard::new( + &Footprint::default(), + store.warp_id(), + "first-access-violates", + false, + ); + + let mut actual = ActualFootprint::new(); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + let _ = view.node(&b); + })) + .expect_err("an undeclared first read must trip the guard"); + + assert_eq!(nodes_read(&actual), vec![b]); + } + + #[test] + fn edges_from_records_a_node_read_not_edge_reads() { + // Enforcement grants outbound adjacency through `n_read`. Recording the + // returned edges as edge reads would report a violation against a + // declaration that is sound under the enforced contract. + let (store, a, _b, _edge) = store_with_a_and_b(); + let mut declared = Footprint::default(); + declared.n_read.insert(NodeKey { + warp_id: store.warp_id(), + local_id: a, + }); + let guard = FootprintGuard::new(&declared, store.warp_id(), "adjacency-read", false); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert_eq!(view.edges_from(&a).count(), 1); + + assert_eq!(nodes_read(&actual), vec![a]); + assert_eq!(actual.edges_read().count(), 0); + assert!(actual.is_sound_under(&declared, store.warp_id())); + } + + #[test] + fn an_edge_existence_query_records_an_edge_read() { + let (store, _a, _b, edge) = store_with_a_and_b(); + let mut declared = Footprint::default(); + declared.e_read.insert(EdgeKey { + warp_id: store.warp_id(), + local_id: edge, + }); + let guard = FootprintGuard::new(&declared, store.warp_id(), "edge-read", false); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert!(view.has_edge(&edge)); + + assert_eq!(actual.edges_read().copied().collect::>(), vec![edge]); + assert_eq!(nodes_read(&actual), Vec::new()); + } + + #[test] + fn a_node_attachment_query_records_the_canonical_node_alpha_key() { + let (store, a, _b, _edge) = store_with_a_and_b(); + let key = AttachmentKey::node_alpha(NodeKey { + warp_id: store.warp_id(), + local_id: a, + }); + let mut declared = Footprint::default(); + declared.a_read.insert(key); + let guard = FootprintGuard::new(&declared, store.warp_id(), "node-attachment", false); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert!(view.node_attachment(&a).is_some()); + + assert_eq!( + actual.attachments_read().copied().collect::>(), + vec![key] + ); + // The attachment axis is distinct: reading a node's attachment is not a + // node read. + assert_eq!(nodes_read(&actual), Vec::new()); + } + + #[test] + fn an_edge_attachment_query_records_the_canonical_edge_beta_key() { + let (store, _a, _b, edge) = store_with_a_and_b(); + let key = AttachmentKey::edge_beta(EdgeKey { + warp_id: store.warp_id(), + local_id: edge, + }); + let mut declared = Footprint::default(); + declared.a_read.insert(key); + let guard = FootprintGuard::new(&declared, store.warp_id(), "edge-attachment", false); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert!(view.edge_attachment(&edge).is_some()); + + assert_eq!( + actual.attachments_read().copied().collect::>(), + vec![key] + ); + assert_eq!(actual.edges_read().count(), 0); + } + + #[test] + fn an_unguarded_view_records_without_enforcing() { + // Recording and enforcement are separable, and the separation is why + // posture must be reported: an empty violation set from an unguarded + // view proves only that nothing was compared. + let (store, _a, b, _edge) = store_with_a_and_b(); + + let mut actual = ActualFootprint::new(); + let mut view = ExecutionGraphView::new(&store, &mut actual); + assert!(view.node(&b).is_some()); + + assert_eq!(nodes_read(&actual), vec![b]); + assert_eq!( + actual.soundness_violations(&Footprint::default(), store.warp_id()), + vec![crate::footprint_guard::ViolationKind::NodeReadNotDeclared( + b + )] + ); + } + + #[test] + fn warp_id_is_not_an_access() { + let (store, _a, _b, _edge) = store_with_a_and_b(); + let guard = FootprintGuard::new( + &Footprint::default(), + store.warp_id(), + "warp-id-not-access", + false, + ); + + let mut actual = ActualFootprint::new(); + let view = ExecutionGraphView::new_guarded(&store, &guard, &mut actual); + assert_eq!(view.warp_id(), store.warp_id()); + + assert!(actual.is_empty()); + } +} diff --git a/crates/warp-core/src/external_action.rs b/crates/warp-core/src/external_action.rs index 41cc0e3ee..d1a743d74 100644 --- a/crates/warp-core/src/external_action.rs +++ b/crates/warp-core/src/external_action.rs @@ -1797,6 +1797,7 @@ impl<'a> ExternalActionPayloadCursor<'a> { } #[cfg(test)] +#[allow(clippy::panic)] mod tests { use super::*; diff --git a/crates/warp-core/src/footprint_guard.rs b/crates/warp-core/src/footprint_guard.rs index e53850bc7..a1d70a776 100644 --- a/crates/warp-core/src/footprint_guard.rs +++ b/crates/warp-core/src/footprint_guard.rs @@ -70,8 +70,6 @@ use crate::attachment::AttachmentOwner; #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] use crate::footprint::Footprint; -#[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] -#[cfg(not(feature = "unsafe_graph"))] use crate::tick_patch::WarpOp; #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] @@ -167,16 +165,15 @@ impl std::fmt::Debug for FootprintViolationWithPanic { // Internal enforcement machinery (only compiled when enforcement is active) // ───────────────────────────────────────────────────────────────────────────── -// Everything below is pub(crate) and only used when enforcement cfg gates are -// active. When `unsafe_graph` disables enforcement, these items are dead code. +// Operation target extraction remains available in every build so actual write +// footprints can be retained even when enforcement is unavailable. The checker +// itself remains cfg-gated below. /// Targets that a [`WarpOp`] writes to, as local ids within a specific warp. /// /// This is the output of [`op_write_targets`] — the single source of truth for /// what a `WarpOp` mutates. Used by enforcement. Available as a shared primitive /// for future scheduling linting (but the scheduler does NOT currently use it). -#[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] -#[cfg(not(feature = "unsafe_graph"))] pub(crate) struct OpTargets { /// Node ids that the op writes/mutates. pub nodes: Vec, @@ -187,6 +184,13 @@ pub(crate) struct OpTargets { /// Whether this is an instance-level op (e.g., `OpenPortal`, `UpsertWarpInstance`, /// `DeleteWarpInstance`). Instance-level ops modify multiverse topology and require /// `is_system` permission. + #[cfg_attr( + not(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") + )), + allow(dead_code) + )] pub is_instance_op: bool, /// The warp the op targets (for cross-warp check). Used to verify ops don't emit /// to warps outside the declared footprint. Most ops set this to `Some(warp_id)`; @@ -194,11 +198,16 @@ pub(crate) struct OpTargets { /// (though currently all instance-level ops do provide a target warp). pub op_warp: Option, /// Static string naming the op variant (e.g. `"UpsertNode"`). + #[cfg_attr( + not(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") + )), + allow(dead_code) + )] pub kind_str: &'static str, } -#[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] -#[cfg(not(feature = "unsafe_graph"))] /// Returns a static string naming the [`WarpOp`] variant. /// /// Single source of truth — never manually type these strings elsewhere. @@ -215,8 +224,6 @@ pub(crate) fn op_kind_str(op: &WarpOp) -> &'static str { } } -#[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] -#[cfg(not(feature = "unsafe_graph"))] /// Canonical extraction of write targets from a [`WarpOp`]. /// /// This is the SINGLE SOURCE OF TRUTH for what a `WarpOp` mutates. diff --git a/crates/warp-core/src/graph_view.rs b/crates/warp-core/src/graph_view.rs index 8683d4c36..e4a08ab1a 100644 --- a/crates/warp-core/src/graph_view.rs +++ b/crates/warp-core/src/graph_view.rs @@ -110,6 +110,16 @@ impl<'a> GraphView<'a> { } } + /// Returns the immutable store behind this compatibility view. + /// + /// Kept crate-private so public callers cannot bypass the capability split. + /// The serial comparison lane uses it only to invoke an observed callback + /// with a throwaway transcript; verification-grade execution uses the + /// evidence-producing per-item core instead. + pub(crate) const fn store(self) -> &'a GraphStore { + self.store + } + /// Creates a new read-only view with a footprint guard attached. /// /// Every read accessor will validate against the guard's declared read set. diff --git a/crates/warp-core/src/head_inbox.rs b/crates/warp-core/src/head_inbox.rs index 233ac05f8..5d6b96525 100644 --- a/crates/warp-core/src/head_inbox.rs +++ b/crates/warp-core/src/head_inbox.rs @@ -926,6 +926,7 @@ impl HeadInbox { } #[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] mod tests { use super::*; diff --git a/crates/warp-core/src/inbox.rs b/crates/warp-core/src/inbox.rs index 7d68efd92..c2668e5a3 100644 --- a/crates/warp-core/src/inbox.rs +++ b/crates/warp-core/src/inbox.rs @@ -23,10 +23,11 @@ use blake3::Hasher; use crate::attachment::AttachmentKey; +use crate::execution_graph_view::ExecutionGraphView; use crate::footprint::{AttachmentSet, EdgeSet, Footprint, NodeSet, PortSet}; use crate::graph_view::GraphView; use crate::ident::{make_node_id, make_type_id, EdgeId, EdgeKey, Hash, NodeId}; -use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule}; +use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule, RuleExecutor}; use crate::tick_patch::WarpOp; use crate::TickDelta; @@ -85,7 +86,7 @@ fn dispatch_inbox_rule_impl() -> RewriteRule { name: DISPATCH_INBOX_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: inbox_matcher, - executor: inbox_executor, + executor: RuleExecutor::observed(inbox_executor), compute_footprint: inbox_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -117,7 +118,7 @@ fn ack_pending_rule_impl() -> RewriteRule { name: ACK_PENDING_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: ack_pending_matcher, - executor: ack_pending_executor, + executor: RuleExecutor::observed(ack_pending_executor), compute_footprint: ack_pending_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -132,7 +133,7 @@ fn inbox_matcher(view: GraphView<'_>, scope: &NodeId) -> bool { && view.edges_from(scope).any(|e| e.ty == pending_ty) } -fn inbox_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { +fn inbox_executor(view: &mut ExecutionGraphView<'_, '_>, scope: &NodeId, delta: &mut TickDelta) { // Drain the pending set by deleting `edge:pending` edges only. // // Ledger nodes are append-only; removing pending edges is queue maintenance. @@ -201,7 +202,11 @@ fn ack_pending_matcher(view: GraphView<'_>, scope: &NodeId) -> bool { view.has_edge(&edge_id) } -fn ack_pending_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { +fn ack_pending_executor( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, +) { // Phase 5: read from view, emit ops to delta (no direct mutation). let warp_id = view.warp_id(); let inbox_id = make_node_id(INBOX_PATH); diff --git a/crates/warp-core/src/lib.rs b/crates/warp-core/src/lib.rs index ade5e6b74..1a6108187 100644 --- a/crates/warp-core/src/lib.rs +++ b/crates/warp-core/src/lib.rs @@ -39,6 +39,7 @@ pub mod math { /// WSC (Write-Streaming Columnar) snapshot format for deterministic serialization. pub mod wsc; +mod actual_footprint; mod admission; mod attachment; mod braid; @@ -69,6 +70,8 @@ mod echo_operation; mod edict_target_ir; mod engine_impl; pub mod evidence; +mod execution_evidence; +mod execution_graph_view; pub mod external_action; #[cfg(not(target_arch = "wasm32"))] pub mod external_action_adapter; @@ -198,6 +201,7 @@ mod worldline_registry; mod worldline_state; // Re-exports for stable public API +pub use actual_footprint::{build_footprint_posture, ActualFootprint, ActualFootprintPosture}; pub use admission::{ AdmissionOutcome, AdmissionOutcomeKind, AdmissionPolicyRef, AffectedRegion, BoundedSite, PluralArtifact, ReintegrationBoundary, @@ -220,7 +224,8 @@ pub use cmd::{ }; pub use constants::{blake3_empty, digest_len0_u64, POLICY_ID_NO_POLICY_V0}; pub use contract_host::{ - eint_op_id, eint_vars_for_op, matches_eint_op, runtime_ingress_eint_read_footprint, + eint_op_id, eint_vars_for_op, matches_eint_op, observed_eint_vars_for_op, + runtime_ingress_eint_read_footprint, }; pub use contract_inverse::{ ContractInverseAdmissionRequest, ContractInverseContext, ContractInverseDerivation, @@ -278,6 +283,8 @@ pub use engine_impl::{ scope_hash, ApplyResult, CommitOutcome, DispatchDisposition, Engine, EngineBuilder, EngineError, ExistingState, FreshStore, IngestDisposition, }; +pub use execution_evidence::{ExecutionEvidenceKey, ExecutionFootprintEvidence}; +pub use execution_graph_view::ExecutionGraphView; pub use footprint::{ pack_port_key, AttachmentSet, EdgeSet, Footprint, NodeSet, PortKey, PortSet, WarpScopedPortKey, }; @@ -457,7 +464,9 @@ pub use revelation::{ SourceDisclosurePolicy, WitnessDigest, }; #[cfg(feature = "native_rule_bootstrap")] -pub use rule::{ConflictPolicy, ExecuteFn, MatchFn, PatternGraph, RewriteRule}; +pub use rule::{ + ConflictPolicy, ExecuteFn, MatchFn, ObservedExecuteFn, PatternGraph, RewriteRule, RuleExecutor, +}; pub use sandbox::DeterminismError; #[cfg(feature = "native_rule_bootstrap")] pub use sandbox::{build_engine, run_pair_determinism, EchoConfig}; diff --git a/crates/warp-core/src/parallel/exec.rs b/crates/warp-core/src/parallel/exec.rs index bef638dc6..e17fa6f82 100644 --- a/crates/warp-core/src/parallel/exec.rs +++ b/crates/warp-core/src/parallel/exec.rs @@ -9,13 +9,21 @@ use std::any::Any; use std::num::NonZeroUsize; use std::sync::atomic::{AtomicUsize, Ordering}; +#[cfg(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") +))] +use crate::actual_footprint::ActualFootprintPosture; +use crate::actual_footprint::{build_footprint_posture, ActualFootprint}; +use crate::execution_evidence::{ExecutionEvidenceKey, ExecutionFootprintEvidence}; +use crate::execution_graph_view::ExecutionGraphView; #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] use crate::footprint_guard::FootprintGuard; use crate::graph::GraphStore; use crate::graph_view::GraphView; use crate::ident::WarpId; -use crate::rule::ExecuteFn; +use crate::rule::{ExecuteFn, ObservedExecuteFn, RuleExecutor}; use crate::tick_delta::{OpOrigin, TickDelta}; use crate::NodeId; @@ -322,11 +330,13 @@ pub(crate) enum ExecItemKind { #[derive(Clone, Copy, Debug)] pub struct ExecItem { /// The execution function to run. - pub exec: ExecuteFn, + pub exec: RuleExecutor, /// The scope node for this execution. pub scope: NodeId, /// Origin metadata for tracking. pub origin: OpOrigin, + /// Canonical position assigned before worker dispatch. + pub(crate) evidence_sequence: u32, /// Classification for enforcement (user vs system). #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] @@ -339,27 +349,46 @@ impl ExecItem { /// This is the default constructor for all externally-registered rules. /// The cfg-gated `kind` field is set to `User` automatically. pub fn new(exec: ExecuteFn, scope: NodeId, origin: OpOrigin) -> Self { + Self::from_rule_executor(RuleExecutor::legacy(exec), scope, origin) + } + + /// Creates an observed user-level `ExecItem`. + /// + /// Reads performed by `exec` pass through [`ExecutionGraphView`] and are + /// eligible for complete per-Action evidence when enforcement is active. + pub fn new_observed(exec: ObservedExecuteFn, scope: NodeId, origin: OpOrigin) -> Self { + Self::from_rule_executor(RuleExecutor::observed(exec), scope, origin) + } + + pub(crate) fn from_rule_executor(exec: RuleExecutor, scope: NodeId, origin: OpOrigin) -> Self { Self { exec, scope, origin, + evidence_sequence: origin.match_ix, #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] kind: ExecItemKind::User, } } + pub(crate) const fn with_evidence_sequence(mut self, sequence: u32) -> Self { + self.evidence_sequence = sequence; + self + } + /// Creates a new system-level `ExecItem`. /// /// System items are internal engine rules (e.g., inbox processing) that /// are allowed to emit instance-level ops under enforcement. #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] - pub(crate) fn new_system(exec: ExecuteFn, scope: NodeId, origin: OpOrigin) -> Self { + pub(crate) fn new_system(exec: RuleExecutor, scope: NodeId, origin: OpOrigin) -> Self { Self { exec, scope, origin, + evidence_sequence: origin.match_ix, kind: ExecItemKind::System, } } @@ -371,6 +400,7 @@ impl ExecItem { /// triggered poisoning. pub struct PoisonedDelta { _delta: TickDelta, + evidence: Vec, panic: Box, } @@ -383,27 +413,64 @@ impl std::fmt::Debug for PoisonedDelta { } impl PoisonedDelta { - #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] - #[cfg(not(feature = "unsafe_graph"))] - pub(crate) fn new(delta: TickDelta, panic: Box) -> Self { + pub(crate) fn new( + delta: TickDelta, + evidence: ExecutionFootprintEvidence, + panic: Box, + ) -> Self { Self { _delta: delta, + evidence: vec![evidence], panic, } } + fn prepend_evidence(&mut self, mut prior: Vec) { + prior.append(&mut self.evidence); + self.evidence = prior; + } + + pub(crate) fn evidence(&self) -> &[ExecutionFootprintEvidence] { + &self.evidence + } + pub(crate) fn into_panic(self) -> Box { self.panic } } +/// One worker's successful delta and the per-Action evidence produced with it. +pub struct WorkerExecution { + delta: TickDelta, + evidence: Vec, +} + +impl WorkerExecution { + fn new(delta: TickDelta, evidence: Vec) -> Self { + Self { delta, evidence } + } + + pub(crate) fn into_parts(self) -> (TickDelta, Vec) { + (self.delta, self.evidence) + } +} + +impl std::fmt::Debug for WorkerExecution { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WorkerExecution") + .field("delta", &"TickDelta") + .field("evidence_count", &self.evidence.len()) + .finish() + } +} + /// Result of a single worker's execution in `execute_work_queue`. /// /// Flattens the nested `Result, WarpId>` into /// a single enum for clearer pattern matching. pub enum WorkerResult { /// Worker completed successfully with a delta to merge. - Success(TickDelta), + Success(WorkerExecution), /// Worker encountered a footprint violation or executor panic. Poisoned(PoisonedDelta), /// Worker failed to resolve a store for the given warp. @@ -421,15 +488,45 @@ impl std::fmt::Debug for WorkerResult { } /// Serial execution baseline. +/// +/// # Footprint evidence posture +/// +/// This lane is **unretained and unenforced**. It builds no [`FootprintGuard`] +/// and publishes no footprint record. Legacy callbacks receive the bare +/// [`GraphView`]; observed callbacks record into a throwaway frame solely to +/// satisfy their capability contract. Authoritative evidence exists only on the +/// work-queue path, whose per-item call site is [`execute_item_enforced`]. +/// +/// That distinction is load-bearing rather than bookkeeping. A verification +/// host that replayed through this lane would have no retained +/// [`ActualFootprint`] at all. Inventing an empty one and reading it as +/// `Actual ⊆ Declared` would report soundness for a rule that lied. See +/// `serial_execution_is_an_unobserved_lane` for the pin. pub fn execute_serial(view: GraphView<'_>, items: &[ExecItem]) -> TickDelta { let mut delta = TickDelta::new(); for item in items { let mut scoped = delta.scoped(item.origin); - (item.exec)(view, &item.scope, scoped.inner_mut()); + execute_unretained(item.exec, view, &item.scope, scoped.inner_mut()); } delta } +fn execute_unretained( + executor: RuleExecutor, + view: GraphView<'_>, + scope: &NodeId, + delta: &mut TickDelta, +) { + match executor { + RuleExecutor::Observed(execute) => { + let mut discarded = ActualFootprint::new(); + let mut observed = ExecutionGraphView::new(view.store(), &mut discarded); + execute(&mut observed, scope, delta); + } + RuleExecutor::Legacy(execute) => execute(view, scope, delta), + } +} + /// Parallel execution entry point. /// /// Uses virtual shard partitioning by default (Phase 6B). @@ -608,7 +705,7 @@ const fn capped_workers(workers: NonZeroUsize) -> NonZeroUsize { fn execute_shard_into_delta(view: GraphView<'_>, items: &[ExecItem], delta: &mut TickDelta) { for item in items { let mut scoped = delta.scoped(item.origin); - (item.exec)(view, &item.scope, scoped.inner_mut()); + execute_unretained(item.exec, view, &item.scope, scoped.inner_mut()); } } @@ -878,17 +975,19 @@ pub fn build_work_units( /// # Footprint Enforcement (cfg-gated) /// /// When enforcement is active, the worker loop: -/// 1. Creates a guarded `GraphView` per item (read enforcement) -/// 2. Wraps execution in `catch_unwind` to ensure write validation runs -/// 3. Validates all emitted ops against the item's guard (write enforcement) -/// 4. Returns a poisoned delta carrying the panic payload +/// 1. Creates one worker-local actual-footprint record per item +/// 2. Gives observed callbacks a guarded [`ExecutionGraphView`] +/// 3. Wraps execution in `catch_unwind` so emitted writes are still recorded +/// 4. Validates all emitted ops against the item's guard +/// 5. Returns evidence before propagating a poisoned delta and panic payload /// /// # Constraints (Non-Negotiable) /// /// 1. **No nested threading**: Items within a unit are executed serially. /// 2. **No long-lived borrows**: `GraphView` is resolved per-unit and dropped /// before claiming the next unit. -/// 3. **`ExecItem` unchanged**: Work units carry items, items don't know their warp. +/// 3. **No graph authority in `ExecItem`**: Work units carry items; stores are +/// still resolved per unit and never embedded in an item. /// /// # Arguments /// @@ -918,7 +1017,7 @@ where if units.is_empty() { return (0..workers) - .map(|_| WorkerResult::Success(TickDelta::new())) + .map(|_| WorkerResult::Success(WorkerExecution::new(TickDelta::new(), Vec::new()))) .collect(); } @@ -933,6 +1032,7 @@ where s.spawn(move || -> WorkerResult { let mut delta = TickDelta::new(); + let mut evidence = Vec::new(); // Work-stealing loop: claim units until none remain loop { @@ -951,10 +1051,12 @@ where // Execute items SERIALLY (no nested threading!) for (idx, item) in unit.items.iter().enumerate() { match execute_item_enforced(store, item, idx, unit, delta) { - Ok(next_delta) => { + Ok((next_delta, record)) => { delta = next_delta; + evidence.push(record); } - Err(poisoned) => { + Err(mut poisoned) => { + poisoned.prepend_evidence(evidence); return WorkerResult::Poisoned(poisoned); } } @@ -963,7 +1065,7 @@ where // View dropped here - no long-lived borrows across warps } - WorkerResult::Success(delta) + WorkerResult::Success(WorkerExecution::new(delta, evidence)) }) }) .collect(); @@ -978,33 +1080,32 @@ where }) } -/// Executes a single item with footprint enforcement (cfg-gated). -/// -/// When enforcement is active: -/// 1. Creates a guarded `GraphView` (read enforcement via `new_guarded`) -/// 2. Wraps execution in `catch_unwind` to ensure write validation runs -/// 3. Validates all emitted ops via `check_op()` (write enforcement) -/// 4. Returns `Err(PoisonedDelta)` on executor panic or footprint violation +struct ItemExecutionOutcome { + delta: TickDelta, + evidence: ExecutionFootprintEvidence, + panic: Option>, +} + +/// Executes one item through the shared evidence-producing core. /// -/// When enforcement is inactive (`unsafe_graph` feature or release without -/// `footprint_enforce_release`), executes directly without validation. -// Result is always Ok when enforcement is compiled out (unsafe_graph), but the -// signature must stay Result for the enforcement path. -#[allow(clippy::unnecessary_wraps)] +/// Observed callbacks receive [`ExecutionGraphView`]; legacy callbacks retain +/// [`GraphView`] and an explicitly non-authoritative posture. Writes are +/// derived for the complete emitted-op suffix before the first `check_op` may +/// unwind, mirroring the read-side record-before-check law. #[inline] -fn execute_item_enforced( +fn execute_item_observed( store: &GraphStore, item: &ExecItem, idx: usize, unit: &WorkUnit, mut delta: TickDelta, -) -> Result { +) -> ItemExecutionOutcome { + use std::panic::{catch_unwind, AssertUnwindSafe}; + // Enforcement path: guarded view + catch_unwind + post-hoc write validation #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] { - use std::panic::{catch_unwind, AssertUnwindSafe}; - // Hard invariant: guards must be populated and aligned with items. // This check runs in all builds (debug and release) when enforcement is active. // If guards are misaligned, it's a bug in the engine's guard construction. @@ -1019,63 +1120,140 @@ fn execute_item_enforced( ); let guard = &unit.guards[idx]; - let view = GraphView::new_guarded(store, guard); - - // Track delta growth for write validation let ops_before = delta.len(); + let mut actual = ActualFootprint::new(); - // Execute under catch_unwind to enforce writes even on panic let exec_result = catch_unwind(AssertUnwindSafe(|| { let mut scoped = delta.scoped(item.origin); - (item.exec)(view, &item.scope, scoped.inner_mut()); + match item.exec { + RuleExecutor::Observed(execute) => { + let mut view = ExecutionGraphView::new_guarded(store, guard, &mut actual); + execute(&mut view, &item.scope, scoped.inner_mut()); + } + RuleExecutor::Legacy(execute) => { + let view = GraphView::new_guarded(store, guard); + execute(view, &item.scope, scoped.inner_mut()); + } + } })); let exec_panic = exec_result.err(); - // Post-hoc write enforcement (runs whether exec succeeded or panicked) + // Derive the complete write record before validation can unwind on its + // first violation. Check-first would leave later emitted targets absent + // from the purported actual footprint. + for op in &delta.ops_ref()[ops_before..] { + actual.record_op(op, store.warp_id()); + } + let check_result = catch_unwind(AssertUnwindSafe(|| { for op in &delta.ops_ref()[ops_before..] { guard.check_op(op); } })); - match (exec_panic, check_result) { - (None, Ok(())) => { - return Ok(delta); - } - (Some(panic), Ok(())) | (None, Err(panic)) => { - return Err(PoisonedDelta::new(delta, panic)); - } - (Some(exec_panic), Err(guard_panic)) => { - let payload = match guard_panic - .downcast::() - { - Ok(violation) => Box::new(crate::footprint_guard::FootprintViolationWithPanic { - violation: *violation, - exec_panic, - }) as Box, - Err(guard_panic) => { - Box::new((exec_panic, guard_panic)) as Box - } - }; - return Err(PoisonedDelta::new(delta, payload)); - } - } + let posture = if item.exec.is_observed() { + build_footprint_posture() + } else { + ActualFootprintPosture::UnavailableLegacyExecutor + }; + let evidence = ExecutionFootprintEvidence::new( + ExecutionEvidenceKey::new( + item.evidence_sequence, + store.warp_id(), + item.scope, + item.origin, + ), + actual, + posture, + ); + return ItemExecutionOutcome { + delta, + evidence, + panic: combine_execution_panics(exec_panic, check_result.err()), + }; } - // Non-enforced path: direct execution (unreachable when enforcement is active, - // since all match arms in the cfg block above return). + // Non-enforced path: the observed ABI still records, but the build posture + // prevents that record from claiming enforcement was active. #[allow(unreachable_code)] { - // Suppress unused variable warnings in non-enforced builds let _ = idx; let _ = unit; + let ops_before = delta.len(); + let mut actual = ActualFootprint::new(); + let exec_result = catch_unwind(AssertUnwindSafe(|| { + let mut scoped = delta.scoped(item.origin); + match item.exec { + RuleExecutor::Observed(execute) => { + let mut view = ExecutionGraphView::new(store, &mut actual); + execute(&mut view, &item.scope, scoped.inner_mut()); + } + RuleExecutor::Legacy(execute) => { + execute(GraphView::new(store), &item.scope, scoped.inner_mut()); + } + } + })); + for op in &delta.ops_ref()[ops_before..] { + actual.record_op(op, store.warp_id()); + } - let view = GraphView::new(store); - let mut scoped = delta.scoped(item.origin); - (item.exec)(view, &item.scope, scoped.inner_mut()); + ItemExecutionOutcome { + delta, + evidence: ExecutionFootprintEvidence::new( + ExecutionEvidenceKey::new( + item.evidence_sequence, + store.warp_id(), + item.scope, + item.origin, + ), + actual, + build_footprint_posture(), + ), + panic: exec_result.err(), + } + } +} + +#[cfg(all( + any(debug_assertions, feature = "footprint_enforce_release"), + not(feature = "unsafe_graph") +))] +fn combine_execution_panics( + exec_panic: Option>, + guard_panic: Option>, +) -> Option> { + match (exec_panic, guard_panic) { + (None, None) => None, + (Some(panic), None) | (None, Some(panic)) => Some(panic), + (Some(exec_panic), Some(guard_panic)) => Some( + match guard_panic.downcast::() { + Ok(violation) => Box::new(crate::footprint_guard::FootprintViolationWithPanic { + violation: *violation, + exec_panic, + }) as Box, + Err(guard_panic) => { + Box::new((exec_panic, guard_panic)) as Box + } + }, + ), + } +} - Ok(delta) +/// Preserves the established delta/poison boundary while delegating all +/// execution and evidence production to [`execute_item_observed`]. +#[inline] +fn execute_item_enforced( + store: &GraphStore, + item: &ExecItem, + idx: usize, + unit: &WorkUnit, + delta: TickDelta, +) -> Result<(TickDelta, ExecutionFootprintEvidence), PoisonedDelta> { + let outcome = execute_item_observed(store, item, idx, unit, delta); + match outcome.panic { + None => Ok((outcome.delta, outcome.evidence)), + Some(panic) => Err(PoisonedDelta::new(outcome.delta, outcome.evidence, panic)), } } @@ -1093,6 +1271,70 @@ mod tests { }; use std::num::NonZeroUsize; + /// Pins the enforcement posture of the serial lane. + /// + /// `execute_serial` builds no [`FootprintGuard`] and publishes no evidence, + /// so a legacy rule that reads a node it never declared runs here without + /// complaint *and* leaves no trace. An observed callback receives its + /// capability, but that transcript is deliberately discarded. A + /// verification host must therefore treat this lane as unavailable rather + /// than inventing an empty read axis and reporting soundness for a rule that + /// lied. + /// + /// The same read through [`ExecutionGraphView`] is recorded. The contrast + /// is what makes posture necessary: the two lanes produce different + /// evidence for identical behaviour, so the evidence must say which lane + /// produced it. + #[test] + fn serial_execution_is_an_unobserved_lane() { + use crate::{ActualFootprint, ActualFootprintPosture, ExecutionGraphView, Footprint}; + + fn reads_an_undeclared_node(view: GraphView<'_>, _scope: &NodeId, _delta: &mut TickDelta) { + let _ = view.node(&NodeId([2u8; 32])); + } + + let mut store = GraphStore::default(); + let node_ty = make_type_id("parallel/unobserved-node"); + let scope = NodeId([1u8; 32]); + let undeclared = NodeId([2u8; 32]); + store.insert_node(scope, NodeRecord { ty: node_ty }); + store.insert_node(undeclared, NodeRecord { ty: node_ty }); + + let items = vec![ExecItem::new( + reads_an_undeclared_node, + scope, + OpOrigin { + intent_id: 0, + rule_id: 1, + match_ix: 0, + op_ix: 0, + }, + )]; + + // Unobserved lane: the undeclared read neither panics nor is recorded. + let delta = execute_serial(GraphView::new(&store), &items); + assert_eq!(delta.len(), 0); + + // Observed capability: the identical read is recorded, and comparing + // it against an empty declaration reports the violation the serial + // lane could not have seen. + let mut actual = ActualFootprint::new(); + let mut observed = ExecutionGraphView::new(&store, &mut actual); + let _ = observed.node(&undeclared); + assert_eq!( + actual.soundness_violations(&Footprint::default(), store.warp_id()), + vec![crate::footprint_guard::ViolationKind::NodeReadNotDeclared( + undeclared + )] + ); + + // So evidence from the serial lane must never be read as a complete + // read axis, and must never ground an admitted witness. + let serial_posture = ActualFootprintPosture::UnavailableLegacyExecutor; + assert!(!serial_posture.read_axis_is_complete()); + assert!(!serial_posture.is_authoritative()); + } + fn test_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { let payload = AtomPayload::new( make_type_id("parallel/policy-test"), diff --git a/crates/warp-core/src/parallel/mod.rs b/crates/warp-core/src/parallel/mod.rs index 0366881b6..b70b6f96b 100644 --- a/crates/warp-core/src/parallel/mod.rs +++ b/crates/warp-core/src/parallel/mod.rs @@ -16,7 +16,8 @@ pub use exec::{ execute_parallel_sharded_with_adaptive_routing, execute_parallel_sharded_with_policy, execute_parallel_with_adaptive_routing, execute_parallel_with_policy, execute_serial, execute_work_queue, resolve_adaptive_shard_routing, DeltaAccumulationPolicy, ExecItem, - ParallelExecutionPolicy, PoisonedDelta, ShardAssignmentPolicy, WorkUnit, WorkerResult, + ParallelExecutionPolicy, PoisonedDelta, ShardAssignmentPolicy, WorkUnit, WorkerExecution, + WorkerResult, }; #[cfg(not(any(test, feature = "delta_validate")))] pub(crate) use merge::check_write_to_new_warp; diff --git a/crates/warp-core/src/provider_contract.rs b/crates/warp-core/src/provider_contract.rs index 9792d5f09..d24623277 100644 --- a/crates/warp-core/src/provider_contract.rs +++ b/crates/warp-core/src/provider_contract.rs @@ -25,7 +25,7 @@ use crate::contract_registry::{ContractMutationHandler, ContractPackageIdentity} use crate::footprint::Footprint; use crate::graph_view::GraphView; use crate::ident::{make_type_id, NodeId}; -use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule}; +use crate::rule::{ConflictPolicy, PatternGraph, RewriteRule, RuleExecutor}; use crate::TickDelta; #[cfg(all(feature = "native_rule_bootstrap", feature = "trusted_runtime"))] @@ -1920,7 +1920,7 @@ fn materialize_mutation_handler( name: dispatch.rule_name, left: PatternGraph { nodes: Vec::new() }, matcher: dispatch.matcher, - executor, + executor: RuleExecutor::legacy(executor), compute_footprint, factor_mask: u64::MAX, conflict_policy: ConflictPolicy::Abort, @@ -2145,8 +2145,12 @@ mod tests { handler.rule.matcher, matcher as ProviderMutationMatchFnV1 )); + assert!(matches!(handler.rule.executor, RuleExecutor::Legacy(_))); + let RuleExecutor::Legacy(installed_executor) = handler.rule.executor else { + return; + }; assert!(std::ptr::fn_addr_eq( - handler.rule.executor, + installed_executor, execute_provider_host:: as ProviderMutationExecuteFnV1 )); assert!(std::ptr::fn_addr_eq( diff --git a/crates/warp-core/src/rule.rs b/crates/warp-core/src/rule.rs index 0c4332823..cf0555637 100644 --- a/crates/warp-core/src/rule.rs +++ b/crates/warp-core/src/rule.rs @@ -3,6 +3,7 @@ //! Rewrite rule definitions. #![cfg_attr(not(feature = "native_rule_bootstrap"), allow(dead_code))] +use crate::execution_graph_view::ExecutionGraphView; use crate::footprint::Footprint; use crate::graph_view::GraphView; use crate::ident::{Hash, NodeId, TypeId}; @@ -25,11 +26,13 @@ pub struct PatternGraph { /// - `&NodeId`: The candidate scope node to test pub type MatchFn = for<'a> fn(GraphView<'a>, &NodeId) -> bool; -/// Function pointer that applies a rewrite to the given scope. +/// Legacy function pointer that applies a rewrite to the given scope. /// -/// Phase 5 signature: executors read from an immutable [`GraphView`] -/// and emit mutations to a [`TickDelta`]. This enforces the separation -/// between observation and mutation required by the deterministic execution model. +/// This ABI cannot record graph reads and therefore always produces +/// `UnavailableLegacyExecutor` evidence. It remains for frozen compatibility +/// callbacks such as provider-v1. Native bootstrap rules use +/// [`ObservedExecuteFn`] instead. Both ABIs preserve the separation between +/// observation and mutation: executors emit mutations to a [`TickDelta`]. /// /// Parameters: /// - `GraphView`: Read-only view over the graph state (Copy type, 8 bytes) @@ -37,6 +40,57 @@ pub type MatchFn = for<'a> fn(GraphView<'a>, &NodeId) -> bool; /// - `&mut TickDelta`: Mutable reference to record emitted changes pub type ExecuteFn = for<'a> fn(GraphView<'a>, &NodeId, &mut TickDelta); +/// Function pointer for an executor whose graph reads are recorded. +/// +/// Unlike legacy [`ExecuteFn`], the observed callback receives an exclusive +/// borrow of [`ExecutionGraphView`]. The view records each attempted read before +/// applying the declared-footprint guard, so an unwind cannot erase the access +/// that caused it. +pub type ObservedExecuteFn = + for<'store, 'frame> fn(&mut ExecutionGraphView<'store, 'frame>, &NodeId, &mut TickDelta); + +/// Execution ABI selected for one rewrite rule. +/// +/// Native rules use [`Observed`](Self::Observed). [`Legacy`](Self::Legacy) +/// remains explicit for frozen compatibility callbacks, including provider-v1; +/// it never produces an authoritative read-footprint record. +#[derive(Clone, Copy)] +pub enum RuleExecutor { + /// Executor whose reads pass through [`ExecutionGraphView`]. + Observed(ObservedExecuteFn), + /// Compatibility executor whose reads pass through legacy [`GraphView`]. + Legacy(ExecuteFn), +} + +impl RuleExecutor { + /// Wraps an executor that records graph reads. + #[must_use] + pub const fn observed(executor: ObservedExecuteFn) -> Self { + Self::Observed(executor) + } + + /// Wraps a frozen compatibility executor. + #[must_use] + pub const fn legacy(executor: ExecuteFn) -> Self { + Self::Legacy(executor) + } + + /// Returns `true` when the callback uses the observed executor ABI. + #[must_use] + pub const fn is_observed(self) -> bool { + matches!(self, Self::Observed(_)) + } +} + +impl core::fmt::Debug for RuleExecutor { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(match self { + Self::Observed(_) => "RuleExecutor::Observed", + Self::Legacy(_) => "RuleExecutor::Legacy", + }) + } +} + /// Function pointer that computes a rewrite footprint at the provided scope. /// /// Phase 5 signature: footprint computation reads from an immutable @@ -86,8 +140,8 @@ pub struct RewriteRule { pub left: PatternGraph, /// Callback used to determine if the rule matches the provided scope. pub matcher: MatchFn, - /// Callback that applies the rewrite to the provided scope. - pub executor: ExecuteFn, + /// Observed or explicitly legacy callback that applies the rewrite. + pub executor: RuleExecutor, /// Callback that computes a footprint for independence checks. pub compute_footprint: FootprintFn, /// Spatial partition bitmask used as an O(1) prefilter. diff --git a/crates/warp-core/src/tick_delta.rs b/crates/warp-core/src/tick_delta.rs index 26701ce6f..6e91a6ac3 100644 --- a/crates/warp-core/src/tick_delta.rs +++ b/crates/warp-core/src/tick_delta.rs @@ -180,8 +180,6 @@ impl TickDelta { } /// Returns a shared reference to the accumulated ops (for footprint validation). - #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] - #[cfg(not(feature = "unsafe_graph"))] pub(crate) fn ops_ref(&self) -> &[WarpOp] { &self.ops } diff --git a/crates/warp-core/src/trusted_runtime_host.rs b/crates/warp-core/src/trusted_runtime_host.rs index 3acc60ce1..3800ef1ca 100644 --- a/crates/warp-core/src/trusted_runtime_host.rs +++ b/crates/warp-core/src/trusted_runtime_host.rs @@ -6264,1727 +6264,1728 @@ fn wal_tick_decision_from_observation( }) } -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - CausalTickReceiptRef, GlobalTick, IngressSubmissionGeneration, IngressTarget, WorldlineId, - WorldlineTick, WriterHeadKey, - }; - use bytes::Bytes; - - type BorrowedActionOutcomeBatchWriter = - for<'a> fn( - &mut TrustedRuntimeWal, - &[(ReceiptCorrelationRecord, WalTickDecision)], - &BTreeMap, - &WalRuntimeStateDeltaRecord, - Hash, - ) -> Result; +fn tick_transaction_digest( + correlation: &ReceiptCorrelationRecord, + decision: WalTickDecision, + state_delta_digest: Hash, +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"tick-transaction"); + hasher.update(&correlation.ticketed_ingress_id); + hasher.update(&correlation.causal_receipt_ref.to_canonical_bytes()); + hasher.update(&correlation.ingress_id); + hash_causal_parent_receipts(&mut hasher, &correlation.causal_parent_receipts); + hasher.update(&[wal_tick_decision_code(decision)]); + hasher.update(&state_delta_digest); + hasher.finalize().into() +} - #[test] - fn tick_receipt_batch_accepts_borrowed_action_outcomes() { - let writer: BorrowedActionOutcomeBatchWriter = TrustedRuntimeWal::record_tick_receipt_batch; - assert!(std::ptr::fn_addr_eq( - writer, - TrustedRuntimeWal::record_tick_receipt_batch as BorrowedActionOutcomeBatchWriter - )); +fn tick_batch_transaction_digest( + correlations: &[(ReceiptCorrelationRecord, WalTickDecision)], + state_delta_digest: Hash, +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"tick-batch-transaction:v1\0"); + hasher.update(&(correlations.len() as u64).to_le_bytes()); + for (correlation, decision) in correlations { + hasher.update(&correlation.ticketed_ingress_id); + hasher.update(&correlation.causal_receipt_ref.to_canonical_bytes()); + hasher.update(&correlation.ingress_id); + hash_causal_parent_receipts(&mut hasher, &correlation.causal_parent_receipts); + hasher.update(&[wal_tick_decision_code(*decision)]); } + hasher.update(&state_delta_digest); + hasher.finalize().into() +} - fn test_head_key() -> WriterHeadKey { - WriterHeadKey { - worldline_id: WorldlineId::from_bytes([9; 32]), - head_id: crate::make_head_id("runtime-wal-test"), - } - } +fn receipt_frontier_digest( + previous: Hash, + receipt: TickReceiptRecord, + correlation: &WalReceiptCorrelationRecord, +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"receipt-frontier"); + hasher.update(&previous); + hasher.update(&receipt.receipt_ref.to_canonical_bytes()); + hasher.update(&[wal_tick_decision_code(receipt.decision)]); + hasher.update(&correlation.receipt_ref.to_canonical_bytes()); + hash_causal_parent_receipts(&mut hasher, &correlation.causal_parent_receipts); + hasher.finalize().into() +} - fn creation_scope_patch(node: crate::NodeKey) -> crate::WorldlineTickPatchV1 { - let attachment = crate::AttachmentKey::node_alpha(node); - let parent_warp = crate::make_warp_id("operation-wal-parent-root"); - let portal = crate::AttachmentKey::node_alpha(crate::NodeKey { - warp_id: parent_warp, - local_id: crate::make_node_id("operation-wal-parent-portal"), - }); - let middle_warp = crate::make_warp_id("operation-wal-middle"); - let middle_portal = crate::AttachmentKey::node_alpha(crate::NodeKey { - warp_id: middle_warp, - local_id: crate::make_node_id("operation-wal-middle-portal"), - }); - crate::WorldlineTickPatchV1 { - header: crate::WorldlineTickHeaderV1 { - commit_global_tick: GlobalTick::from_raw(1), - policy_id: 7, - rule_pack_id: [3; 32], - plan_digest: [4; 32], - decision_digest: [5; 32], - rewrites_digest: [6; 32], - }, - // The parent worldline root is intentionally different from the - // descendant node's WARP id. - warp_id: parent_warp, - ops: vec![ - crate::WarpOp::UpsertNode { - node, - record: crate::NodeRecord { - ty: crate::make_type_id("operation-wal-created-node"), - }, - }, - crate::WarpOp::SetAttachment { - key: attachment, - value: Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( - crate::make_type_id("operation-wal-created-attachment"), - Bytes::from_static(b"created"), - ))), - }, - ], - in_slots: vec![ - crate::SlotId::Node(node), - crate::SlotId::Attachment(attachment), - crate::SlotId::Attachment(portal), - crate::SlotId::Attachment(middle_portal), - ], - out_slots: vec![ - crate::SlotId::Node(node), - crate::SlotId::Attachment(attachment), - ], - patch_digest: [7; 32], - } +fn hash_causal_parent_receipts( + hasher: &mut blake3::Hasher, + parents: &[crate::CausalTickReceiptRef], +) { + if parents.is_empty() { + return; + } + hasher.update(b"causal-parent-tick-receipts:v2\0"); + hasher.update(&(parents.len() as u64).to_le_bytes()); + for parent in parents { + hasher.update(&parent.to_canonical_bytes()); } +} - fn creation_scope_parent_state(node: crate::NodeKey) -> crate::WorldlineState { - let parent_warp = crate::make_warp_id("operation-wal-parent-root"); - let parent_node = crate::make_node_id("operation-wal-parent-portal"); - let portal = crate::AttachmentKey::node_alpha(crate::NodeKey { - warp_id: parent_warp, - local_id: parent_node, - }); - let middle_warp = crate::make_warp_id("operation-wal-middle"); - let middle_node = crate::make_node_id("operation-wal-middle-portal"); - let middle_portal = crate::AttachmentKey::node_alpha(crate::NodeKey { - warp_id: middle_warp, - local_id: middle_node, - }); - let child_root = crate::make_node_id("operation-wal-descendant-root"); +fn wal_tick_decision_code(decision: WalTickDecision) -> u8 { + match decision { + WalTickDecision::Applied => 1, + WalTickDecision::RejectedFootprintConflict => 2, + WalTickDecision::Obstructed => 3, + } +} - let mut parent_store = crate::GraphStore::new(parent_warp); - parent_store.insert_node( - parent_node, - crate::NodeRecord { - ty: crate::make_type_id("operation-wal-parent-node"), - }, - ); - parent_store.set_node_attachment( - parent_node, - Some(crate::AttachmentValue::Descend(middle_warp)), - ); - let mut middle_store = crate::GraphStore::new(middle_warp); - middle_store.insert_node( - middle_node, - crate::NodeRecord { - ty: crate::make_type_id("operation-wal-middle-node"), - }, - ); - middle_store.set_node_attachment( - middle_node, - Some(crate::AttachmentValue::Descend(node.warp_id)), - ); - let mut child_store = crate::GraphStore::new(node.warp_id); - child_store.insert_node( - child_root, - crate::NodeRecord { - ty: crate::make_type_id("operation-wal-descendant-root-node"), - }, - ); +fn executable_operation_catalog_frontier_digest( + previous: Hash, + package_id: crate::EchoOperationPackageIdV1, + retained_installation_bytes: &[u8], +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"executable-operation-catalog-frontier"); + hasher.update(&previous); + hasher.update(&package_id.as_hash()); + hasher.update(&(retained_installation_bytes.len() as u64).to_le_bytes()); + hasher.update(retained_installation_bytes); + hasher.finalize().into() +} - let mut warp_state = crate::WarpState::new(); - warp_state.upsert_instance( - crate::WarpInstance { - warp_id: parent_warp, - root_node: parent_node, - parent: None, - }, - parent_store, - ); - warp_state.upsert_instance( - crate::WarpInstance { - warp_id: middle_warp, - root_node: middle_node, - parent: Some(portal), - }, - middle_store, - ); - warp_state.upsert_instance( - crate::WarpInstance { - warp_id: node.warp_id, - root_node: child_root, - parent: Some(middle_portal), - }, - child_store, - ); - crate::WorldlineState::new( - warp_state, - crate::NodeKey { - warp_id: parent_warp, - local_id: parent_node, - }, - ) - .expect("the recovery parent-state fixture is lawful") - } +fn executable_operation_receipt_frontier_digest(previous: Hash, receipt_digest: Hash) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"executable-operation-receipt-frontier"); + hasher.update(&previous); + hasher.update(&receipt_digest); + hasher.finalize().into() +} - #[test] - fn executable_operation_index_preserves_legacy_root_without_action_outcomes() { - let operation_coordinate = "echo.test.LegacyRecoveryIndex.v1"; - let authority_profile_identity = [0x17; 32]; - let budget = crate::EchoOperationBudgetV1::new(7, 1_024, 1_024); - let package = crate::ExecutableOperationPackageV1::new( - operation_coordinate, - "echo.test.LegacyRecoveryIndex.Obstruction.v1", - crate::EchoOperationSemanticClosureV1::new( - [0x10; 32], - [0x11; 32], - [0x12; 32], - [0x13; 32], - "echo.test.legacy-index-schema/v1", - [0x14; 32], - "echo.test.legacy-index-lawpack/v1", - [0x15; 32], - ), - crate::echo_operation_target_profile_identity_v1(), - authority_profile_identity, - budget, - crate::EchoOperationProgramV1::anchored_node_attachment_compare_and_set( - crate::make_type_id("legacy-index-node"), - crate::make_type_id("legacy-index-attachment"), - 128, - ), - ); - let package_bytes = package - .to_canonical_bytes() - .expect("the legacy-index package is canonical"); - let package_id = crate::echo_operation_package_id_v1(&package_bytes); - let admitted = admit_package_v1( - &crate::EchoOperationAdmissionPolicyV1::exact( - package_id, - operation_coordinate, - authority_profile_identity, - budget, - ), - package_bytes, - ) - .expect("the legacy-index package is admitted"); - let installed = - installed_from_admitted(admitted).expect("the legacy-index package installs"); +fn executable_operation_installation_transaction_digest( + catalog_frontier: Hash, + package_id: crate::EchoOperationPackageIdV1, + retained_installation_bytes: &[u8], +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime:executable-operation-installation-transaction:v1\0"); + hasher.update(&catalog_frontier); + hasher.update(&package_id.as_hash()); + hasher.update(&(retained_installation_bytes.len() as u64).to_le_bytes()); + hasher.update(retained_installation_bytes); + hasher.finalize().into() +} - assert_eq!( - recovered_echo_operation_index_root([0x20; 32], &[installed], &[], &[]) - .expect("the legacy index root is computable"), - [ - 0xf9, 0xcf, 0x53, 0xf2, 0xad, 0xaf, 0x93, 0x1a, 0xa0, 0xe6, 0x06, 0xc6, 0x5a, 0x91, - 0x6f, 0xfa, 0x0b, 0x4f, 0xf5, 0x50, 0x66, 0x32, 0x8c, 0x1c, 0x48, 0xf7, 0x22, 0xcd, - 0x1e, 0x0d, 0x40, 0x2e, - ] - ); - } +fn executable_operation_tick_transaction_digest( + receipt_frontier: Hash, + runtime_state_frontier: Hash, + receipt_digest: Hash, + state_delta_digest: Hash, +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime:executable-operation-tick-transaction:v1\0"); + hasher.update(&receipt_frontier); + hasher.update(&runtime_state_frontier); + hasher.update(&receipt_digest); + hasher.update(&state_delta_digest); + hasher.finalize().into() +} - #[test] - fn creation_wal_scope_accepts_descendants_and_rejects_mutated_shapes() { - let node = crate::NodeKey { - warp_id: crate::make_warp_id("operation-wal-descendant"), - local_id: crate::make_node_id("operation-wal-created-node"), - }; - let installed_program = - crate::EchoOperationProgramV1::anchored_node_attachment_create_if_absent( - crate::make_type_id("operation-wal-created-node"), - crate::make_type_id("operation-wal-created-attachment"), - 7, - ); - let update_program = - crate::EchoOperationProgramV1::anchored_node_attachment_compare_and_set( - crate::make_type_id("operation-wal-created-node"), - crate::make_type_id("operation-wal-created-attachment"), - 7, - ); - let patch = creation_scope_patch(node); - let parent_state = creation_scope_parent_state(node); - assert_eq!( - operation_patch_scope_v1(&patch, &installed_program), - Some(node), - "the parent worldline root must not erase descendant operation scope" - ); - assert_eq!( - operation_patch_scope_in_parent_state_v1(&patch, &installed_program, &parent_state), - Some(node), - "activation recovery must corroborate the exact retained portal chain" - ); - assert!(operation_application_basis_matches_scope_v1( - &installed_program, - node, - crate::echo_operation_anchored_node_absent_application_basis_v1(node), - )); - assert!( - !operation_application_basis_matches_scope_v1( - &installed_program, - node, - EchoOperationApplicationBasisV1::new([0x91; 32], [0x92; 32]), - ), - "creation recovery must bind the receipt to the canonical absence proposition" - ); +fn runtime_state_frontier_digest( + previous: Hash, + correlation: &ReceiptCorrelationRecord, + state_delta_digest: Hash, +) -> Hash { + runtime_state_frontier_digest_from_fields( + previous, + correlation.commit_hash, + state_delta_digest, + correlation.commit_global_tick, + correlation.worldline_tick_after, + ) +} - let mut node_occupied_parent = parent_state.clone(); - node_occupied_parent - .warp_state - .store_mut(&node.warp_id) - .expect("the fixture retains its descendant store") - .insert_node( - node.local_id, - crate::NodeRecord { - ty: crate::make_type_id("operation-wal-existing-node"), - }, - ); - assert_eq!( - operation_patch_scope_in_parent_state_v1( - &patch, - &installed_program, - &node_occupied_parent - ), - None, - "creation recovery must reject an occupied node even when its attachment is absent" - ); +fn runtime_state_frontier_digest_from_fields( + previous: Hash, + commit_hash: Hash, + state_delta_digest: Hash, + commit_global_tick: crate::GlobalTick, + worldline_tick_after: crate::WorldlineTick, +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"runtime-state-frontier"); + hasher.update(&previous); + hasher.update(&commit_hash); + hasher.update(&state_delta_digest); + hasher.update(&commit_global_tick.as_u64().to_le_bytes()); + hasher.update(&worldline_tick_after.as_u64().to_le_bytes()); + hasher.finalize().into() +} - let mut attachment_occupied_parent = parent_state.clone(); - attachment_occupied_parent - .warp_state - .store_mut(&node.warp_id) - .expect("the fixture retains its descendant store") - .set_node_attachment( - node.local_id, - Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( - crate::make_type_id("operation-wal-existing-attachment"), - Bytes::from_static(b"occupied"), - ))), - ); - assert_eq!( - operation_patch_scope_in_parent_state_v1( - &patch, - &installed_program, - &attachment_occupied_parent - ), - None, - "creation recovery must reject an orphan attachment even when its node is absent" - ); +fn recovered_legacy_runtime_state_frontier_digest( + previous: Hash, + correlation: WalReceiptCorrelationRecord, + state_delta_digest: Hash, +) -> Hash { + let mut hasher = blake3::Hasher::new(); + hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); + hasher.update(b"runtime-state-frontier:recovered"); + hasher.update(&previous); + hasher.update(&correlation.receipt_ref.to_canonical_bytes()); + hasher.update(&state_delta_digest); + hasher.finalize().into() +} - let mut fully_occupied_parent = node_occupied_parent; - fully_occupied_parent - .warp_state - .store_mut(&node.warp_id) - .expect("the fixture retains its descendant store") - .set_node_attachment( - node.local_id, - Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( - crate::make_type_id("operation-wal-existing-attachment"), - Bytes::from_static(b"occupied"), - ))), - ); - assert_eq!( - operation_patch_scope_in_parent_state_v1( - &patch, - &installed_program, - &fully_occupied_parent - ), - None, - "creation recovery must reject a fully occupied target" - ); +struct RecoveredRuntimeWalIndexEvidence<'a> { + submissions: &'a RecoveredSubmissionIndex, + receipts: &'a RecoveredReceiptIndex, + witnessed_submissions: &'a WitnessedSubmissionPersistenceSnapshot, + missing_submission_envelopes: &'a [Hash], + provenance_entries: &'a [ProvenanceEntry], + missing_runtime_state_deltas: &'a [Hash], + causal_anchor_history: &'a [WitnessedCausalAnchorAdmission], + installed_echo_operations: &'a [InstalledEchoOperationV1], + echo_operation_receipts: &'a [EchoOperationReceiptV1], + echo_operation_action_outcomes: &'a [(Hash, Hash, EchoOperationActionOutcomeV1)], +} - assert_eq!( - operation_patch_scope_v1(&patch, &update_program), - None, - "the update profile must reject the creation program's two-op patch" - ); +fn runtime_wal_recovery_certificate( + report: &RecoveryScanReport, + indexes: &RecoveredRuntimeWalIndexEvidence<'_>, +) -> Result { + let recovered_frontier_root = report + .last_commit_digest() + .unwrap_or_else(|| trusted_runtime_wal_digest("recovery-frontier:empty")); + let recovered_indexes_root = recovered_runtime_wal_indexes_root(indexes)?; + Ok(build_recovery_certificate( + report, + None, + (indexes.missing_submission_envelopes.len() + indexes.missing_runtime_state_deltas.len()) + as u64, + recovered_frontier_root, + recovered_indexes_root, + )) +} - let mut missing_portal_read = patch.clone(); - missing_portal_read.in_slots.retain(|slot| { - matches!( - slot, - crate::SlotId::Node(candidate) if candidate == &node - ) || matches!( - slot, - crate::SlotId::Attachment(candidate) - if candidate == &crate::AttachmentKey::node_alpha(node) - ) - }); - assert_eq!( - operation_patch_scope_v1(&missing_portal_read, &installed_program), - None, - "a descendant operation must retain the root portal dependency" - ); +fn recovered_runtime_wal_indexes_root( + indexes: &RecoveredRuntimeWalIndexEvidence<'_>, +) -> Result { + let recovered_indexes_root = recovered_submission_material_index_root( + recovered_submission_receipt_index_root(indexes.submissions, indexes.receipts), + indexes.witnessed_submissions, + indexes.missing_submission_envelopes, + ); + let runtime_root = recovered_runtime_state_delta_index_root( + recovered_indexes_root, + indexes.provenance_entries, + indexes.missing_runtime_state_deltas, + )?; + let causal_anchor_root = + recovered_causal_anchor_index_root(runtime_root, indexes.causal_anchor_history); + recovered_echo_operation_index_root( + causal_anchor_root, + indexes.installed_echo_operations, + indexes.echo_operation_receipts, + indexes.echo_operation_action_outcomes, + ) +} - let mut duplicate_portal_read = patch.clone(); - duplicate_portal_read - .in_slots - .push(duplicate_portal_read.in_slots[2]); - assert_eq!( - operation_patch_scope_v1(&duplicate_portal_read, &installed_program), - None, - "a descendant operation must reject duplicate portal dependencies" - ); +fn recovered_echo_operation_index_root( + base_root: Hash, + installations: &[InstalledEchoOperationV1], + receipts: &[EchoOperationReceiptV1], + action_outcomes: &[(Hash, Hash, EchoOperationActionOutcomeV1)], +) -> Result { + let legacy_root = + recovered_echo_operation_legacy_index_root(base_root, installations, receipts)?; + if action_outcomes.is_empty() { + return Ok(legacy_root); + } + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime-wal:executable-operation-index:v2\0"); + hasher.update(&legacy_root); + hasher.update(&(action_outcomes.len() as u64).to_le_bytes()); + for (submission_id, ingress_id, outcome) in action_outcomes { + let bytes = retain_action_outcome_v1(*submission_id, *ingress_id, outcome)?; + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + } + Ok(hasher.finalize().into()) +} - let mut substituted_root_portal = patch.clone(); - substituted_root_portal.in_slots[2] = - crate::SlotId::Attachment(crate::AttachmentKey::node_alpha(crate::NodeKey { - warp_id: substituted_root_portal.warp_id, - local_id: crate::make_node_id("operation-wal-unrelated-root-portal"), - })); - assert_eq!( - operation_patch_scope_in_parent_state_v1( - &substituted_root_portal, - &installed_program, - &parent_state - ), - None, - "recovery must reject an unrelated root-owned portal substituted for the exact descent chain" - ); +fn recovered_echo_operation_legacy_index_root( + base_root: Hash, + installations: &[InstalledEchoOperationV1], + receipts: &[EchoOperationReceiptV1], +) -> Result { + if installations.is_empty() && receipts.is_empty() { + return Ok(base_root); + } + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime-wal:executable-operation-index:v1\0"); + hasher.update(&base_root); + hasher.update(&(installations.len() as u64).to_le_bytes()); + for installed in installations { + let bytes = retain_installation_v1(installed)?; + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + } + hasher.update(&(receipts.len() as u64).to_le_bytes()); + for receipt in receipts { + let bytes = receipt.to_canonical_bytes()?; + hasher.update(&(bytes.len() as u64).to_le_bytes()); + hasher.update(&bytes); + } + Ok(hasher.finalize().into()) +} - let mut missing_middle_portal = patch.clone(); - missing_middle_portal.in_slots.remove(3); - assert_eq!( - operation_patch_scope_v1(&missing_middle_portal, &installed_program), - Some(node), - "the byte-shape layer alone cannot infer a portal's Descend target" - ); - assert_eq!( - operation_patch_scope_in_parent_state_v1( - &missing_middle_portal, - &installed_program, - &parent_state - ), - None, - "activation recovery must reject a patch that omits an intermediate portal" - ); +fn recovered_causal_anchor_index_root( + base_root: Hash, + causal_anchor_history: &[WitnessedCausalAnchorAdmission], +) -> Hash { + if causal_anchor_history.is_empty() { + return base_root; + } + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime-wal:causal-anchor-history-index:v1\0"); + hasher.update(&base_root); + hasher.update(&(causal_anchor_history.len() as u64).to_le_bytes()); + for entry in causal_anchor_history { + let admission = entry.admission(); + hasher.update(admission.fact().anchor_id().as_bytes()); + hasher.update(&entry.basis_before().frontier_digest); + hasher.update(&entry.basis_after().frontier_digest); + let fact_bytes = admission.fact().to_payload_bytes(); + hasher.update(&(fact_bytes.len() as u64).to_le_bytes()); + hasher.update(&fact_bytes); + let receipt_bytes = admission.receipt().to_payload_bytes(); + hasher.update(&(receipt_bytes.len() as u64).to_le_bytes()); + hasher.update(&receipt_bytes); + hasher.update(&admission.transaction_id().as_hash()); + hasher.update(&admission.committed_lsn().as_u64().to_le_bytes()); + hasher.update(admission.commit_digest()); + } + hasher.finalize().into() +} - let mut reversed = patch.clone(); - reversed.ops.reverse(); - assert_eq!( - operation_patch_scope_v1(&reversed, &installed_program), - None +fn recovered_submission_material_index_root( + base_root: Hash, + witnessed_submissions: &WitnessedSubmissionPersistenceSnapshot, + missing_submission_envelopes: &[Hash], +) -> Hash { + if witnessed_submissions.is_empty() && missing_submission_envelopes.is_empty() { + return base_root; + } + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime-wal:submission-material-index:v1\0"); + hasher.update(&base_root); + hasher.update(&(witnessed_submissions.len() as u64).to_le_bytes()); + for record in witnessed_submissions.records() { + hasher.update(&record.submission.submission_id); + hasher.update(&record.submission.ingress_id); + hasher.update(record.submission.head_key.worldline_id.as_bytes()); + hasher.update(record.submission.head_key.head_id.as_bytes()); + hasher.update( + &record + .submission + .submission_generation + .as_u64() + .to_le_bytes(), ); + let retained_bytes = record.envelope.to_retained_bytes_v2(); + hasher.update(&(retained_bytes.len() as u64).to_le_bytes()); + hasher.update(&retained_bytes); + } + hasher.update(&(missing_submission_envelopes.len() as u64).to_le_bytes()); + for submission_id in missing_submission_envelopes { + hasher.update(submission_id); + } + hasher.finalize().into() +} - let mut missing_node_write = patch.clone(); - missing_node_write.ops.remove(0); - assert_eq!( - operation_patch_scope_v1(&missing_node_write, &installed_program), - None - ); +fn recovered_runtime_state_delta_index_root( + base_root: Hash, + provenance_entries: &[ProvenanceEntry], + missing_runtime_state_deltas: &[Hash], +) -> Result { + if provenance_entries.is_empty() && missing_runtime_state_deltas.is_empty() { + return Ok(base_root); + } + let mut hasher = blake3::Hasher::new(); + hasher.update(b"echo:trusted-runtime-wal:runtime-state-delta-index:v1\0"); + hasher.update(&base_root); + hasher.update(&(provenance_entries.len() as u64).to_le_bytes()); + for entry in provenance_entries { + let retained_bytes = crate::provenance_codec::encode_local_commit_v1(entry)?; + hasher.update(&(retained_bytes.len() as u64).to_le_bytes()); + hasher.update(&retained_bytes); + } + hasher.update(&(missing_runtime_state_deltas.len() as u64).to_le_bytes()); + for receipt_digest in missing_runtime_state_deltas { + hasher.update(receipt_digest); + } + Ok(hasher.finalize().into()) +} - let mut attachment_only_output = patch.clone(); - attachment_only_output.out_slots.remove(0); - assert_eq!( - operation_patch_scope_v1(&attachment_only_output, &installed_program), - None - ); +#[cfg(test)] +#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::{ + CausalTickReceiptRef, GlobalTick, IngressSubmissionGeneration, IngressTarget, WorldlineId, + WorldlineTick, WriterHeadKey, + }; + use bytes::Bytes; - let mut mismatched_attachment = patch; - let other_node = crate::NodeKey { - warp_id: node.warp_id, - local_id: crate::make_node_id("operation-wal-other-node"), - }; - let crate::WarpOp::SetAttachment { key, .. } = &mut mismatched_attachment.ops[1] else { - panic!("fixture has the canonical attachment operation"); - }; - *key = crate::AttachmentKey::node_alpha(other_node); - assert_eq!( - operation_patch_scope_v1(&mismatched_attachment, &installed_program), - None - ); + type BorrowedActionOutcomeBatchWriter = + for<'a> fn( + &mut TrustedRuntimeWal, + &[(ReceiptCorrelationRecord, WalTickDecision)], + &BTreeMap, + &WalRuntimeStateDeltaRecord, + Hash, + ) -> Result; - let mut wrong_node_type = creation_scope_patch(node); - let crate::WarpOp::UpsertNode { record, .. } = &mut wrong_node_type.ops[0] else { - panic!("fixture has the canonical node operation"); - }; - record.ty = crate::make_type_id("operation-wal-wrong-node-type"); - assert_eq!( - operation_patch_scope_v1(&wrong_node_type, &installed_program), - None, - "recovery must reject a node type the installed program cannot emit" - ); - - let mut wrong_attachment_type = creation_scope_patch(node); - let crate::WarpOp::SetAttachment { value, .. } = &mut wrong_attachment_type.ops[1] else { - panic!("fixture has the canonical attachment operation"); - }; - *value = Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( - crate::make_type_id("operation-wal-wrong-attachment-type"), - Bytes::from_static(b"created"), - ))); - assert_eq!( - operation_patch_scope_v1(&wrong_attachment_type, &installed_program), - None, - "recovery must reject an attachment type the installed program cannot emit" - ); + #[test] + fn tick_receipt_batch_accepts_borrowed_action_outcomes() { + let writer: BorrowedActionOutcomeBatchWriter = TrustedRuntimeWal::record_tick_receipt_batch; + assert!(std::ptr::fn_addr_eq( + writer, + TrustedRuntimeWal::record_tick_receipt_batch as BorrowedActionOutcomeBatchWriter + )); + } - let mut descended_attachment = creation_scope_patch(node); - let crate::WarpOp::SetAttachment { value, .. } = &mut descended_attachment.ops[1] else { - panic!("fixture has the canonical attachment operation"); - }; - *value = Some(crate::AttachmentValue::Descend(crate::make_warp_id( - "operation-wal-hidden-descendant", - ))); - assert_eq!( - operation_patch_scope_v1(&descended_attachment, &installed_program), - None, - "recovery must reject attachment algebras the installed program cannot emit" - ); + fn test_head_key() -> WriterHeadKey { + WriterHeadKey { + worldline_id: WorldlineId::from_bytes([9; 32]), + head_id: crate::make_head_id("runtime-wal-test"), + } + } - let mut oversized_attachment = creation_scope_patch(node); - let crate::WarpOp::SetAttachment { value, .. } = &mut oversized_attachment.ops[1] else { - panic!("fixture has the canonical attachment operation"); - }; - *value = Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( - crate::make_type_id("operation-wal-created-attachment"), - Bytes::from_static(b"too-long"), - ))); - assert_eq!( - operation_patch_scope_v1(&oversized_attachment, &installed_program), - None, - "recovery must enforce the installed program's replacement bound" - ); + fn creation_scope_patch(node: crate::NodeKey) -> crate::WorldlineTickPatchV1 { + let attachment = crate::AttachmentKey::node_alpha(node); + let parent_warp = crate::make_warp_id("operation-wal-parent-root"); + let portal = crate::AttachmentKey::node_alpha(crate::NodeKey { + warp_id: parent_warp, + local_id: crate::make_node_id("operation-wal-parent-portal"), + }); + let middle_warp = crate::make_warp_id("operation-wal-middle"); + let middle_portal = crate::AttachmentKey::node_alpha(crate::NodeKey { + warp_id: middle_warp, + local_id: crate::make_node_id("operation-wal-middle-portal"), + }); + crate::WorldlineTickPatchV1 { + header: crate::WorldlineTickHeaderV1 { + commit_global_tick: GlobalTick::from_raw(1), + policy_id: 7, + rule_pack_id: [3; 32], + plan_digest: [4; 32], + decision_digest: [5; 32], + rewrites_digest: [6; 32], + }, + // The parent worldline root is intentionally different from the + // descendant node's WARP id. + warp_id: parent_warp, + ops: vec![ + crate::WarpOp::UpsertNode { + node, + record: crate::NodeRecord { + ty: crate::make_type_id("operation-wal-created-node"), + }, + }, + crate::WarpOp::SetAttachment { + key: attachment, + value: Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( + crate::make_type_id("operation-wal-created-attachment"), + Bytes::from_static(b"created"), + ))), + }, + ], + in_slots: vec![ + crate::SlotId::Node(node), + crate::SlotId::Attachment(attachment), + crate::SlotId::Attachment(portal), + crate::SlotId::Attachment(middle_portal), + ], + out_slots: vec![ + crate::SlotId::Node(node), + crate::SlotId::Attachment(attachment), + ], + patch_digest: [7; 32], + } } - #[test] - fn operation_recovery_rejects_uncorroborated_frontier_root() { - let expected_frontiers = [AffectedFrontier { - kind: AffectedFrontierKind::ExecutableOperationCatalog, - before_digest: [1; 32], - after_digest: [2; 32], - }]; - let expected = affected_frontiers_root(&expected_frontiers); - let actual = [3; 32]; - let transaction_id = WalTransactionId::from_hash([4; 32]); - let transaction = crate::causal_wal::WalRecoveredTransaction { - commit: WalTransactionCommit { - writer_epoch: WriterEpochId::from_hash([5; 32]), - transaction_id, - transaction_kind: WalTransactionKind::ExecutableOperationInstallation, - first_lsn: Lsn::from_raw(0), - last_lsn: Lsn::from_raw(0), - record_count: 1, - records_root: [6; 32], - affected_frontiers_root: actual, - previous_committed_transaction_digest: [7; 32], - durability_mode: WalDurabilityMode::StrictFilesystem, - schema_version: 1, - commit_digest: [8; 32], + fn creation_scope_parent_state(node: crate::NodeKey) -> crate::WorldlineState { + let parent_warp = crate::make_warp_id("operation-wal-parent-root"); + let parent_node = crate::make_node_id("operation-wal-parent-portal"); + let portal = crate::AttachmentKey::node_alpha(crate::NodeKey { + warp_id: parent_warp, + local_id: parent_node, + }); + let middle_warp = crate::make_warp_id("operation-wal-middle"); + let middle_node = crate::make_node_id("operation-wal-middle-portal"); + let middle_portal = crate::AttachmentKey::node_alpha(crate::NodeKey { + warp_id: middle_warp, + local_id: middle_node, + }); + let child_root = crate::make_node_id("operation-wal-descendant-root"); + + let mut parent_store = crate::GraphStore::new(parent_warp); + parent_store.insert_node( + parent_node, + crate::NodeRecord { + ty: crate::make_type_id("operation-wal-parent-node"), }, - frames: Vec::new(), - }; + ); + parent_store.set_node_attachment( + parent_node, + Some(crate::AttachmentValue::Descend(middle_warp)), + ); + let mut middle_store = crate::GraphStore::new(middle_warp); + middle_store.insert_node( + middle_node, + crate::NodeRecord { + ty: crate::make_type_id("operation-wal-middle-node"), + }, + ); + middle_store.set_node_attachment( + middle_node, + Some(crate::AttachmentValue::Descend(node.warp_id)), + ); + let mut child_store = crate::GraphStore::new(node.warp_id); + child_store.insert_node( + child_root, + crate::NodeRecord { + ty: crate::make_type_id("operation-wal-descendant-root-node"), + }, + ); - assert_eq!( - validate_echo_operation_frontier_root(&transaction, &expected_frontiers), - Err(TrustedRuntimeWalError::EchoOperationFrontierMismatch { - transaction_id: transaction_id.as_hash(), - expected, - actual, - }) + let mut warp_state = crate::WarpState::new(); + warp_state.upsert_instance( + crate::WarpInstance { + warp_id: parent_warp, + root_node: parent_node, + parent: None, + }, + parent_store, + ); + warp_state.upsert_instance( + crate::WarpInstance { + warp_id: middle_warp, + root_node: middle_node, + parent: Some(portal), + }, + middle_store, + ); + warp_state.upsert_instance( + crate::WarpInstance { + warp_id: node.warp_id, + root_node: child_root, + parent: Some(middle_portal), + }, + child_store, ); + crate::WorldlineState::new( + warp_state, + crate::NodeKey { + warp_id: parent_warp, + local_id: parent_node, + }, + ) + .expect("the recovery parent-state fixture is lawful") } #[test] - fn operation_recovery_requires_tick_scope_to_bind_the_patch_target() { - let node = crate::NodeKey { - warp_id: crate::make_warp_id("operation-recovery-scope"), - local_id: crate::make_node_id("operation-recovery-target"), - }; - let attachment_slot = crate::AttachmentKey::node_alpha(node); - let rule_id = [21; 32]; - let attachment_type = crate::make_type_id("operation-recovery-attachment"); - let program = crate::EchoOperationProgramV1::anchored_node_attachment_compare_and_set( - crate::make_type_id("operation-recovery-node"), - attachment_type, - 7, + fn executable_operation_index_preserves_legacy_root_without_action_outcomes() { + let operation_coordinate = "echo.test.LegacyRecoveryIndex.v1"; + let authority_profile_identity = [0x17; 32]; + let budget = crate::EchoOperationBudgetV1::new(7, 1_024, 1_024); + let package = crate::ExecutableOperationPackageV1::new( + operation_coordinate, + "echo.test.LegacyRecoveryIndex.Obstruction.v1", + crate::EchoOperationSemanticClosureV1::new( + [0x10; 32], + [0x11; 32], + [0x12; 32], + [0x13; 32], + "echo.test.legacy-index-schema/v1", + [0x14; 32], + "echo.test.legacy-index-lawpack/v1", + [0x15; 32], + ), + crate::echo_operation_target_profile_identity_v1(), + authority_profile_identity, + budget, + crate::EchoOperationProgramV1::anchored_node_attachment_compare_and_set( + crate::make_type_id("legacy-index-node"), + crate::make_type_id("legacy-index-attachment"), + 128, + ), ); - let runtime_patch = crate::WarpTickPatchV1::new( - 7, - rule_id, - crate::TickCommitStatus::Committed, - vec![ - crate::SlotId::Node(node), - crate::SlotId::Attachment(attachment_slot), - ], - vec![crate::SlotId::Attachment(attachment_slot)], - vec![crate::WarpOp::SetAttachment { - key: attachment_slot, - value: Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( - attachment_type, - Bytes::from_static(b"updated"), - ))), - }], - ); - let patch = crate::WorldlineTickPatchV1 { - header: crate::WorldlineTickHeaderV1 { - commit_global_tick: GlobalTick::from_raw(1), - policy_id: runtime_patch.policy_id(), - rule_pack_id: runtime_patch.rule_pack_id(), - plan_digest: [22; 32], - decision_digest: [23; 32], - rewrites_digest: [24; 32], - }, - warp_id: node.warp_id, - ops: runtime_patch.ops().to_vec(), - in_slots: runtime_patch.in_slots().to_vec(), - out_slots: runtime_patch.out_slots().to_vec(), - patch_digest: runtime_patch.digest(), - }; - let receipt = |scope, scope_hash| { - crate::TickReceipt::new( - crate::TxId::from_raw(1), - vec![crate::TickReceiptEntry { - rule_id, - scope_hash, - scope, - disposition: crate::TickReceiptDisposition::Applied, - }], - vec![Vec::new()], - ) - }; - let valid = receipt(node, crate::scope_hash(&rule_id, &node)); - assert!(operation_tick_binds_patch_v1( - &valid, &patch, rule_id, &program, - )); - - let wrong_scope = crate::NodeKey { - warp_id: node.warp_id, - local_id: crate::make_node_id("operation-recovery-wrong-scope"), - }; - let self_consistent_wrong_scope = - receipt(wrong_scope, crate::scope_hash(&rule_id, &wrong_scope)); - assert!(!operation_tick_binds_patch_v1( - &self_consistent_wrong_scope, - &patch, - rule_id, - &program, - )); + let package_bytes = package + .to_canonical_bytes() + .expect("the legacy-index package is canonical"); + let package_id = crate::echo_operation_package_id_v1(&package_bytes); + let admitted = admit_package_v1( + &crate::EchoOperationAdmissionPolicyV1::exact( + package_id, + operation_coordinate, + authority_profile_identity, + budget, + ), + package_bytes, + ) + .expect("the legacy-index package is admitted"); + let installed = + installed_from_admitted(admitted).expect("the legacy-index package installs"); - let forged_scope_hash = receipt(node, [25; 32]); - assert!(!operation_tick_binds_patch_v1( - &forged_scope_hash, - &patch, - rule_id, - &program, - )); + assert_eq!( + recovered_echo_operation_index_root([0x20; 32], &[installed], &[], &[]) + .expect("the legacy index root is computable"), + [ + 0xf9, 0xcf, 0x53, 0xf2, 0xad, 0xaf, 0x93, 0x1a, 0xa0, 0xe6, 0x06, 0xc6, 0x5a, 0x91, + 0x6f, 0xfa, 0x0b, 0x4f, 0xf5, 0x50, 0x66, 0x32, 0x8c, 0x1c, 0x48, 0xf7, 0x22, 0xcd, + 0x1e, 0x0d, 0x40, 0x2e, + ] + ); } #[test] - fn operation_recovery_corroborates_non_genesis_parent_basis_material() { - let head_key = test_head_key(); - let parent_state_root = [31; 32]; - let parent_commit = [32; 32]; - let parent_global_tick = GlobalTick::from_raw(7); - let parent = ProvenanceEntry { - worldline_id: head_key.worldline_id, - worldline_tick: WorldlineTick::ZERO, - commit_global_tick: parent_global_tick, - head_key: Some(head_key), - parents: Vec::new(), - event_kind: crate::ProvenanceEventKind::LocalCommit, - expected: crate::HashTriplet { - state_root: parent_state_root, - patch_digest: [33; 32], - commit_hash: parent_commit, - }, - patch: None, - tick_receipt: None, - outputs: Vec::new(), - atom_writes: Vec::new(), - }; - let child = ProvenanceEntry { - worldline_id: head_key.worldline_id, - worldline_tick: WorldlineTick::from_raw(1), - commit_global_tick: GlobalTick::from_raw(8), - head_key: Some(head_key), - parents: vec![parent.as_ref()], - event_kind: crate::ProvenanceEventKind::LocalCommit, - expected: crate::HashTriplet { - state_root: [34; 32], - patch_digest: [35; 32], - commit_hash: [36; 32], - }, - patch: None, - tick_receipt: None, - outputs: Vec::new(), - atom_writes: Vec::new(), + fn creation_wal_scope_accepts_descendants_and_rejects_mutated_shapes() { + let node = crate::NodeKey { + warp_id: crate::make_warp_id("operation-wal-descendant"), + local_id: crate::make_node_id("operation-wal-created-node"), }; - let basis = EchoOperationEvaluationBasisV1::new( - head_key, - WorldlineTick::from_raw(1), - Some(parent_global_tick), - parent_state_root, - parent_commit, - EchoOperationApplicationBasisV1::new([37; 32], [38; 32]), + let installed_program = + crate::EchoOperationProgramV1::anchored_node_attachment_create_if_absent( + crate::make_type_id("operation-wal-created-node"), + crate::make_type_id("operation-wal-created-attachment"), + 7, + ); + let update_program = + crate::EchoOperationProgramV1::anchored_node_attachment_compare_and_set( + crate::make_type_id("operation-wal-created-node"), + crate::make_type_id("operation-wal-created-attachment"), + 7, + ); + let patch = creation_scope_patch(node); + let parent_state = creation_scope_parent_state(node); + assert_eq!( + operation_patch_scope_v1(&patch, &installed_program), + Some(node), + "the parent worldline root must not erase descendant operation scope" ); - let parent_coordinate = (parent.worldline_id, parent.worldline_tick); - assert!( - validate_operation_receipt_parent_material(basis, &child, &BTreeMap::new()).is_err() + assert_eq!( + operation_patch_scope_in_parent_state_v1(&patch, &installed_program, &parent_state), + Some(node), + "activation recovery must corroborate the exact retained portal chain" ); - let provenance = BTreeMap::from([(parent_coordinate, &parent)]); - validate_operation_receipt_parent_material(basis, &child, &provenance) - .expect("the exact retained parent corroborates every causal basis field"); - assert!(evaluation_basis_matches_recovered_coordinate( - basis, - &provenance + assert!(operation_application_basis_matches_scope_v1( + &installed_program, + node, + crate::echo_operation_anchored_node_absent_application_basis_v1(node), )); + assert!( + !operation_application_basis_matches_scope_v1( + &installed_program, + node, + EchoOperationApplicationBasisV1::new([0x91; 32], [0x92; 32]), + ), + "creation recovery must bind the receipt to the canonical absence proposition" + ); - let mut wrong_root = parent.clone(); - wrong_root.expected.state_root = [39; 32]; - let provenance = BTreeMap::from([(parent_coordinate, &wrong_root)]); - assert!(validate_operation_receipt_parent_material(basis, &child, &provenance).is_err()); - assert!(!evaluation_basis_matches_recovered_coordinate( - basis, - &provenance - )); + let mut node_occupied_parent = parent_state.clone(); + node_occupied_parent + .warp_state + .store_mut(&node.warp_id) + .expect("the fixture retains its descendant store") + .insert_node( + node.local_id, + crate::NodeRecord { + ty: crate::make_type_id("operation-wal-existing-node"), + }, + ); + assert_eq!( + operation_patch_scope_in_parent_state_v1( + &patch, + &installed_program, + &node_occupied_parent + ), + None, + "creation recovery must reject an occupied node even when its attachment is absent" + ); - let mut wrong_global_tick = parent.clone(); - wrong_global_tick.commit_global_tick = GlobalTick::from_raw(9); - let provenance = BTreeMap::from([(parent_coordinate, &wrong_global_tick)]); - assert!(validate_operation_receipt_parent_material(basis, &child, &provenance).is_err()); - assert!(!evaluation_basis_matches_recovered_coordinate( - basis, - &provenance - )); - } + let mut attachment_occupied_parent = parent_state.clone(); + attachment_occupied_parent + .warp_state + .store_mut(&node.warp_id) + .expect("the fixture retains its descendant store") + .set_node_attachment( + node.local_id, + Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( + crate::make_type_id("operation-wal-existing-attachment"), + Bytes::from_static(b"occupied"), + ))), + ); + assert_eq!( + operation_patch_scope_in_parent_state_v1( + &patch, + &installed_program, + &attachment_occupied_parent + ), + None, + "creation recovery must reject an orphan attachment even when its node is absent" + ); - fn test_correlation(receipt_digest: Hash) -> ReceiptCorrelationRecord { - let head_key = test_head_key(); - ReceiptCorrelationRecord { - ticketed_ingress_id: [1; 32], - submission_id: [2; 32], - ticket_digest: [3; 32], - ingress_id: [4; 32], - head_key, - contract: None, - commit_global_tick: GlobalTick::from_raw(1), - worldline_tick_after: WorldlineTick::from_raw(1), - tick_receipt_digest: receipt_digest, - commit_hash: [5; 32], - causal_receipt_ref: CausalTickReceiptRef { - worldline_id: head_key.worldline_id, - worldline_tick_after: WorldlineTick::from_raw(1), - commit_global_tick: GlobalTick::from_raw(1), - commit_hash: [5; 32], - submission_id: [2; 32], - ticket_digest: [3; 32], - receipt_content_digest: receipt_digest, - }, - causal_parent_receipts: Vec::new(), - } - } - - fn test_causal_anchor_claim(basis_frontier: CausalFrontierRef) -> CausalAnchorClaim { - CausalAnchorClaim::from_admission_request(CausalAnchorAdmissionRequest { - schema_version: crate::CAUSAL_ANCHOR_SCHEMA_VERSION, - subject: crate::CausalAnchorSubject::new( - "jedit", - "BufferWorldline", - "worldline:recovery-basis", + let mut fully_occupied_parent = node_occupied_parent; + fully_occupied_parent + .warp_state + .store_mut(&node.warp_id) + .expect("the fixture retains its descendant store") + .set_node_attachment( + node.local_id, + Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( + crate::make_type_id("operation-wal-existing-attachment"), + Bytes::from_static(b"occupied"), + ))), + ); + assert_eq!( + operation_patch_scope_in_parent_state_v1( + &patch, + &installed_program, + &fully_occupied_parent ), - basis_frontier, - retained_roots: vec![crate::CausalAnchorRoot::AppSubjectRoot { - app_id: "jedit".to_owned(), - subject_kind: "RopeHead".to_owned(), - id: "head:recovery-basis".to_owned(), - role: crate::CausalAnchorAppRootRole::Authority, - }], - materialization_roots: Vec::new(), - purpose: crate::CausalAnchorPurpose::Recovery, - }) - .expect("test causal-anchor claim should be valid") - } - - #[test] - fn runtime_wal_recovery_rejects_anchor_claimed_at_unrelated_basis() { - let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let recovered_basis = wal.current_causal_anchor_basis(); - let claimed_basis = CausalFrontierRef::from_digest([0x5a; 32]); - assert_ne!(claimed_basis, recovered_basis); - wal.record_causal_anchor_admission(test_causal_anchor_claim(claimed_basis), [0x6b; 32]) - .expect("internal test setup should append malformed historical evidence"); - - let report = wal - .store - .recover_read_only() - .expect("malformed committed transaction should remain physically readable"); - let cursor_error = TrustedRuntimeWalCursor::from_recovery(&report) - .expect_err("writer recovery must reject the unrelated anchor basis"); - assert!(matches!( - cursor_error, - TrustedRuntimeWalError::CausalAnchorBasisMismatch { - claimed, - recovered, - .. - } if claimed == claimed_basis.frontier_digest - && recovered == recovered_basis.frontier_digest - )); - - let reading_error = wal - .recover_read_only() - .expect_err("recovery must reject an anchor claim at an unrelated basis"); - assert!(matches!( - reading_error, - TrustedRuntimeWalError::CausalAnchorBasisMismatch { - claimed, - recovered, - .. - } if claimed == claimed_basis.frontier_digest - && recovered == recovered_basis.frontier_digest - )); - } - - #[test] - fn runtime_wal_recovery_rejects_unattested_anchor_frontier_transition() { - let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let claim = test_causal_anchor_claim(wal.current_causal_anchor_basis()); - let support_policy_digest = [0x7c; 32]; - let transaction_id = WalTransactionId::from_hash(causal_anchor_transaction_digest( - wal.causal_anchor_frontier_digest, - claim.claim_digest(), - &support_policy_digest, - )); - let transaction = build_causal_anchor_admission_transaction( - wal.causal_anchor_builder(transaction_id), - claim, - support_policy_digest, - vec![AffectedFrontier { - kind: AffectedFrontierKind::CausalAnchorIndex, - before_digest: [0x8d; 32], - after_digest: [0x9e; 32], - }], - ) - .expect("internal test setup should build malformed frontier evidence"); - wal.append_transaction(transaction) - .expect("malformed historical evidence should remain physically appendable"); + None, + "creation recovery must reject a fully occupied target" + ); - let report = wal - .store - .recover_read_only() - .expect("malformed committed transaction should remain physically readable"); - let cursor_error = TrustedRuntimeWalCursor::from_recovery(&report) - .expect_err("writer recovery must reject an unattested anchor frontier transition"); - assert!(matches!( - cursor_error, - TrustedRuntimeWalError::CausalAnchorFrontierMismatch { .. } - )); + assert_eq!( + operation_patch_scope_v1(&patch, &update_program), + None, + "the update profile must reject the creation program's two-op patch" + ); - let reading_error = wal - .recover_read_only() - .expect_err("read-only recovery must reject an unattested anchor frontier transition"); - assert!(matches!( - reading_error, - TrustedRuntimeWalError::CausalAnchorFrontierMismatch { .. } - )); - } + let mut missing_portal_read = patch.clone(); + missing_portal_read.in_slots.retain(|slot| { + matches!( + slot, + crate::SlotId::Node(candidate) if candidate == &node + ) || matches!( + slot, + crate::SlotId::Attachment(candidate) + if candidate == &crate::AttachmentKey::node_alpha(node) + ) + }); + assert_eq!( + operation_patch_scope_v1(&missing_portal_read, &installed_program), + None, + "a descendant operation must retain the root portal dependency" + ); - #[test] - fn causal_anchor_recovery_traversal_drives_cursor_and_witnessed_history() { - let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let claim = test_causal_anchor_claim(wal.current_causal_anchor_basis()); - wal.record_causal_anchor_admission(claim, [0x4f; 32]) - .expect("test causal-anchor admission should commit"); - let report = wal - .store - .recover_read_only() - .expect("committed causal-anchor admission should recover"); + let mut duplicate_portal_read = patch.clone(); + duplicate_portal_read + .in_slots + .push(duplicate_portal_read.in_slots[2]); + assert_eq!( + operation_patch_scope_v1(&duplicate_portal_read, &installed_program), + None, + "a descendant operation must reject duplicate portal dependencies" + ); - let traversal = traverse_recovered_causal_anchors(&report) - .expect("shared causal-anchor traversal should validate recovery"); - let cursor = TrustedRuntimeWalCursor::from_recovery(&report) - .expect("writer cursor should consume the shared traversal"); - let (history, frontiers) = recover_witnessed_causal_anchor_history(&report) - .expect("read-only history should consume the shared traversal"); + let mut substituted_root_portal = patch.clone(); + substituted_root_portal.in_slots[2] = + crate::SlotId::Attachment(crate::AttachmentKey::node_alpha(crate::NodeKey { + warp_id: substituted_root_portal.warp_id, + local_id: crate::make_node_id("operation-wal-unrelated-root-portal"), + })); + assert_eq!( + operation_patch_scope_in_parent_state_v1( + &substituted_root_portal, + &installed_program, + &parent_state + ), + None, + "recovery must reject an unrelated root-owned portal substituted for the exact descent chain" + ); - assert_eq!(history.len(), 1); - assert_eq!(history.len(), traversal.history.len()); - assert_eq!(frontiers, traversal.causal_history_frontiers); + let mut missing_middle_portal = patch.clone(); + missing_middle_portal.in_slots.remove(3); assert_eq!( - cursor.causal_history_frontier_digest, - traversal - .causal_history_frontiers - .last() - .expect("traversal must retain its terminal frontier") - .frontier_digest + operation_patch_scope_v1(&missing_middle_portal, &installed_program), + Some(node), + "the byte-shape layer alone cannot infer a portal's Descend target" ); assert_eq!( - cursor.causal_anchor_frontier_digest, - traversal.causal_anchor_frontier_digest + operation_patch_scope_in_parent_state_v1( + &missing_middle_portal, + &installed_program, + &parent_state + ), + None, + "activation recovery must reject a patch that omits an intermediate portal" ); - } - #[test] - fn causal_anchor_claim_lookup_uses_projection_without_wal_replay() { - let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let claim = test_causal_anchor_claim(wal.current_causal_anchor_basis()); - let claim_digest = *claim.claim_digest(); - let admitted = wal - .record_causal_anchor_admission(claim, [0x5f; 32]) - .expect("test causal-anchor admission should commit"); - wal.recover_read_only_call_count.set(0); + let mut reversed = patch.clone(); + reversed.ops.reverse(); + assert_eq!( + operation_patch_scope_v1(&reversed, &installed_program), + None + ); - let recovered = wal - .causal_anchor_by_claim(&claim_digest, None) - .expect("claim lookup should use a validated projection"); + let mut missing_node_write = patch.clone(); + missing_node_write.ops.remove(0); + assert_eq!( + operation_patch_scope_v1(&missing_node_write, &installed_program), + None + ); - assert_eq!(recovered, Some(admitted)); + let mut attachment_only_output = patch.clone(); + attachment_only_output.out_slots.remove(0); assert_eq!( - wal.recover_read_only_call_count.get(), - 0, - "idempotency lookup must not replay committed WAL history" + operation_patch_scope_v1(&attachment_only_output, &installed_program), + None ); - } - #[test] - fn runtime_wal_tick_decision_rejects_pending_observation_as_invariant() { - let err = wal_tick_decision_from_observation( - IntentOutcomeObservation::Pending { - submission_id: [2; 32], - submission_generation: IngressSubmissionGeneration::from_raw(1), - ticketed_ingress_id: Some([6; 32]), - }, - [7; 32], - ) - .expect_err("pending outcome cannot produce scheduler tick WAL evidence"); - - assert!(matches!( - err, - TrustedRuntimeWalError::TickOutcomeUnavailable { - submission_id, - receipt_digest, - } if submission_id == [2; 32] && receipt_digest == [7; 32] - )); - } - - #[test] - fn runtime_wal_tick_decision_rejects_receipt_digest_mismatch_as_invariant() { - let err = wal_tick_decision_from_observation( - IntentOutcomeObservation::Decided { - correlation: Box::new(test_correlation([8; 32])), - decision: IntentOutcomeDecision::Applied { - receipt_entry_index: 0, - rule_id: [9; 32], - }, - }, - [7; 32], - ) - .expect_err("mismatched receipt digest cannot produce scheduler tick WAL evidence"); - - assert!(matches!( - err, - TrustedRuntimeWalError::TickReceiptDigestMismatch { - expected_receipt_digest, - observed_receipt_digest, - } if expected_receipt_digest == [7; 32] && observed_receipt_digest == [8; 32] - )); - } - - #[test] - fn runtime_wal_tick_decision_rejects_missing_receipt_entry_as_invariant() { - let err = wal_tick_decision_from_observation( - IntentOutcomeObservation::Decided { - correlation: Box::new(test_correlation([7; 32])), - decision: IntentOutcomeDecision::NoMatchingReceiptEntry { - tick_receipt_digest: [7; 32], - }, - }, - [7; 32], - ) - .expect_err("missing receipt entries cannot produce scheduler tick WAL evidence"); - - assert!(matches!( - err, - TrustedRuntimeWalError::TickOutcomeUnavailable { - submission_id, - receipt_digest, - } if submission_id == [2; 32] && receipt_digest == [7; 32] - )); - } - - #[test] - fn runtime_wal_tick_decision_maps_matching_outcome() { - let decision = wal_tick_decision_from_observation( - IntentOutcomeObservation::Decided { - correlation: Box::new(test_correlation([7; 32])), - decision: IntentOutcomeDecision::Applied { - receipt_entry_index: 0, - rule_id: [9; 32], - }, - }, - [7; 32], - ) - .expect("matching outcome should map to a WAL tick decision"); - - assert_eq!(decision, WalTickDecision::Applied); - } + let mut mismatched_attachment = patch; + let other_node = crate::NodeKey { + warp_id: node.warp_id, + local_id: crate::make_node_id("operation-wal-other-node"), + }; + let crate::WarpOp::SetAttachment { key, .. } = &mut mismatched_attachment.ops[1] else { + panic!("fixture has the canonical attachment operation"); + }; + *key = crate::AttachmentKey::node_alpha(other_node); + assert_eq!( + operation_patch_scope_v1(&mismatched_attachment, &installed_program), + None + ); - #[test] - fn runtime_wal_recovery_marks_legacy_acceptance_without_envelope_material() { - let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let handle = IntentSubmissionHandle { - ingress_id: [11; 32], - head_key: test_head_key(), - submission_id: [12; 32], - submission_generation: IngressSubmissionGeneration::from_raw(1), - duplicate: false, + let mut wrong_node_type = creation_scope_patch(node); + let crate::WarpOp::UpsertNode { record, .. } = &mut wrong_node_type.ops[0] else { + panic!("fixture has the canonical node operation"); }; - let record = SubmissionAcceptanceRecord { - submission_id: handle.submission_id, - canonical_envelope_digest: handle.ingress_id, - idempotency_key_digest: None, - acceptance_evidence_digest: acceptance_evidence_digest(handle), + record.ty = crate::make_type_id("operation-wal-wrong-node-type"); + assert_eq!( + operation_patch_scope_v1(&wrong_node_type, &installed_program), + None, + "recovery must reject a node type the installed program cannot emit" + ); + + let mut wrong_attachment_type = creation_scope_patch(node); + let crate::WarpOp::SetAttachment { value, .. } = &mut wrong_attachment_type.ops[1] else { + panic!("fixture has the canonical attachment operation"); }; - let transaction = crate::causal_wal::build_submission_acceptance_transaction( - wal.builder( - WalTransactionKind::SubmissionIntake, - WalAppendAuthority::SubmissionIntake, - WalTransactionId::from_hash(submission_transaction_digest(handle, record)), - ), - record, - vec![AffectedFrontier { - kind: AffectedFrontierKind::SubmissionQueue, - before_digest: wal.submission_frontier_digest, - after_digest: submission_frontier_digest(wal.submission_frontier_digest, record), - }], - ) - .expect("legacy acceptance transaction should build"); - wal.append_transaction(transaction) - .expect("legacy acceptance transaction should commit"); + *value = Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( + crate::make_type_id("operation-wal-wrong-attachment-type"), + Bytes::from_static(b"created"), + ))); + assert_eq!( + operation_patch_scope_v1(&wrong_attachment_type, &installed_program), + None, + "recovery must reject an attachment type the installed program cannot emit" + ); - let recovery = wal - .recover_read_only() - .expect("legacy acceptance should remain inspectable"); + let mut descended_attachment = creation_scope_patch(node); + let crate::WarpOp::SetAttachment { value, .. } = &mut descended_attachment.ops[1] else { + panic!("fixture has the canonical attachment operation"); + }; + *value = Some(crate::AttachmentValue::Descend(crate::make_warp_id( + "operation-wal-hidden-descendant", + ))); + assert_eq!( + operation_patch_scope_v1(&descended_attachment, &installed_program), + None, + "recovery must reject attachment algebras the installed program cannot emit" + ); - assert!(recovery.witnessed_submissions.is_empty()); + let mut oversized_attachment = creation_scope_patch(node); + let crate::WarpOp::SetAttachment { value, .. } = &mut oversized_attachment.ops[1] else { + panic!("fixture has the canonical attachment operation"); + }; + *value = Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( + crate::make_type_id("operation-wal-created-attachment"), + Bytes::from_static(b"too-long"), + ))); assert_eq!( - recovery.missing_submission_envelopes, - vec![handle.submission_id] + operation_patch_scope_v1(&oversized_attachment, &installed_program), + None, + "recovery must enforce the installed program's replacement bound" ); - assert_eq!(recovery.certificate.obstruction_count, 1); } #[test] - fn runtime_wal_recovery_marks_legacy_tick_without_replayable_state_material() { - let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let receipt = TickReceiptRecord { - receipt_ref: CausalTickReceiptRef { - worldline_id: WorldlineId::from_bytes([20; 32]), - worldline_tick_after: WorldlineTick::from_raw(1), - commit_global_tick: GlobalTick::from_raw(1), - commit_hash: [26; 32], - submission_id: [21; 32], - ticket_digest: [22; 32], - receipt_content_digest: [23; 32], + fn operation_recovery_rejects_uncorroborated_frontier_root() { + let expected_frontiers = [AffectedFrontier { + kind: AffectedFrontierKind::ExecutableOperationCatalog, + before_digest: [1; 32], + after_digest: [2; 32], + }]; + let expected = affected_frontiers_root(&expected_frontiers); + let actual = [3; 32]; + let transaction_id = WalTransactionId::from_hash([4; 32]); + let transaction = crate::causal_wal::WalRecoveredTransaction { + commit: WalTransactionCommit { + writer_epoch: WriterEpochId::from_hash([5; 32]), + transaction_id, + transaction_kind: WalTransactionKind::ExecutableOperationInstallation, + first_lsn: Lsn::from_raw(0), + last_lsn: Lsn::from_raw(0), + record_count: 1, + records_root: [6; 32], + affected_frontiers_root: actual, + previous_committed_transaction_digest: [7; 32], + durability_mode: WalDurabilityMode::StrictFilesystem, + schema_version: 1, + commit_digest: [8; 32], }, - decision: WalTickDecision::Applied, - }; - let correlation = WalReceiptCorrelationRecord { - receipt_ref: receipt.receipt_ref, - causal_parent_receipts: Vec::new(), + frames: Vec::new(), }; - let legacy_state_delta_digest = [24; 32]; - let transaction = crate::causal_wal::build_tick_transaction( - wal.builder( - WalTransactionKind::SchedulerTick, - WalAppendAuthority::TrustedScheduler, - WalTransactionId::from_hash([25; 32]), - ), - receipt, - correlation.clone(), - legacy_state_delta_digest, - vec![ - AffectedFrontier { - kind: AffectedFrontierKind::ReceiptIndex, - before_digest: wal.receipt_frontier_digest, - after_digest: receipt_frontier_digest( - wal.receipt_frontier_digest, - receipt, - &correlation, - ), - }, - AffectedFrontier { - kind: AffectedFrontierKind::RuntimeState, - before_digest: wal.runtime_state_frontier_digest, - after_digest: trusted_runtime_wal_digest("legacy-runtime-frontier"), - }, - ], - ) - .expect("legacy tick transaction should build"); - wal.append_transaction(transaction) - .expect("legacy tick transaction should commit"); - - let recovery = wal - .recover_read_only() - .expect("legacy tick should remain inspectable"); - assert!(recovery.provenance_entries.is_empty()); assert_eq!( - recovery.missing_runtime_state_deltas, - vec![receipt.receipt_ref.identity_digest()] + validate_echo_operation_frontier_root(&transaction, &expected_frontiers), + Err(TrustedRuntimeWalError::EchoOperationFrontierMismatch { + transaction_id: transaction_id.as_hash(), + expected, + actual, + }) ); - assert_eq!(recovery.certificate.obstruction_count, 1); } #[test] - fn runtime_wal_submission_record_selection_rejects_duplicate_singular_evidence() { - let wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let head_key = test_head_key(); - let envelope = IngressEnvelope::local_intent( - IngressTarget::ExactHead { key: head_key }, - crate::make_intent_kind("runtime-wal-duplicate-submission-record"), - b"duplicate-submission-record".to_vec(), + fn operation_recovery_requires_tick_scope_to_bind_the_patch_target() { + let node = crate::NodeKey { + warp_id: crate::make_warp_id("operation-recovery-scope"), + local_id: crate::make_node_id("operation-recovery-target"), + }; + let attachment_slot = crate::AttachmentKey::node_alpha(node); + let rule_id = [21; 32]; + let attachment_type = crate::make_type_id("operation-recovery-attachment"); + let program = crate::EchoOperationProgramV1::anchored_node_attachment_compare_and_set( + crate::make_type_id("operation-recovery-node"), + attachment_type, + 7, ); - let handle = IntentSubmissionHandle { - ingress_id: envelope.ingress_id(), - head_key, - submission_id: [21; 32], - submission_generation: IngressSubmissionGeneration::from_raw(1), - duplicate: false, + let runtime_patch = crate::WarpTickPatchV1::new( + 7, + rule_id, + crate::TickCommitStatus::Committed, + vec![ + crate::SlotId::Node(node), + crate::SlotId::Attachment(attachment_slot), + ], + vec![crate::SlotId::Attachment(attachment_slot)], + vec![crate::WarpOp::SetAttachment { + key: attachment_slot, + value: Some(crate::AttachmentValue::Atom(crate::AtomPayload::new( + attachment_type, + Bytes::from_static(b"updated"), + ))), + }], + ); + let patch = crate::WorldlineTickPatchV1 { + header: crate::WorldlineTickHeaderV1 { + commit_global_tick: GlobalTick::from_raw(1), + policy_id: runtime_patch.policy_id(), + rule_pack_id: runtime_patch.rule_pack_id(), + plan_digest: [22; 32], + decision_digest: [23; 32], + rewrites_digest: [24; 32], + }, + warp_id: node.warp_id, + ops: runtime_patch.ops().to_vec(), + in_slots: runtime_patch.in_slots().to_vec(), + out_slots: runtime_patch.out_slots().to_vec(), + patch_digest: runtime_patch.digest(), }; - let acceptance = SubmissionAcceptanceRecord { - submission_id: handle.submission_id, - canonical_envelope_digest: handle.ingress_id, - idempotency_key_digest: None, - acceptance_evidence_digest: acceptance_evidence_digest(handle), + let receipt = |scope, scope_hash| { + crate::TickReceipt::new( + crate::TxId::from_raw(1), + vec![crate::TickReceiptEntry { + rule_id, + scope_hash, + scope, + disposition: crate::TickReceiptDisposition::Applied, + }], + vec![Vec::new()], + ) }; - let retained_envelope = submission_envelope_record(&envelope, handle); - - for (index, duplicate_kind) in [ - WalRecordKind::SubmissionAcceptedRecorded, - WalRecordKind::SubmissionEnvelopeRetained, - ] - .into_iter() - .enumerate() - { - let mut transaction_id = [22; 32]; - transaction_id[0] = u8::try_from(index).expect("fixture index must fit in u8"); - let mut builder = wal.builder( - WalTransactionKind::SubmissionIntake, - WalAppendAuthority::SubmissionIntake, - WalTransactionId::from_hash(transaction_id), - ); - builder - .push_record( - WalRecordKind::SubmissionAcceptedRecorded, - acceptance.to_payload_bytes(), - ) - .expect("fixture acceptance must append"); - if duplicate_kind == WalRecordKind::SubmissionAcceptedRecorded { - builder - .push_record( - WalRecordKind::SubmissionAcceptedRecorded, - acceptance.to_payload_bytes(), - ) - .expect("duplicate acceptance must append"); - } - builder - .push_record( - WalRecordKind::SubmissionEnvelopeRetained, - retained_envelope.to_payload_bytes(), - ) - .expect("fixture retained envelope must append"); - if duplicate_kind == WalRecordKind::SubmissionEnvelopeRetained { - builder - .push_record( - WalRecordKind::SubmissionEnvelopeRetained, - retained_envelope.to_payload_bytes(), - ) - .expect("duplicate retained envelope must append"); - } - let transaction = builder - .commit(Vec::new()) - .expect("duplicate-kind transaction must commit structurally"); - transaction - .validate() - .expect("duplicate-kind transaction must remain structurally valid"); - let recovered = crate::causal_wal::WalRecoveredTransaction { - commit: transaction.commit, - frames: transaction.frames, - }; + let valid = receipt(node, crate::scope_hash(&rule_id, &node)); + assert!(operation_tick_binds_patch_v1( + &valid, &patch, rule_id, &program, + )); - let result = if duplicate_kind == WalRecordKind::SubmissionAcceptedRecorded { - submission_acceptance_record_from_transaction(&recovered).map(|_| ()) - } else { - let report = RecoveryScanReport { - transactions: vec![recovered], - tail_posture: crate::causal_wal::RecoveryTailPosture::Clean, - }; - let submissions = - recover_submission_index(&report).expect("canonical acceptance should recover"); - recover_witnessed_submission_material(&report, &submissions).map(|_| ()) - }; + let wrong_scope = crate::NodeKey { + warp_id: node.warp_id, + local_id: crate::make_node_id("operation-recovery-wrong-scope"), + }; + let self_consistent_wrong_scope = + receipt(wrong_scope, crate::scope_hash(&rule_id, &wrong_scope)); + assert!(!operation_tick_binds_patch_v1( + &self_consistent_wrong_scope, + &patch, + rule_id, + &program, + )); - assert!(matches!( - result, - Err(TrustedRuntimeWalError::Recovery(WalRecoveryError::Index( - WalRecoveryIndexError::Decode(WalDecodeError::InvalidEmbeddedFrame) - ))) - )); - } + let forged_scope_hash = receipt(node, [25; 32]); + assert!(!operation_tick_binds_patch_v1( + &forged_scope_hash, + &patch, + rule_id, + &program, + )); } #[test] - fn runtime_wal_tick_record_selection_rejects_duplicate_singular_evidence() { - let wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let receipt = TickReceiptRecord { - receipt_ref: CausalTickReceiptRef { - worldline_id: WorldlineId::from_bytes([30; 32]), - worldline_tick_after: WorldlineTick::from_raw(1), - commit_global_tick: GlobalTick::from_raw(1), - commit_hash: [31; 32], - submission_id: [32; 32], - ticket_digest: [33; 32], - receipt_content_digest: [34; 32], + fn operation_recovery_corroborates_non_genesis_parent_basis_material() { + let head_key = test_head_key(); + let parent_state_root = [31; 32]; + let parent_commit = [32; 32]; + let parent_global_tick = GlobalTick::from_raw(7); + let parent = ProvenanceEntry { + worldline_id: head_key.worldline_id, + worldline_tick: WorldlineTick::ZERO, + commit_global_tick: parent_global_tick, + head_key: Some(head_key), + parents: Vec::new(), + event_kind: crate::ProvenanceEventKind::LocalCommit, + expected: crate::HashTriplet { + state_root: parent_state_root, + patch_digest: [33; 32], + commit_hash: parent_commit, }, - decision: WalTickDecision::Applied, + patch: None, + tick_receipt: None, + outputs: Vec::new(), + atom_writes: Vec::new(), }; - let correlation = WalReceiptCorrelationRecord { - receipt_ref: receipt.receipt_ref, - causal_parent_receipts: Vec::new(), + let child = ProvenanceEntry { + worldline_id: head_key.worldline_id, + worldline_tick: WorldlineTick::from_raw(1), + commit_global_tick: GlobalTick::from_raw(8), + head_key: Some(head_key), + parents: vec![parent.as_ref()], + event_kind: crate::ProvenanceEventKind::LocalCommit, + expected: crate::HashTriplet { + state_root: [34; 32], + patch_digest: [35; 32], + commit_hash: [36; 32], + }, + patch: None, + tick_receipt: None, + outputs: Vec::new(), + atom_writes: Vec::new(), }; - let receipt_bytes = receipt.to_payload_bytes(); - let correlation_bytes = correlation.to_payload_bytes(); + let basis = EchoOperationEvaluationBasisV1::new( + head_key, + WorldlineTick::from_raw(1), + Some(parent_global_tick), + parent_state_root, + parent_commit, + EchoOperationApplicationBasisV1::new([37; 32], [38; 32]), + ); + let parent_coordinate = (parent.worldline_id, parent.worldline_tick); + assert!( + validate_operation_receipt_parent_material(basis, &child, &BTreeMap::new()).is_err() + ); + let provenance = BTreeMap::from([(parent_coordinate, &parent)]); + validate_operation_receipt_parent_material(basis, &child, &provenance) + .expect("the exact retained parent corroborates every causal basis field"); + assert!(evaluation_basis_matches_recovered_coordinate( + basis, + &provenance + )); - for (index, duplicate_kind) in [ - WalRecordKind::TickReceiptRecorded, - WalRecordKind::ReceiptCorrelationRecorded, - WalRecordKind::RuntimeStateDeltaRecorded, - ] - .into_iter() - .enumerate() - { - let mut transaction_id = [35; 32]; - transaction_id[0] = u8::try_from(index).expect("fixture index must fit in u8"); - let mut builder = wal.builder( - WalTransactionKind::SchedulerTick, - WalAppendAuthority::TrustedScheduler, - WalTransactionId::from_hash(transaction_id), - ); - builder - .push_record(WalRecordKind::TickReceiptRecorded, receipt_bytes.clone()) - .expect("fixture tick receipt must append"); - if duplicate_kind == WalRecordKind::TickReceiptRecorded { - builder - .push_record(WalRecordKind::TickReceiptRecorded, receipt_bytes.clone()) - .expect("duplicate tick receipt must append"); - } - builder - .push_record( - WalRecordKind::ReceiptCorrelationRecorded, - correlation_bytes.clone(), - ) - .expect("fixture correlation must append"); - if duplicate_kind == WalRecordKind::ReceiptCorrelationRecorded { - builder - .push_record( - WalRecordKind::ReceiptCorrelationRecorded, - correlation_bytes.clone(), - ) - .expect("duplicate correlation must append"); - } - builder - .push_record(WalRecordKind::RuntimeStateDeltaRecorded, [36; 32].to_vec()) - .expect("fixture state delta must append"); - if duplicate_kind == WalRecordKind::RuntimeStateDeltaRecorded { - builder - .push_record(WalRecordKind::RuntimeStateDeltaRecorded, [36; 32].to_vec()) - .expect("duplicate state delta must append"); - } - let transaction = builder - .commit(Vec::new()) - .expect("duplicate-kind transaction must commit structurally"); - transaction - .validate() - .expect("duplicate-kind transaction must remain structurally valid"); - let recovered = crate::causal_wal::WalRecoveredTransaction { - commit: transaction.commit, - frames: transaction.frames, - }; + let mut wrong_root = parent.clone(); + wrong_root.expected.state_root = [39; 32]; + let provenance = BTreeMap::from([(parent_coordinate, &wrong_root)]); + assert!(validate_operation_receipt_parent_material(basis, &child, &provenance).is_err()); + assert!(!evaluation_basis_matches_recovered_coordinate( + basis, + &provenance + )); - assert!(matches!( - tick_records_from_transaction(&recovered), - Err(TrustedRuntimeWalError::Recovery(WalRecoveryError::Index( - WalRecoveryIndexError::Decode(WalDecodeError::InvalidEmbeddedFrame) - ))) - )); - } + let mut wrong_global_tick = parent.clone(); + wrong_global_tick.commit_global_tick = GlobalTick::from_raw(9); + let provenance = BTreeMap::from([(parent_coordinate, &wrong_global_tick)]); + assert!(validate_operation_receipt_parent_material(basis, &child, &provenance).is_err()); + assert!(!evaluation_basis_matches_recovered_coordinate( + basis, + &provenance + )); } - #[test] - fn runtime_wal_legacy_tick_rejects_action_outcome_frame() { - let wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); - let receipt = TickReceiptRecord { - receipt_ref: CausalTickReceiptRef { - worldline_id: WorldlineId::from_bytes([40; 32]), + fn test_correlation(receipt_digest: Hash) -> ReceiptCorrelationRecord { + let head_key = test_head_key(); + ReceiptCorrelationRecord { + ticketed_ingress_id: [1; 32], + submission_id: [2; 32], + ticket_digest: [3; 32], + ingress_id: [4; 32], + head_key, + contract: None, + commit_global_tick: GlobalTick::from_raw(1), + worldline_tick_after: WorldlineTick::from_raw(1), + tick_receipt_digest: receipt_digest, + commit_hash: [5; 32], + causal_receipt_ref: CausalTickReceiptRef { + worldline_id: head_key.worldline_id, worldline_tick_after: WorldlineTick::from_raw(1), commit_global_tick: GlobalTick::from_raw(1), - commit_hash: [41; 32], - submission_id: [42; 32], - ticket_digest: [43; 32], - receipt_content_digest: [44; 32], + commit_hash: [5; 32], + submission_id: [2; 32], + ticket_digest: [3; 32], + receipt_content_digest: receipt_digest, }, - decision: WalTickDecision::Obstructed, - }; - let correlation = WalReceiptCorrelationRecord { - receipt_ref: receipt.receipt_ref, causal_parent_receipts: Vec::new(), - }; - let action_outcome = vec![0xa5]; + } + } - let mut builder = wal.builder( - WalTransactionKind::SchedulerTick, - WalAppendAuthority::TrustedScheduler, - WalTransactionId::from_hash([51; 32]), - ); - builder - .push_record( - WalRecordKind::TickReceiptRecorded, - receipt.to_payload_bytes(), - ) - .expect("fixture legacy receipt must append"); - builder - .push_record( - WalRecordKind::ReceiptCorrelationRecorded, - correlation.to_payload_bytes(), - ) - .expect("fixture legacy correlation must append"); - builder - .push_record( - WalRecordKind::ExecutableOperationActionOutcomeRecorded, - action_outcome, - ) - .expect("adversarial Action outcome must append structurally"); - builder - .push_record(WalRecordKind::RuntimeStateDeltaRecorded, [52; 32].to_vec()) - .expect("fixture state delta must append"); - let transaction = builder - .commit(Vec::new()) - .expect("adversarial legacy transaction must commit structurally"); - let recovered = crate::causal_wal::WalRecoveredTransaction { - commit: transaction.commit, - frames: transaction.frames, - }; + fn test_causal_anchor_claim(basis_frontier: CausalFrontierRef) -> CausalAnchorClaim { + CausalAnchorClaim::from_admission_request(CausalAnchorAdmissionRequest { + schema_version: crate::CAUSAL_ANCHOR_SCHEMA_VERSION, + subject: crate::CausalAnchorSubject::new( + "jedit", + "BufferWorldline", + "worldline:recovery-basis", + ), + basis_frontier, + retained_roots: vec![crate::CausalAnchorRoot::AppSubjectRoot { + app_id: "jedit".to_owned(), + subject_kind: "RopeHead".to_owned(), + id: "head:recovery-basis".to_owned(), + role: crate::CausalAnchorAppRootRole::Authority, + }], + materialization_roots: Vec::new(), + purpose: crate::CausalAnchorPurpose::Recovery, + }) + .expect("test causal-anchor claim should be valid") + } + + #[test] + fn runtime_wal_recovery_rejects_anchor_claimed_at_unrelated_basis() { + let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let recovered_basis = wal.current_causal_anchor_basis(); + let claimed_basis = CausalFrontierRef::from_digest([0x5a; 32]); + assert_ne!(claimed_basis, recovered_basis); + wal.record_causal_anchor_admission(test_causal_anchor_claim(claimed_basis), [0x6b; 32]) + .expect("internal test setup should append malformed historical evidence"); + + let report = wal + .store + .recover_read_only() + .expect("malformed committed transaction should remain physically readable"); + let cursor_error = TrustedRuntimeWalCursor::from_recovery(&report) + .expect_err("writer recovery must reject the unrelated anchor basis"); + assert!(matches!( + cursor_error, + TrustedRuntimeWalError::CausalAnchorBasisMismatch { + claimed, + recovered, + .. + } if claimed == claimed_basis.frontier_digest + && recovered == recovered_basis.frontier_digest + )); + let reading_error = wal + .recover_read_only() + .expect_err("recovery must reject an anchor claim at an unrelated basis"); assert!(matches!( - tick_record_batch_from_transaction(&recovered), - Err(TrustedRuntimeWalError::Recovery(WalRecoveryError::Index( - WalRecoveryIndexError::Decode(WalDecodeError::InvalidEmbeddedFrame) - ))) + reading_error, + TrustedRuntimeWalError::CausalAnchorBasisMismatch { + claimed, + recovered, + .. + } if claimed == claimed_basis.frontier_digest + && recovered == recovered_basis.frontier_digest )); } #[test] - fn recovered_correlation_rejects_parents_not_bound_by_envelope() { - let head_key = test_head_key(); - let envelope_parent = CausalTickReceiptRef { - worldline_id: head_key.worldline_id, - worldline_tick_after: WorldlineTick::from_raw(2), - commit_global_tick: GlobalTick::from_raw(2), - commit_hash: [31; 32], - submission_id: [32; 32], - ticket_digest: [33; 32], - receipt_content_digest: [34; 32], - }; - let envelope = IngressEnvelope::local_intent_with_causal_parents( - IngressTarget::ExactHead { key: head_key }, - crate::make_intent_kind("runtime-wal-parent-validation"), - b"parent-validation".to_vec(), - vec![IngressCausalParent::TickReceipt { - receipt_ref: envelope_parent, + fn runtime_wal_recovery_rejects_unattested_anchor_frontier_transition() { + let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let claim = test_causal_anchor_claim(wal.current_causal_anchor_basis()); + let support_policy_digest = [0x7c; 32]; + let transaction_id = WalTransactionId::from_hash(causal_anchor_transaction_digest( + wal.causal_anchor_frontier_digest, + claim.claim_digest(), + &support_policy_digest, + )); + let transaction = build_causal_anchor_admission_transaction( + wal.causal_anchor_builder(transaction_id), + claim, + support_policy_digest, + vec![AffectedFrontier { + kind: AffectedFrontierKind::CausalAnchorIndex, + before_digest: [0x8d; 32], + after_digest: [0x9e; 32], }], - ); - let witnessed = WitnessedSubmissionPersistenceSnapshot::new(vec![ - WitnessedSubmissionPersistenceRecord { - submission: IntentSubmissionRecord { - submission_id: [2; 32], - ingress_id: envelope.ingress_id(), - head_key, - submission_generation: IngressSubmissionGeneration::from_raw(1), - }, - envelope, - }, - ]); - let mut correlation = ReceiptCorrelationPersistenceRecord::from(&test_correlation([7; 32])); - correlation.causal_parent_receipts = vec![CausalTickReceiptRef { - ticket_digest: [35; 32], - ..envelope_parent - }]; - - assert_eq!( - validate_recovered_causal_parent_evidence(&witnessed, &[correlation.clone()]), - Err( - TrustedRuntimeWalError::ReceiptCorrelationCausalParentsMismatch { - submission_id: correlation.submission_id, - receipt_ref_digest: correlation.causal_receipt_ref.identity_digest(), - } - ) - ); - } -} + ) + .expect("internal test setup should build malformed frontier evidence"); + wal.append_transaction(transaction) + .expect("malformed historical evidence should remain physically appendable"); -fn tick_transaction_digest( - correlation: &ReceiptCorrelationRecord, - decision: WalTickDecision, - state_delta_digest: Hash, -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"tick-transaction"); - hasher.update(&correlation.ticketed_ingress_id); - hasher.update(&correlation.causal_receipt_ref.to_canonical_bytes()); - hasher.update(&correlation.ingress_id); - hash_causal_parent_receipts(&mut hasher, &correlation.causal_parent_receipts); - hasher.update(&[wal_tick_decision_code(decision)]); - hasher.update(&state_delta_digest); - hasher.finalize().into() -} + let report = wal + .store + .recover_read_only() + .expect("malformed committed transaction should remain physically readable"); + let cursor_error = TrustedRuntimeWalCursor::from_recovery(&report) + .expect_err("writer recovery must reject an unattested anchor frontier transition"); + assert!(matches!( + cursor_error, + TrustedRuntimeWalError::CausalAnchorFrontierMismatch { .. } + )); -fn tick_batch_transaction_digest( - correlations: &[(ReceiptCorrelationRecord, WalTickDecision)], - state_delta_digest: Hash, -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"tick-batch-transaction:v1\0"); - hasher.update(&(correlations.len() as u64).to_le_bytes()); - for (correlation, decision) in correlations { - hasher.update(&correlation.ticketed_ingress_id); - hasher.update(&correlation.causal_receipt_ref.to_canonical_bytes()); - hasher.update(&correlation.ingress_id); - hash_causal_parent_receipts(&mut hasher, &correlation.causal_parent_receipts); - hasher.update(&[wal_tick_decision_code(*decision)]); + let reading_error = wal + .recover_read_only() + .expect_err("read-only recovery must reject an unattested anchor frontier transition"); + assert!(matches!( + reading_error, + TrustedRuntimeWalError::CausalAnchorFrontierMismatch { .. } + )); } - hasher.update(&state_delta_digest); - hasher.finalize().into() -} -fn receipt_frontier_digest( - previous: Hash, - receipt: TickReceiptRecord, - correlation: &WalReceiptCorrelationRecord, -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"receipt-frontier"); - hasher.update(&previous); - hasher.update(&receipt.receipt_ref.to_canonical_bytes()); - hasher.update(&[wal_tick_decision_code(receipt.decision)]); - hasher.update(&correlation.receipt_ref.to_canonical_bytes()); - hash_causal_parent_receipts(&mut hasher, &correlation.causal_parent_receipts); - hasher.finalize().into() -} + #[test] + fn causal_anchor_recovery_traversal_drives_cursor_and_witnessed_history() { + let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let claim = test_causal_anchor_claim(wal.current_causal_anchor_basis()); + wal.record_causal_anchor_admission(claim, [0x4f; 32]) + .expect("test causal-anchor admission should commit"); + let report = wal + .store + .recover_read_only() + .expect("committed causal-anchor admission should recover"); -fn hash_causal_parent_receipts( - hasher: &mut blake3::Hasher, - parents: &[crate::CausalTickReceiptRef], -) { - if parents.is_empty() { - return; - } - hasher.update(b"causal-parent-tick-receipts:v2\0"); - hasher.update(&(parents.len() as u64).to_le_bytes()); - for parent in parents { - hasher.update(&parent.to_canonical_bytes()); - } -} + let traversal = traverse_recovered_causal_anchors(&report) + .expect("shared causal-anchor traversal should validate recovery"); + let cursor = TrustedRuntimeWalCursor::from_recovery(&report) + .expect("writer cursor should consume the shared traversal"); + let (history, frontiers) = recover_witnessed_causal_anchor_history(&report) + .expect("read-only history should consume the shared traversal"); -fn wal_tick_decision_code(decision: WalTickDecision) -> u8 { - match decision { - WalTickDecision::Applied => 1, - WalTickDecision::RejectedFootprintConflict => 2, - WalTickDecision::Obstructed => 3, + assert_eq!(history.len(), 1); + assert_eq!(history.len(), traversal.history.len()); + assert_eq!(frontiers, traversal.causal_history_frontiers); + assert_eq!( + cursor.causal_history_frontier_digest, + traversal + .causal_history_frontiers + .last() + .expect("traversal must retain its terminal frontier") + .frontier_digest + ); + assert_eq!( + cursor.causal_anchor_frontier_digest, + traversal.causal_anchor_frontier_digest + ); } -} -fn executable_operation_catalog_frontier_digest( - previous: Hash, - package_id: crate::EchoOperationPackageIdV1, - retained_installation_bytes: &[u8], -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"executable-operation-catalog-frontier"); - hasher.update(&previous); - hasher.update(&package_id.as_hash()); - hasher.update(&(retained_installation_bytes.len() as u64).to_le_bytes()); - hasher.update(retained_installation_bytes); - hasher.finalize().into() -} + #[test] + fn causal_anchor_claim_lookup_uses_projection_without_wal_replay() { + let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let claim = test_causal_anchor_claim(wal.current_causal_anchor_basis()); + let claim_digest = *claim.claim_digest(); + let admitted = wal + .record_causal_anchor_admission(claim, [0x5f; 32]) + .expect("test causal-anchor admission should commit"); + wal.recover_read_only_call_count.set(0); -fn executable_operation_receipt_frontier_digest(previous: Hash, receipt_digest: Hash) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"executable-operation-receipt-frontier"); - hasher.update(&previous); - hasher.update(&receipt_digest); - hasher.finalize().into() -} + let recovered = wal + .causal_anchor_by_claim(&claim_digest, None) + .expect("claim lookup should use a validated projection"); -fn executable_operation_installation_transaction_digest( - catalog_frontier: Hash, - package_id: crate::EchoOperationPackageIdV1, - retained_installation_bytes: &[u8], -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime:executable-operation-installation-transaction:v1\0"); - hasher.update(&catalog_frontier); - hasher.update(&package_id.as_hash()); - hasher.update(&(retained_installation_bytes.len() as u64).to_le_bytes()); - hasher.update(retained_installation_bytes); - hasher.finalize().into() -} + assert_eq!(recovered, Some(admitted)); + assert_eq!( + wal.recover_read_only_call_count.get(), + 0, + "idempotency lookup must not replay committed WAL history" + ); + } -fn executable_operation_tick_transaction_digest( - receipt_frontier: Hash, - runtime_state_frontier: Hash, - receipt_digest: Hash, - state_delta_digest: Hash, -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime:executable-operation-tick-transaction:v1\0"); - hasher.update(&receipt_frontier); - hasher.update(&runtime_state_frontier); - hasher.update(&receipt_digest); - hasher.update(&state_delta_digest); - hasher.finalize().into() -} + #[test] + fn runtime_wal_tick_decision_rejects_pending_observation_as_invariant() { + let err = wal_tick_decision_from_observation( + IntentOutcomeObservation::Pending { + submission_id: [2; 32], + submission_generation: IngressSubmissionGeneration::from_raw(1), + ticketed_ingress_id: Some([6; 32]), + }, + [7; 32], + ) + .expect_err("pending outcome cannot produce scheduler tick WAL evidence"); -fn runtime_state_frontier_digest( - previous: Hash, - correlation: &ReceiptCorrelationRecord, - state_delta_digest: Hash, -) -> Hash { - runtime_state_frontier_digest_from_fields( - previous, - correlation.commit_hash, - state_delta_digest, - correlation.commit_global_tick, - correlation.worldline_tick_after, - ) -} + assert!(matches!( + err, + TrustedRuntimeWalError::TickOutcomeUnavailable { + submission_id, + receipt_digest, + } if submission_id == [2; 32] && receipt_digest == [7; 32] + )); + } -fn runtime_state_frontier_digest_from_fields( - previous: Hash, - commit_hash: Hash, - state_delta_digest: Hash, - commit_global_tick: crate::GlobalTick, - worldline_tick_after: crate::WorldlineTick, -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"runtime-state-frontier"); - hasher.update(&previous); - hasher.update(&commit_hash); - hasher.update(&state_delta_digest); - hasher.update(&commit_global_tick.as_u64().to_le_bytes()); - hasher.update(&worldline_tick_after.as_u64().to_le_bytes()); - hasher.finalize().into() -} + #[test] + fn runtime_wal_tick_decision_rejects_receipt_digest_mismatch_as_invariant() { + let err = wal_tick_decision_from_observation( + IntentOutcomeObservation::Decided { + correlation: Box::new(test_correlation([8; 32])), + decision: IntentOutcomeDecision::Applied { + receipt_entry_index: 0, + rule_id: [9; 32], + }, + }, + [7; 32], + ) + .expect_err("mismatched receipt digest cannot produce scheduler tick WAL evidence"); -fn recovered_legacy_runtime_state_frontier_digest( - previous: Hash, - correlation: WalReceiptCorrelationRecord, - state_delta_digest: Hash, -) -> Hash { - let mut hasher = blake3::Hasher::new(); - hasher.update(TRUSTED_RUNTIME_WAL_DOMAIN); - hasher.update(b"runtime-state-frontier:recovered"); - hasher.update(&previous); - hasher.update(&correlation.receipt_ref.to_canonical_bytes()); - hasher.update(&state_delta_digest); - hasher.finalize().into() -} + assert!(matches!( + err, + TrustedRuntimeWalError::TickReceiptDigestMismatch { + expected_receipt_digest, + observed_receipt_digest, + } if expected_receipt_digest == [7; 32] && observed_receipt_digest == [8; 32] + )); + } -struct RecoveredRuntimeWalIndexEvidence<'a> { - submissions: &'a RecoveredSubmissionIndex, - receipts: &'a RecoveredReceiptIndex, - witnessed_submissions: &'a WitnessedSubmissionPersistenceSnapshot, - missing_submission_envelopes: &'a [Hash], - provenance_entries: &'a [ProvenanceEntry], - missing_runtime_state_deltas: &'a [Hash], - causal_anchor_history: &'a [WitnessedCausalAnchorAdmission], - installed_echo_operations: &'a [InstalledEchoOperationV1], - echo_operation_receipts: &'a [EchoOperationReceiptV1], - echo_operation_action_outcomes: &'a [(Hash, Hash, EchoOperationActionOutcomeV1)], -} + #[test] + fn runtime_wal_tick_decision_rejects_missing_receipt_entry_as_invariant() { + let err = wal_tick_decision_from_observation( + IntentOutcomeObservation::Decided { + correlation: Box::new(test_correlation([7; 32])), + decision: IntentOutcomeDecision::NoMatchingReceiptEntry { + tick_receipt_digest: [7; 32], + }, + }, + [7; 32], + ) + .expect_err("missing receipt entries cannot produce scheduler tick WAL evidence"); -fn runtime_wal_recovery_certificate( - report: &RecoveryScanReport, - indexes: &RecoveredRuntimeWalIndexEvidence<'_>, -) -> Result { - let recovered_frontier_root = report - .last_commit_digest() - .unwrap_or_else(|| trusted_runtime_wal_digest("recovery-frontier:empty")); - let recovered_indexes_root = recovered_runtime_wal_indexes_root(indexes)?; - Ok(build_recovery_certificate( - report, - None, - (indexes.missing_submission_envelopes.len() + indexes.missing_runtime_state_deltas.len()) - as u64, - recovered_frontier_root, - recovered_indexes_root, - )) -} + assert!(matches!( + err, + TrustedRuntimeWalError::TickOutcomeUnavailable { + submission_id, + receipt_digest, + } if submission_id == [2; 32] && receipt_digest == [7; 32] + )); + } -fn recovered_runtime_wal_indexes_root( - indexes: &RecoveredRuntimeWalIndexEvidence<'_>, -) -> Result { - let recovered_indexes_root = recovered_submission_material_index_root( - recovered_submission_receipt_index_root(indexes.submissions, indexes.receipts), - indexes.witnessed_submissions, - indexes.missing_submission_envelopes, - ); - let runtime_root = recovered_runtime_state_delta_index_root( - recovered_indexes_root, - indexes.provenance_entries, - indexes.missing_runtime_state_deltas, - )?; - let causal_anchor_root = - recovered_causal_anchor_index_root(runtime_root, indexes.causal_anchor_history); - recovered_echo_operation_index_root( - causal_anchor_root, - indexes.installed_echo_operations, - indexes.echo_operation_receipts, - indexes.echo_operation_action_outcomes, - ) -} + #[test] + fn runtime_wal_tick_decision_maps_matching_outcome() { + let decision = wal_tick_decision_from_observation( + IntentOutcomeObservation::Decided { + correlation: Box::new(test_correlation([7; 32])), + decision: IntentOutcomeDecision::Applied { + receipt_entry_index: 0, + rule_id: [9; 32], + }, + }, + [7; 32], + ) + .expect("matching outcome should map to a WAL tick decision"); + + assert_eq!(decision, WalTickDecision::Applied); + } + + #[test] + fn runtime_wal_recovery_marks_legacy_acceptance_without_envelope_material() { + let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let handle = IntentSubmissionHandle { + ingress_id: [11; 32], + head_key: test_head_key(), + submission_id: [12; 32], + submission_generation: IngressSubmissionGeneration::from_raw(1), + duplicate: false, + }; + let record = SubmissionAcceptanceRecord { + submission_id: handle.submission_id, + canonical_envelope_digest: handle.ingress_id, + idempotency_key_digest: None, + acceptance_evidence_digest: acceptance_evidence_digest(handle), + }; + let transaction = crate::causal_wal::build_submission_acceptance_transaction( + wal.builder( + WalTransactionKind::SubmissionIntake, + WalAppendAuthority::SubmissionIntake, + WalTransactionId::from_hash(submission_transaction_digest(handle, record)), + ), + record, + vec![AffectedFrontier { + kind: AffectedFrontierKind::SubmissionQueue, + before_digest: wal.submission_frontier_digest, + after_digest: submission_frontier_digest(wal.submission_frontier_digest, record), + }], + ) + .expect("legacy acceptance transaction should build"); + wal.append_transaction(transaction) + .expect("legacy acceptance transaction should commit"); + + let recovery = wal + .recover_read_only() + .expect("legacy acceptance should remain inspectable"); + + assert!(recovery.witnessed_submissions.is_empty()); + assert_eq!( + recovery.missing_submission_envelopes, + vec![handle.submission_id] + ); + assert_eq!(recovery.certificate.obstruction_count, 1); + } + + #[test] + fn runtime_wal_recovery_marks_legacy_tick_without_replayable_state_material() { + let mut wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let receipt = TickReceiptRecord { + receipt_ref: CausalTickReceiptRef { + worldline_id: WorldlineId::from_bytes([20; 32]), + worldline_tick_after: WorldlineTick::from_raw(1), + commit_global_tick: GlobalTick::from_raw(1), + commit_hash: [26; 32], + submission_id: [21; 32], + ticket_digest: [22; 32], + receipt_content_digest: [23; 32], + }, + decision: WalTickDecision::Applied, + }; + let correlation = WalReceiptCorrelationRecord { + receipt_ref: receipt.receipt_ref, + causal_parent_receipts: Vec::new(), + }; + let legacy_state_delta_digest = [24; 32]; + let transaction = crate::causal_wal::build_tick_transaction( + wal.builder( + WalTransactionKind::SchedulerTick, + WalAppendAuthority::TrustedScheduler, + WalTransactionId::from_hash([25; 32]), + ), + receipt, + correlation.clone(), + legacy_state_delta_digest, + vec![ + AffectedFrontier { + kind: AffectedFrontierKind::ReceiptIndex, + before_digest: wal.receipt_frontier_digest, + after_digest: receipt_frontier_digest( + wal.receipt_frontier_digest, + receipt, + &correlation, + ), + }, + AffectedFrontier { + kind: AffectedFrontierKind::RuntimeState, + before_digest: wal.runtime_state_frontier_digest, + after_digest: trusted_runtime_wal_digest("legacy-runtime-frontier"), + }, + ], + ) + .expect("legacy tick transaction should build"); + wal.append_transaction(transaction) + .expect("legacy tick transaction should commit"); + + let recovery = wal + .recover_read_only() + .expect("legacy tick should remain inspectable"); + + assert!(recovery.provenance_entries.is_empty()); + assert_eq!( + recovery.missing_runtime_state_deltas, + vec![receipt.receipt_ref.identity_digest()] + ); + assert_eq!(recovery.certificate.obstruction_count, 1); + } + + #[test] + fn runtime_wal_submission_record_selection_rejects_duplicate_singular_evidence() { + let wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let head_key = test_head_key(); + let envelope = IngressEnvelope::local_intent( + IngressTarget::ExactHead { key: head_key }, + crate::make_intent_kind("runtime-wal-duplicate-submission-record"), + b"duplicate-submission-record".to_vec(), + ); + let handle = IntentSubmissionHandle { + ingress_id: envelope.ingress_id(), + head_key, + submission_id: [21; 32], + submission_generation: IngressSubmissionGeneration::from_raw(1), + duplicate: false, + }; + let acceptance = SubmissionAcceptanceRecord { + submission_id: handle.submission_id, + canonical_envelope_digest: handle.ingress_id, + idempotency_key_digest: None, + acceptance_evidence_digest: acceptance_evidence_digest(handle), + }; + let retained_envelope = submission_envelope_record(&envelope, handle); + + for (index, duplicate_kind) in [ + WalRecordKind::SubmissionAcceptedRecorded, + WalRecordKind::SubmissionEnvelopeRetained, + ] + .into_iter() + .enumerate() + { + let mut transaction_id = [22; 32]; + transaction_id[0] = u8::try_from(index).expect("fixture index must fit in u8"); + let mut builder = wal.builder( + WalTransactionKind::SubmissionIntake, + WalAppendAuthority::SubmissionIntake, + WalTransactionId::from_hash(transaction_id), + ); + builder + .push_record( + WalRecordKind::SubmissionAcceptedRecorded, + acceptance.to_payload_bytes(), + ) + .expect("fixture acceptance must append"); + if duplicate_kind == WalRecordKind::SubmissionAcceptedRecorded { + builder + .push_record( + WalRecordKind::SubmissionAcceptedRecorded, + acceptance.to_payload_bytes(), + ) + .expect("duplicate acceptance must append"); + } + builder + .push_record( + WalRecordKind::SubmissionEnvelopeRetained, + retained_envelope.to_payload_bytes(), + ) + .expect("fixture retained envelope must append"); + if duplicate_kind == WalRecordKind::SubmissionEnvelopeRetained { + builder + .push_record( + WalRecordKind::SubmissionEnvelopeRetained, + retained_envelope.to_payload_bytes(), + ) + .expect("duplicate retained envelope must append"); + } + let transaction = builder + .commit(Vec::new()) + .expect("duplicate-kind transaction must commit structurally"); + transaction + .validate() + .expect("duplicate-kind transaction must remain structurally valid"); + let recovered = crate::causal_wal::WalRecoveredTransaction { + commit: transaction.commit, + frames: transaction.frames, + }; + + let result = if duplicate_kind == WalRecordKind::SubmissionAcceptedRecorded { + submission_acceptance_record_from_transaction(&recovered).map(|_| ()) + } else { + let report = RecoveryScanReport { + transactions: vec![recovered], + tail_posture: crate::causal_wal::RecoveryTailPosture::Clean, + }; + let submissions = + recover_submission_index(&report).expect("canonical acceptance should recover"); + recover_witnessed_submission_material(&report, &submissions).map(|_| ()) + }; + + assert!(matches!( + result, + Err(TrustedRuntimeWalError::Recovery(WalRecoveryError::Index( + WalRecoveryIndexError::Decode(WalDecodeError::InvalidEmbeddedFrame) + ))) + )); + } + } + + #[test] + fn runtime_wal_tick_record_selection_rejects_duplicate_singular_evidence() { + let wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let receipt = TickReceiptRecord { + receipt_ref: CausalTickReceiptRef { + worldline_id: WorldlineId::from_bytes([30; 32]), + worldline_tick_after: WorldlineTick::from_raw(1), + commit_global_tick: GlobalTick::from_raw(1), + commit_hash: [31; 32], + submission_id: [32; 32], + ticket_digest: [33; 32], + receipt_content_digest: [34; 32], + }, + decision: WalTickDecision::Applied, + }; + let correlation = WalReceiptCorrelationRecord { + receipt_ref: receipt.receipt_ref, + causal_parent_receipts: Vec::new(), + }; + let receipt_bytes = receipt.to_payload_bytes(); + let correlation_bytes = correlation.to_payload_bytes(); + + for (index, duplicate_kind) in [ + WalRecordKind::TickReceiptRecorded, + WalRecordKind::ReceiptCorrelationRecorded, + WalRecordKind::RuntimeStateDeltaRecorded, + ] + .into_iter() + .enumerate() + { + let mut transaction_id = [35; 32]; + transaction_id[0] = u8::try_from(index).expect("fixture index must fit in u8"); + let mut builder = wal.builder( + WalTransactionKind::SchedulerTick, + WalAppendAuthority::TrustedScheduler, + WalTransactionId::from_hash(transaction_id), + ); + builder + .push_record(WalRecordKind::TickReceiptRecorded, receipt_bytes.clone()) + .expect("fixture tick receipt must append"); + if duplicate_kind == WalRecordKind::TickReceiptRecorded { + builder + .push_record(WalRecordKind::TickReceiptRecorded, receipt_bytes.clone()) + .expect("duplicate tick receipt must append"); + } + builder + .push_record( + WalRecordKind::ReceiptCorrelationRecorded, + correlation_bytes.clone(), + ) + .expect("fixture correlation must append"); + if duplicate_kind == WalRecordKind::ReceiptCorrelationRecorded { + builder + .push_record( + WalRecordKind::ReceiptCorrelationRecorded, + correlation_bytes.clone(), + ) + .expect("duplicate correlation must append"); + } + builder + .push_record(WalRecordKind::RuntimeStateDeltaRecorded, [36; 32].to_vec()) + .expect("fixture state delta must append"); + if duplicate_kind == WalRecordKind::RuntimeStateDeltaRecorded { + builder + .push_record(WalRecordKind::RuntimeStateDeltaRecorded, [36; 32].to_vec()) + .expect("duplicate state delta must append"); + } + let transaction = builder + .commit(Vec::new()) + .expect("duplicate-kind transaction must commit structurally"); + transaction + .validate() + .expect("duplicate-kind transaction must remain structurally valid"); + let recovered = crate::causal_wal::WalRecoveredTransaction { + commit: transaction.commit, + frames: transaction.frames, + }; -fn recovered_echo_operation_index_root( - base_root: Hash, - installations: &[InstalledEchoOperationV1], - receipts: &[EchoOperationReceiptV1], - action_outcomes: &[(Hash, Hash, EchoOperationActionOutcomeV1)], -) -> Result { - let legacy_root = - recovered_echo_operation_legacy_index_root(base_root, installations, receipts)?; - if action_outcomes.is_empty() { - return Ok(legacy_root); - } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime-wal:executable-operation-index:v2\0"); - hasher.update(&legacy_root); - hasher.update(&(action_outcomes.len() as u64).to_le_bytes()); - for (submission_id, ingress_id, outcome) in action_outcomes { - let bytes = retain_action_outcome_v1(*submission_id, *ingress_id, outcome)?; - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(&bytes); + assert!(matches!( + tick_records_from_transaction(&recovered), + Err(TrustedRuntimeWalError::Recovery(WalRecoveryError::Index( + WalRecoveryIndexError::Decode(WalDecodeError::InvalidEmbeddedFrame) + ))) + )); + } } - Ok(hasher.finalize().into()) -} -fn recovered_echo_operation_legacy_index_root( - base_root: Hash, - installations: &[InstalledEchoOperationV1], - receipts: &[EchoOperationReceiptV1], -) -> Result { - if installations.is_empty() && receipts.is_empty() { - return Ok(base_root); - } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime-wal:executable-operation-index:v1\0"); - hasher.update(&base_root); - hasher.update(&(installations.len() as u64).to_le_bytes()); - for installed in installations { - let bytes = retain_installation_v1(installed)?; - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(&bytes); - } - hasher.update(&(receipts.len() as u64).to_le_bytes()); - for receipt in receipts { - let bytes = receipt.to_canonical_bytes()?; - hasher.update(&(bytes.len() as u64).to_le_bytes()); - hasher.update(&bytes); - } - Ok(hasher.finalize().into()) -} + #[test] + fn runtime_wal_legacy_tick_rejects_action_outcome_frame() { + let wal = TrustedRuntimeWal::new_in_memory().expect("test WAL should initialize"); + let receipt = TickReceiptRecord { + receipt_ref: CausalTickReceiptRef { + worldline_id: WorldlineId::from_bytes([40; 32]), + worldline_tick_after: WorldlineTick::from_raw(1), + commit_global_tick: GlobalTick::from_raw(1), + commit_hash: [41; 32], + submission_id: [42; 32], + ticket_digest: [43; 32], + receipt_content_digest: [44; 32], + }, + decision: WalTickDecision::Obstructed, + }; + let correlation = WalReceiptCorrelationRecord { + receipt_ref: receipt.receipt_ref, + causal_parent_receipts: Vec::new(), + }; + let action_outcome = vec![0xa5]; -fn recovered_causal_anchor_index_root( - base_root: Hash, - causal_anchor_history: &[WitnessedCausalAnchorAdmission], -) -> Hash { - if causal_anchor_history.is_empty() { - return base_root; - } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime-wal:causal-anchor-history-index:v1\0"); - hasher.update(&base_root); - hasher.update(&(causal_anchor_history.len() as u64).to_le_bytes()); - for entry in causal_anchor_history { - let admission = entry.admission(); - hasher.update(admission.fact().anchor_id().as_bytes()); - hasher.update(&entry.basis_before().frontier_digest); - hasher.update(&entry.basis_after().frontier_digest); - let fact_bytes = admission.fact().to_payload_bytes(); - hasher.update(&(fact_bytes.len() as u64).to_le_bytes()); - hasher.update(&fact_bytes); - let receipt_bytes = admission.receipt().to_payload_bytes(); - hasher.update(&(receipt_bytes.len() as u64).to_le_bytes()); - hasher.update(&receipt_bytes); - hasher.update(&admission.transaction_id().as_hash()); - hasher.update(&admission.committed_lsn().as_u64().to_le_bytes()); - hasher.update(admission.commit_digest()); - } - hasher.finalize().into() -} + let mut builder = wal.builder( + WalTransactionKind::SchedulerTick, + WalAppendAuthority::TrustedScheduler, + WalTransactionId::from_hash([51; 32]), + ); + builder + .push_record( + WalRecordKind::TickReceiptRecorded, + receipt.to_payload_bytes(), + ) + .expect("fixture legacy receipt must append"); + builder + .push_record( + WalRecordKind::ReceiptCorrelationRecorded, + correlation.to_payload_bytes(), + ) + .expect("fixture legacy correlation must append"); + builder + .push_record( + WalRecordKind::ExecutableOperationActionOutcomeRecorded, + action_outcome, + ) + .expect("adversarial Action outcome must append structurally"); + builder + .push_record(WalRecordKind::RuntimeStateDeltaRecorded, [52; 32].to_vec()) + .expect("fixture state delta must append"); + let transaction = builder + .commit(Vec::new()) + .expect("adversarial legacy transaction must commit structurally"); + let recovered = crate::causal_wal::WalRecoveredTransaction { + commit: transaction.commit, + frames: transaction.frames, + }; -fn recovered_submission_material_index_root( - base_root: Hash, - witnessed_submissions: &WitnessedSubmissionPersistenceSnapshot, - missing_submission_envelopes: &[Hash], -) -> Hash { - if witnessed_submissions.is_empty() && missing_submission_envelopes.is_empty() { - return base_root; + assert!(matches!( + tick_record_batch_from_transaction(&recovered), + Err(TrustedRuntimeWalError::Recovery(WalRecoveryError::Index( + WalRecoveryIndexError::Decode(WalDecodeError::InvalidEmbeddedFrame) + ))) + )); } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime-wal:submission-material-index:v1\0"); - hasher.update(&base_root); - hasher.update(&(witnessed_submissions.len() as u64).to_le_bytes()); - for record in witnessed_submissions.records() { - hasher.update(&record.submission.submission_id); - hasher.update(&record.submission.ingress_id); - hasher.update(record.submission.head_key.worldline_id.as_bytes()); - hasher.update(record.submission.head_key.head_id.as_bytes()); - hasher.update( - &record - .submission - .submission_generation - .as_u64() - .to_le_bytes(), + + #[test] + fn recovered_correlation_rejects_parents_not_bound_by_envelope() { + let head_key = test_head_key(); + let envelope_parent = CausalTickReceiptRef { + worldline_id: head_key.worldline_id, + worldline_tick_after: WorldlineTick::from_raw(2), + commit_global_tick: GlobalTick::from_raw(2), + commit_hash: [31; 32], + submission_id: [32; 32], + ticket_digest: [33; 32], + receipt_content_digest: [34; 32], + }; + let envelope = IngressEnvelope::local_intent_with_causal_parents( + IngressTarget::ExactHead { key: head_key }, + crate::make_intent_kind("runtime-wal-parent-validation"), + b"parent-validation".to_vec(), + vec![IngressCausalParent::TickReceipt { + receipt_ref: envelope_parent, + }], ); - let retained_bytes = record.envelope.to_retained_bytes_v2(); - hasher.update(&(retained_bytes.len() as u64).to_le_bytes()); - hasher.update(&retained_bytes); - } - hasher.update(&(missing_submission_envelopes.len() as u64).to_le_bytes()); - for submission_id in missing_submission_envelopes { - hasher.update(submission_id); - } - hasher.finalize().into() -} + let witnessed = WitnessedSubmissionPersistenceSnapshot::new(vec![ + WitnessedSubmissionPersistenceRecord { + submission: IntentSubmissionRecord { + submission_id: [2; 32], + ingress_id: envelope.ingress_id(), + head_key, + submission_generation: IngressSubmissionGeneration::from_raw(1), + }, + envelope, + }, + ]); + let mut correlation = ReceiptCorrelationPersistenceRecord::from(&test_correlation([7; 32])); + correlation.causal_parent_receipts = vec![CausalTickReceiptRef { + ticket_digest: [35; 32], + ..envelope_parent + }]; -fn recovered_runtime_state_delta_index_root( - base_root: Hash, - provenance_entries: &[ProvenanceEntry], - missing_runtime_state_deltas: &[Hash], -) -> Result { - if provenance_entries.is_empty() && missing_runtime_state_deltas.is_empty() { - return Ok(base_root); - } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"echo:trusted-runtime-wal:runtime-state-delta-index:v1\0"); - hasher.update(&base_root); - hasher.update(&(provenance_entries.len() as u64).to_le_bytes()); - for entry in provenance_entries { - let retained_bytes = crate::provenance_codec::encode_local_commit_v1(entry)?; - hasher.update(&(retained_bytes.len() as u64).to_le_bytes()); - hasher.update(&retained_bytes); - } - hasher.update(&(missing_runtime_state_deltas.len() as u64).to_le_bytes()); - for receipt_digest in missing_runtime_state_deltas { - hasher.update(receipt_digest); + assert_eq!( + validate_recovered_causal_parent_evidence(&witnessed, &[correlation.clone()]), + Err( + TrustedRuntimeWalError::ReceiptCorrelationCausalParentsMismatch { + submission_id: correlation.submission_id, + receipt_ref_digest: correlation.causal_receipt_ref.identity_digest(), + } + ) + ); } - Ok(hasher.finalize().into()) } diff --git a/crates/warp-core/src/worldline_state.rs b/crates/warp-core/src/worldline_state.rs index 096703d6f..fc33faf9d 100644 --- a/crates/warp-core/src/worldline_state.rs +++ b/crates/warp-core/src/worldline_state.rs @@ -16,6 +16,7 @@ use std::collections::BTreeSet; use thiserror::Error; use crate::clock::WorldlineTick; +use crate::execution_evidence::ExecutionFootprintEvidence; use crate::graph::GraphStore; use crate::head::WriterHeadKey; use crate::ident::{make_node_id, make_type_id, make_warp_id, Hash, NodeId, NodeKey}; @@ -89,6 +90,8 @@ pub struct WorldlineState { pub(crate) last_snapshot: Option, /// Sequential history of committed ticks for this worldline. pub(crate) tick_history: Vec<(Snapshot, TickReceipt, WarpTickPatchV1)>, + /// Canonically ordered per-Action footprints from the latest execution. + pub(crate) last_execution_footprints: Vec, /// Last finalized materialization channels for this worldline. pub(crate) last_materialization: Vec, /// Last materialization errors for this worldline. @@ -131,6 +134,7 @@ impl WorldlineState { root, last_snapshot: None, tick_history: Vec::new(), + last_execution_footprints: Vec::new(), last_materialization: Vec::new(), last_materialization_errors: Vec::new(), tx_counter: 0, @@ -283,6 +287,13 @@ impl WorldlineState { &self.tick_history } + /// Returns canonically ordered per-Action footprints from the latest + /// execution attempt against this worldline. + #[must_use] + pub fn last_execution_footprints(&self) -> &[ExecutionFootprintEvidence] { + &self.last_execution_footprints + } + /// Returns the most recent finalized materialization channels. #[must_use] pub fn last_materialization(&self) -> &[FinalizedChannel] { @@ -335,6 +346,7 @@ impl WorldlineState { initial_state: self.initial_state.clone(), last_snapshot: self.last_snapshot.clone(), tick_history: self.tick_history.clone(), + last_execution_footprints: self.last_execution_footprints.clone(), last_materialization: self.last_materialization.clone(), last_materialization_errors: self.last_materialization_errors.clone(), tx_counter: self.tx_counter, @@ -354,6 +366,7 @@ impl WorldlineState { initial_state, last_snapshot: None, tick_history: Vec::new(), + last_execution_footprints: Vec::new(), last_materialization: Vec::new(), last_materialization_errors: Vec::new(), tx_counter: 0, diff --git a/crates/warp-core/tests/causal_wal_hardening_tests.rs b/crates/warp-core/tests/causal_wal_hardening_tests.rs index 8451c5a41..089289153 100644 --- a/crates/warp-core/tests/causal_wal_hardening_tests.rs +++ b/crates/warp-core/tests/causal_wal_hardening_tests.rs @@ -163,8 +163,26 @@ fn builder_on_segment( transaction_kind: WalTransactionKind, segment_id: WalSegmentId, ) -> WalTransactionBuilder { - WalTransactionBuilder::new( + builder_in_epoch( epoch_id(), + label, + first_lsn, + authority, + transaction_kind, + segment_id, + ) +} + +fn builder_in_epoch( + writer_epoch: WriterEpochId, + label: &str, + first_lsn: Lsn, + authority: WalAppendAuthority, + transaction_kind: WalTransactionKind, + segment_id: WalSegmentId, +) -> WalTransactionBuilder { + WalTransactionBuilder::new( + writer_epoch, segment_id, transaction_id(&format!("hardening:tx:{label}")), transaction_kind, @@ -220,6 +238,25 @@ fn submission_transaction_on_segment( )) } +fn submission_transaction_in_epoch( + writer_epoch: WriterEpochId, + label: &str, + first_lsn: Lsn, +) -> WalCommittedTransaction { + must_ok(build_submission_acceptance_transaction( + builder_in_epoch( + writer_epoch, + label, + first_lsn, + WalAppendAuthority::SubmissionIntake, + WalTransactionKind::SubmissionIntake, + WalSegmentId::from_raw(1), + ), + submission_acceptance(label), + vec![frontier(label, AffectedFrontierKind::SubmissionQueue)], + )) +} + fn tick_transaction( label: &str, first_lsn: Lsn, @@ -964,11 +1001,123 @@ fn filesystem_writer_lease_refuses_overlap_before_takeover() { drop(active); let successor = must_ok(contender.acquire_fresh_writer_epoch(Lsn::from_raw(0))); assert_eq!(successor.previous_epoch_id, Some(epoch_id())); - assert!(successor.started_at_lsn > Lsn::from_raw(0)); + // Start-LSN semantics after an empty predecessor belong to + // `filesystem_successor_reuses_next_unallocated_lsn_after_empty_epoch`. + // This test witnesses lease overlap and takeover linkage only. drop(contender); must_ok(fs::remove_dir_all(root)); } +/// An epoch's start LSN is the next unallocated *frame* coordinate. +/// +/// An LSN is assigned to a WAL frame. Acquiring a writer epoch persists ledger +/// evidence but emits no frame, so an epoch that committed nothing spent +/// nothing and its successor resumes at the same coordinate. Advancing past it +/// invents a phantom coordinate that `validate_recovery_frame_order` reports as +/// [`WalValidationError::LsnContinuityMismatch`] to every later reader — one +/// hole per barren restart, which is what an inspect-then-close host produces +/// on every open. +/// +/// Epoch-chain advancement is still strict; it is carried by epoch identity, +/// fencing evidence, and predecessor linkage, not by the start LSN. +#[test] +fn filesystem_successor_reuses_next_unallocated_lsn_after_empty_epoch() { + let root = temp_wal_root("writer-epoch-empty"); + + // Epoch 1 opens at 0 and writes nothing. + { + let mut store = must_ok(FilesystemWalStore::open(&root, WalSegmentId::from_raw(1))); + must_ok(store.acquire_writer_epoch(writer_epoch_request())); + } + + // Epoch 2 links to the empty epoch 1 and resumes at the same coordinate. + let mut store = must_ok(FilesystemWalStore::open(&root, WalSegmentId::from_raw(1))); + let successor = must_ok(store.acquire_fresh_writer_epoch(Lsn::from_raw(0))); + assert_eq!(successor.previous_epoch_id, Some(epoch_id())); + assert_eq!(successor.started_at_lsn, Lsn::from_raw(0)); + + // Reusing the coordinate must be more than pleasing metadata: the frame + // written there is the WAL's first, and recovery must find it contiguous. + must_ok(store.append_transaction(submission_transaction_in_epoch( + successor.epoch_id, + "empty-epoch-successor", + Lsn::from_raw(0), + ))); + drop(store); + + let report = must_ok(recover_filesystem_store( + &root, + RecoveryAccessMode::ReadOnly, + )); + assert_eq!(report.tail_posture, RecoveryTailPosture::Clean); + assert_eq!(report.last_committed_lsn(), Some(Lsn::from_raw(1))); + must_ok(fs::remove_dir_all(root)); +} + +/// An uncommitted tail is not an empty epoch. +/// +/// A predecessor that wrote frames but committed none of them looks closure- +/// empty, but its coordinates are occupied on disk. Recovery must resolve or +/// truncate that tail before a successor may write, and the successor still +/// resumes after the predecessor's *committed* frames rather than reusing them. +#[test] +fn filesystem_successor_resumes_after_a_truncated_uncommitted_tail() { + let mut fixture = WalHardeningFixture::new("writer-epoch-tail"); + fixture.append_submission("tail-committed", Lsn::from_raw(0)); + fixture.append_uncommitted_tick_frame("tail-extra", Lsn::from_raw(2)); + let root = fixture.root.clone(); + drop(fixture); + + // Writable recovery resolves the tail before any successor may write. + let report = must_ok(recover_filesystem_store( + &root, + RecoveryAccessMode::Writable, + )); + assert_eq!( + report.tail_posture, + RecoveryTailPosture::TruncatedAfter(Lsn::from_raw(1)) + ); + + // The predecessor committed through LSN 1, so it is not empty: the + // successor advances past those frames instead of reusing their + // coordinates. + let mut store = must_ok(FilesystemWalStore::open(&root, WalSegmentId::from_raw(1))); + let successor = must_ok(store.acquire_fresh_writer_epoch(Lsn::from_raw(2))); + assert_eq!(successor.started_at_lsn, Lsn::from_raw(2)); + drop(store); + + must_ok(fs::remove_dir_all(root)); +} + +/// A predecessor with committed frames does advance its successor. +/// +/// The empty-epoch rule must not leak into the ordinary case: after a +/// predecessor that committed through `final_lsn`, the successor starts at +/// `final_lsn + 1` and reusing `final_lsn` is a regression. +#[test] +fn filesystem_successor_advances_past_committed_frames() { + let root = temp_wal_root("writer-epoch-committed"); + + // Epoch 1 commits one two-frame transaction at LSNs 0..=1. + { + let mut store = must_ok(FilesystemWalStore::open(&root, WalSegmentId::from_raw(1))); + must_ok(store.acquire_writer_epoch(writer_epoch_request())); + must_ok(store.append_transaction(submission_transaction( + "committed-predecessor", + Lsn::from_raw(0), + ))); + } + + // Even asked for a lower minimum, the successor may not reuse a spent + // coordinate. + let mut store = must_ok(FilesystemWalStore::open(&root, WalSegmentId::from_raw(1))); + let successor = must_ok(store.acquire_fresh_writer_epoch(Lsn::from_raw(0))); + assert_eq!(successor.started_at_lsn, Lsn::from_raw(2)); + drop(store); + + must_ok(fs::remove_dir_all(root)); +} + #[test] fn filesystem_writer_epoch_ledger_digest_mismatch_fails_closed() { let root = temp_wal_root("writer-epoch-ledger-digest"); diff --git a/crates/warp-core/tests/common/mod.rs b/crates/warp-core/tests/common/mod.rs index 59d075b02..9cd145366 100644 --- a/crates/warp-core/tests/common/mod.rs +++ b/crates/warp-core/tests/common/mod.rs @@ -15,11 +15,11 @@ use warp_core::{ compute_commit_hash_v2, make_edge_id, make_head_id, make_node_id, make_type_id, make_warp_id, ApplyResult, AtomPayload, AtomWriteSet, AttachmentKey, AttachmentSet, AttachmentValue, - ConflictPolicy, CursorId, EdgeId, EdgeRecord, Engine, EngineBuilder, Footprint, GlobalTick, - GraphStore, Hash, HashTriplet, LocalProvenanceStore, NodeId, NodeKey, NodeRecord, - OutputFrameSet, PatternGraph, ProvenanceEntry, ProvenanceStore, RewriteRule, SessionId, - TickCommitStatus, WarpId, WarpOp, WarpTickPatchV1, WorldlineId, WorldlineState, WorldlineTick, - WorldlineTickHeaderV1, WorldlineTickPatchV1, WriterHeadKey, + ConflictPolicy, CursorId, EdgeId, EdgeRecord, Engine, EngineBuilder, ExecutionGraphView, + Footprint, GlobalTick, GraphStore, Hash, HashTriplet, LocalProvenanceStore, NodeId, NodeKey, + NodeRecord, OutputFrameSet, PatternGraph, ProvenanceEntry, ProvenanceStore, RewriteRule, + RuleExecutor, SessionId, TickCommitStatus, WarpId, WarpOp, WarpTickPatchV1, WorldlineId, + WorldlineState, WorldlineTick, WorldlineTickHeaderV1, WorldlineTickPatchV1, WriterHeadKey, }; // ============================================================================= @@ -273,7 +273,7 @@ fn make_parallel_touch_rule() -> RewriteRule { // Match if the node exists view.node(scope).is_some() }, - executor: |view, scope, delta| { + executor: RuleExecutor::observed(|view: &mut ExecutionGraphView<'_, '_>, scope, delta| { // Phase 5: read from view, emit ops to delta (no direct mutation). let marker_payload = AtomPayload::new( parallel_marker_type_id(), @@ -286,7 +286,7 @@ fn make_parallel_touch_rule() -> RewriteRule { local_id: *scope, }); delta.push(WarpOp::SetAttachment { key, value }); - }, + }), compute_footprint: |view, scope| { let mut a_write = AttachmentSet::default(); if view.node(scope).is_some() { @@ -997,18 +997,20 @@ macro_rules! make_touch_rule { name: $rule_name, left: warp_core::PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: |view, scope, delta| { - let marker_payload = warp_core::AtomPayload::new( - warp_core::make_type_id($marker_type), - bytes::Bytes::from_static($marker_bytes), - ); - let value = Some(warp_core::AttachmentValue::Atom(marker_payload)); - let key = warp_core::AttachmentKey::node_alpha(warp_core::NodeKey { - warp_id: view.warp_id(), - local_id: *scope, - }); - delta.push(warp_core::WarpOp::SetAttachment { key, value }); - }, + executor: warp_core::RuleExecutor::observed( + |view: &mut warp_core::ExecutionGraphView<'_, '_>, scope, delta| { + let marker_payload = warp_core::AtomPayload::new( + warp_core::make_type_id($marker_type), + bytes::Bytes::from_static($marker_bytes), + ); + let value = Some(warp_core::AttachmentValue::Atom(marker_payload)); + let key = warp_core::AttachmentKey::node_alpha(warp_core::NodeKey { + warp_id: view.warp_id(), + local_id: *scope, + }); + delta.push(warp_core::WarpOp::SetAttachment { key, value }); + }, + ), compute_footprint: |view, scope| { let mut a_write = warp_core::AttachmentSet::default(); if view.node(scope).is_some() { diff --git a/crates/warp-core/tests/dpo_concurrency_litmus.rs b/crates/warp-core/tests/dpo_concurrency_litmus.rs index ed8d7851b..ad580e836 100644 --- a/crates/warp-core/tests/dpo_concurrency_litmus.rs +++ b/crates/warp-core/tests/dpo_concurrency_litmus.rs @@ -10,8 +10,8 @@ use echo_dry_tests::{motion_rule, port_rule, MOTION_RULE_NAME, PORT_RULE_NAME}; use warp_core::{ encode_motion_atom_payload, make_node_id, make_type_id, ApplyResult, AttachmentValue, Engine, - EngineError, Footprint, GraphStore, GraphView, NodeId, NodeRecord, PatternGraph, RewriteRule, - TickDelta, + EngineError, ExecutionGraphView, Footprint, GraphStore, GraphView, NodeId, NodeRecord, + PatternGraph, RewriteRule, RuleExecutor, TickDelta, }; const LITMUS_PORT_READ_0: &str = "litmus/port_read_0"; @@ -28,7 +28,12 @@ fn litmus_port_read_matcher(view: GraphView<'_>, scope: &NodeId) -> bool { view.node(scope).is_some() } -fn litmus_port_read_executor(_view: GraphView<'_>, _scope: &NodeId, _delta: &mut TickDelta) {} +fn litmus_port_read_executor( + _view: &mut ExecutionGraphView<'_, '_>, + _scope: &NodeId, + _delta: &mut TickDelta, +) { +} fn litmus_port_read_0_footprint(view: GraphView<'_>, scope: &NodeId) -> Footprint { let warp_id = view.warp_id(); @@ -58,7 +63,7 @@ fn litmus_port_read_0_rule() -> RewriteRule { name: LITMUS_PORT_READ_0, left: PatternGraph { nodes: Vec::new() }, matcher: litmus_port_read_matcher, - executor: litmus_port_read_executor, + executor: RuleExecutor::observed(litmus_port_read_executor), compute_footprint: litmus_port_read_0_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, @@ -72,7 +77,7 @@ fn litmus_port_read_1_rule() -> RewriteRule { name: LITMUS_PORT_READ_1, left: PatternGraph { nodes: Vec::new() }, matcher: litmus_port_read_matcher, - executor: litmus_port_read_executor, + executor: RuleExecutor::observed(litmus_port_read_executor), compute_footprint: litmus_port_read_1_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/duplicate_rule_registration_tests.rs b/crates/warp-core/tests/duplicate_rule_registration_tests.rs index 67bc0ef27..ba20abed3 100644 --- a/crates/warp-core/tests/duplicate_rule_registration_tests.rs +++ b/crates/warp-core/tests/duplicate_rule_registration_tests.rs @@ -4,14 +4,14 @@ use blake3::Hasher; use echo_dry_tests::{motion_rule, MOTION_RULE_NAME}; use warp_core::{ - make_node_id, make_type_id, ConflictPolicy, Engine, GraphStore, GraphView, NodeRecord, - PatternGraph, RewriteRule, TickDelta, + make_node_id, make_type_id, ConflictPolicy, Engine, ExecutionGraphView, GraphStore, GraphView, + NodeRecord, PatternGraph, RewriteRule, RuleExecutor, TickDelta, }; fn noop_match(_: GraphView<'_>, _: &warp_core::NodeId) -> bool { true } -fn noop_exec(_: GraphView<'_>, _: &warp_core::NodeId, _delta: &mut TickDelta) {} +fn noop_exec(_: &mut ExecutionGraphView<'_, '_>, _: &warp_core::NodeId, _delta: &mut TickDelta) {} fn noop_fp(_: GraphView<'_>, _: &warp_core::NodeId) -> warp_core::Footprint { warp_core::Footprint::default() } @@ -53,7 +53,7 @@ fn registering_duplicate_rule_id_is_rejected() { name: "motion/duplicate", left: PatternGraph { nodes: vec![] }, matcher: noop_match, - executor: noop_exec, + executor: RuleExecutor::observed(noop_exec), compute_footprint: noop_fp, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/external_consumer_contract_fixture_tests.rs b/crates/warp-core/tests/external_consumer_contract_fixture_tests.rs index a5c5bf6e6..a8b02e110 100644 --- a/crates/warp-core/tests/external_consumer_contract_fixture_tests.rs +++ b/crates/warp-core/tests/external_consumer_contract_fixture_tests.rs @@ -128,8 +128,12 @@ fn replace_matches(view: GraphView<'_>, scope: &NodeId) -> bool { warp_core::eint_vars_for_op(view, scope, REPLACE_RANGE_OP_ID).is_some() } -fn replace_execute(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { - let Some(vars) = warp_core::eint_vars_for_op(view, scope, REPLACE_RANGE_OP_ID) else { +fn replace_execute( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, +) { + let Some(vars) = warp_core::observed_eint_vars_for_op(view, scope, REPLACE_RANGE_OP_ID) else { return; }; let warp_id = view.warp_id(); @@ -177,7 +181,7 @@ fn replace_rule() -> warp_core::RewriteRule { name: REPLACE_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: replace_matches, - executor: replace_execute, + executor: warp_core::RuleExecutor::observed(replace_execute), compute_footprint: replace_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/installed_contract_intent_pipeline_tests.rs b/crates/warp-core/tests/installed_contract_intent_pipeline_tests.rs index fcd03bb5f..d0a0f31c5 100644 --- a/crates/warp-core/tests/installed_contract_intent_pipeline_tests.rs +++ b/crates/warp-core/tests/installed_contract_intent_pipeline_tests.rs @@ -159,8 +159,12 @@ fn contract_matches(view: GraphView<'_>, scope: &NodeId) -> bool { warp_core::eint_vars_for_op(view, scope, MUTATION_OP_ID) == Some(MUTATION_VARS) } -fn contract_execute(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { - if warp_core::eint_vars_for_op(view, scope, MUTATION_OP_ID) != Some(MUTATION_VARS) { +fn contract_execute( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, +) { + if warp_core::observed_eint_vars_for_op(view, scope, MUTATION_OP_ID) != Some(MUTATION_VARS) { return; } let warp_id = view.warp_id(); @@ -208,7 +212,7 @@ fn contract_rule() -> warp_core::RewriteRule { name: MUTATION_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: contract_matches, - executor: contract_execute, + executor: warp_core::RuleExecutor::observed(contract_execute), compute_footprint: contract_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, @@ -224,7 +228,11 @@ fn conflict_matches(view: GraphView<'_>, scope: &NodeId) -> bool { warp_core::eint_vars_for_op(view, scope, CONFLICT_OP_ID).is_some() } -fn conflict_execute(view: GraphView<'_>, _scope: &NodeId, delta: &mut TickDelta) { +fn conflict_execute( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + _scope: &NodeId, + delta: &mut TickDelta, +) { let warp_id = view.warp_id(); let result = shared_conflict_node_id(); delta.push(warp_core::WarpOp::UpsertNode { @@ -252,7 +260,7 @@ fn conflict_rule() -> warp_core::RewriteRule { name: CONFLICT_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: conflict_matches, - executor: conflict_execute, + executor: warp_core::RuleExecutor::observed(conflict_execute), compute_footprint: conflict_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/installed_contract_registry_tests.rs b/crates/warp-core/tests/installed_contract_registry_tests.rs index 6451ea190..45128590f 100644 --- a/crates/warp-core/tests/installed_contract_registry_tests.rs +++ b/crates/warp-core/tests/installed_contract_registry_tests.rs @@ -168,7 +168,7 @@ fn mutation_rule(name: &'static str) -> RewriteRule { name, left: PatternGraph { nodes: vec![] }, matcher: matches, - executor: execute, + executor: warp_core::RuleExecutor::legacy(execute), compute_footprint: footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/parallel_engine_worker_invariance.rs b/crates/warp-core/tests/parallel_engine_worker_invariance.rs index 173cbea80..e8e87bb35 100644 --- a/crates/warp-core/tests/parallel_engine_worker_invariance.rs +++ b/crates/warp-core/tests/parallel_engine_worker_invariance.rs @@ -384,6 +384,62 @@ fn worker_count_invariance_for_writer_advance() { } } +/// Per-Action evidence uses canonical pre-dispatch identity, not worker order. +#[test] +fn execution_evidence_order_is_worker_count_invariant() { + use warp_core::{ApplyResult, EngineBuilder, ExecutionFootprintEvidence, NodeRecord}; + + const TOUCH_RULE_NAME: &str = "t16e/touch"; + let make_touch_rule = || make_touch_rule!("t16e/touch", "t16e/marker", b"touched-t16e"); + let node_ty = warp_core::make_type_id("t16e/node"); + let mut base_store = warp_core::GraphStore::default(); + let root = warp_core::make_node_id("t16e/root"); + base_store.insert_node(root, NodeRecord { ty: node_ty }); + + let mut scopes = vec![root]; + for i in 1..20 { + let scope = warp_core::make_node_id(&format!("t16e/node{i}")); + base_store.insert_node(scope, NodeRecord { ty: node_ty }); + scopes.push(scope); + } + + let execute = |workers| -> Vec { + let mut engine = EngineBuilder::new(base_store.clone(), root) + .workers(workers) + .build(); + engine + .register_rule(make_touch_rule()) + .expect("failed to register rule"); + let tx = engine.begin(); + for scope in &scopes { + assert!(matches!( + engine.apply(tx, TOUCH_RULE_NAME, scope), + Ok(ApplyResult::Applied) + )); + } + engine.commit(tx).expect("commit failed"); + engine.last_execution_footprints().to_vec() + }; + + let baseline = execute(1); + assert_eq!(baseline.len(), scopes.len()); + assert_eq!( + baseline + .iter() + .map(ExecutionFootprintEvidence::sequence) + .collect::>(), + (0..u32::try_from(scopes.len()).expect("scope count fits u32")).collect::>() + ); + + for &workers in WORKER_COUNTS { + assert_eq!( + execute(workers), + baseline, + "execution evidence changed with {workers} workers" + ); + } +} + /// T16 variant: Worker count invariance with shuffled ingress order. /// /// This test combines worker count invariance with permutation invariance. diff --git a/crates/warp-core/tests/parallel_footprints.rs b/crates/warp-core/tests/parallel_footprints.rs index 2e4411352..2f78ab29a 100644 --- a/crates/warp-core/tests/parallel_footprints.rs +++ b/crates/warp-core/tests/parallel_footprints.rs @@ -77,11 +77,11 @@ fn t3_3_deletes_that_share_adjacency_bucket_must_conflict() { mod enforcement { use std::panic::{catch_unwind, AssertUnwindSafe}; use warp_core::{ - make_edge_id, make_node_id, make_type_id, make_warp_id, ApplyResult, AtomPayload, - AttachmentKey, AttachmentSet, AttachmentValue, ConflictPolicy, EdgeRecord, EdgeSet, Engine, - Footprint, FootprintViolation, FootprintViolationWithPanic, GraphStore, GraphView, NodeId, - NodeKey, NodeRecord, NodeSet, PatternGraph, PortSet, RewriteRule, TickDelta, ViolationKind, - WarpInstance, WarpOp, + make_edge_id, make_node_id, make_type_id, make_warp_id, ActualFootprintPosture, + ApplyResult, AtomPayload, AttachmentKey, AttachmentSet, AttachmentValue, ConflictPolicy, + EdgeRecord, EdgeSet, Engine, Footprint, FootprintViolation, FootprintViolationWithPanic, + GraphStore, GraphView, NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, PortSet, + RewriteRule, RuleExecutor, TickDelta, ViolationKind, WarpInstance, WarpOp, }; // ============================================================================= @@ -136,7 +136,7 @@ mod enforcement { name, left: PatternGraph { nodes: vec![] }, matcher: always_match, - executor, + executor: RuleExecutor::legacy(executor), compute_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -191,6 +191,183 @@ mod enforcement { ); } + #[test] + fn observed_execution_retains_reads_and_writes_before_enforcement_unwinds() { + let scope = make_node_id("observed-evidence-scope"); + let undeclared = make_node_id("observed-evidence-undeclared"); + let mut engine = build_enforcement_engine(scope); + engine + .register_rule(RewriteRule { + id: test_rule_id("test/observed-evidence"), + name: "test/observed-evidence", + left: PatternGraph { nodes: vec![] }, + matcher: always_match, + executor: RuleExecutor::observed(|view, _scope, delta| { + let undeclared = make_node_id("observed-evidence-undeclared"); + delta.push(WarpOp::UpsertNode { + node: NodeKey { + warp_id: view.warp_id(), + local_id: undeclared, + }, + record: NodeRecord { + ty: make_type_id("observed-evidence-node"), + }, + }); + let _ = view.node(&undeclared); + }), + compute_footprint: |_view, _scope| Footprint::default(), + factor_mask: 0, + conflict_policy: ConflictPolicy::Abort, + join_fn: None, + }) + .expect("register observed rule"); + + let tx = engine.begin(); + assert!(matches!( + engine.apply(tx, "test/observed-evidence", &scope), + Ok(ApplyResult::Applied) + )); + let result = catch_unwind(AssertUnwindSafe(|| engine.commit(tx))); + assert!( + result.is_err(), + "undeclared read must retain the ordinary panic" + ); + + let evidence = engine.last_execution_footprints(); + assert_eq!(evidence.len(), 1); + let record = &evidence[0]; + assert_eq!(record.scope(), scope); + assert!(record.posture().is_authoritative()); + assert_eq!( + record.actual().nodes_read().copied().collect::>(), + vec![undeclared] + ); + assert_eq!( + record.actual().nodes_write().copied().collect::>(), + vec![undeclared] + ); + } + + #[test] + fn successful_observed_execution_retains_authoritative_reads_and_writes() { + let scope = make_node_id("observed-success-scope"); + let target = make_node_id("observed-success-target"); + let mut engine = build_enforcement_engine(scope); + engine + .register_rule(RewriteRule { + id: test_rule_id("test/observed-success"), + name: "test/observed-success", + left: PatternGraph { nodes: vec![] }, + matcher: always_match, + executor: RuleExecutor::observed(|view, scope, delta| { + let target = make_node_id("observed-success-target"); + let _ = view.node(scope); + delta.push(WarpOp::UpsertNode { + node: NodeKey { + warp_id: view.warp_id(), + local_id: target, + }, + record: NodeRecord { + ty: make_type_id("observed-success-node"), + }, + }); + }), + compute_footprint: |view, scope| { + let mut footprint = Footprint::default(); + footprint.n_read.insert_with_warp(view.warp_id(), *scope); + footprint + .n_write + .insert_with_warp(view.warp_id(), make_node_id("observed-success-target")); + footprint + }, + factor_mask: 0, + conflict_policy: ConflictPolicy::Abort, + join_fn: None, + }) + .expect("register observed rule"); + + let tx = engine.begin(); + assert!(matches!( + engine.apply(tx, "test/observed-success", &scope), + Ok(ApplyResult::Applied) + )); + engine.commit(tx).expect("commit observed rule"); + + let evidence = engine.last_execution_footprints(); + assert_eq!(evidence.len(), 1); + let record = &evidence[0]; + assert_eq!( + record.posture(), + ActualFootprintPosture::RecordedAndEnforced + ); + assert_eq!( + record.actual().nodes_read().copied().collect::>(), + vec![scope] + ); + assert_eq!( + record.actual().nodes_write().copied().collect::>(), + vec![target] + ); + } + + #[test] + fn legacy_execution_retains_writes_but_marks_the_read_axis_unknown() { + let scope = make_node_id("legacy-evidence-scope"); + let target = make_node_id("legacy-evidence-target"); + let mut engine = build_enforcement_engine(scope); + engine + .register_rule(RewriteRule { + id: test_rule_id("test/legacy-evidence"), + name: "test/legacy-evidence", + left: PatternGraph { nodes: vec![] }, + matcher: always_match, + executor: RuleExecutor::legacy(|view, _scope, delta| { + delta.push(WarpOp::UpsertNode { + node: NodeKey { + warp_id: view.warp_id(), + local_id: make_node_id("legacy-evidence-target"), + }, + record: NodeRecord { + ty: make_type_id("legacy-evidence-node"), + }, + }); + }), + compute_footprint: |view, _scope| { + let mut footprint = Footprint::default(); + footprint + .n_write + .insert_with_warp(view.warp_id(), make_node_id("legacy-evidence-target")); + footprint + }, + factor_mask: 0, + conflict_policy: ConflictPolicy::Abort, + join_fn: None, + }) + .expect("register legacy rule"); + + let tx = engine.begin(); + assert!(matches!( + engine.apply(tx, "test/legacy-evidence", &scope), + Ok(ApplyResult::Applied) + )); + engine.commit(tx).expect("commit legacy rule"); + + let evidence = engine.last_execution_footprints(); + assert_eq!(evidence.len(), 1); + let record = &evidence[0]; + assert_eq!( + record.posture(), + ActualFootprintPosture::UnavailableLegacyExecutor + ); + assert!(!record.posture().read_axis_is_complete()); + assert!(!record.posture().is_authoritative()); + assert!(record.actual().nodes_read().next().is_none()); + assert_eq!( + record.actual().nodes_write().copied().collect::>(), + vec![target] + ); + } + // ============================================================================= // t3_5: NodeWriteNotDeclared — emits UpsertNode for undeclared target // ============================================================================= diff --git a/crates/warp-core/tests/provider_contract_admission_tests.rs b/crates/warp-core/tests/provider_contract_admission_tests.rs index a9b91e7da..2d4abd410 100644 --- a/crates/warp-core/tests/provider_contract_admission_tests.rs +++ b/crates/warp-core/tests/provider_contract_admission_tests.rs @@ -227,7 +227,7 @@ fn legacy_collision_package() -> InstalledContractPackage<'static> { name: LEGACY_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: counting_matcher, - executor: counting_executor, + executor: warp_core::RuleExecutor::legacy(counting_executor), compute_footprint: counting_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, @@ -461,7 +461,7 @@ fn host_with_provider_rule_name_reserved() -> TrustedRuntimeHost { name: rule_name, left: PatternGraph { nodes: vec![] }, matcher: counting_matcher, - executor: counting_executor, + executor: warp_core::RuleExecutor::legacy(counting_executor), compute_footprint: counting_footprint, factor_mask: 0, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/slice_theorem_proof.rs b/crates/warp-core/tests/slice_theorem_proof.rs index ad6faccb6..b2ed6b39a 100644 --- a/crates/warp-core/tests/slice_theorem_proof.rs +++ b/crates/warp-core/tests/slice_theorem_proof.rs @@ -81,7 +81,11 @@ fn rule_id(name: &str) -> warp_core::Hash { // ============================================================================= // R1: reads A, writes B attachment (writes known value V) -fn r1_executor(view: GraphView<'_>, _scope: &NodeId, delta: &mut TickDelta) { +fn r1_executor( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + _scope: &NodeId, + delta: &mut TickDelta, +) { let _ = view.node(&node_id(0)); let key = AttachmentKey::node_alpha(NodeKey { warp_id: view.warp_id(), @@ -124,7 +128,7 @@ fn r1_rule() -> RewriteRule { name: R1_NAME, left: PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: r1_executor, + executor: warp_core::RuleExecutor::observed(r1_executor), compute_footprint: r1_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -133,7 +137,11 @@ fn r1_rule() -> RewriteRule { } // R2: reads C, writes D attachment (independent) -fn r2_executor(view: GraphView<'_>, _scope: &NodeId, delta: &mut TickDelta) { +fn r2_executor( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + _scope: &NodeId, + delta: &mut TickDelta, +) { let _ = view.node(&node_id(2)); let key = AttachmentKey::node_alpha(NodeKey { warp_id: view.warp_id(), @@ -176,7 +184,7 @@ fn r2_rule() -> RewriteRule { name: R2_NAME, left: PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: r2_executor, + executor: warp_core::RuleExecutor::observed(r2_executor), compute_footprint: r2_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -185,7 +193,11 @@ fn r2_rule() -> RewriteRule { } // R3: reads E, writes F attachment (independent) -fn r3_executor(view: GraphView<'_>, _scope: &NodeId, delta: &mut TickDelta) { +fn r3_executor( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + _scope: &NodeId, + delta: &mut TickDelta, +) { let _ = view.node(&node_id(4)); let key = AttachmentKey::node_alpha(NodeKey { warp_id: view.warp_id(), @@ -228,7 +240,7 @@ fn r3_rule() -> RewriteRule { name: R3_NAME, left: PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: r3_executor, + executor: warp_core::RuleExecutor::observed(r3_executor), compute_footprint: r3_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -237,7 +249,11 @@ fn r3_rule() -> RewriteRule { } // R4: reads B attachment, writes G attachment (DEPENDENT on R1 — R1 writes B) -fn r4_executor(view: GraphView<'_>, _scope: &NodeId, delta: &mut TickDelta) { +fn r4_executor( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + _scope: &NodeId, + delta: &mut TickDelta, +) { let _ = view.node(&node_id(1)); let attachment = view.node_attachment(&node_id(1)); // Transform: if R1 has written, produce "r4-saw-r1", else "r4-no-input" @@ -293,7 +309,7 @@ fn r4_rule() -> RewriteRule { name: R4_NAME, left: PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: r4_executor, + executor: warp_core::RuleExecutor::observed(r4_executor), compute_footprint: r4_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -302,7 +318,11 @@ fn r4_rule() -> RewriteRule { } // R5: reads H, writes I attachment (independent) -fn r5_executor(view: GraphView<'_>, _scope: &NodeId, delta: &mut TickDelta) { +fn r5_executor( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + _scope: &NodeId, + delta: &mut TickDelta, +) { let _ = view.node(&node_id(7)); let key = AttachmentKey::node_alpha(NodeKey { warp_id: view.warp_id(), @@ -345,7 +365,7 @@ fn r5_rule() -> RewriteRule { name: R5_NAME, left: PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: r5_executor, + executor: warp_core::RuleExecutor::observed(r5_executor), compute_footprint: r5_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -354,7 +374,11 @@ fn r5_rule() -> RewriteRule { } // R6: reads J (in engine's root warp), attempts cross-warp emission into W2 -fn r6_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { +fn r6_executor( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, +) { let _ = view.node(scope); // Attempt to emit into W2 (wrong warp — our engine always uses make_warp_id("root")) let w2 = make_warp_id("slice-w2"); @@ -392,7 +416,7 @@ fn r6_rule() -> RewriteRule { name: R6_NAME, left: PatternGraph { nodes: vec![] }, matcher: |view, scope| view.node(scope).is_some(), - executor: r6_executor, + executor: warp_core::RuleExecutor::observed(r6_executor), compute_footprint: r6_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/tick_receipt_tests.rs b/crates/warp-core/tests/tick_receipt_tests.rs index 1412faa13..7845a223b 100644 --- a/crates/warp-core/tests/tick_receipt_tests.rs +++ b/crates/warp-core/tests/tick_receipt_tests.rs @@ -11,9 +11,10 @@ use echo_dry_tests::{motion_rule, MOTION_RULE_NAME}; use warp_core::{ encode_motion_atom_payload, make_node_id, make_type_id, scope_hash, AttachmentValue, - ConflictPolicy, Engine, Footprint, GraphStore, GraphView, Hash, NodeId, NodeKey, NodeRecord, - PatternGraph, RewriteRule, TickDelta, TickReceipt, TickReceiptDisposition, TickReceiptEntry, - TickReceiptPartsError, TickReceiptRejection, TxId, WarpId, + ConflictPolicy, Engine, ExecutionGraphView, Footprint, GraphStore, GraphView, Hash, NodeId, + NodeKey, NodeRecord, PatternGraph, RewriteRule, RuleExecutor, TickDelta, TickReceipt, + TickReceiptDisposition, TickReceiptEntry, TickReceiptPartsError, TickReceiptRejection, TxId, + WarpId, }; fn rule_id(name: &str) -> Hash { @@ -55,7 +56,7 @@ fn always_match(_: GraphView<'_>, _: &NodeId) -> bool { true } -fn exec_noop(_: GraphView<'_>, _: &NodeId, _delta: &mut TickDelta) {} +fn exec_noop(_: &mut ExecutionGraphView<'_, '_>, _: &NodeId, _delta: &mut TickDelta) {} fn other_of(scope: &NodeId) -> NodeId { NodeId(blake3::hash(&scope.0).into()) @@ -341,7 +342,7 @@ fn commit_with_receipt_records_multi_blocker_causality() { name: RULE_A, left: PatternGraph { nodes: vec![] }, matcher: always_match, - executor: exec_noop, + executor: RuleExecutor::observed(exec_noop), compute_footprint: fp_write_scope, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -354,7 +355,7 @@ fn commit_with_receipt_records_multi_blocker_causality() { name: RULE_B, left: PatternGraph { nodes: vec![] }, matcher: always_match, - executor: exec_noop, + executor: RuleExecutor::observed(exec_noop), compute_footprint: fp_write_scope, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -367,7 +368,7 @@ fn commit_with_receipt_records_multi_blocker_causality() { name: RULE_C, left: PatternGraph { nodes: vec![] }, matcher: always_match, - executor: exec_noop, + executor: RuleExecutor::observed(exec_noop), compute_footprint: fp_write_scope_and_other, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, diff --git a/crates/warp-core/tests/trusted_runtime_host_loop_tests.rs b/crates/warp-core/tests/trusted_runtime_host_loop_tests.rs index c2bdc9591..f535918ad 100644 --- a/crates/warp-core/tests/trusted_runtime_host_loop_tests.rs +++ b/crates/warp-core/tests/trusted_runtime_host_loop_tests.rs @@ -193,8 +193,12 @@ fn empty_engine() -> warp_core::Engine { .build() } -fn contract_execute(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { - if warp_core::eint_vars_for_op(view, scope, MUTATION_OP_ID) != Some(MUTATION_VARS) { +fn contract_execute( + view: &mut warp_core::ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, +) { + if warp_core::observed_eint_vars_for_op(view, scope, MUTATION_OP_ID) != Some(MUTATION_VARS) { return; } let warp_id = view.warp_id(); @@ -246,7 +250,7 @@ fn contract_rule() -> warp_core::RewriteRule { name: MUTATION_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: contract_matches, - executor: contract_execute, + executor: warp_core::RuleExecutor::observed(contract_execute), compute_footprint: contract_footprint, factor_mask: 0, conflict_policy: warp_core::ConflictPolicy::Abort, diff --git a/docs/adr/0027-first-class-falsification-witnesses.md b/docs/adr/0027-first-class-falsification-witnesses.md new file mode 100644 index 000000000..f1fea56b6 --- /dev/null +++ b/docs/adr/0027-first-class-falsification-witnesses.md @@ -0,0 +1,318 @@ + + + +# ADR 0027: First-Class Falsification Witnesses + +- **Status:** Proposed +- **Date:** 2026-08-01 + +## Context + +Echo admits meaning. It admits operation packages, invocations, causal anchors, +external-action settlements, and observations, and in each case it separates the +party that proposes something from the authority that admits it. Echo has no +corresponding category for the opposite fact: a durable, replayable record that +one of its own semantic claims was shown to be false. + +Today that evidence lives in test logs. A `proptest` failure produces a seed, a +shrunk case, and a nonzero exit code. None of those are admitted Echo facts. The +seed does not survive a strategy change. The shrunk case carries no basis, no +observer, no aperture, and no rights posture, so it cannot say _who_ was entitled +to observe the contradiction or _against what exact meaning_ it holds. The exit +code cannot distinguish a refuted property from an obstruction, a scheduler +rejection, a conflict, or a crash. + +The gap is concrete and already named in the repository. +`docs/topics/GeneratedRules.md` states that generated footprints are compile-time +claims, that runtime footprint checking is a generator-correctness oracle, and +that the `footprint_enforce_release` qualification lane is not wired into CI. +Echo asserts footprint honesty and cannot presently produce a durable artifact +demonstrating a violation of it. + +Three temptations are available and all three are wrong: + +- **A boolean on the claim.** Setting `claim.is_falsified` mutates the target + worldline to say it had a bug. It destroys the distinction between what Echo + admitted and what Echo later learned, and it makes the fact unverifiable — + a flag carries no experiment. +- **A specialized obstruction.** An obstruction says Echo could not complete + something. A falsification says Echo completed everything and the answer was + wrong. Collapsing them makes "the evaluator lacked authority" and "the property + is false" the same fact. +- **A richer test-report format.** Any format authored by the discovering tool + inherits that tool's trust. A fuzzer, a model checker, a remote Kitten, or a + human can all be buggy, nondeterministic, or adversarial. + +## Decision + +Echo gains a new admitted semantic-evidence category: the falsification witness. + +> **Anyone may discover and propose a counterexample. Only Echo may admit that +> the counterexample falsifies an exact property instance.** + +An admitted witness asserts one narrow proposition: + +> Given property **P**, its exact semantic closure and lawpack **L**, evaluation +> basis **B**, observer **O**, optic and aperture **A**, rights and budget +> posture **R**, and a replayable causal experiment **H**, Echo reproduced a +> reading **V** for which the property evaluator returned the typed violation +> class **C**. + +Four artifacts carry that proposition, with distinct trust postures: + +| Artifact | Trust posture | +| -------------------------------- | --------------------------- | +| `GeneratedPropertyV1` | Admitted authored meaning | +| `PropertyInstanceV1` | Echo-bound | +| `CounterexampleProposalV1` | Untrusted | +| `AdmittedFalsificationWitnessV1` | Authoritative Echo evidence | + +`docs/topics/FalsificationWitnesses.md` holds the field-level schemas, the +verification pseudocode, the reduction law, the threat table, and the delivery +sequence. This record fixes the boundaries those schemas must respect. + +### The trust boundary + +Discovery sits outside admission. A discovery engine's generator, seed, search +order, coverage map, heuristic score, and locally shrunk output are explanatory +provenance and nothing more. Echo recomputes every semantic fact. A proposal that +asserts a property failed has exactly the standing of a proposal that asserts +anything else: none, until Echo reproduces it. + +This mirrors the executable-operation corridor, where exact package bytes do not +independently confer a coordinate, installation, invocability, or authority, and +where the scheduler — not application code — owns private evaluation. A property +package is likewise not self-authorizing: a predicate-program digest cannot mint +a public property coordinate or grant observation rights. Installation begins +from an admitted package and policy. + +The verifier, slicer, reducer, and admission entry point are not methods on an +application-facing handle, for the same reason transitional direct operation +prepare/commit is hidden from `TrustedRuntimeApp`. Application code must not +choose when private evaluation or publication occurs. + +### The outcome taxonomy is a closed sum + +Property evaluation returns exactly one of `HoldsForCase`, `Violated`, +`Obstructed`, or `RuntimeFault`. + +`HoldsForCase` says the concrete case did not falsify the property. It is not a +proof of a universal statement. `Obstructed` says Echo could not complete the +required execution, observation, or interpretation under the bound contract — it +says nothing about whether the property holds. `RuntimeFault` says Echo failed to +maintain its own invariants; a fault is not promoted to a semantic refutation +merely because it occurred during property evaluation. Only `Violated` can lead +to an admitted witness. + +Echo does not get to blur these under load. A campaign that cannot obtain its +retained material reports obstruction, not refutation. + +### Admission requires fresh-host exact replay + +In-process re-evaluation is preflight evidence. It detects immediate +nondeterminism cheaply, and shared process state can mask exactly the +dependencies a witness must prove. It is not the admission boundary. + +Admission requires reconstructing the property instance on a fresh verification +host: exact package installation, exact basis reconstruction, submission through +ordinary Action ingress, scheduler-owned evaluation, observation under the bound +observer plan and aperture, and evaluation by the exact property program. This +follows the existing fail-closed pattern in operation recovery, which +re-evaluates the compiler-owned result projection over retained canonical input +and requires byte-for-byte equality before publishing. + +Echo must not label same-interpreter replay "independent." Independently +implemented replay is a higher evidence grade and a later goal. + +### Reduction preserves a typed violation class + +A reducer that only preserves "some failure" can silently walk from one bug to +another and present the result as a minimal example of the first. Every accepted +reduction step must preserve the property-declared violation class under an +explicit `ViolationEquivalencePolicyV1`. + +The interestingness predicate is therefore not "the process exited nonzero." A +candidate is interesting only if it is canonically valid, replays from the exact +bound basis, observes successfully under the exact bound observer, aperture, and +rights, returns `Violated`, and returns an equivalent violation. + +Aperture is part of the claim, not a reduction target. It may be reduced only +when the property explicitly quantifies over apertures, and the result is a new +`PropertyInstanceV1`. + +### Minimality is always qualified + +Echo claims `LocallyIrreducible` or `BudgetExhausted`, never an unqualified +global minimum. `ExhaustivelyMinimal` exists but is reserved for explicitly +finite bounded domains with an enumeration certificate. + +Reduction budgets are deterministic counters — candidate evaluations, Actions +replayed, scheduler passes, property evaluations, retained bytes loaded, phases, +dependency edges. Wall-clock deadlines may protect an operator but must never +participate in the canonical result, because host speed differs. An interrupted +campaign records `ExternallyInterrupted` or `ReductionObstructed`; it must not +claim the deterministic budget was exhausted. + +Minimality includes least sufficient revelation, not only fewest Actions. + +### Two identities, not one + +`SemanticCounterexampleIdV1` commits the property instance, minimized case, +reduced experiment, violation class, and violating read coordinate. It excludes +fuzzer name, seed, shrinker version, submitter, reduction trace, host identity, +byte placement, and admission coordinate. It answers: _is this the same semantic +counterexample?_ + +`FalsificationArtifactIdV1` commits the semantic id plus the full evidence +envelope. It answers: _is this the same admitted evidence envelope?_ + +Two tools finding the same case must converge on one semantic identity while +retaining distinct provenance. Reducer search traces must stay out of the +semantic identity, because reducer algorithms will improve and the semantic +object is the refuting case, not the path that found it. + +This extends the existing retained-evidence distinction, where the semantic +coordinate says what question bytes answer while the content hash identifies the +bytes. + +### The target worldline is never rewritten + +Verification runs on private hosts or strands. The admitted witness is appended +to a separate evidence worldline whose history says Echo admitted evidence +_about_ the target history. The admission transaction affects exactly one +evidence-worldline frontier and zero target-worldline frontiers. + +The evidence worldline's state is an append-only catalog. Derived indexes — +witnesses per property, current claim posture, regression sets — are disposable +and rebuildable from the WAL. + +An old witness remains permanently valid against its original property instance. +A fix does not retroactively invalidate historical evidence; reapplication +against a successor property produces a _new_ artifact carrying +`StillFalsifies`, `NoLongerFalsifies`, `Inapplicable`, or `Obstructed`. A release +gate asks whether successor outcomes are acceptable, never whether old witnesses +have been deleted. + +### Durability precedes publication + +The witness commits through one failure-atomic WAL transaction under +`WalAppendAuthority::AdmissionKernel`, which already exists and already governs +causal-anchor admission. The transaction kind is new; so are its record kinds. +Nothing — witness, index, or receipt — is published before the transaction is +committed and flushed. + +Recovery revalidates shape and authority, decodes bounded payloads canonically, +recomputes both identities, cross-checks the receipt, and rebuilds indexes. A +missing retained object must not make a witness disappear: recovery surfaces it +as evidence-unavailable or replay-obstructed, preserving the historical fact that +it was admitted while refusing to claim it is presently replayable. This is the +posture retained evidence already takes, where missing material obstructs +explicitly instead of becoming an empty read, a cache hit, or a generic failure. + +### Enforcement posture is part of the evidence + +The footprint guard is compiled out unless `debug_assertions` or +`footprint_enforce_release` is enabled. A witness produced under a build where +the guard was inert demonstrates nothing. The enforcement posture is recorded in +the replay certificate and checked at admission. + +## Consequences + +### The first vertical is footprint honesty, and it is blocked on a real gap + +`GeneratedFootprintSoundness@1` states `Actual ⊆ Declared` for reads and writes. +Verification established that Echo cannot evaluate this today, for a reason +sharper than expected: `FootprintGuard` holds only the declared sets and has no +accumulator. It compares each access against what was declared and panics on a +miss. Nothing records what an Action actually touched. + +`FootprintViolation` does not close the gap. It is a `std::panic::panic_any` +payload naming the single access that tripped the guard, so catching the unwind +yields one violating access rather than an actual footprint. + +The guard must therefore grow an opt-in accumulating sink, additive to the +existing checker, leaving the panic path unchanged. The panic is the correct +response in ordinary execution, where an undeclared access is a programmer error +rather than a recoverable condition, and this record does not authorize removing +it. + +Because the guard is constructed once per rule execution and pre-filtered to one +warp, an accumulator hung off that instance is per-Action by construction. No new +observation projection is required: actual footprints reach the read-only +evaluator on the execution-evidence channel, which keeps the bound observation +aperture untouched. + +### Fixtures must not be manufactured by weakening admission + +The production-shaped fixture is a _false property_ over a _lawful_ operation: a +valid operation reads `A` and `B` while a deliberately false property claims its +reads are contained in `{A}`. A second fixture exercises the existing guard +through a provider or Wesley callback under `footprint_enforce_release`, and its +evidence grade must state that provider-native callback replay depends on +reinstalling the exact ambient callback implementation. + +Forcing an invalid footprint into an executable-operation package to exercise the +witness system is rejected. The operation corridor's package/program/footprint +closure is a security property, not a test inconvenience. + +### WAL rollout is reader-first + +The WAL decoder rejects unknown transaction and record codes rather than skipping +them. New writers must not emit falsification records until every reader capable +of opening that WAL is upgraded: ship decoders and recovery logic first, advertise +capability, then activate writing behind an upgraded writer epoch, preferably at +a segment boundary. An older binary meeting the new epoch must refuse read-write +activation rather than truncate. + +Retained evidence gains new explicit roles with append-only stable tags rather +than overloading `RetainedEvidenceRole::Witness`. Existing identities are +unchanged because existing tags do not move. + +### Costs accepted + +- **Fresh-host replay is expensive.** Reconstructing a host per reduction + candidate may dominate campaign cost. Snapshot-plus-suffix optimization is + permitted only after proving equivalence to fresh reconstruction. +- **Campaigns are not Ticks.** A reduction campaign spans hundreds or thousands + of bounded replays and cannot occupy one atomic scheduler unit. A campaign + coordinator is required, and no attempt gains authority from being requested by + it. +- **A shared evaluator can share a defect.** Property evaluation and replay + verification run on the same implementation, so a common bug is invisible. + Evidence grades must say so honestly. +- **More surface to keep honest.** Four artifacts, two identity laws, a reduction + law, and a new WAL transaction kind are a significant addition to a runtime + that already carries a large operation surface. + +### Alternatives rejected + +| Alternative | Why rejected | +| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | +| Store only a property-test seed | Strategy and RNG changes silently destroy the case; nondeterministic strategies break seed persistence outright. | +| Treat a test runner's shrink result as authoritative | Imports the discovering tool's trust into Echo's evidence. | +| Mutate the target worldline with a bug flag | Destroys the admitted/learned distinction and carries no experiment. | +| Model falsification as an obstruction | Conflates "could not complete" with "completed and was wrong." | +| Call every failed replay a falsification | Promotes obstructions and runtime faults into semantic refutations. | +| Claim global minimality from a local reducer | Unprovable and unstable across reducer revisions. | +| Allow silent rebasing of a witness | A changed basis is a different property instance. | +| Allow aperture widening during replay | Manufactures contradictions by observing more than the claim entitled. | +| Reuse `CausalSuffixBundle` as a replay package | It is a shape-only witnessed shell, not a materialized, executable replay bundle. | +| Treat same-interpreter replay as independent evidence | Overstates the evidence grade. | +| Weaken executable-operation admission to build a dishonest fixture | Trades a security property for test convenience. | +| Admit a witness under inert footprint enforcement | The experiment proves nothing if the guard was compiled out. | + +## References + +- `docs/topics/FalsificationWitnesses.md` — schemas, verification pseudocode, + reduction law, threat model, test matrix, and delivery roadmap. +- [ADR 0014](0014-generated-rule-authorship-and-footprints.md) — generated rule + authorship and footprint honesty. +- [ADR 0021](0021-public-optic-observation-boundary.md) — public WARP optic over + internal observation. +- [ADR 0023](0023-admitted-executable-operation-packages.md) — admitted + executable operation packages. +- [ADR 0025](0025-scheduler-owned-executable-operation-actions.md) — + scheduler-owned executable-operation Actions. +- `docs/topics/GeneratedRules.md` — the stated absence of a false-footprint + negative oracle. +- `docs/topics/WAL.md` — WAL truth boundary. diff --git a/docs/adr/README.md b/docs/adr/README.md index a971974cc..57bbd0ef3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -48,6 +48,7 @@ track work, progress, priority, or release readiness. | [0024](0024-anchored-node-creation-from-absence.md) | Accepted | Anchored-node creation as a separate executable program | | [0025](0025-scheduler-owned-executable-operation-actions.md) | Accepted | Scheduler-owned executable-operation Actions | | [0026](0026-durable-external-action-settlement.md) | Accepted | Request-before-effect and settlement-before-resumption | +| [0027](0027-first-class-falsification-witnesses.md) | Proposed | Admitted falsification witnesses over exact property instances | ADR 0006 predates this index contract and did not declare a status. Its superseded tombstone preserves that fact without silently ratifying the old diff --git a/docs/invariants/DECLARATIVE-RULE-AUTHORSHIP.md b/docs/invariants/DECLARATIVE-RULE-AUTHORSHIP.md index 017d03817..7a408a72b 100644 --- a/docs/invariants/DECLARATIVE-RULE-AUTHORSHIP.md +++ b/docs/invariants/DECLARATIVE-RULE-AUTHORSHIP.md @@ -67,10 +67,13 @@ surface. ### R2 — Native rewrite functions are bootstrap-only -`RewriteRule`, `MatchFn`, and `ExecuteFn` are bootstrap-only trusted-code -surfaces. They MAY exist for engine internals, internal system rules, -transitional bootstrap code, and tests, but they MUST NOT be treated as the -long-term public authoring boundary for application rewrite logic. +`RewriteRule`, `MatchFn`, `ObservedExecuteFn`, legacy `ExecuteFn`, and +`RuleExecutor` are bootstrap-only trusted-code surfaces. They MAY exist for +engine internals, internal system rules, transitional bootstrap code, and tests, +but they MUST NOT be treated as the long-term public authoring boundary for +application rewrite logic. Native bootstrap rules use the observed executor ABI +so Echo can retain their actual reads; compatibility callbacks that cannot be +observed carry an explicitly non-authoritative evidence posture. Echo's default public Rust API MUST NOT expose native rewrite authoring as a supported extension surface. If a temporary bootstrap seam remains, it must be diff --git a/docs/topics/CausalAnchors.md b/docs/topics/CausalAnchors.md index 8c1871da4..9fba26edc 100644 --- a/docs/topics/CausalAnchors.md +++ b/docs/topics/CausalAnchors.md @@ -33,7 +33,7 @@ The boundary is: | CAS object | A hash addresses bytes, facts, manifests, or projection material; availability requires separate CAS evidence. | | Projection cache | Derived observer-relative materialization can be reused when its basis, aperture, observer authority, policy, schema, evaluator, and coverage match. | | Causal-anchor claim | A canonical claim binds a subject, supplied basis-frontier digest, root sets, and purpose without claiming admission. | -| Trusted admitted anchor | Echo validated a current logical basis and host-owned root support, then committed the claim and receipt atomically through the causal WAL. | +| Trusted admitted anchor | Echo validated a current logical basis and host-owned root support, then committed the claim and receipt atomically through the causal WAL. | | Domain checkpoint | An application explains what an Echo anchor means in domain terms. | A graph-wide materialized checkpoint may exist as an export, backup, or diagnostic @@ -423,13 +423,13 @@ not delete authority, and it must be reconstructible from the ordered history. The authority questions have these concrete answers: -| Question | Answer | -| --- | --- | -| What is authoritative after restart? | The committed anchor fact/receipt transition in witnessed Echo control history. | -| Is `causal_anchor_by_id()` a WAL-owned registry? | No. It is a projection over reconstructed witnessed history; the WAL only carries the committed transition. | -| Are lookup maps disposable? | Yes. No persistent anchor lookup map is required for correctness. | -| Can anchors participate in basis-pinned observations and provenance? | Yes. Witnessed entries bind pre- and post-admission frontiers, the admitted fact, its receipt, and durable commit evidence. | -| Can anchors participate in Continuum exchange? | Yes. WSC causal-history profile version 2 carries explicit anchor fact, receipt, transaction, LSN, and commit evidence without granting local admission authority. | +| Question | Answer | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| What is authoritative after restart? | The committed anchor fact/receipt transition in witnessed Echo control history. | +| Is `causal_anchor_by_id()` a WAL-owned registry? | No. It is a projection over reconstructed witnessed history; the WAL only carries the committed transition. | +| Are lookup maps disposable? | Yes. No persistent anchor lookup map is required for correctness. | +| Can anchors participate in basis-pinned observations and provenance? | Yes. Witnessed entries bind pre- and post-admission frontiers, the admitted fact, its receipt, and durable commit evidence. | +| Can anchors participate in Continuum exchange? | Yes. WSC causal-history profile version 2 carries explicit anchor fact, receipt, transaction, LSN, and commit evidence without granting local admission authority. | ### Continuum exchange posture diff --git a/docs/topics/FalsificationWitnesses.md b/docs/topics/FalsificationWitnesses.md new file mode 100644 index 000000000..e8f047bc2 --- /dev/null +++ b/docs/topics/FalsificationWitnesses.md @@ -0,0 +1,1792 @@ + + + +# Falsification witnesses + +**Status:** proposed. Roadmap stages 1 and 2 are implemented; stages 3 through +14 remain open. ADR 0027 remains Proposed. This document is both the design for +first-class falsification artifacts and the roadmap that sequences their +delivery. + +**Governing rule:** + +> Anyone may discover and propose a counterexample. Only Echo may admit that the +> counterexample falsifies an exact property instance. + +Every source anchor below is `path#Lline@c354d5316`. Claims that could not be +anchored to code are marked **(unbuilt)** and are design intent, not present +behaviour. + +## Executive summary + +Echo should implement falsification as a new **admitted semantic-evidence +category**, not as a test-log format, a specialized obstruction, a mutable +`claim.is_falsified` flag, or a bag of `proptest` metadata. + +An admitted falsification witness asserts a narrow proposition: + +> Given property **P**, its exact semantic closure and lawpack **L**, evaluation +> basis **B**, observer **O**, optic and aperture **A**, rights and budget +> posture **R**, and a replayable causal experiment **H**, Echo reproduced a +> reading **V** for which the property evaluator returned the typed violation +> class **C**. + +That proposition is materially different from an obstruction, scheduler +rejection, conflict, crash, or uncommitted preparation. The existing +executable-operation design already separates package identity, invocation +admission, exact basis, private evaluation, actual footprint, scheduler +composition, receipt, WAL material, and terminal outcome. Falsification reuses +those propositions without collapsing them. + +Four public artifact types: + +| Artifact | Trust posture | Purpose | +| -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GeneratedPropertyV1` | Admitted authored meaning | Defines an executable semantic claim, observation contract, violation classifier, reduction law, and resource bounds. | +| `PropertyInstanceV1` | Echo-bound | Binds that property to one exact target basis, observer plan or instance, optic, aperture, rights posture, and property parameters. | +| `CounterexampleProposalV1` | Untrusted | Carries a candidate case or Action sequence plus discovery provenance such as fuzzer, seed, generator, and shrinker versions. | +| `AdmittedFalsificationWitnessV1` | Authoritative Echo evidence | Carries the minimized replayable causal experiment, violating reading, typed violation, replay certificate, minimization posture, and dual identities. | + +Core implementation decisions: + +1. **Property evaluation is read-only and bounded.** It may inspect exact + retained execution and observation evidence but may not mutate the target + worldline. +2. **Discovery and admission are separate.** A fuzzer, human, model checker, + Graft, or remote Kitten can propose a case; none may mint an Echo + falsification witness. +3. **Fresh-host exact replay is required for admission.** In-process replay is + useful preflight evidence but is not the admission boundary. +4. **Reduction preserves a typed violation class**, not merely "some failure." + Otherwise the reducer can silently jump from one bug to another. +5. **Minimality is explicitly qualified.** Version one normally claims + `LocallyIrreducible` or `BudgetExhausted`, never an unqualified global + minimum. +6. **The target worldline is never rewritten to say it had a bug.** The witness + is appended to a separate evidence worldline and cites the target history. +7. **Semantic counterexample identity is separate from the exact + artifact-envelope identity.** Discovery provenance and reduction traces must + not fragment the identity of the underlying counterexample. +8. **The first vertical is footprint honesty, without weakening + executable-operation admission to manufacture a liar.** Use a false generated + property over a lawful hook-free operation as the production-shaped witness, + plus a compatibility fixture exercising the existing footprint guard. + +## What already exists + +These are the load-bearing seams the design reuses. Each is verified against the +worktree at `c354d5316`. + +| Seam | Where | What it gives falsification | +| ----------------------------- | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Exact evaluation basis | `crates/warp-core/src/echo_operation.rs#L2614@c354d5316` | `EchoOperationEvaluationBasisV1` binds writer head, worldline tick, optional commit global tick, state root, commit id, and an application basis. This is the basis a `PropertyInstanceV1` pins. | +| Application basis proposition | `crates/warp-core/src/echo_operation.rs#L2584@c354d5316` | `EchoOperationApplicationBasisV1` separates schema identity from value identity. | +| Retained evidence roles | `crates/warp-core/src/retained_evidence.rs#L24@c354d5316` | `RetainedEvidenceRole` has six variants with stable tags `0..=5` (`crates/warp-core/src/retained_evidence.rs#L39@c354d5316`). Falsification appends new tags rather than moving old ones. | +| Missing-evidence honesty | `crates/warp-core/src/retained_evidence.rs#L3@c354d5316` | "CAS names bytes. These references name retained evidence under contract semantics so missing material can obstruct explicitly instead of becoming an empty read, cache hit, or generic runtime failure." This is exactly the posture a witness needs when its replay material is gone. | +| Observation frame | `crates/warp-core/src/observation.rs#L126@c354d5316` | `ObservationFrame` = `CommitBoundary` \| `RecordedTruth` \| `QueryView`. | +| Observation projection kinds | `crates/warp-core/src/observation.rs#L146@c354d5316` | `ObservationProjectionKind` = `Head` \| `Snapshot` \| `TruthChannels` \| `Query`. The frame/projection validity matrix is enforced at `crates/warp-core/src/observation.rs#L2344@c354d5316`. | +| Observer plan | `crates/warp-core/src/observation.rs#L607@c354d5316` | `ReadingObserverPlan` = `Builtin { plan }` \| `Authored { plan }`. A property instance pins one of these, not "a plan with similar output." | +| Footprint guard | `crates/warp-core/src/footprint_guard.rs#L120@c354d5316` | `FootprintViolation { rule_name, warp_id, kind, op_kind }` with `ViolationKind` at `crates/warp-core/src/footprint_guard.rs#L89@c354d5316`. | +| WAL transaction kinds | `crates/warp-core/src/causal_wal.rs#L323@c354d5316` | Twelve kinds, stable codes `1..=12`. **Next free transaction code is 13.** | +| WAL append authority | `crates/warp-core/src/causal_wal.rs#L304@c354d5316` | `WalAppendAuthority::AdmissionKernel` already exists and is required by `CausalAnchorAdmission` (`crates/warp-core/src/causal_wal.rs#L383@c354d5316`). Falsification admission reuses it. | +| WAL record kinds | `crates/warp-core/src/causal_wal.rs#L424@c354d5316` | Includes `RetainedMaterialRefRecorded`. Highest stable record code in use is 31; **next free record code is 32.** | +| Unknown-code rejection | `crates/warp-core/src/causal_wal.rs#L414@c354d5316` | `from_code` returns `WalDecodeError::UnknownEnumCode` rather than skipping. This is why rollout must be reader-first. | +| Shape-only suffix bundle | `crates/echo-wasm-abi/src/kernel_port.rs#L2341@c354d5316` | `CausalSuffixBundle` is a "witnessed suffix bundle exchanged across a hot/cold runtime boundary" carrying a `WitnessedSuffixShell` and a digest. It is not a replay package. | +| Read identity | `crates/echo-wasm-abi/src/kernel_port.rs#L810@c354d5316` | `ReadIdentity` is the observation identity a witness cites. | +| Footprint enforcement flag | `crates/warp-core/Cargo.toml#L65@c354d5316` | `footprint_enforce_release` feature; enforcement is otherwise `debug_assertions`-only (`crates/warp-core/src/lib.rs#L86@c354d5316`) and is mutually exclusive with `unsafe_graph` (`crates/warp-core/src/lib.rs#L22@c354d5316`). | +| Stated CI gap | `docs/topics/GeneratedRules.md#L269@c354d5316` | "The `footprint_enforce_release` qualification lane is not wired into CI." | + +### Corrections applied to the source draft + +Four claims in the originating draft did not survive verification. They are +corrected here, and the corrections are load-bearing. + +1. **`FootprintViolation` is a panic payload, not a returned value.** The + doc comment at `crates/warp-core/src/footprint_guard.rs#L117@c354d5316` says + it is the "violation payload for `std::panic::panic_any`," matchable via + `downcast_ref::()`. It reaches a caller through unwind, + not through a `Result`. A property evaluator therefore cannot simply receive + it — and more fundamentally, the guard accumulates nothing, so the actual + footprint the property must compare against does not exist anywhere. See + [The guard checks; it does not record](#the-guard-checks-it-does-not-record). +2. **There is no `ExecutableActionEvidence` observation projection.** The only + projections are `Head`, `Snapshot`, `TruthChannels { channels }`, and + `Query { query_id, vars_bytes }`, and `RecordedTruth` is valid only with + `TruthChannels` (`crates/warp-core/src/observation.rs#L2344@c354d5316`). + Actual per-Action footprints now exist in scheduler-owned execution evidence. + Stage 3 will deliver them to the property evaluator on the execution-evidence + channel rather than through a new projection, so that the bound observation + aperture is not widened. +3. **`WalAppendAuthority::AdmissionKernel` already exists** and does not need to + be added; only a new transaction kind and record kinds are new. +4. **The guard reports `NodeReadNotDeclared`-style variants, not a generic + "undeclared access."** The violation classifier must project + `ViolationKind` faithfully, including `CrossWarpEmission`, + `UnauthorizedInstanceOp`, and `OpWarpUnknown`, which are not read/write + footprint violations at all and must not be merged into one class. + +## Architectural model and trust boundary + +Three separations that ordinary property-test runners blur: + +| Separation | Why Echo needs it | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- | +| Discovery versus admission | The party finding a case may be buggy, malicious, nondeterministic, or running a different implementation. | +| Experiment execution versus claim observation | Executing an Action lawfully does not by itself determine what a particular observer and aperture may claim about it. | +| Semantic counterexample versus evidence envelope | The same counterexample may be found by different tools and wrapped in different replay or reduction evidence. | + +```mermaid +flowchart TD + GP[GeneratedPropertyV1
admitted semantic claim] + PI[PropertyInstanceV1
exact basis + observer + aperture] + D[Discovery engines
proptest, fuzzer, human, model checker, remote Kitten] + CP[CounterexampleProposalV1
untrusted candidate] + PV[Echo proposal validator
canonical and bounded] + RV[Echo replay verifier
private verification lane] + SL[Causal slicer
conservative dependency closure] + RD[Deterministic reducer
typed violation preserving] + FH[Fresh-host replay verifier] + AW[AdmittedFalsificationWitnessV1] + EW[Evidence worldline + WAL] + CI[Derived claim posture and regression index] + REF[Typed refusal or obstruction] + + GP --> PI + D --> CP + PI --> CP + CP --> PV + PV --> RV + RV -->|violation reproduced| SL + SL --> RD + RD --> FH + FH -->|exact verification succeeds| AW + AW --> EW + EW --> CI + + RV -->|holds, obstructed, malformed, or faulted| REF + FH -->|mismatch| REF +``` + +**Discovery engines are outside the admission trust boundary.** Their generator, +seed, search order, coverage map, heuristic score, and locally shrunk output are +explanatory provenance. Echo must not accept their statement that a case failed. +This mirrors the operation path: exact package bytes do not independently confer +an operation coordinate, installation, invocability, or authority, and the +scheduler — not application code — owns private evaluation. + +**The property package is not self-authorizing.** `GeneratedPropertyV1` may carry +an evaluator program, but installation begins from an admitted property package +and policy, exactly as executable-operation installation begins from an admitted +operation package rather than a naked program digest +(`crates/warp-core/src/echo_operation.rs#L153@c354d5316` distinguishes +`EchoOperationPackageIdV1` from `EchoOperationProgramIdV1` at +`crates/warp-core/src/echo_operation.rs#L166@c354d5316`). A property evaluator +digest cannot mint a public property coordinate or grant observation rights. + +**Property evaluation returns a closed outcome sum:** + +```rust +pub enum PropertyEvaluationOutcomeV1 { + HoldsForCase { + evaluation_id: PropertyEvaluationIdV1, + reading_id: ReadIdentity, + }, + Violated { + evaluation_id: PropertyEvaluationIdV1, + violation_class_id: ViolationClassIdV1, + violation_payload: RetainedEvidenceRef, + reading_id: ReadIdentity, + }, + Obstructed { + evaluation_id: PropertyEvaluationIdV1, + obstruction: ContractObstruction, + }, + RuntimeFault { + evaluation_id: PropertyEvaluationIdV1, + fault_id: RuntimeFaultId, + }, +} +``` + +`ContractObstruction` is the existing type at +`crates/warp-core/src/contract_obstruction.rs@c354d5316`, already imported by +retained evidence (`crates/warp-core/src/retained_evidence.rs#L11@c354d5316`). + +- `HoldsForCase` means only that the concrete case did not falsify the property. + It is not a proof of a universal statement. A finite run can find a + counterexample; successful sampled cases remain finite testing evidence. +- `Obstructed` means Echo could not complete the required execution, observation, + or interpretation under the bound contract. It does not mean the property held + or failed. +- `RuntimeFault` means Echo failed to maintain its own runtime invariants. A + fault is not promoted into a semantic refutation merely because it happened + while evaluating a property. +- `Violated` means the exact property evaluator lawfully received its required + reading and returned a typed negative result. This is the only path toward an + admitted falsification witness. + +**Long campaigns must not be single Ticks.** One proposal intake may be an +ordinary Action, but search and reduction can involve hundreds or thousands of +replays. Tick semantics intentionally make one scheduler decision, one private +successor, one receipt, one provenance advance, and one failure-atomic WAL +transaction; `ExecutableOperationTick` +(`crates/warp-core/src/causal_wal.rs#L341@c354d5316`) commits exactly one +executable-operation consequence under `WalAppendAuthority::ExecutionKernel` +(`crates/warp-core/src/causal_wal.rs#L384@c354d5316`). Stuffing a whole reduction +campaign into that atomic unit would create an unbounded scheduler job. + +Campaign state machine: + +```text +AcceptedProposal + -> CandidateReproduced + -> SliceComputed + -> ReductionInProgress + -> ReductionTerminal + -> FreshHostVerified + -> WitnessAdmitted + +Terminal alternatives: + ProposalRefused + ReproductionObstructed + NoViolationReproduced + ReductionObstructed + ReductionBudgetExhausted + FreshHostMismatch + RuntimeFaulted +``` + +Each replay attempt is bounded and independently attributable. The campaign +coordinator may schedule many such attempts, but no attempt gains authority +merely because the coordinator requested it. + +## Artifact schemas and API placement + +The schemas below are deliberately verbose. Falsification is exactly the place +where "we can infer that later" becomes future archaeology. + +### Generated property schema + +| Field | Type | Required semantic meaning | +| ------------------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `schema_version` | `u32` | Canonical artifact version. | +| `property_coordinate` | `Coordinate` | Public authored name of the semantic claim. | +| `property_package_id` | `GeneratedPropertyPackageIdV1` | Content identity of exact canonical package bytes. | +| `semantic_closure_digest` | `Hash` | Binds authored source, canonical meaning, Core/IR, compiler profile, and imported resources. | +| `lawpack_coordinate` | `Coordinate` | Exact governing lawpack. | +| `lawpack_digest` | `Hash` | Exact lawpack content identity. | +| `subject_contract` | `PropertySubjectContractV1` | Which operation package, program, Action kind, history family, or evidence category is quantified over. | +| `quantified_domain` | `DomainRefV1` | Canonical case domain or parameter schema. | +| `case_codec` | `CodecRefV1` | Exact canonical encoding for concrete cases. | +| `basis_contract` | `BasisContractRefV1` | Required runtime and application basis fields. | +| `observation_contract` | `ObservationContractRefV1` | Required frame, projection, observer-plan class, optic, aperture schema, rights class, and freshness rules. | +| `predicate_program` | `PropertyProgramRefV1` | Echo-interpreted read-only property evaluator. | +| `predicate_program_digest` | `Hash` | Exact evaluator bytes. | +| `evaluator_abi` | `ProfileRefV1` | Versioned property-evaluator ABI. | +| `intrinsic_profile` | `ProfileRefV1` | Digest-locked deterministic intrinsic set. | +| `violation_schema` | `SchemaRefV1` | Canonical typed violation payload schema. | +| `violation_classifier` | `ViolationClassifierRefV1` | Defines stable failure classes used to prevent reducer bug-hopping. | +| `violation_equivalence_policy` | `ViolationEquivalencePolicyV1` | Says what must remain equivalent during reduction. | +| `reduction_law` | `ReductionLawRefV1` | Ordered, versioned, digest-locked candidate transformations. | +| `declared_budget` | `PropertyBudgetV1` | Evaluation and observation bounds for one case. | +| `reduction_budget_ceiling` | `ReductionBudgetV1` | Maximum campaign work this package permits. | +| `authority_requirements` | `PropertyAuthorityRequirementsV1` | Authority needed to install, instantiate, observe, and retain results. | +| `result_interpretation` | `Coordinate` | Meaning of `HoldsForCase`, `Violated`, and typed obstruction results. | + +`subject_contract` must not be a free-form query. Version one supports only a +small closed sum: + +```rust +pub enum PropertySubjectContractV1 { + ExecutableOperationAction { + operation_coordinate: Coordinate, + package_id: EchoOperationPackageIdV1, + }, + ProviderRuleExecution { + provider_package_id: ProviderPackageIdV1, + rule_id: RuleId, + }, + RecordedActionOutcome { + action_kind: IntentKind, + outcome_schema: SchemaRefV1, + }, +} +``` + +### Property instance schema + +| Field | Type | Required semantic meaning | +| ---------------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `property_id` | `GeneratedPropertyIdV1` | Exact admitted property meaning. | +| `property_admission_id` | `PropertyAdmissionIdV1` | Echo-owned admission evidence. | +| `target_worldline_id` | `WorldlineId` | Worldline whose history or state is under examination. | +| `runtime_basis` | `EchoOperationEvaluationBasisV1` | Exact writer head, ticks, root, commit, and application-basis proposition. | +| `basis_id` | `EchoOperationEvaluationBasisIdV1` | Domain-separated identity of the complete basis (`crates/warp-core/src/echo_operation.rs#L192@c354d5316`). | +| `property_parameters` | `RetainedEvidenceRef` | Canonical quantifier or fixture parameters. | +| `observer_plan` | `ReadingObserverPlan` | Exact built-in or authored plan. | +| `observer_plan_id` | `ObserverPlanId` | Stable plan identity. | +| `observer_instance` | `Option` | Exact hosted observer state when the read is stateful. | +| `observer_instance_evidence` | `Option` | Recoverable observer-instance state or certificate. | +| `frame` | `ObservationFrame` | Commit boundary, recorded truth, or query view. | +| `projection` | `ObservationProjection` | Exact projection requested. Must satisfy the frame/projection validity matrix. | +| `optic_id` | `OpticId` | Exact optic law. | +| `focus` | `OpticFocus` | Exact semantic focus. | +| `aperture_bytes` | `RetainedEvidenceRef` | Canonical aperture descriptor. | +| `aperture_digest` | `Hash` | Exact aperture identity. | +| `rights_evidence` | `RetainedEvidenceRef` | Rights and authority posture for the observation. | +| `observation_budget` | `ObservationReadBudget` | Exact read budget. | +| `freshness_requirement` | `FreshnessRequirementV1` | Required frontier or global-tick relation. | +| `instance_id` | `PropertyInstanceIdV1` | Identity of all the preceding bindings. | + +The existing observation request already resolves a requested coordinate to an +exact worldline tick, optional global Tick, state root, and commit hash while +carrying observer plan, optional observer instance, budget, and rights +(`crates/warp-core/src/observation.rs#L246@c354d5316`). Reuse that contract +rather than recreating a falsification-only read path. + +Observer and aperture binding is not decoration. An apparent contradiction +observed through one aperture cannot be silently generalized to all observers; +basis, aperture, path, authority, and carried evidence are part of what a claim +is entitled to assert. + +### Counterexample proposal schema + +| Field | Type | Trust treatment | +| --------------------------- | ---------------------------------- | ---------------------------------------------------------- | +| `schema_version` | `u32` | Canonically decoded before allocation-heavy work. | +| `property_instance_id` | `PropertyInstanceIdV1` | Must resolve to an installed, admitted instance. | +| `canonical_case` | `RetainedEvidenceRef` | Treated as untrusted bytes until codec validation. | +| `proposed_actions` | `Vec` | Canonical Action envelopes or exact invocation bytes. | +| `proposed_source_basis` | `EchoOperationEvaluationBasisIdV1` | Must equal the instance basis; never authorizes rebasing. | +| `expected_violation_class` | `Option` | Hint only; Echo recomputes it. | +| `source_witness` | `Option` | Optional original failure log, receipt, or remote witness. | +| `discovery_tool_coordinate` | `Coordinate` | Explanatory provenance. | +| `discovery_tool_digest` | `Hash` | Exact tool or adapter identity. | +| `generator_digest` | `Option` | Explanatory provenance. | +| `generator_seed` | `Option<[u8; 32]>` | Reproduction hint, not semantic evidence. | +| `case_index` | `Option` | Discovery hint. | +| `local_shrinker_digest` | `Option` | Explains prior shrinking but has no admission authority. | +| `discovery_budget` | `DiscoveryBudgetV1` | Records bounded search effort. | +| `proposal_id` | `CounterexampleProposalIdV1` | Identity of the exact proposal envelope. | + +The explicit case must be sufficient for Echo replay. A seed alone is inadequate +because property-testing strategies and RNG behaviour change between versions, +and nondeterministic strategies break seed-based failure persistence outright. + +### Admitted witness schema + +| Field | Type | Required proposition | +| ---------------------------- | ---------------------------- | --------------------------------------------------------------------- | +| `schema_version` | `u32` | Canonical witness version. | +| `property_instance` | `PropertyInstanceV1` | Exact claim, basis, observer, aperture, and rights. | +| `proposal_ref` | `RetainedEvidenceRef` | Exact source proposal retained for attribution. | +| `original_case` | `RetainedEvidenceRef` | First Echo-reproduced candidate. | +| `minimized_case` | `RetainedEvidenceRef` | Canonical reduced case. | +| `source_experiment` | `ReplayableCausalSliceV1` | Reproduced source experiment before reduction. | +| `reduced_experiment` | `ReplayableCausalSliceV1` | Minimized replayable experiment. | +| `execution_outcomes` | `Vec` | Exact typed Action outcomes. | +| `tick_receipts` | `Vec` | Exact receipts for the reduced replay. | +| `violating_reading_id` | `ReadIdentity` | Exact observation identity. | +| `violating_reading_payload` | `RetainedEvidenceRef` | Exact reading supplied to the property. | +| `property_evaluation` | `RetainedEvidenceRef` | Exact evaluator input/output envelope. | +| `violation_class_id` | `ViolationClassIdV1` | Stable bug class preserved by reduction. | +| `violation_payload` | `RetainedEvidenceRef` | Typed negative witness. | +| `minimization_evidence` | `MinimizationEvidenceV1` | Reduction law, trace root, budget use, and minimality posture. | +| `replay_certificate` | `ReplayCertificateV1` | Fresh-host reconstruction and equality checks. | +| `retention_manifest` | `RetentionManifestV1` | Complete list of required retained objects and availability postures. | +| `semantic_counterexample_id` | `SemanticCounterexampleIdV1` | Identity of the semantic counterexample. | +| `artifact_id` | `FalsificationArtifactIdV1` | Identity of this exact evidence envelope. | +| `admission_coordinate` | `ProvenanceRef` | Evidence-worldline admission point. | + +`ReplayableCausalSliceV1` must be a **new** type. `CausalSuffixBundle` +(`crates/echo-wasm-abi/src/kernel_port.rs#L2341@c354d5316`) carries a +`WitnessedSuffixShell` plus a `bundle_digest` — a compact shape-only shell, not a +materialized state snapshot, raw patch stream, transport endpoint, or executable +replay bundle. Reusing that name would overstate its present proposition. + +```rust +pub struct ReplayableCausalSliceV1 { + pub base_frontier: ProvenanceRef, + pub target_frontier: ProvenanceRef, + + pub source_worldline_id: WorldlineId, + pub ordered_submission_refs: Vec, + pub ordered_tick_refs: Vec, + pub ordered_action_outcome_refs: Vec, + + pub required_package_refs: Vec, + pub required_property_refs: Vec, + pub required_basis_refs: Vec, + pub required_observer_refs: Vec, + + pub dependency_graph_ref: RetainedEvidenceRef, + pub closure_digest: Hash, +} +``` + +### Proposed API surface + +Echo is a library/runtime boundary rather than an HTTP daemon, so these are Rust +and ABI entry points first. A future service can map them onto RPC without +changing the semantic nouns. + +| Layer | Proposed entry point | Authority and behaviour | +| -------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| Compiler/publication | `emit_generated_property_v1(...)` | Produces canonical package bytes; no installation authority. | +| Trusted host | `admit_generated_property_v1(package, policy)` | Validates exact semantic closure and returns opaque admission evidence. | +| Trusted host | `install_admitted_property_v1(admitted, retained_bytes)` | Runtime-control-owned atomic installation. | +| Application-facing | `submit_counterexample_proposal_v1(instance_id, proposal_bytes)` | Ordinary ingress; acknowledgement only after proposal-envelope WAL commit. | +| Application-facing | `observe_counterexample_proposal_v1(handle)` | Read-only status: pending, verifying, reduced, admitted, refused, obstructed. | +| Verification kernel | `replay_property_case_v1(instance, case, mode)` | One bounded private replay; not exposed to ordinary applications. | +| Verification kernel | `compute_causal_slice_v1(reproduced_case)` | Produces a conservative replay candidate and dependency evidence. | +| Verification kernel | `reduce_counterexample_v1(candidate, budget)` | Executes deterministic versioned reduction. | +| Admission kernel | `admit_falsification_witness_v1(verified_material)` | Revalidates all identities and writes one failure-atomic WAL transaction. | +| Observation service | `observe_falsification_witness_v1(witness_id, request)` | Observer- and rights-bound witness read. | +| Regression service | `reapply_witness_v1(witness_id, successor_instance_id)` | Evaluates an old case against a new property or lawpack without modifying the old witness. | +| CLI | `cargo xtask falsify --property ... --proposal ...` | Development adapter for discovery, replay, and reduction. | +| CLI | `warp falsification inspect ` | Displays semantic bindings, retention posture, replay and minimality evidence. | +| CLI | `warp falsification replay ` | Performs a fresh-host replay and emits corroboration or obstruction evidence. | + +The property verifier, reducer, and final admission entry point must not be +methods on an application-facing handle, for the same reason transitional direct +operation prepare/commit is hidden from `TrustedRuntimeApp`: application code +must not choose when private evaluation or publication occurs. + +## Replay, causal slicing, and minimization + +### Exact replay semantics + +Admission requires **fresh-host semantic replay**, not only receipt validation. + +A replay succeeds only if Echo reconstructs the same property instance and +produces a violation in the same property-defined violation equivalence class. +Byte identity is required for deterministic artifacts — canonical Action +envelopes, installed package bytes, property package bytes, reduced case bytes, +result projections, and evaluator output — where the reduction law says those +bytes are invariant. State roots and receipt identities are compared where the +reduced replay claims exact reconstruction; they are **not** compared to the +original unreduced run after Actions have been deleted, because the reduced run +is a distinct counterfactual lane. + +```mermaid +sequenceDiagram + participant A as Admission Kernel + participant C as Retention/CAS + participant H as Fresh Runtime Host + participant S as Scheduler + participant O as Observation Service + participant P as Property Evaluator + participant W as Evidence WAL + + A->>C: Resolve exact packages, property, basis, case, observer material + C-->>A: Canonical bytes and retention postures + A->>H: Create fresh verification host + A->>H: Install exact operation and property packages + A->>H: Reconstruct exact base frontier + loop Canonical reduced Actions + A->>H: Submit ordinary Action envelope + H->>S: Stage through normal ingress + S->>S: Build bounded scheduler Tick + S-->>A: Typed outcome, receipt, state delta + end + A->>O: Observe exact coordinate, plan, optic, aperture, rights + O-->>A: Reading and ReadIdentity + A->>P: Evaluate exact property program + P-->>A: Violated(class, payload) + A->>A: Compare class, identities, budgets, closure + A->>W: Append witness admission transaction + W-->>A: Durable commit + A-->>A: Publish witness and derived indexes +``` + +```text +function verify_for_admission(material): + require canonical_decode(material) + require all_declared_lengths_within_limits(material) + + instance = resolve_exact_property_instance(material.property_instance_id) + require instance.identity == material.property_instance_id + + retained = resolve_retention_manifest(material.retention_manifest) + if any required item is unavailable: + return Obstructed(MissingRetention(required_item)) + + host = FreshVerificationHost.new( + evaluator_abi = instance.property.evaluator_abi, + intrinsic_profile = instance.property.intrinsic_profile + ) + + install_exact(host, retained.operation_packages) + install_exact(host, retained.property_package) + reconstruct_exact_basis(host, instance.runtime_basis) + + replayed_outcomes = [] + for submission in material.reduced_experiment.ordered_submission_refs: + envelope = load_and_canonical_decode(submission) + require envelope.target_basis == instance.runtime_basis + handle = host.submit_through_ordinary_ingress(envelope) + outcome = host.run_scheduler_until_decided( + handle, + deterministic_pass_budget = material.replay_budget.scheduler_passes + ) + replayed_outcomes.append(outcome) + + reading = host.observe( + coordinate = instance.runtime_basis.observation_coordinate(), + frame = instance.frame, + projection = instance.projection, + observer_plan = instance.observer_plan, + observer_instance = restore(instance.observer_instance_evidence), + optic = instance.optic_id, + focus = instance.focus, + aperture = load(instance.aperture_bytes), + rights = load(instance.rights_evidence), + budget = instance.observation_budget + ) + + property_outcome = evaluate_read_only( + program = instance.property.predicate_program, + parameters = instance.property_parameters, + case = material.minimized_case, + reading = reading, + execution_evidence = replayed_outcomes, + budget = instance.property.declared_budget + ) + + match property_outcome: + Violated(class, payload): + require equivalent_violation( + policy = instance.property.violation_equivalence_policy, + expected = material.violation_class_id, + actual = class, + expected_payload = material.violation_payload, + actual_payload = payload + ) + require replay_closure_matches(material, host, reading, replayed_outcomes) + return ReplayVerified(build_certificate(...)) + + HoldsForCase: + return Refused(ViolationNotReproduced) + + Obstructed(reason): + return Obstructed(reason) + + RuntimeFault(fault): + return RuntimeFault(fault) +``` + +Operation recovery already re-evaluates compiler-owned result projection over +retained canonical input and requires byte-for-byte equality before publishing +the recovered result or receipt. Falsification replay follows the same +fail-closed pattern. + +The replay boundary captures only semantic dependencies the property requires. +General record/replay systems capture operating-system and CPU nondeterminism to +reproduce an execution exactly; Echo's scheduler, canonical encodings, operation +programs, bases, and receipts already define a substantially narrower +deterministic semantic boundary. + +### Causal-slice computation + +The slicer's job is not initially to prove minimality. Its job is to produce a +**conservative replay-closed candidate** containing every Action and evidence +object that may have affected the violating reading. This is a dynamic backward +slice over Echo's executed dependency graph, specialized to Actions, slots, +receipts, basis facts, observer inputs, and property-evaluator dependencies. + +Slice criterion: + +```text +( + property_instance_id, + violating_read_identity, + violation_class_id, + property_evaluator_dependency_set +) +``` + +The algorithm uses **actual** execution dependencies whenever the property itself +concerns declared dependency claims. A footprint-honesty slicer must not trust +the declared footprint that is being challenged. + +```text +function compute_conservative_slice(reproduced_run): + criterion = dependency_seed_from( + violating_reading = reproduced_run.reading, + property_trace = reproduced_run.property_evaluation_trace, + violation_payload = reproduced_run.violation_payload + ) + + needed_slots = criterion.read_slots + needed_receipts = criterion.receipt_ids + needed_results = criterion.result_fields + needed_evidence = criterion.evidence_refs + required_actions = ordered_set() + required_ticks = ordered_set() + + for tick in reverse(reproduced_run.committed_and_decided_ticks): + tick_required = false + + for action in reverse(tick.actions): + actual_reads = action.actual_read_footprint + actual_writes = action.actual_write_footprint + + affects_slot = intersects(actual_writes, needed_slots) + affects_receipt = action.receipt_id in needed_receipts + affects_result = action.result_id in needed_results + affects_control = action.decision_id in criterion.control_dependencies + affects_observer = action.evidence_refs intersects needed_evidence + + if affects_slot or affects_receipt or affects_result + or affects_control or affects_observer: + + required_actions.add(action.id) + tick_required = true + + needed_slots = + (needed_slots - actual_writes) union actual_reads + + needed_receipts union= action.causal_parent_receipts + needed_evidence union= action.package_and_authority_refs + needed_evidence union= action.basis_and_obstruction_refs + criterion.control_dependencies union= + action.scheduler_blockers_and_selection_dependencies + + if tick_required: + required_ticks.add(tick.id) + needed_evidence union= tick.scheduler_rule_refs + needed_evidence union= tick.frontier_and_state_delta_refs + + required_basis = close_basis_dependencies( + reproduced_run.base_frontier, + needed_slots, + needed_receipts, + needed_evidence + ) + + candidate = ReplayableCausalSliceV1( + base_frontier = required_basis.frontier, + target_frontier = reproduced_run.target_frontier, + ordered_actions = canonical_order(required_actions), + ordered_ticks = canonical_order(required_ticks), + required_evidence = canonical_order(needed_evidence), + dependency_graph = retained_dependency_graph(...) + ) + + require conservative_closure_check(candidate, reproduced_run) + return candidate +``` + +Dependency edges the graph must carry: + +| Dependency edge | Example | +| ---------------------- | ---------------------------------------------------------------------------------------------------- | +| Data read-after-write | Action `a7` read a slot last written by `a3`. | +| Scheduler decision | `a7` was rejected because `a4` occupied an overlapping actual footprint. | +| Receipt causality | The observed receipt cites earlier receipt parents. | +| Package interpretation | An outcome can only be decoded under an exact installed package and schema. | +| Basis interpretation | An application-basis value resolves against a particular parent frontier. | +| Observer dependency | The property read depends on a retained observer plan, aperture, rights evidence, or instance state. | +| Property dependency | The evaluator inspected a specific reading field, result field, or receipt member. | + +A reduced experiment is **not literally the original history with records +deleted**. Removing an Action changes Tick membership, receipt bytes, state +roots, commit identities, and possibly scheduler outcomes. The reduced experiment +is a new counterfactual verification lane derived from the same pinned basis. The +witness therefore needs both the first reproduced source experiment and the +reduced experiment, plus the reduction relation between them. + +### Deterministic reduction law + +Delta Debugging requires an automated interestingness predicate and reduces a +failure-inducing configuration systematically. Its guarantee is local minimality +with respect to its tested deletion relation, not proof of a globally smallest +semantic explanation. + +Echo's interestingness predicate: + +```text +Interesting(candidate) := + candidate is canonically valid + AND candidate replays from the exact bound basis + AND observation succeeds under the exact bound observer/aperture/rights + AND the property returns Violated + AND the violation is equivalent under the property's declared + ViolationEquivalencePolicyV1 +``` + +A "process exits nonzero" predicate is unacceptable. A malformed candidate, +obstruction, different violation, or widened aperture must not count as +preserving the same semantic refutation. + +```text +function reduce(candidate, law, budget): + best = candidate + trace = [] + + for phase in law.ordered_phases: + changed = true + + while changed and budget.remaining(): + changed = false + + proposals = phase.generate_candidates(best) + proposals = canonical_sort_and_deduplicate(proposals) + + for proposal in proposals: + budget.charge_candidate_evaluation(proposal) + + preflight = validate_candidate_structure(proposal) + if preflight is invalid: + trace.append(RejectedMalformed(proposal)) + continue + + outcome = replay_and_evaluate(proposal) + + match outcome: + SameViolationClass(evidence): + require metric(proposal) < metric(best) + trace.append(AcceptedReduction(best, proposal, evidence)) + best = proposal + changed = true + break + + DifferentViolationClass(class): + trace.append(RejectedBugHop(proposal, class)) + + Holds: + trace.append(RejectedNoLongerViolates(proposal)) + + Obstructed(reason): + trace.append(ObstructedCandidate(proposal, reason)) + + RuntimeFault(fault): + return ReductionRuntimeFault(fault) + + posture = certify_minimality_posture(best, trace, budget, law) + return Reduced(best, trace_root(trace), posture) +``` + +Fixed transformation order: + +```text +Tick-group deletion +-> Action-group deletion +-> single-Action deletion +-> causal-evidence pruning +-> property-declared case-structure shrinking +-> scalar/byte shrinking +-> aperture simplification, only if the property quantifies over apertures +-> basis-material pruning, never basis substitution +``` + +Aperture reduction is dangerous. For an ordinary property instance the aperture +is part of the claim and must remain fixed. It may be reduced only when the +property explicitly defines aperture as a reducible quantified parameter, and the +result is a new `PropertyInstanceV1`. + +### Reduction metric comparison + +| Metric | Strength | Failure mode | Recommendation | +| ---------------------------- | ----------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------- | +| Total serialized bytes | Simple, objective, storage-oriented | Favors opaque compressed Actions over semantically simpler histories. | Late tie-breaker. | +| Action count | Produces understandable causal stories | Ignores complexity inside one giant Action. | Primary v1 metric. | +| Tick count | Rewards short scheduler histories | Can retain many Actions in one Tick. | Secondary to Action count. | +| Causal depth | Highlights short dependency chains | Multiple broad Actions may have shallow depth. | Diagnostic coordinate, not sole order. | +| Retained dependency count | Reduces proof and replay closure | Can discard readable payload while retaining opaque references. | Third or fourth coordinate. | +| Canonical case-tree size | Works well for structured property inputs | Requires property-specific structural semantics. | Use after Action-level reduction. | +| Weighted semantic complexity | Can express human preferences | Weights become political, unstable, and hard to reproduce. | Avoid in v1. | +| Replay cost | Optimizes CI time | Host- and hardware-dependent unless expressed as deterministic counters. | Record separately; never make wall time semantic. | + +Lexicographic metric: + +```text +M(candidate) = +( + admitted_action_count, + tick_count, + causal_dependency_count, + canonical_case_node_count, + canonical_action_payload_bytes, + required_basis_bytes, + violating_reading_bytes, + candidate_canonical_digest +) +``` + +The final digest is only a deterministic tie-breaker. A weighted sum is rejected +because changing weights reorders candidates nonlocally and makes "minimal" +unstable across profile revisions. + +### Budget law and minimality posture + +Semantic budgets use deterministic counters: + +```rust +pub struct ReductionBudgetV1 { + pub max_candidate_evaluations: u64, + pub max_total_actions_replayed: u64, + pub max_scheduler_passes: u64, + pub max_property_evaluations: u64, + pub max_retained_bytes_loaded: u64, + pub max_reduction_phases: u32, + pub max_dependency_edges: u64, +} +``` + +A wall-clock timeout may protect an operator but must not participate in the +canonical reduction result, because host speed and scheduling differ. If an +operational deadline interrupts reduction, the artifact records +`ExternallyInterrupted` or `ReductionObstructed` — never a false claim that the +deterministic reduction budget was exhausted. + +```rust +pub enum MinimalityPostureV1 { + Unreduced, + + LocallyIrreducible { + reduction_law_digest: Hash, + metric: ReductionMetricV1, + direct_reduction_frontier_digest: Hash, + checked_candidate_count: u64, + }, + + BudgetExhausted { + reduction_law_digest: Hash, + metric: ReductionMetricV1, + consumed: ReductionBudgetV1, + unexplored_frontier_digest: Hash, + }, + + ReductionObstructed { + reduction_law_digest: Hash, + metric: ReductionMetricV1, + obstruction: ContractObstruction, + }, + + ExhaustivelyMinimal { + reduction_law_digest: Hash, + bounded_domain_digest: Hash, + enumeration_certificate: RetainedEvidenceRef, + }, +} +``` + +`ExhaustivelyMinimal` is rare and available only for explicitly finite bounded +domains. The normal production claim is `LocallyIrreducible`. + +### Replay strategy comparison + +| Strategy | Fidelity | Cost | Admission role | +| ----------------------------------------- | -------------------------------------------------------------------------------- | -------------------------- | -------------------------------------------------------------------------- | +| Same-process repeated evaluation | Detects immediate nondeterminism cheaply | Low | Preflight only. Shared process state can mask dependencies. | +| Snapshot restore plus exact suffix replay | Strong when the snapshot and suffix are retained and verified | Medium | Acceptable optimization after proving equivalence to fresh reconstruction. | +| Fresh-host semantic replay | Reconstructs installation, basis, Actions, scheduler, observation, and evaluator | Medium to high | Required v1 admission boundary. | +| Receipt-only validation | Checks retained claims but does not rerun the experiment | Low | Insufficient for admission; useful integrity check. | +| Full OS record/replay | Captures external process nondeterminism | High and platform-specific | Optional corroboration for native/provider compatibility paths. | +| Independently implemented replay | Strongest protection against shared evaluator defects | Very high | Future higher evidence grade, not a v1 requirement. | + +Echo's verification-grade terminology already distinguishes deterministic +self-validation, structurally separate verifier paths, finite independent +conformance evidence, and fresh-host reconstruction. Falsification must reuse +that honesty rather than label same-interpreter replay "independent." + +## Durability, identities, observer binding, and security + +### Semantic and artifact identities + +One identity is not enough. + +```text +SemanticCounterexampleIdV1 = + BLAKE3( + "echo:semantic-counterexample:v1\0" + || property_instance_id + || minimized_case_semantic_digest + || reduced_experiment_semantic_digest + || violation_class_id + || violating_read_semantic_coordinate + ) +``` + +This identity intentionally excludes fuzzer name, seed, local shrinker version, +proposal submitter, reduction trace, fresh-host machine identity, retained byte +placement, and admission timestamp or coordinate. It answers: + +> Is this the same semantic counterexample to the same exact property instance? + +```text +FalsificationArtifactIdV1 = + BLAKE3( + "echo:falsification-artifact:v1\0" + || semantic_counterexample_id + || proposal_ref_id + || original_case_ref_id + || minimized_case_ref_id + || source_experiment_closure_digest + || reduced_experiment_closure_digest + || violation_payload_ref_id + || minimization_evidence_id + || replay_certificate_id + || retention_manifest_id + ) +``` + +It answers: + +> Is this the same admitted evidence envelope? + +The domain-separation style matches existing identity domains such as +`echo:retained-evidence-ref-id:v1\0` +(`crates/warp-core/src/retained_evidence.rs#L18@c354d5316`). It also follows the +existing retained-evidence distinction: the semantic coordinate says what +question bytes answer, while the content hash and length identify the retained +bytes. Equal bytes may answer different semantic questions. + +The violation class is separate from the exact violation payload: + +```text +ViolationClassId = + H(property_id || "actual-read-outside-declared-read-footprint") +``` + +The payload may identify the precise slot. A reducer may remove one undeclared +access and leave another of the same class. Whether that counts as the same +counterexample is controlled by: + +```rust +pub enum ViolationEquivalencePolicyV1 { + ExactPayload, + SameClass, + SameClassAndSemanticKey { + key_projection: PropertyProjectionRefV1, + }, +} +``` + +For the first vertical, use `SameClassAndSemanticKey` binding the operation +coordinate and violation axis (`read` or `write`) but not necessarily the exact +slot. That prevents switching to an unrelated property failure while allowing +useful shrinking. + +### WAL admission and evidence worldline + +The target worldline must remain unchanged. The admitted witness is appended to a +dedicated evidence worldline whose history says that Echo admitted evidence +**about** the target history. + +The evidence worldline may advance — otherwise nothing can be durably admitted — +but its state is an append-only evidence catalog, not a mutable mirror of target +claims. Derived indexes such as "all witnesses for property P" or "current claim +posture" must be rebuildable. + +New WAL transaction kind, appended at the next free stable code **13** +(`crates/warp-core/src/causal_wal.rs#L339@c354d5316` currently ends at 12): + +```rust +WalTransactionKind::FalsificationWitnessAdmission // stable_code = 13 +``` + +with append authority `WalAppendAuthority::AdmissionKernel`, which already exists +and is already used by `CausalAnchorAdmission` +(`crates/warp-core/src/causal_wal.rs#L383@c354d5316`). + +New record kinds start at the next free stable record code **32** (highest in use +is 31). Record grammar: + +```text +FalsificationWitnessRecorded +FalsificationReplayCertificateRecorded +FalsificationMinimizationEvidenceRecorded +RetainedMaterialRefRecorded * N +FalsificationAdmissionReceiptRecorded +``` + +`RetainedMaterialRefRecorded` already exists +(`crates/warp-core/src/causal_wal.rs#L444@c354d5316`) and is reused unchanged. + +If the generic evidence-worldline representation requires a graph delta, exactly +one replayable evidence-state delta may follow. It must modify only the evidence +worldline and must not cite itself as proof of target mutation. + +The transaction builder requires: + +```text +one witness record +one replay certificate +one minimization record +one admission receipt +a canonical unique retained-material manifest +one affected evidence-worldline frontier +zero affected target-worldline frontiers +``` + +```text +function admit_verified_witness(verified): + require verified.fresh_host_certificate.status == ReplayVerified + require identity_recomputation_matches(verified) + require retention_manifest_complete_or_explicitly_obstructed(verified) + require verified.target_affected_frontiers is empty + + tx = WalTransactionBuilder.new( + kind = FalsificationWitnessAdmission, + authority = AdmissionKernel + ) + + tx.push(FalsificationWitnessRecorded, encode(verified.witness)) + tx.push(FalsificationReplayCertificateRecorded, + encode(verified.replay_certificate)) + tx.push(FalsificationMinimizationEvidenceRecorded, + encode(verified.minimization_evidence)) + + for material in canonical_unique(verified.retention_manifest.items): + tx.push(RetainedMaterialRefRecorded, encode(material)) + + tx.push(FalsificationAdmissionReceiptRecorded, + encode(build_admission_receipt(verified))) + + committed = wal.append_and_flush( + tx.commit(affected_frontiers = [verified.evidence_frontier]) + ) + + publish_after_commit( + witness = verified.witness, + derived_claim_index = rebuild_or_increment(committed) + ) +``` + +Recovery must: + +1. Validate transaction shape and authority. +2. Decode all bounded payloads canonically. +3. Recompute semantic and artifact identities. +4. Cross-check the receipt against the witness and replay certificate. +5. Rebuild the retention index. +6. Mark missing retained bytes as a typed availability obstruction. +7. Rebuild disposable property/witness/regression indexes. +8. Publish no partial witness if any mandatory identity is inconsistent. + +A missing retained object must not make the witness disappear. Retained evidence +already represents missing coordinate and missing content as explicit postures +with typed obstructions. Recovery surfaces the admitted witness as +`EvidenceUnavailable` or `ReplayObstructed`, preserving the historical fact that +it was admitted while refusing to claim it is presently replayable. + +### Observer, aperture, and basis constraints + +Non-negotiable replay constraints: + +| Binding | Rule | +| ------------------------ | ------------------------------------------------------------------------------------------------ | +| Runtime basis | Exact bytes must match; no silent rebase, retarget, or "latest equivalent" substitution. | +| Application basis | Exact package-declared proposition and codec must match. | +| Observer plan | Same admitted plan identity, not merely a plan with similar output. | +| Observer instance | Restore exact retained state or obstruct. Never replace with a fresh empty instance. | +| Optic | Same optic law and profile. | +| Focus | Same semantic target. | +| Aperture | Exact canonical descriptor unless the property explicitly quantifies over reducible apertures. | +| Rights | Same or a deliberately stricter posture authorized by the property; never silently widen access. | +| Observation budget | Same deterministic ceiling or a stricter one that still completes. | +| Freshness | Must satisfy the original frontier/global-Tick relation. | +| Property evaluator | Same program, ABI, intrinsic profile, and lawpack. | +| Violation interpretation | Same violation classifier and equivalence policy. | + +A changed basis produces a new property instance. Executable-operation semantics +already forbid silently rebasing a prepared operation and treat a new evaluation +as a new witnessed attempt; falsification replay is at least as strict. + +### Security and trust threats + +| Threat | Attack or failure | Mitigation | +| -------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Forged discovery result | Submitter claims a property failed without a real replay. | Treat proposals as untrusted; Echo recomputes everything. | +| Seed-only disappearance | Generator or RNG changes and no longer produces the case. | Retain the explicit canonical case; seed is provenance only. | +| Bug-hopping reducer | Reducer removes material and preserves a different failure. | Typed `ViolationClassId` plus property-defined equivalence policy. | +| Malicious shrinker | Property package proposes transformations that alter semantics or consume unbounded resources. | Digest-lock reducers, bound outputs before allocation, replay every accepted candidate. | +| Declared-footprint circularity | Slicer trusts the declared footprint while testing whether that declaration is honest. | Use independently recorded actual accesses and patch targets. | +| Observer escalation | Replay widens aperture or rights until a contradiction appears. | Bind exact aperture, rights, observer plan, and instance. | +| Stale-basis laundering | A witness against old semantics is presented as current. | Bind exact property, package, lawpack, basis, and freshness; use explicit successor reapplication outcomes. | +| Package substitution | Equal program bytes are treated as the same operation or property. | Begin resolution from admitted package identity; program digest remains subordinate. | +| Missing evidence hidden as empty | Retention loss makes a reading appear to contain no conflicting evidence. | Typed missing-coordinate and missing-content obstruction postures. | +| Replay cache poisoning | Cached result for one basis or property is reused for another. | Cache key includes complete property-instance identity, case digest, replay profile, and reduction law. | +| Resource exhaustion | Huge proposals, dependency graphs, reducer frontiers, or retained manifests. | Strict preallocation limits and deterministic counters at every decode and replay boundary. | +| Runtime nondeterminism | Same candidate gives different outcomes. | Repeated preflight, then fresh-host replay; inconsistent outcomes prevent admission and produce diagnostic evidence. | +| Shared-implementation defect | Property and replay evaluator share the same bug. | Honest evidence grade; future independent implementation or proof-carrying corroboration. | +| WAL partial publication | Witness index appears although durable transaction failed. | Commit and flush before publishing witness or indexes. | +| Target-worldline contamination | Verification attempts alter application state. | Private verification hosts or strands; final WAL affects only evidence frontier. | +| Privacy leakage | Minimal witness still contains secrets unnecessary to the violation. | Minimize retained support, enforce observer rights, permit citation-only or redacted evidence postures where replay law permits. | +| Hash-domain confusion | A content digest is accepted as semantic authority. | Domain-separated identities and typed wrappers for package, property, case, reading, violation, and artifact identities. | +| Unwind-boundary laundering | A footprint panic is caught and reported as a property violation without proving the guard was active. | Record the enforcement posture (`debug_assertions` / `footprint_enforce_release`) in the replay certificate; a witness produced with enforcement disabled is not admissible. | + +Minimality includes **least sufficient revelation**, not only fewest Actions. + +### Sample minimized witness + +Illustrative, with placeholder digests. Note that `projection` is +`TruthChannels`: `RecordedTruth` admits no other projection today +(`crates/warp-core/src/observation.rs#L2350@c354d5316`), and a dedicated +executable-action-evidence projection is **unbuilt**. + +```json +{ + "schema": "echo.admitted-falsification-witness/v1", + "property_instance": { + "property_id": "3c5a8f609b85f5ac4bc0bb83901312f4704698d528c1ce9e50f1da52c57db724", + "property_coordinate": "echo.property/generated-footprint-soundness@1", + "lawpack_coordinate": "echo.lawpack/footprint-honesty@1", + "lawpack_digest": "a2a4f85f273fc4e62bf395f8971dfd1b668f82e86c20649f55dc7d03192ce510", + "target_worldline_id": "0ca91dddf93a746ac315c4763b9beaad60ba444beac098b38f4a5f522f48b162", + "basis": { + "writer_worldline_id": "0ca91dddf93a746ac315c4763b9beaad60ba444beac098b38f4a5f522f48b162", + "writer_head_id": "11b1757b4a990598efb36a364cad66c87de773f5c5c7a1739a22cbd2b562ad39", + "worldline_tick": 7, + "commit_global_tick": 12, + "state_root": "4418a71b7934f1714ca4838dbe375ce9ec49cc0a82caa2cd14fffe6a69b87d2e", + "commit_id": "fdc17256f381b46511db7f50b5b40a3cc9dd71233d8ea290c7145ed0df3392a1", + "application_basis_schema_digest": "8c89565a989688b53d5cc7de9e0b28475f05670e7f52397dc86c9c2a45f90dad", + "application_basis_value_digest": "a35e2f37ec68ad0daf4d67a44f727fca474d9252ba19c1623fcde9cc56ac2fa9" + }, + "observer": { + "plan_id": "37cd17da6fe6caf83b47151bd50fddf82f59b252f8ca09ceca455bb0e1d35a3c", + "instance_id": null, + "frame": "RecordedTruth", + "projection": { + "TruthChannels": { + "channels": ["echo.channel/action-footprint@1"] + } + }, + "optic_id": "a92575fa1847f735ae296d54fa3fb92b3b2bd69f7c9a8e42d4cd0f22a8468123", + "aperture_digest": "173bb8c7fed77a2364fe356b715ff39f7fe0b5971fc1250a7f5ee6313940f70b", + "rights_evidence_ref": "cc9a913350c68080700baeb8fea63deba393fa20e5eecffd0ea5a6560c402de8", + "observation_budget": { + "max_bytes": 65536, + "max_nodes": 256, + "max_edges": 512 + } + } + }, + "original_case_ref": { + "semantic_digest": "1d11f66ad23aa7d0c53fb07061c61c54e13ec5b7e086ca8b86c2d72616827365", + "content_hash": "e166692fd5af5af1425b52fc4511bb69517e5bc84e78c71b58af7eb5c00dbf95", + "byte_len": 3112 + }, + "minimized_case_ref": { + "semantic_digest": "c467360b2679d7083c9e01ec1f1b11df7c93d5e76c67680880560c505a87553f", + "content_hash": "1ee9a91b1029cb84982fb8e7b6ee2e6e91aa5669af43da522305a596a93cc3ca", + "byte_len": 184 + }, + "reduced_experiment": { + "base_frontier": { + "worldline_tick": 7, + "commit_id": "fdc17256f381b46511db7f50b5b40a3cc9dd71233d8ea290c7145ed0df3392a1" + }, + "target_frontier": { + "worldline_tick": 8, + "commit_id": "86fd9152dfec7bfbd87e0cbd73f644a9831af05ceccfba6130b735a5e4ce033f" + }, + "submission_count": 1, + "tick_count": 1, + "action_outcome_count": 1, + "closure_digest": "9565723ae5d22cbabf35d724cda4ada0c483227a5135b027e13ea4c30b392897" + }, + "violation": { + "class_id": "d638922fd3f719ec2d036e8d1707936e054aaab7943740b42465d36981f41f73", + "code": "actual_read_outside_declared_read_footprint", + "guard_violation_kind": "NodeReadNotDeclared", + "semantic_key": { + "operation_coordinate": "echo.operation/fixture-read-a-and-b@1", + "axis": "read" + }, + "declared_slot": "node-A.alpha", + "actual_undeclared_slot": "node-B.alpha", + "payload_ref": { + "content_hash": "bc1754c4e90f906944e726852afccbe43ed101e287b206772fae9f6c16272144", + "byte_len": 212 + } + }, + "minimality": { + "posture": "LocallyIrreducible", + "reduction_law_digest": "e3c390efc8c131746244040e77c1e99645e297bd5681599ca4099104e3f0dc75", + "metric": { + "action_count": 1, + "tick_count": 1, + "dependency_count": 9, + "case_node_count": 4, + "action_payload_bytes": 184 + }, + "checked_candidate_count": 27, + "trace_root": "2bc11cf79d74ae89c726f5ab68c5cf2ec3f91c3b8b732de08ef80323953f14f1" + }, + "replay": { + "posture": "FreshHostVerified", + "footprint_enforcement": "footprint_enforce_release", + "runtime_profile_digest": "70f979104e1154b17f3ba446d3d2ce84e2fbf65046a0a72582d1e50b465bc935", + "property_evaluator_digest": "8a99655f9749598328a327cecc075c13da04b7b7d7906b27dcbcf80d3fece5ec", + "replayed_violation_class_id": "d638922fd3f719ec2d036e8d1707936e054aaab7943740b42465d36981f41f73", + "certificate_id": "184effb17c897e2f49f8b4364b26abf6c96761367ae33df86672df9af8d966a5" + }, + "semantic_counterexample_id": "511be4d123908cd2f4267de98be83c2537622dbfb915eed540da37e4411952d8", + "artifact_id": "2a851666e2890b692919afc091d8e78d85365ee9fcab80824d468f55c54b142a" +} +``` + +## Footprint-honesty vertical + +### Why footprint honesty is the correct first property + +Generated footprints are compile-time claims; runtime footprint checking is a +generator-correctness oracle; release qualification should exercise +`footprint_enforce_release`; and `docs/topics/GeneratedRules.md#L269@c354d5316` +states plainly that "the `footprint_enforce_release` qualification lane is not +wired into CI." + +The guard gives a precise vocabulary for undeclared reads and writes +(`crates/warp-core/src/footprint_guard.rs#L89@c354d5316`): + +| `ViolationKind` variant | Axis | +| ------------------------------------------- | -------------- | +| `NodeReadNotDeclared(NodeId)` | read | +| `EdgeReadNotDeclared(EdgeId)` | read | +| `AttachmentReadNotDeclared(AttachmentKey)` | read | +| `NodeWriteNotDeclared(NodeId)` | write | +| `EdgeWriteNotDeclared(EdgeId)` | write | +| `AttachmentWriteNotDeclared(AttachmentKey)` | write | +| `CrossWarpEmission { op_warp }` | scope | +| `UnauthorizedInstanceOp` | authority | +| `OpWarpUnknown` | guard-internal | + +Footprint honesty is attractive because the claim is crisp, the actual access +trace is finite, the violation is local and typed, the reducer has a natural tiny +target, the property needs no application-specific business semantics, and the +repository already names the missing negative oracle. + +The property: + +```text +GeneratedFootprintSoundness@1 + +For every evaluated Action a: + +ActualReadFootprint(a) ⊆ AdmittedDeclaredReadFootprint(a) +ActualWriteFootprint(a) ⊆ AdmittedDeclaredWriteFootprint(a) +``` + +The last three `ViolationKind` variants are **not** read/write footprint +violations and must map to distinct violation classes. `OpWarpUnknown` is +documented as a guard-internal safety net for future match-arm omissions +(`crates/warp-core/src/footprint_guard.rs#L108@c354d5316`); a property that +observes it should report a `RuntimeFault`, not a semantic refutation. + +Portal-chain and descended-target dependencies must be included, because retained +footprints and patch inputs include the validated root-to-target portal chain. + +### The guard checks; it does not record + +This is the vertical's first hard blocker and its first unit of work. + +`FootprintGuard` (`crates/warp-core/src/footprint_guard.rs#L341@c354d5316`) +holds exactly six declared sets — `nodes_read`, `nodes_write`, `edges_read`, +`edges_write`, `attachments_read`, `attachments_write` — plus `warp_id`, +`rule_name`, and `is_system`. **There is no accumulator field.** Every +`check_*` method takes `&self`, compares one access against the corresponding +declared set, and panics on a miss. + +Nothing in Echo therefore records what an Action _actually_ touched. Not +per-Action, not per-Tick, not anywhere. The guard answers "was this access +declared?" and immediately forgets. Four consequences: + +1. **The subset relation cannot be evaluated today.** + `GeneratedFootprintSoundness@1` is stated as + `ActualRead(a) ⊆ DeclaredRead(a)`. One side of that relation is never + materialized. A property evaluator cannot compare against a set that does + not exist. +2. **`FootprintViolation` is not a substitute for the actual footprint.** It is + a panic payload (`crates/warp-core/src/footprint_guard.rs#L115@c354d5316`) + thrown via `std::panic::panic_any` and matched with `downcast_ref`. It names + the single access that tripped the guard and nothing else. Catching the + unwind yields one violating access, not `ActualReadFootprint(a)`. +3. **The guard is crate-private.** It is declared `pub(crate)` + (`crates/warp-core/src/footprint_guard.rs#L341@c354d5316`), so it is not + reachable from a verification host outside `warp-core`. Either the sink and + host live inside the crate, or the guard grows a deliberately narrow public + seam. Widening it to `pub` wholesale would export an enforcement detail. +4. **The guard is compiled out unless `debug_assertions` or + `footprint_enforce_release` is enabled** + (`crates/warp-core/src/lib.rs#L86@c354d5316`), and it is additionally + `#[cfg(not(feature = "unsafe_graph"))]`, which is mutually exclusive with + `footprint_enforce_release` at the crate root + (`crates/warp-core/src/lib.rs#L22@c354d5316`). A witness produced under a + build where the guard was inert proves nothing, so the enforcement posture + belongs in the replay certificate and must be checked at admission. + +Required behavior: accumulate every attempted access — not only violating ones — +into an ordered, canonical per-Action record retained as execution evidence. The +accumulator must be additive to the existing checker: the ordinary panic still +fires afterwards, unchanged. That panic is the correct response in ordinary +execution, where an undeclared access is a programmer error and not a +recoverable application condition, and removing it is out of scope. + +**Landed:** `ActualFootprint` +(`crates/warp-core/src/actual_footprint.rs@95a55f49f`) is the accumulator and +the subset check. `soundness_violations` returns the existing `ViolationKind` +vocabulary in a fixed axis order, and `from_ops` derives the **write** axis from +an emitted op sequence using `op_write_targets` — the same extraction +enforcement uses, so a recorded write set and an enforced write check cannot +disagree. + +The **read** axis was the remaining implementation problem. Reads reached the +guard through `GraphView`, and guards live in `WorkUnit.guards` +(`crates/warp-core/src/parallel/exec.rs#L831@c354d5316`), which workers borrow +from a shared slice (`crates/warp-core/src/parallel/exec.rs#L1021@c354d5316`). + +**No synchronization is required.** The scheduler already provides exclusivity: +`execute_work_queue` hands out unit indices with +`next_unit.fetch_add(1, Ordering::Relaxed)` +(`crates/warp-core/src/parallel/exec.rs#L925@c354d5316`), so each unit is +claimed by exactly one worker, and items inside a unit run serially +(`crates/warp-core/src/parallel/exec.rs#L951@c354d5316`). A guard is never +touched concurrently. Adding a lock would re-implement a guarantee the scheduler +already makes. + +What the shared slice does impose is a _type-level_ obligation: `s.spawn` over +`&[WorkUnit]` requires `WorkUnit: Sync`, because the borrow checker cannot see +the atomic-claim protocol. A `RefCell` placed inside `FootprintGuard` therefore +fails to compile — not because concurrent access is possible, but because the +compiler cannot prove it is not. + +The resolution is to keep mutable state out of the shared structure entirely: an +accumulator owned by the worker's own frame is already exclusive, never crosses +a thread, never enters `WorkUnit`, and leaves `WorkUnit: Sync` untouched. + +**Resolved (James):** neither of the two candidate shapes as written. Not an +accumulator reference inside the existing `GraphView` — a `RefCell` violates its +documented contract and costs it `Sync`, while a `&mut ActualFootprint` forces +every accessor to `&mut self`, removes `Copy`/`Clone`, and contaminates the +matcher, footprint-computation, serial, and legacy parallel APIs that share the +type. Not reconstructing `FootprintGuard` inside the worker either — the guard +is already built once per item by `attach_footprint_guards`, so that move buys +nothing and still cannot mutate through a `&self` accessor. + +The adopted shape is a distinct executor-only capability: + +```text +Shared prepared work WorkUnit { items, guards } +Worker-local frame ActualFootprint, TickDelta +Executor capability ExecutionGraphView { store, declared, actual } +``` + +`GraphView` keeps its contract, its `Copy`, and its `Sync`; the mutable +execution frame — not the declared guard — moves into exclusive worker +ownership. The `DO NOT add interior mutability` prohibition +(`crates/warp-core/src/graph_view.rs#L65@c354d5316`) is honoured rather than +lawyered around: the accumulator is not graph state, but it is still mutable +execution state, and giving it a separate capability makes that visible in the +type system instead of hiding it behind "technically this mutation is only +telemetry." + +**Landed:** `ExecutionGraphView` +(`crates/warp-core/src/execution_graph_view.rs`) is that capability. It is +deliberately not `Copy` and not `Clone`, its accessors take `&mut self`, it +records **before** consulting the guard so the access that trips enforcement is +in the transcript before the unwind, and its axis mapping mirrors the guard +exactly — `edges_from` records a _node_ read, because a node in `n_read` grants +its outbound adjacency and a finer record would manufacture violations against +a sound declaration. An absent resource is still a recorded coordinate. + +**Landed:** the executor ABI now distinguishes `ObservedExecuteFn` from legacy +`ExecuteFn` through `RuleExecutor`. Native production rules and generated +contract-host rules receive `&mut ExecutionGraphView`; provider-v1 remains on +the frozen `ProviderMutationExecuteFnV1`/`GraphView` shape and is materialized as +`RuleExecutor::Legacy`. A legacy callback is never wrapped in an ordinary +`GraphView` and relabelled observed: its write axis is still derived from emitted +ops, but its read axis remains unknown and its posture is +`UnavailableLegacyExecutor`. + +`execute_item_observed` is the single scheduler-owned per-item core. It owns the +`ActualFootprint` on the worker frame, catches the executor unwind, derives every +emitted write before checking any op, retains `ExecutionFootprintEvidence`, and +only then resumes the ordinary panic. `ExecutionEvidenceKey` is assigned from +canonical pre-dispatch identity, so evidence is sorted independently of worker +claim and completion order. `Engine::last_execution_footprints` and +`WorldlineState::last_execution_footprints` retain the canonical per-Action +records for the next stage's evaluator delivery. + +The executable witnesses cover successful observed execution, an observed read +violation after a prior emitted write, a legacy execution with known writes and +unknown reads, provider-v1 materializing only as legacy, generated consumer code +using the observed ABI, and identical evidence across worker counts. + +### Serial execution is unguarded + +`execute_serial` (`crates/warp-core/src/parallel/exec.rs#L424@c354d5316`) +constructs no guard and retains no footprint evidence. Legacy callbacks receive +the bare `GraphView`; observed callbacks can record only into a throwaway frame. +Authoritative enforcement and retention exist only on the scheduler work-queue +path. + +A verification host that replays serially therefore runs with enforcement inert +and would record an empty actual footprint for an execution that touched +everything. Under [acceptance criterion 21](#acceptance-criteria) such a replay +cannot admit a witness. This is not optional bookkeeping: an unguarded lane that +silently reports "no violations" is precisely the false-negative the vertical +exists to prevent. + +**Landed:** `ActualFootprintPosture` +(`crates/warp-core/src/actual_footprint.rs`) makes the distinction a value +rather than an assumption: + +| Posture | Read axis | May ground a witness | +| ---------------------------- | --------- | -------------------- | +| `RecordedAndEnforced` | complete | yes | +| `RecordedWithoutEnforcement` | complete | no | +| `UnavailableLegacyExecutor` | unknown | no | +| `UnavailableBuildProfile` | unknown | no | + +The load-bearing distinction is `read_axis_is_complete`. An empty read axis from +an unobserved lane must read as _unknown_, never as _this execution read +nothing_ — that inference is the false negative itself. `build_footprint_posture` +caps every lane by what the binary actually compiled, so a build without +enforcement cannot claim evidence it is incapable of producing. + +`serial_execution_is_an_unobserved_lane` +(`crates/warp-core/src/parallel/exec.rs`) pins the lane by contrast rather than +by assertion of intent: one executor reading one undeclared node runs through +`execute_serial` without panicking and without leaving a trace, and the +identical read through `ExecutionGraphView` is recorded and reported as +`NodeReadNotDeclared`. Two lanes, identical behaviour, different evidence — +which is exactly why the evidence must name its lane. + +Granularity comes from the scheduler item boundary. The guard is constructed +once per rule execution and pre-filtered to a single warp +(`crates/warp-core/src/footprint_guard.rs#L358@c354d5316`), while the accumulator +is constructed once on the worker frame for that same item. Each retained record +is therefore per-Action and per-warp by construction. No separate projection is +needed to avoid a per-Tick union. + +### Two fixtures, not one compromised runtime + +**Production-shaped hook-free property fixture.** A valid executable operation +lawfully reads slots `A` and `B`. A deliberately false `GeneratedPropertyV1` +instance claims that its actual reads are contained in `{A}`. The operation +package remains valid; the false semantic claim is what is refuted. This proves +property admission, exact instance binding, ordinary Action replay, +actual-footprint observation, violation evaluation, slicing and reduction, +fresh-host replay, and WAL admission and recovery — without weakening operation +package admission or adding a runtime profile that permits invalid operation +artifacts. + +**Generated-pack compatibility fixture.** A fixture-only provider or Wesley +callback declares `{A}` but reads `{A, B}` under `footprint_enforce_release`. The +verification host catches the typed `FootprintViolation` and converts that +fixture-origin event into a property violation. This proves the negative oracle +the repository says is missing, but the resulting evidence grade must explicitly +state that provider-native callback replay depends on reinstalling the exact +ambient callback implementation. It must not be presented as portable hook-free +operation evidence. + +Forcing an invalid footprint into the existing executable-operation package +solely to exercise the witness system is the wrong move. The operation corridor's +exact package/program/footprint closure is a security property, not a test +inconvenience. + +### Dishonest-fixture test matrix + +| Test | Mutation or setup | Expected result | +| ----------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | +| Read violation | Declared reads `{A}`, actual reads `{A,B}` | Admitted witness, class `actual_read_outside_declared_read_footprint`, guard kind `NodeReadNotDeclared`. | +| Write violation | Declared writes `{A}`, emitted patch writes `{A,B}` | Admitted witness with write-axis violation. | +| Attachment axis | Undeclared attachment read | Distinct class from node/edge read; not merged. | +| Cross-warp emission | Action emits to another warp | `CrossWarpEmission` maps to a scope class, never merged with read/write. | +| Unauthorized instance op | Non-system rule emits an instance-level op | Authority class, distinct from footprint classes. | +| Guard-internal | `OpWarpUnknown` observed | `RuntimeFault`, never an admitted witness. | +| Hidden portal read | Descended target omits one portal attachment | Violation includes the omitted portal dependency. | +| Exact honest footprint | Declared equals actual | `HoldsForCase`; no witness admitted. | +| Superset declaration | Declared safely contains actual | `HoldsForCase`; the property does not demand footprint minimality. | +| Guard disabled | Replay attempted without `debug_assertions` or `footprint_enforce_release` | Admission refused; enforcement posture recorded as inert. | +| Malformed proposal | Noncanonical Action or case bytes | Proposal refused before replay. | +| Wrong basis | Proposal cites another commit or root | Typed stale/foreign-basis refusal; no replay. | +| Wrong lawpack | Rebind property to another digest | Identity mismatch before execution. | +| Wrong observer | Substitute another plan or instance | Instance mismatch or observation obstruction. | +| Invalid frame/projection | `RecordedTruth` with `Head` | Rejected by the existing validity matrix. | +| Wider aperture | Replay asks to inspect additional coordinates | Refused as a different property instance. | +| Different violation | Reduction changes read violation into write violation | Candidate rejected as bug-hopping. | +| Removed causal write | Reducer deletes required setup Action | Candidate holds or obstructs; reduction rejected. | +| Missing retained package | Delete package bytes after admission | Recovered witness remains admitted; replay posture becomes missing-content obstructed. | +| Mutated result bytes | Change retained property output | Recovery fails closed before publication. | +| Mutated replay certificate | Rebind host profile or closure digest | Artifact identity mismatch. | +| Crash before WAL commit | Kill after record assembly but before flush | No witness visible after recovery. | +| Crash after WAL commit | Kill before live publication | Witness reconstructed and published on recovery. | +| Duplicate admission | Submit identical verified material twice | Idempotent same semantic/artifact identity; no duplicate authoritative fact. | +| Distinct discovery provenance | Find same case with another seed/tool | Same semantic counterexample id, different proposal or artifact provenance where retained. | +| Budget exhaustion | Limit reducer before local frontier exhausted | `BudgetExhausted`, never `LocallyIrreducible`. | +| Obstructed child candidate | One reduction candidate lacks required authority | Candidate recorded as obstructed; no false minimality claim. | +| Fresh-host mismatch | In-process replay violates but fresh host holds | Admission refused; diagnostic mismatch retained separately. | + +### Test layers + +| Test layer | Required coverage | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Canonical codec tests | Round trip, truncation, trailing bytes, duplicate map keys, noncanonical ordering, unknown variants, allocation ceilings. | +| Identity tests | Every schema field mutation changes the correct identity; excluded discovery metadata does not change semantic counterexample identity. | +| Property evaluator tests | Closed outcome sum, deterministic budget accounting, no target mutation, exact violation classification. | +| Guard reification tests | Every `ViolationKind` variant reaches retained evidence; panic behaviour outside the verification host is unchanged. | +| Slicer tests | Conservative closure, read-after-write dependencies, scheduler blockers, receipt parents, observer dependencies, portal chains. | +| Reducer tests | Deterministic candidate order, cache correctness, bug-hop rejection, local irreducibility certification, budget and obstruction postures. | +| Replay tests | Same-process repeatability, fresh-host verification, exact package/basis/observer reconstruction, stale-basis refusal. | +| WAL tests | Required record order, append authority, affected-frontier restrictions, crashpoints, corruption, duplicate identities, missing retention, unknown-code rejection. | +| End-to-end tests | Proposal through ordinary ingress, minimized witness admission, observation, recovery, and regression reapplication. | +| Differential tests | Optional comparison against `proptest` or another reducer on finite fixture cases; never treated as universal equivalence. | +| Privacy tests | Rights and aperture cannot be widened; redacted/citation-only evidence remains properly typed. | + +Property-based testing should be used heavily on the codecs, reduction law, and +identity projections. + +### Acceptance criteria + +The vertical is complete only when all of the following hold. + +1. A `GeneratedPropertyV1` can be independently admitted and installed without + becoming an operation or authority token. +2. A `PropertyInstanceV1` binds the exact basis, observer plan or instance, + optic, aperture, rights, budget, and freshness contract. +3. A proposal acknowledgement follows durable intake commit and does not imply + verification. +4. Submission does not evaluate the operation, property, or observer. +5. Echo reproduces the candidate through ordinary Action ingress and + scheduler-owned evaluation. +6. The hook-free footprint fixture produces a typed negative property result. +7. An honest footprint fixture produces `HoldsForCase` and no falsification + witness. +8. The slicer retains every actual dependency needed to reproduce the negative + reading. +9. The reducer reaches a one-Action, one-Tick witness for the canonical fixture + or reports exactly why it cannot. +10. Deleting any direct reduction child from a `LocallyIrreducible` witness fails + the same-violation interestingness predicate. +11. A budget-limited run reports `BudgetExhausted`. +12. Fresh-host replay reproduces the violation class under the exact property + instance. +13. Rebinding package, lawpack, basis, observer, aperture, evaluator, case, or + violation evidence fails closed. +14. Witness WAL commit precedes publication. +15. Crash before commit recovers no witness; crash after commit recovers the full + witness. +16. Target application state and target frontier remain unchanged by verification + and admission. +17. The evidence-worldline frontier advances exactly once. +18. Missing retained bytes become typed obstruction posture rather than silent + deletion. +19. Two discovery tools finding the same minimized case converge on one semantic + counterexample identity. +20. An old witness can be reapplied to a successor property without altering the + historical witness. +21. A witness cannot be admitted from a replay where footprint enforcement was + inert. + +## Roadmap + +### Stages + +| # | Stage | Status | Deliverable | Exit condition | +| --- | --------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | ADR | Done | `docs/adr/0027-first-class-falsification-witnesses.md` | Trust boundary, outcome taxonomy, identity law, minimality language, evidence-worldline rule, and non-goals accepted. Coupled edit to `tests/docs/test_adr_namespace.sh` landed. | +| 2 | Footprint accumulation | Done | `ActualFootprint`, `ExecutionGraphView`, `ActualFootprintPosture`, `RuleExecutor`, `ExecutionFootprintEvidence` | A canonical per-Action actual read/write footprint is retained as evidence; the ordinary panic path is unchanged; the guard remains crate-private. | +| 3 | Execution-evidence delivery | Open | Actual footprints reach the property evaluator on the `execution_evidence` channel bound to Action outcomes | A read-only evaluator can compute `Actual ⊆ Declared` without a new observation projection and without widening the bound aperture. | +| 4 | Schemas | Open | Core and ABI DTOs for four artifacts plus violation, replay, minimization, and causal-slice support types | Canonical codecs, bounds, golden vectors, mutation tests. | +| 5 | Property admission | Open | Exact package admission and installation | Naked predicate programs cannot install or evaluate. | +| 6 | Discovery adapter | Open | `cargo xtask falsify` consuming explicit proposals and optionally `proptest` output | Seed is provenance; explicit case is replayable. | +| 7 | Verifier | Open | One bounded exact-basis replay through ordinary scheduler and observation surfaces | Returns closed property outcome without target mutation. | +| 8 | Slicer | Open | Conservative backward dependency closure | Every retained fixture slice replays; removal candidates delegated to the reducer. | +| 9 | Reducer | Open | Deterministic phase order, lexicographic metric, violation equivalence, budgets, cache | Stable output across repeated runs and fresh hosts. | +| 10 | Fresh-host certificate | Open | Complete reconstruction and comparison | Same violation class and closure reproduced from retained material. | +| 11 | WAL admission | Open | Transaction code 13, record codes from 32, evidence-worldline frontier, recovery indexes | Crashpoint suite passes. | +| 12 | Footprint vertical | Open | Hook-free false property plus generated-pack compatibility fixture | One locally irreducible admitted witness and one honest non-witness. | +| 13 | Regression reuse | Open | Reapply admitted cases to successor properties | `StillFalsifies`, `NoLongerFalsifies`, `Inapplicable`, and `Obstructed` are durable typed outcomes. | +| 14 | Release qualification | Open | CI lane with false-footprint oracle under release enforcement | Generated footprint claims cannot silently ship without negative-oracle coverage. | + +Stages 2 and 3 did not appear in the originating draft. Both are consequences of +verification, and they split along a clean seam: stage 2 is scheduler-side +(**produce and retain** the actual footprint), stage 3 is evaluator-side +(**deliver** it to a read-only property). + +Stage 3 deliberately does not add an observation projection. The property +evaluator already receives execution evidence on a channel separate from the +reading — see `evaluate_read_only(..., execution_evidence = replayed_outcomes, +...)` in [Exact replay semantics](#exact-replay-semantics). Routing actual +footprints there keeps the observation aperture untouched, which matters because +[widening an aperture during replay](#observer-aperture-and-basis-constraints) is +precisely what the design forbids. Inventing a footprint projection would have +enlarged the surface the witness is bound to for no gain. + +Stage 14 has a coupled edit. `docs/topics/GeneratedRules.md#L269@c354d5316` +asserts the lane is not wired into CI, and +`tests/docs/test_generated_rule_truth.sh#L49@c354d5316` requires that literal +sentence to be present. Wiring the lane means changing the topic sentence and the +doc-truth assertion in the same commit. + +The ADR must explicitly reject: + +- storing only a property-test seed; +- treating a test runner's shrink result as authoritative; +- mutating the target worldline with a "bug flag"; +- calling every failed replay a falsification; +- claiming global minimality from a local reducer; +- silently rebasing a witness; +- widening observer aperture during replay; +- treating `CausalSuffixBundle` as a replay package; +- treating same-interpreter replay as independent implementation evidence; +- weakening executable-operation admission merely to create a dishonest fixture; +- admitting a witness produced under inert footprint enforcement. + +### Repository placement + +```text +crates/warp-core/src/falsification.rs +crates/warp-core/src/falsification_identity.rs +crates/warp-core/src/falsification_replay.rs +crates/warp-core/src/falsification_slice.rs +crates/warp-core/src/falsification_reduce.rs + +crates/echo-wasm-abi/src/kernel_port.rs + GeneratedPropertyV1 DTOs + PropertyInstanceV1 DTOs + CounterexampleProposalV1 DTOs + AdmittedFalsificationWitnessV1 DTOs + +crates/warp-core/tests/falsification_codec_tests.rs +crates/warp-core/tests/falsification_identity_tests.rs +crates/warp-core/tests/falsification_replay_tests.rs +crates/warp-core/tests/falsification_reducer_tests.rs +crates/warp-core/tests/falsification_wal_tests.rs +crates/warp-core/tests/footprint_honesty_vertical.rs + +xtask/src/falsification.rs +docs/adr/0027-first-class-falsification-witnesses.md +docs/topics/FalsificationWitnesses.md +``` + +Do not put the reducer in `echo_operation.rs`. That module already owns a large +operation semantic surface — 27 exported `EchoOperation*` structs at +`c354d5316`. Falsification depends on operations but is not an operation subtype. + +### Regression reuse semantics + +An old witness remains permanently valid against its original property instance. +A fix does not retroactively invalidate historical evidence. Reapplication +produces a new artifact: + +```rust +pub enum WitnessReapplicationOutcomeV1 { + StillFalsifies { + new_violation: RetainedEvidenceRef, + }, + NoLongerFalsifies { + holds_evidence: RetainedEvidenceRef, + }, + Inapplicable { + reason: InapplicabilityReasonV1, + }, + Obstructed { + obstruction: ContractObstruction, + }, + RuntimeFault { + fault_id: RuntimeFaultId, + }, +} +``` + +A release gate asks: + +```text +For every admitted witness relevant to successor lawpack L2: + outcome must be NoLongerFalsifies or explicitly Inapplicable +``` + +It must not ask whether the old witness has been deleted. + +### Migration and backward compatibility + +Additive at the semantic level: + +- Existing `EchoOperationPackageV1`, invocation, preparation, receipt, and + Action-outcome bytes remain unchanged. +- Existing provider-v1 packages remain compatibility infrastructure. +- Existing observation requests and artifacts remain the canonical read + substrate. +- Existing retained-evidence references remain valid. +- `CausalSuffixBundle` remains shape-only. +- Existing claim or receipt indexes do not acquire mutable falsification + booleans. + +The WAL migration needs care. The decoder uses stable numeric transaction and +record codes and rejects unknown enum values +(`crates/warp-core/src/causal_wal.rs#L414@c354d5316`) rather than promising that +old readers skip them. New writers must not emit falsification records until all +readers capable of opening that WAL have been upgraded. + +| Phase | Behaviour | +| ------------------------ | --------------------------------------------------------------------------------------------------------------- | +| Reader-first | Ship decoders, recovery logic, types, and feature capability without writing new records. | +| Capability advertisement | WAL root or runtime profile announces support for the falsification transaction schema. | +| Writer activation | Enable new records only after the runtime owns an upgraded writer epoch. | +| Segment boundary | Prefer beginning emission in a new WAL segment or writer epoch for operational clarity. | +| Downgrade protection | An older binary encountering the new epoch must refuse read-write activation rather than truncate or overwrite. | +| Index rebuild | New falsification indexes are derived from WAL and may be dropped and rebuilt. | + +For retained evidence: + +| Choice | Advantage | Cost | +| ------------------------------------------------------------------------------------------------- | ------------------------ | ------------------------------------------------------- | +| Reuse `RetainedEvidenceRole::Witness` in v1 | Minimal codec churn | Falsification-specific roles are less visible. | +| Add `PropertyArtifact`, `CounterexampleCase`, and `FalsificationWitness` variants at tags 6, 7, 8 | Clear semantic inventory | Requires explicit stable tags and reader-first rollout. | + +The better long-term choice is new explicit roles with append-only stable tags. +Existing identities are unchanged because old variant tags do not move +(`crates/warp-core/src/retained_evidence.rs#L39@c354d5316`). + +Do not put the reducer's search trace into the semantic counterexample identity. +Reducer algorithms will improve; different search structures can reach smaller or +differently canonicalized cases while preserving the same interestingness +predicate. The semantic object is the exact refuting case under its property +instance; the path by which Echo found and certified it belongs to the evidence +envelope. + +## Open questions + +1. **Evidence worldline representation.** Is the evidence worldline an ordinary + worldline with a constrained state schema, or a distinct kind with its own + frontier rules? This determines whether existing frontier-advance machinery is + reusable or whether a parallel path is needed. +2. **Property evaluator ABI host.** Does the evaluator run under the existing + WASM ABI (`crates/echo-wasm-abi`), a new interpreter, or as a restricted + native profile? The read-only guarantee is easiest to enforce in the first, + cheapest in the third. +3. **Fresh-host cost.** Fresh-host reconstruction per reduction candidate may be + prohibitive for large campaigns. The snapshot-plus-suffix optimization is + listed as acceptable "after proving equivalence to fresh reconstruction" — + what does that proof look like concretely? +4. **Closed — guard visibility seam.** `FootprintGuard` remains `pub(crate)`. + `ExecutionGraphView::new_guarded` is the narrow crate-private construction + seam; only the resulting evidence DTOs cross the public boundary. +5. **Closed — accumulator cost under enforcement.** Scheduler-owned execution + records one bounded set-based transcript per executed Action. Unretained + serial and legacy compatibility lanes cannot claim authoritative evidence; + no optional sink can silently turn missing reads into an empty read set. + +**Closed:** _Footprint observation granularity_. `FootprintGuard` is constructed +once per rule execution and pre-filtered to a single warp +(`crates/warp-core/src/footprint_guard.rs#L358@c354d5316`), and +`execute_item_observed` constructs one worker-local accumulator for that exact +item. The per-Tick union that would have broken the single-Action reduction +target cannot arise. + +## The proposition + +> A falsification witness is an admitted, observer-bound, basis-bound, +> replay-certified, minimally qualified negative witness against one exact +> semantic claim. + +That is stronger than a failed test and narrower than a declaration that "the +system is wrong." It gives Echo a durable memory not only of what it accepted, +but of the smallest causal experiments through which its own semantic claims were +lawfully shown to fail. diff --git a/docs/topics/README.md b/docs/topics/README.md index 04493eed3..992202c0a 100644 --- a/docs/topics/README.md +++ b/docs/topics/README.md @@ -13,6 +13,7 @@ ADR. - [Causal anchors](CausalAnchors.md) - [Contract inverse admission](ContractInverseAdmission.md) - [External actions](ExternalActions.md) +- [Falsification witnesses](FalsificationWitnesses.md) - [Generated rule authorship](GeneratedRules.md) - [Obstructions](Obstructions.md) - [Runtime authority](RuntimeAuthority.md) diff --git a/docs/topics/WAL.md b/docs/topics/WAL.md index 829624f04..8bc316b61 100644 --- a/docs/topics/WAL.md +++ b/docs/topics/WAL.md @@ -209,11 +209,38 @@ The filesystem store holds an operating-system writer lease for the complete active epoch. A second live process cannot append, close, or replace that epoch. After process loss releases the lease, the trusted runtime closes the recovered active epoch under the newly acquired lease and derives a fresh -successor with a monotonic start LSN, new epoch identity, fencing token, and -lease evidence bound to the exact latest predecessor and its final commit. -Duplicate identities, stale or missing predecessor links, reused fencing -evidence, LSN regression, corrupted ledgers, and commits without their epoch -ledger fail closed before append. +successor with a new epoch identity, fencing token, and lease evidence bound to +the exact latest predecessor and its final commit. Duplicate identities, stale +or missing predecessor links, reused fencing evidence, LSN regression, +corrupted ledgers, and commits without their epoch ledger fail closed before +append. + +Epoch-chain advancement is strict, but the start LSN is not the thing that +advances. An LSN names a WAL _frame_; acquiring an epoch persists ledger +evidence and emits no frame. An epoch's start LSN is therefore the next +unallocated frame coordinate, and it is non-regressing rather than universally +strictly increasing: + +```text +previous epoch committed frames: + successor.start = previous.final_lsn + 1 + +previous epoch committed no frames: + successor.start = previous.start +``` + +An epoch is not entitled to spend an LSN it never wrote. Requiring a strict +advance past an empty epoch would invent a phantom coordinate, and recovery +reports the resulting hole as `LsnContinuityMismatch` to every later reader — +one hole per barren restart, which is what an inspect-then-close host produces +on every open. A missing LSN must keep meaning missing or corrupt material, an +explicitly classified uncommitted tail, or an obstruction; never "a writer +epoch may have silently eaten it." + +An epoch that wrote frames but committed none is closure-empty yet holds +occupied coordinates. Recovery resolves or truncates that tail before a +successor may write, and the successor still resumes after the predecessor's +committed frames. The operating-system lease is the filesystem adapter's exclusion authority. The persisted fencing, process, host, and lease fields are deterministic diff --git a/docs/topics/security/README.md b/docs/topics/security/README.md index d8990d799..e59029a4d 100644 --- a/docs/topics/security/README.md +++ b/docs/topics/security/README.md @@ -37,13 +37,13 @@ ignored test is not an implemented security boundary. Security claims in this topic set use these labels: -| Label | Meaning | -| ------------- | ----------------------------------------------------------------------- | -| Implemented | Current code and an executable witness enforce the claim. | -| Partial | A typed boundary or isolated control exists, but the end-to-end path is incomplete. | -| Required | The architecture requires the control, but current code does not prove it. | -| Assumption | Echo relies on the host, operating system, dependency, or cryptographic premise. | -| Non-goal | Echo deliberately does not claim this property at the named boundary. | +| Label | Meaning | +| ----------- | ----------------------------------------------------------------------------------- | +| Implemented | Current code and an executable witness enforce the claim. | +| Partial | A typed boundary or isolated control exists, but the end-to-end path is incomplete. | +| Required | The architecture requires the control, but current code does not prove it. | +| Assumption | Echo relies on the host, operating system, dependency, or cryptographic premise. | +| Non-goal | Echo deliberately does not claim this property at the named boundary. | The status applies to the exact proposition in its row. An implemented digest check does not make authentication implemented. An implemented recovery test @@ -209,23 +209,23 @@ failure is still an availability failure. ## Current Posture Matrix -| Boundary or property | Status | Current posture | -| ------------------------------------- | ------------ | --------------- | -| Canonical causal-anchor claims | Implemented | Canonical roots, schema checks, claim digests, and value/admission separation have executable witnesses. | -| Causal-anchor trusted admission | Implemented | Current basis and host-owned exact root support are required before atomic WAL fact/receipt commit. | -| Trusted runtime WAL commit/recovery | Implemented | Commit-before-return, crash-tail exclusion, corruption refusal, and read-only index rebuild are witnessed. | -| Same-process API authority split | Implemented | App handles cannot install policies, append WAL facts, tick, or recover; this is not process isolation. | -| Generated package compatibility | Implemented | Registry, schema, artifact, codec, and operation bindings reject incompatible packages. | -| Production intent authentication | Required | Current canonical intent acceptance does not prove an authenticated session or principal. | +| Boundary or property | Status | Current posture | +| ------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Canonical causal-anchor claims | Implemented | Canonical roots, schema checks, claim digests, and value/admission separation have executable witnesses. | +| Causal-anchor trusted admission | Implemented | Current basis and host-owned exact root support are required before atomic WAL fact/receipt commit. | +| Trusted runtime WAL commit/recovery | Implemented | Commit-before-return, crash-tail exclusion, corruption refusal, and read-only index rebuild are witnessed. | +| Same-process API authority split | Implemented | App handles cannot install policies, append WAL facts, tick, or recover; this is not process isolation. | +| Generated package compatibility | Implemented | Registry, schema, artifact, codec, and operation bindings reject incompatible packages. | +| Production intent authentication | Required | Current canonical intent acceptance does not prove an authenticated session or principal. | | End-to-end target authorization | Partial | Capability grant and obstruction machinery exists; all public app paths and trusted expiry/revocation policy are not yet wired through it. | -| Product optic authorization | Partial | Basis, aperture, attachment, and budget checks exist; trusted capability and law binding is incomplete. | -| WAL payload confidentiality | Non-goal now | Current writes retain full plaintext payload bytes. | -| Secure deletion from causal history | Non-goal | Append-only evidence and replicated/CAS material cannot promise erasure without a separate design. | -| Continuum peer/channel authentication | Required | Transport arrival is non-authoritative, but production peer identity and channel security need separate proof. | -| Whole-store rollback detection | Required | Internal chains detect inconsistency; a valid older store needs an external freshness anchor to be distinguishable. | -| Comprehensive hostile-input DoS | Partial | Local budgets and checked codecs exist; no system-wide adversarial resource envelope is proven. | -| Compromised trusted host resistance | Non-goal | A host that owns admission keys, policy, scheduler, and storage can violate Echo's local trust assumptions. | -| Side-channel resistance | Non-goal now | Timing, access-pattern, memory-remanence, and speculative-execution leakage are not presently claimed. | +| Product optic authorization | Partial | Basis, aperture, attachment, and budget checks exist; trusted capability and law binding is incomplete. | +| WAL payload confidentiality | Non-goal now | Current writes retain full plaintext payload bytes. | +| Secure deletion from causal history | Non-goal | Append-only evidence and replicated/CAS material cannot promise erasure without a separate design. | +| Continuum peer/channel authentication | Required | Transport arrival is non-authoritative, but production peer identity and channel security need separate proof. | +| Whole-store rollback detection | Required | Internal chains detect inconsistency; a valid older store needs an external freshness anchor to be distinguishable. | +| Comprehensive hostile-input DoS | Partial | Local budgets and checked codecs exist; no system-wide adversarial resource envelope is proven. | +| Compromised trusted host resistance | Non-goal | A host that owns admission keys, policy, scheduler, and storage can violate Echo's local trust assumptions. | +| Side-channel resistance | Non-goal now | Timing, access-pattern, memory-remanence, and speculative-execution leakage are not presently claimed. | ## Trusted Computing Base diff --git a/tests/docs/test_adr_namespace.sh b/tests/docs/test_adr_namespace.sh index 4253337f3..9ba4d0b79 100755 --- a/tests/docs/test_adr_namespace.sh +++ b/tests/docs/test_adr_namespace.sh @@ -37,9 +37,10 @@ readonly current_adrs=( "0024-anchored-node-creation-from-absence.md" "0025-scheduler-owned-executable-operation-actions.md" "0026-durable-external-action-settlement.md" + "0027-first-class-falsification-witnesses.md" ) -readonly current_adr_last=26 +readonly current_adr_last=27 readonly superseded_legacy_adrs=( "ADR-0003-Materialization-Bus.md" diff --git a/xtask/src/runtime_counter_diagnostic.rs b/xtask/src/runtime_counter_diagnostic.rs index 19d42123c..0a2344f68 100644 --- a/xtask/src/runtime_counter_diagnostic.rs +++ b/xtask/src/runtime_counter_diagnostic.rs @@ -13,11 +13,11 @@ use warp_core::wsc::{build_one_warp_input, validate_wsc, write_wsc_one_warp, Wsc use warp_core::{ derive_witnessed_suffix_shell_digest, export_suffix, import_suffix, make_edge_id, make_intent_kind, make_node_id, make_type_id, AtomPayload, AttachmentKey, AttachmentValue, - CausalSuffixBundle, ConflictPolicy, EdgeRecord, ExportSuffixRequest, Footprint, GraphStore, - GraphView, Hash, ImportSuffixRequest, IngressEnvelope, IngressTarget, NodeId, NodeKey, - NodeRecord, PatternGraph, ProvenanceRef, RewriteRule, TickDelta, TickReceipt, - TickReceiptDisposition, TickReceiptRejection, WarpOp, WitnessedSuffixAdmissionContext, - WitnessedSuffixAdmissionOutcome, WitnessedSuffixExportContext, + CausalSuffixBundle, ConflictPolicy, EdgeRecord, ExecutionGraphView, ExportSuffixRequest, + Footprint, GraphStore, GraphView, Hash, ImportSuffixRequest, IngressEnvelope, IngressTarget, + NodeId, NodeKey, NodeRecord, PatternGraph, ProvenanceRef, RewriteRule, RuleExecutor, TickDelta, + TickReceipt, TickReceiptDisposition, TickReceiptRejection, WarpOp, + WitnessedSuffixAdmissionContext, WitnessedSuffixAdmissionOutcome, WitnessedSuffixExportContext, WitnessedSuffixLocalAdmissionPosture, WorldlineId, WorldlineState, WorldlineTick, }; @@ -408,7 +408,7 @@ fn counter_rule() -> RewriteRule { name: COUNTER_RULE_NAME, left: PatternGraph { nodes: vec![] }, matcher: counter_matcher, - executor: counter_executor, + executor: RuleExecutor::observed(counter_executor), compute_footprint: counter_footprint, factor_mask: 1, conflict_policy: ConflictPolicy::Abort, @@ -420,8 +420,8 @@ fn counter_matcher(view: GraphView<'_>, scope: &NodeId) -> bool { read_intent_amount(&view, scope).is_some() } -fn counter_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) { - let Some(amount) = read_intent_amount(&view, scope) else { +fn counter_executor(view: &mut ExecutionGraphView<'_, '_>, scope: &NodeId, delta: &mut TickDelta) { + let Some(amount) = read_observed_intent_amount(view, scope) else { return; }; let current = view @@ -441,6 +441,20 @@ fn counter_executor(view: GraphView<'_>, scope: &NodeId, delta: &mut TickDelta) }); } +fn read_observed_intent_amount( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, +) -> Option { + let attachment = view.node_attachment(scope)?; + let AttachmentValue::Atom(atom) = attachment else { + return None; + }; + if atom.type_id != make_type_id(INTENT_ATTACHMENT_TYPE) { + return None; + } + parse_amount(atom.bytes.as_ref()) +} + fn counter_footprint(view: GraphView<'_>, scope: &NodeId) -> Footprint { let warp_id = view.warp_id(); let event = NodeKey {