From b3ec78d35f5872460ec20413341003b0d3b80bb2 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 13 Oct 2026 20:54:25 -0700 Subject: [PATCH 1/3] Retain observed execution footprints (first part of #716) Ports the evidence foundation of draft PR #716 onto current main, per the owner direction to split that PR for re-integration. - ActualFootprint records the graph resources an execution touched and compares them with the declared Footprint; ActualFootprintPosture states what a record may claim. - ExecutionGraphView is the executor-only capability that records reads before the declared-footprint guard applies, so an unwind cannot erase the access that caused it. - RuleExecutor chooses the execution ABI per rule: Observed for native rules, Legacy for frozen compatibility callbacks such as provider-v1, which produce UnavailableLegacyExecutor evidence. - ExecutionFootprintEvidence keyed by canonical execution sequence is kept per worldline (last_execution_footprints) and carried through worker merges and worldline checkpoints. ExecItem takes that sequence explicitly. - The branch tests for the recorder, worker invariance, checkpoints and the differential write check come with it. Adapted to main: - WorldlineStateParts and the rollback copy carry the new field. - One host test fixture that assigns a GraphView executor is wrapped as RuleExecutor::legacy, keeping its behaviour. - New sequence arguments in tests use checked conversions. - Two conflicts kept main, which already had the change in fuller form: the domain-separated snapshot root and a test lint allowance. Not included, and why: - The admission proposal: moved to #842. - Writer-lease release and barren-epoch fixes: already on main. - The trusted_runtime_host.rs change: on inspection it only moves the inline test module, a relocation that belongs with #821. - required-features for feature-gated test targets: a separate improvement. Refs #716. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 58 ++ crates/echo-dind-tests/src/rules.rs | 81 +- crates/echo-dry-tests/src/demo_rules.rs | 22 +- crates/echo-dry-tests/src/rules.rs | 9 +- crates/echo-wesley-gen/src/main.rs | 19 +- crates/echo-wesley-gen/tests/generation.rs | 11 +- .../warp-benches/benches/parallel_baseline.rs | 9 +- .../warp-benches/benches/scheduler_drain.rs | 8 +- .../tests/support/runtime_wal_fixture.rs | 11 +- crates/warp-core/src/actual_footprint.rs | 906 ++++++++++++++++++ crates/warp-core/src/attachment.rs | 2 - crates/warp-core/src/cmd.rs | 22 +- crates/warp-core/src/contract_host.rs | 22 + crates/warp-core/src/coordinator.rs | 19 +- crates/warp-core/src/engine_impl.rs | 222 +++-- crates/warp-core/src/execution_evidence.rs | 133 +++ crates/warp-core/src/execution_graph_view.rs | 497 ++++++++++ crates/warp-core/src/footprint_guard.rs | 32 +- crates/warp-core/src/graph_view.rs | 10 + crates/warp-core/src/inbox.rs | 15 +- crates/warp-core/src/lib.rs | 13 +- crates/warp-core/src/parallel/exec.rs | 421 ++++++-- crates/warp-core/src/parallel/mod.rs | 3 +- crates/warp-core/src/parallel/shard.rs | 2 +- crates/warp-core/src/provider_contract.rs | 10 +- crates/warp-core/src/rule.rs | 66 +- crates/warp-core/src/tick_delta.rs | 2 - crates/warp-core/src/worldline_state.rs | 38 + crates/warp-core/tests/common/mod.rs | 40 +- .../warp-core/tests/dpo_concurrency_litmus.rs | 15 +- .../duplicate_rule_registration_tests.rs | 8 +- ...xternal_consumer_contract_fixture_tests.rs | 10 +- ...nstalled_contract_intent_pipeline_tests.rs | 18 +- .../installed_contract_registry_tests.rs | 2 +- .../parallel_engine_integration_multiwarp.rs | 6 + .../parallel_engine_worker_invariance.rs | 56 ++ crates/warp-core/tests/parallel_footprints.rs | 189 +++- .../warp-core/tests/parallel_parallel_exec.rs | 2 + .../tests/parallel_stress_multiwarp.rs | 3 + .../provider_contract_admission_tests.rs | 4 +- crates/warp-core/tests/slice_theorem_proof.rs | 48 +- crates/warp-core/tests/tick_receipt_tests.rs | 15 +- .../tests/trusted_runtime_host_loop_tests.rs | 12 +- xtask/src/runtime_counter_diagnostic.rs | 30 +- 44 files changed, 2802 insertions(+), 319 deletions(-) create mode 100644 crates/warp-core/src/actual_footprint.rs create mode 100644 crates/warp-core/src/execution_evidence.rs create mode 100644 crates/warp-core/src/execution_graph_view.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eff69759..be8ff7707 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,59 @@ ### 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. + - `submit_intents_with_runtime_wal_ack` accepts several intents and returns after one storage sync has made all of their acceptance transactions durable. Each intent writes the same acceptance transaction as a single submission, so recovery is unchanged. No acceptance of a batch counts as durable, or can be scheduled, before that sync succeeds. A failed batch acknowledges nothing. If the runtime refuses an intent before anything is written for it, the intents before it stay accepted and are synced. If an append or the sync fails, the call's intake is rolled back and the WAL refuses further work with `StorageFailed` until it is reopened. - `SlotWriteIndex` resolves the value of one node, edge or attachment slot at a tick cursor from tick patches and the initial boundary, without materializing a graph or replaying unrelated ticks. It indexes the operations that wrote each slot, including values cleared by node, edge and instance deletion, and truncates in proportion to the ticks removed. A reader is bound to the exact patches the index was built from by patch digest, and truncation refuses patches that are not the indexed suffix. It is a derived reading aid; the runtime does not use it yet. @@ -1769,6 +1822,11 @@ Applied, Rejected, Obstructed}` with receipt evidence and typed contract ### Changed +- `ExecItem` constructors require an explicit canonical evidence sequence. + Manually assembled work units no longer silently reuse a per-rule match + index as the execution sequence. Engine callers pass the existing batch-wide + enumeration directly, preserving scheduler order. + - Echo and Edict share exact `bunny-num` 0.6.0 for the canonical signed Q32.32 foundation. `warp-math` exposes Bunny's checked type and delegates existing `DFix64` arithmetic and motion conversions while preserving saturation, 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/parallel_baseline.rs b/crates/warp-benches/benches/parallel_baseline.rs index d29872212..bcffda0fe 100644 --- a/crates/warp-benches/benches/parallel_baseline.rs +++ b/crates/warp-benches/benches/parallel_baseline.rs @@ -3,7 +3,12 @@ // criterion_group!/criterion_main! expand to undocumented functions that cannot // carry #[allow] (attributes on macro invocations are ignored). Crate-level // suppress is required for benchmark binaries using Criterion. -#![allow(missing_docs, clippy::panic, clippy::items_after_statements)] +#![allow( + missing_docs, + clippy::panic, + clippy::expect_used, + clippy::items_after_statements +)] //! Parallel execution performance baseline benchmarks. //! //! Measures parallel vs serial execution across different workload sizes @@ -94,6 +99,7 @@ fn make_exec_items(nodes: &[NodeId]) -> Vec { .enumerate() .map(|(i, &scope)| { ExecItem::new( + u32::try_from(i).expect("the test sequence fits in u32"), touch_executor, scope, OpOrigin { @@ -217,6 +223,7 @@ fn make_multi_warp_setup( let id = make_node_id(&format!("bench/w{w}/n{i}")); store.insert_node(id, NodeRecord { ty: node_ty }); items.push(ExecItem::new( + u32::try_from(w * items_per_warp + i).expect("the test sequence fits in u32"), touch_executor, id, OpOrigin { 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..aff3b6bb7 100644 --- a/crates/warp-cli/tests/support/runtime_wal_fixture.rs +++ b/crates/warp-cli/tests/support/runtime_wal_fixture.rs @@ -169,7 +169,14 @@ 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) { +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(); let result = result_node_id(); delta.push(WarpOp::UpsertNode { @@ -215,7 +222,7 @@ fn contract_rule() -> warp_core::RewriteRule { name: 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/crates/warp-core/src/actual_footprint.rs b/crates/warp-core/src/actual_footprint.rs new file mode 100644 index 000000000..d2b686861 --- /dev/null +++ b/crates/warp-core/src/actual_footprint.rs @@ -0,0 +1,906 @@ +// 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, OpTargets, 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); + self.record_targets(&targets, warp_id); + } + + pub(crate) fn record_targets(&mut self, targets: &OpTargets, warp_id: WarpId) { + 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()) + ); + } + + 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. + #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] + #[cfg(not(feature = "unsafe_graph"))] + 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/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 08484db8b..82f832237 100644 --- a/crates/warp-core/src/coordinator.rs +++ b/crates/warp-core/src/coordinator.rs @@ -5236,8 +5236,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; }; @@ -5301,7 +5306,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, @@ -5832,7 +5837,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, @@ -5853,7 +5858,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 @@ -5875,7 +5880,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 d7004ac56..4fe6b024d 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: Arc, @@ -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") @@ -2144,9 +2170,19 @@ impl Engine { | crate::inbox::ACK_PENDING_RULE_NAME ); if is_system { - ExecItem::new_system(exec, rw.scope.local_id, rw.origin) + ExecItem::new_system( + evidence_sequence, + exec, + rw.scope.local_id, + rw.origin, + ) } else { - ExecItem::new(exec, rw.scope.local_id, rw.origin) + ExecItem::from_rule_executor( + evidence_sequence, + exec, + rw.scope.local_id, + rw.origin, + ) } } #[cfg(any( @@ -2155,7 +2191,12 @@ impl Engine { ))] { let _ = name; - ExecItem::new(exec, rw.scope.local_id, rw.origin) + ExecItem::from_rule_executor( + evidence_sequence, + exec, + rw.scope.local_id, + rw.origin, + ) } }) .collect(); @@ -2181,7 +2222,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 +2563,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 +2620,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 +2905,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 +2990,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 +3001,7 @@ fn collect_guard_metadata( by_warp .values() .flatten() - .map(|(rw, _exec, name)| { + .map(|(rw, _exec, name, _evidence_sequence)| { ( ( rw.origin, @@ -3197,44 +3268,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 +3381,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 +3393,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 +3408,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 +3433,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 +3446,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 +3544,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 +3566,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..866a66766 --- /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/footprint_guard.rs b/crates/warp-core/src/footprint_guard.rs index e53850bc7..58c22c2f3 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. @@ -492,9 +499,14 @@ impl FootprintGuard { /// 3. Missing `op_warp` on non-instance ops is always an error /// 4. Node/edge/attachment targets must be in the write sets #[track_caller] + #[cfg(test)] pub(crate) fn check_op(&self, op: &WarpOp) { let targets = op_write_targets(op); + self.check_targets(&targets); + } + #[track_caller] + pub(crate) fn check_targets(&self, targets: &OpTargets) { // 1. Instance-level ops blocked for user rules if targets.is_instance_op && !self.is_system { std::panic::panic_any(FootprintViolation { 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/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 28fa6c75a..73b02bc9d 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; @@ -73,6 +74,8 @@ pub mod edict_read; 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; @@ -203,6 +206,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, @@ -225,7 +229,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, @@ -283,6 +288,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, }; @@ -462,7 +469,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..9b03fa062 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"))] @@ -338,11 +348,52 @@ 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 { + /// + /// The caller must supply a canonical sequence unique across the complete + /// work-unit batch; a per-rule match index is not an execution identity. + /// Omitting that sequence is rejected: + /// + /// ```compile_fail + /// use warp_core::{GraphView, NodeId, OpOrigin, TickDelta}; + /// use warp_core::parallel::ExecItem; + /// fn execute(_: GraphView<'_>, _: &NodeId, _: &mut TickDelta) {} + /// let item = ExecItem::new(execute, NodeId([0; 32]), OpOrigin::default()); + /// ``` + pub fn new(evidence_sequence: u32, exec: ExecuteFn, scope: NodeId, origin: OpOrigin) -> Self { + Self::from_rule_executor(evidence_sequence, 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. + /// As with [`Self::new`], the caller supplies the batch-wide canonical + /// evidence sequence rather than a per-rule match index. + pub fn new_observed( + evidence_sequence: u32, + exec: ObservedExecuteFn, + scope: NodeId, + origin: OpOrigin, + ) -> Self { + Self::from_rule_executor( + evidence_sequence, + RuleExecutor::observed(exec), + scope, + origin, + ) + } + + pub(crate) fn from_rule_executor( + evidence_sequence: u32, + exec: RuleExecutor, + scope: NodeId, + origin: OpOrigin, + ) -> Self { Self { exec, scope, origin, + evidence_sequence, #[cfg(any(debug_assertions, feature = "footprint_enforce_release"))] #[cfg(not(feature = "unsafe_graph"))] kind: ExecItemKind::User, @@ -355,11 +406,17 @@ impl ExecItem { /// 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( + evidence_sequence: u32, + exec: RuleExecutor, + scope: NodeId, + origin: OpOrigin, + ) -> Self { Self { exec, scope, origin, + evidence_sequence, kind: ExecItemKind::System, } } @@ -371,6 +428,7 @@ impl ExecItem { /// triggered poisoning. pub struct PoisonedDelta { _delta: TickDelta, + evidence: Vec, panic: Box, } @@ -383,27 +441,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 +516,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 +733,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 +1003,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 +1045,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 +1060,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 +1079,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 +1093,7 @@ where // View dropped here - no long-lived borrows across warps } - WorkerResult::Success(delta) + WorkerResult::Success(WorkerExecution::new(delta, evidence)) }) }) .collect(); @@ -978,33 +1108,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,67 +1148,149 @@ 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. + let targets = delta.ops_ref()[ops_before..] + .iter() + .map(crate::footprint_guard::op_write_targets) + .collect::>(); + for target in &targets { + actual.record_targets(target, store.warp_id()); + } + let check_result = catch_unwind(AssertUnwindSafe(|| { - for op in &delta.ops_ref()[ops_before..] { - guard.check_op(op); + for target in &targets { + guard.check_targets(target); } })); - 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(), + } + } +} - Ok(delta) +#[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 + } + }, + ), + } +} + +/// 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)), } } #[cfg(test)] +#[allow(clippy::expect_used)] mod tests { use super::{ execute_parallel_with_adaptive_routing, execute_parallel_with_policy, @@ -1093,6 +1304,71 @@ 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( + 0, + 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"), @@ -1124,6 +1400,7 @@ mod tests { let scope = NodeId(bytes); store.insert_node(scope, NodeRecord { ty: node_ty }); items.push(ExecItem::new( + u32::try_from(i).expect("the test sequence fits in u32"), test_executor, scope, OpOrigin { 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/parallel/shard.rs b/crates/warp-core/src/parallel/shard.rs index d727a1c42..60695c63e 100644 --- a/crates/warp-core/src/parallel/shard.rs +++ b/crates/warp-core/src/parallel/shard.rs @@ -245,7 +245,7 @@ mod tests { let items: Vec = (0..100) .map(|i| { let node = crate::make_node_id(&format!("partition-test-{i}")); - ExecItem::new(dummy_exec, node, OpOrigin::default()) + ExecItem::new(i, dummy_exec, node, OpOrigin::default()) }) .collect(); 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/worldline_state.rs b/crates/warp-core/src/worldline_state.rs index 3884a2a16..c2403f5af 100644 --- a/crates/warp-core/src/worldline_state.rs +++ b/crates/warp-core/src/worldline_state.rs @@ -17,6 +17,7 @@ use std::sync::Arc; 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}; @@ -95,6 +96,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. @@ -130,6 +133,7 @@ pub(crate) struct WorldlineStateParts { pub(crate) last_materialization_errors: Vec, pub(crate) tx_counter: u64, pub(crate) committed_ingress: BTreeSet<(WriterHeadKey, Hash)>, + pub(crate) last_execution_footprints: Vec, } /// Test instrumentation that counts calls. @@ -195,6 +199,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, @@ -384,6 +389,7 @@ impl WorldlineState { last_materialization_errors: parts.last_materialization_errors, tx_counter: parts.tx_counter, committed_ingress: parts.committed_ingress, + last_execution_footprints: parts.last_execution_footprints, retained_state_root: None, #[cfg(any(test, feature = "host_test"))] state_root_computations: TestCallCount::default(), @@ -438,6 +444,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] { @@ -490,6 +503,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, @@ -510,6 +524,7 @@ impl WorldlineState { initial_state: self.initial_state.clone(), last_snapshot: self.last_snapshot.clone(), tick_history: Vec::new(), + 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, @@ -532,6 +547,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, @@ -1029,6 +1045,7 @@ mod tests { #[test] fn replay_checkpoint_clone_preserves_replay_artifacts_but_clears_ingress_ledger() { let mut state = WorldlineState::empty(); + state.last_execution_footprints = vec![sample_execution_evidence(*state.root())]; state.last_snapshot = Some(Snapshot { root: *state.root(), hash: [3u8; 32], @@ -1132,12 +1149,17 @@ mod tests { state.last_materialization_errors.len() ); assert_eq!(checkpoint.tx_counter, state.tx_counter); + assert_eq!( + checkpoint.last_execution_footprints, + state.last_execution_footprints + ); assert!(checkpoint.committed_ingress.is_empty()); } #[test] fn replay_base_from_initial_resets_frontier_metadata() { let mut state = WorldlineState::empty(); + state.last_execution_footprints = vec![sample_execution_evidence(*state.root())]; let snapshot = Snapshot { root: *state.root(), hash: [1u8; 32], @@ -1198,9 +1220,25 @@ mod tests { ); assert!(replay_base.last_snapshot.is_none()); assert!(replay_base.tick_history.is_empty()); + assert!(replay_base.last_execution_footprints.is_empty()); assert!(replay_base.last_materialization.is_empty()); assert!(replay_base.last_materialization_errors.is_empty()); assert_eq!(replay_base.tx_counter, 0); assert!(replay_base.committed_ingress.is_empty()); } + + fn sample_execution_evidence(node: NodeKey) -> crate::ExecutionFootprintEvidence { + let mut actual = crate::ActualFootprint::new(); + actual.record_node_read(node.local_id); + crate::ExecutionFootprintEvidence::new( + crate::ExecutionEvidenceKey::new( + 7, + node.warp_id, + node.local_id, + crate::OpOrigin::default(), + ), + actual, + crate::ActualFootprintPosture::RecordedWithoutEnforcement, + ) + } } 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_integration_multiwarp.rs b/crates/warp-core/tests/parallel_engine_integration_multiwarp.rs index 40bb62d80..9895819f6 100644 --- a/crates/warp-core/tests/parallel_engine_integration_multiwarp.rs +++ b/crates/warp-core/tests/parallel_engine_integration_multiwarp.rs @@ -62,6 +62,7 @@ fn make_exec_items(nodes: &[NodeId]) -> Vec { .enumerate() .map(|(i, &scope)| { ExecItem::new( + u32::try_from(i).expect("the test sequence fits in u32"), touch_executor, scope, OpOrigin { @@ -83,6 +84,7 @@ fn make_mixed_exec_items(node_groups: &[Vec]) -> Vec { for nodes in node_groups { for &scope in nodes { items.push(ExecItem::new( + u32::try_from(intent_counter).expect("the test sequence fits in u32"), touch_executor, scope, OpOrigin { @@ -518,6 +520,7 @@ fn interleaved_warp_ordering_invariance() { let mut pattern_ab = Vec::new(); for i in 0..10 { pattern_ab.push(ExecItem::new( + u32::try_from(i * 2).expect("the test sequence fits in u32"), touch_executor, nodes_a[i], OpOrigin { @@ -528,6 +531,7 @@ fn interleaved_warp_ordering_invariance() { }, )); pattern_ab.push(ExecItem::new( + u32::try_from(i * 2 + 1).expect("the test sequence fits in u32"), touch_executor, nodes_b[i], OpOrigin { @@ -543,6 +547,7 @@ fn interleaved_warp_ordering_invariance() { let mut pattern_ba = Vec::new(); for i in 0..10 { pattern_ba.push(ExecItem::new( + u32::try_from(i * 2).expect("the test sequence fits in u32"), touch_executor, nodes_b[i], OpOrigin { @@ -553,6 +558,7 @@ fn interleaved_warp_ordering_invariance() { }, )); pattern_ba.push(ExecItem::new( + u32::try_from(i * 2 + 1).expect("the test sequence fits in u32"), touch_executor, nodes_a[i], OpOrigin { 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/parallel_parallel_exec.rs b/crates/warp-core/tests/parallel_parallel_exec.rs index 16be5b017..06c79bc37 100644 --- a/crates/warp-core/tests/parallel_parallel_exec.rs +++ b/crates/warp-core/tests/parallel_parallel_exec.rs @@ -10,6 +10,7 @@ //! These tests require the `delta_validate` feature to be enabled: //! ```sh //! cargo test --package warp-core --test parallel_parallel_exec --features delta_validate +#![allow(clippy::expect_used)] //! ``` // This test file only compiles when the delta_validate feature is enabled. @@ -63,6 +64,7 @@ fn make_exec_items(nodes: &[NodeId]) -> Vec { .enumerate() .map(|(i, &scope)| { ExecItem::new( + u32::try_from(i).expect("the test sequence fits in u32"), touch_executor, scope, OpOrigin { diff --git a/crates/warp-core/tests/parallel_stress_multiwarp.rs b/crates/warp-core/tests/parallel_stress_multiwarp.rs index 5a6e7358c..0173c5c0c 100644 --- a/crates/warp-core/tests/parallel_stress_multiwarp.rs +++ b/crates/warp-core/tests/parallel_stress_multiwarp.rs @@ -65,6 +65,7 @@ fn make_exec_items(nodes: &[NodeId]) -> Vec { .enumerate() .map(|(i, &scope)| { ExecItem::new( + u32::try_from(i).expect("the test sequence fits in u32"), touch_executor, scope, OpOrigin { @@ -85,6 +86,7 @@ fn make_exec_items_for_warp(nodes: &[NodeId], warp_index: usize) -> Vec 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 3009954f3..e2905a4f4 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, @@ -1247,7 +1251,7 @@ fn observation_change_discovery_retains_intervening_writes_after_values_are_rest host.enable_runtime_wal(TrustedRuntimeWalConfig::filesystem(&path)) .expect("WAL"); let mut package = package(); - package.mutation_handlers[0].rule.executor = toggle_root; + package.mutation_handlers[0].rule.executor = warp_core::RuleExecutor::legacy(toggle_root); package.mutation_handlers[0].rule.matcher = matches; package.mutation_handlers[0].rule.compute_footprint = footprint; host.register_contract_package(package).expect("package"); 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 { From bd4672bdbe17f0070f3ce0fb4ec5dbc48fbea416 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 13 Oct 2026 21:13:48 -0700 Subject: [PATCH 2/3] Evidence: retain only merged executions; keep aborted ones apart; share the transcript - Engine::last_execution_footprints, and the copy in worldline state, now hold only evidence from an execution whose worker changes merged. An attempt that unwinds or fails publishes to last_aborted_execution_footprints instead: a failure artifact, cleared at the next execution, never part of worldline state, checkpoints or history. The access that caused an unwind is still inspectable, which is the point of recording reads before the guard. - The retained transcript is held behind shared ownership, so a rollback marker or checkpoint no longer deep-copies the previous execution records. - execution_evidence.rs states what a record does not claim yet: it is not bound to its commit, does not carry the declared footprint, and reports UnavailableBuildProfile without enforcement. Those belong to the admission design in #842. Found by review of #844. Co-Authored-By: Claude Opus 5.5 --- crates/warp-core/src/engine_impl.rs | 66 ++++++++++++++----- crates/warp-core/src/execution_evidence.rs | 15 +++++ crates/warp-core/src/worldline_state.rs | 12 ++-- crates/warp-core/tests/parallel_footprints.rs | 5 +- 4 files changed, 76 insertions(+), 22 deletions(-) diff --git a/crates/warp-core/src/engine_impl.rs b/crates/warp-core/src/engine_impl.rs index 4fe6b024d..82a07a18e 100644 --- a/crates/warp-core/src/engine_impl.rs +++ b/crates/warp-core/src/engine_impl.rs @@ -482,8 +482,12 @@ 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, + /// Canonically ordered per-Action footprints from the latest execution + /// whose changes merged. Part of the worldline's retained state. + last_execution_footprints: std::sync::Arc<[ExecutionFootprintEvidence]>, + /// Per-Action footprints from the latest execution attempt that unwound. + /// It committed nothing; this is a failure artifact, never history. + last_aborted_execution_footprints: std::sync::Arc<[ExecutionFootprintEvidence]>, intent_log: Vec<(u64, crate::attachment::AtomPayload)>, /// Initial state (U0) snapshot preserved for replay via `jump_to_tick`. initial_state: Arc, @@ -545,7 +549,7 @@ struct RuntimeCommitStateGuard<'a> { saved_initial_state: SavedField>, saved_last_snapshot: SavedField>, saved_tick_history: SavedField>, - saved_last_execution_footprints: SavedField>, + saved_last_execution_footprints: SavedField>, saved_last_materialization: SavedField>, saved_last_materialization_errors: SavedField>, committed_ingress: SavedField>, @@ -932,7 +936,8 @@ impl Engine { }, last_snapshot: None, tick_history: Vec::new(), - last_execution_footprints: Vec::new(), + last_execution_footprints: std::sync::Arc::default(), + last_aborted_execution_footprints: std::sync::Arc::default(), intent_log: Vec::new(), initial_state, bus, @@ -1132,7 +1137,8 @@ impl Engine { current_root: root, last_snapshot: None, tick_history: Vec::new(), - last_execution_footprints: Vec::new(), + last_execution_footprints: std::sync::Arc::default(), + last_aborted_execution_footprints: std::sync::Arc::default(), intent_log: Vec::new(), initial_state, bus, @@ -1899,7 +1905,8 @@ impl Engine { if tx.value() == 0 || !self.live_txs.contains(&tx.value()) { return Err(EngineError::UnknownTx); } - self.last_execution_footprints.clear(); + self.last_execution_footprints = std::sync::Arc::default(); + self.last_aborted_execution_footprints = std::sync::Arc::default(); 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. @@ -2023,7 +2030,8 @@ impl Engine { self.live_txs.remove(&tx.value()); self.scheduler.finalize_tx(tx); self.bus.clear(); - self.last_execution_footprints.clear(); + self.last_execution_footprints = std::sync::Arc::default(); + self.last_aborted_execution_footprints = std::sync::Arc::default(); self.last_materialization.clear(); self.last_materialization_errors.clear(); } @@ -2222,7 +2230,11 @@ 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, &mut self.last_execution_footprints)?; + let ops = merge_parallel_deltas( + worker_results, + &mut self.last_execution_footprints, + &mut self.last_aborted_execution_footprints, + )?; // 4. Apply the merged ops to the state let patch = WarpTickPatchV1::new( @@ -2564,17 +2576,28 @@ impl Engine { } /// Returns canonically ordered per-Action footprints from the latest - /// scheduler execution attempt. + /// scheduler execution whose changes merged. /// - /// 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. + /// An execution that unwinds publishes nothing here; see + /// [`Self::last_aborted_execution_footprints`]. Posture remains part of + /// every record and decides what it may claim. #[must_use] pub fn last_execution_footprints(&self) -> &[ExecutionFootprintEvidence] { &self.last_execution_footprints } + /// Returns per-Action footprints from the latest execution attempt that + /// unwound, for a caller that deliberately catches the ordinary panic and + /// needs the exact access that caused it. + /// + /// The attempt committed nothing. These records are a failure artifact: + /// they are not part of worldline state, checkpoints or history, and are + /// cleared when the next execution starts. + #[must_use] + pub fn last_aborted_execution_footprints(&self) -> &[ExecutionFootprintEvidence] { + &self.last_aborted_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. /// @@ -2907,7 +2930,8 @@ impl Engine { /// Panics (via `resume_unwind`) if any delta was poisoned by an executor or enforcement panic. fn merge_parallel_deltas( worker_results: Vec, - retained_evidence: &mut Vec, + retained_evidence: &mut std::sync::Arc<[ExecutionFootprintEvidence]>, + aborted_evidence: &mut std::sync::Arc<[ExecutionFootprintEvidence]>, ) -> Result, EngineError> { // Convert WorkerResult to the format expected by merge paths let mut all_deltas = Vec::with_capacity(worker_results.len()); @@ -2930,11 +2954,23 @@ fn merge_parallel_deltas( } } evidence.sort_by_key(ExecutionFootprintEvidence::key); - *retained_evidence = evidence; + // Retained evidence describes an execution whose changes merged. An + // attempt that unwinds or fails is published separately, as a failure + // artifact that is never part of worldline state. + *retained_evidence = std::sync::Arc::default(); + *aborted_evidence = evidence.clone().into(); if let Some(warp_id) = missing_store { return Err(EngineError::UnknownWarp(warp_id)); } + let ops = merge_worker_deltas(all_deltas)?; + *aborted_evidence = std::sync::Arc::default(); + *retained_evidence = evidence.into(); + Ok(ops) +} +fn merge_worker_deltas( + all_deltas: Vec>, +) -> Result, EngineError> { #[cfg(any(test, feature = "delta_validate"))] { merge_deltas(all_deltas).map_err(|conflict| { diff --git a/crates/warp-core/src/execution_evidence.rs b/crates/warp-core/src/execution_evidence.rs index 155843a44..1859b9dd1 100644 --- a/crates/warp-core/src/execution_evidence.rs +++ b/crates/warp-core/src/execution_evidence.rs @@ -2,6 +2,21 @@ // © James Ross Ω FLYING•ROBOTS //! Per-Action evidence retained from scheduler-owned execution. //! +//! # What a record does not claim +//! +//! These records are a foundation, not admitted evidence. Until the admission +//! design in issue #842 is accepted and built: +//! +//! - A record is not bound to the commit that produced it. It carries a +//! batch-local sequence and the scheduler origin, not a worldline tick, +//! commit hash or basis; its meaning comes from where it is held. +//! - A record does not carry the footprint the rule declared, so +//! `Actual ⊆ Declared` cannot be evaluated from the record alone. +//! - In a build without footprint enforcement, an observed execution reports +//! `UnavailableBuildProfile` even though its reads were recorded. +//! +//! Nothing in Echo admits a witness from these records yet. +//! //! 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 diff --git a/crates/warp-core/src/worldline_state.rs b/crates/warp-core/src/worldline_state.rs index c2403f5af..dbdf87a0f 100644 --- a/crates/warp-core/src/worldline_state.rs +++ b/crates/warp-core/src/worldline_state.rs @@ -97,7 +97,7 @@ pub struct WorldlineState { /// 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, + pub(crate) last_execution_footprints: Arc<[ExecutionFootprintEvidence]>, /// Last finalized materialization channels for this worldline. pub(crate) last_materialization: Vec, /// Last materialization errors for this worldline. @@ -133,7 +133,7 @@ pub(crate) struct WorldlineStateParts { pub(crate) last_materialization_errors: Vec, pub(crate) tx_counter: u64, pub(crate) committed_ingress: BTreeSet<(WriterHeadKey, Hash)>, - pub(crate) last_execution_footprints: Vec, + pub(crate) last_execution_footprints: Arc<[ExecutionFootprintEvidence]>, } /// Test instrumentation that counts calls. @@ -199,7 +199,7 @@ impl WorldlineState { root, last_snapshot: None, tick_history: Vec::new(), - last_execution_footprints: Vec::new(), + last_execution_footprints: Arc::default(), last_materialization: Vec::new(), last_materialization_errors: Vec::new(), tx_counter: 0, @@ -547,7 +547,7 @@ impl WorldlineState { initial_state, last_snapshot: None, tick_history: Vec::new(), - last_execution_footprints: Vec::new(), + last_execution_footprints: Arc::default(), last_materialization: Vec::new(), last_materialization_errors: Vec::new(), tx_counter: 0, @@ -1045,7 +1045,7 @@ mod tests { #[test] fn replay_checkpoint_clone_preserves_replay_artifacts_but_clears_ingress_ledger() { let mut state = WorldlineState::empty(); - state.last_execution_footprints = vec![sample_execution_evidence(*state.root())]; + state.last_execution_footprints = vec![sample_execution_evidence(*state.root())].into(); state.last_snapshot = Some(Snapshot { root: *state.root(), hash: [3u8; 32], @@ -1159,7 +1159,7 @@ mod tests { #[test] fn replay_base_from_initial_resets_frontier_metadata() { let mut state = WorldlineState::empty(); - state.last_execution_footprints = vec![sample_execution_evidence(*state.root())]; + state.last_execution_footprints = vec![sample_execution_evidence(*state.root())].into(); let snapshot = Snapshot { root: *state.root(), hash: [1u8; 32], diff --git a/crates/warp-core/tests/parallel_footprints.rs b/crates/warp-core/tests/parallel_footprints.rs index 2f78ab29a..8ec01ad0b 100644 --- a/crates/warp-core/tests/parallel_footprints.rs +++ b/crates/warp-core/tests/parallel_footprints.rs @@ -233,7 +233,10 @@ mod enforcement { "undeclared read must retain the ordinary panic" ); - let evidence = engine.last_execution_footprints(); + // The attempt committed nothing, so it is not retained evidence. The + // access that caused the unwind is kept as a failure artifact. + assert!(engine.last_execution_footprints().is_empty()); + let evidence = engine.last_aborted_execution_footprints(); assert_eq!(evidence.len(), 1); let record = &evidence[0]; assert_eq!(record.scope(), scope); From 3e5f89cb6f126d4108e7ce8f6a019fe7cbe9c8f3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 13 Oct 2026 21:23:11 -0700 Subject: [PATCH 3/3] Evidence: promote to retained only when the tick seals An operation can satisfy its declared footprint and still fail when the merged patch is applied, for example a DeleteNode for a node that does not exist. Evidence was promoted to retained right after the merge, so such a tick left retained evidence although it sealed nothing. Evidence now waits in the aborted slot through the merge and the patch application, and commit promotes it only after the snapshot, receipt and history entry exist. A test deletes a missing node and checks that nothing is retained and the attempt is in the aborted record. Found by review of #844. Co-Authored-By: Claude Opus 5.5 --- crates/warp-core/src/engine_impl.rs | 19 ++-- crates/warp-core/tests/parallel_footprints.rs | 95 ++++++++++++++++++- 2 files changed, 103 insertions(+), 11 deletions(-) diff --git a/crates/warp-core/src/engine_impl.rs b/crates/warp-core/src/engine_impl.rs index 82a07a18e..0a414e398 100644 --- a/crates/warp-core/src/engine_impl.rs +++ b/crates/warp-core/src/engine_impl.rs @@ -2014,6 +2014,11 @@ impl Engine { self.last_snapshot = Some(snapshot.clone()); self.tick_history .push((snapshot.clone(), receipt.clone(), patch.clone())); + // The tick is sealed: its patch applied and its snapshot, receipt and + // history entry exist. Only now does its execution evidence become + // retained evidence. + self.last_execution_footprints = + std::mem::take(&mut self.last_aborted_execution_footprints); // Mark transaction as closed/inactive and finalize scheduler accounting. self.live_txs.remove(&tx.value()); self.scheduler.finalize_tx(tx); @@ -2954,18 +2959,16 @@ fn merge_parallel_deltas( } } evidence.sort_by_key(ExecutionFootprintEvidence::key); - // Retained evidence describes an execution whose changes merged. An - // attempt that unwinds or fails is published separately, as a failure - // artifact that is never part of worldline state. + // Evidence waits in the aborted slot until the tick seals. The commit + // promotes it to retained evidence only after the patch applied and the + // snapshot, receipt and history entry exist; an attempt that unwinds or + // fails anywhere before that leaves it as a failure artifact. *retained_evidence = std::sync::Arc::default(); - *aborted_evidence = evidence.clone().into(); + *aborted_evidence = evidence.into(); if let Some(warp_id) = missing_store { return Err(EngineError::UnknownWarp(warp_id)); } - let ops = merge_worker_deltas(all_deltas)?; - *aborted_evidence = std::sync::Arc::default(); - *retained_evidence = evidence.into(); - Ok(ops) + merge_worker_deltas(all_deltas) } fn merge_worker_deltas( diff --git a/crates/warp-core/tests/parallel_footprints.rs b/crates/warp-core/tests/parallel_footprints.rs index 8ec01ad0b..fb17e6297 100644 --- a/crates/warp-core/tests/parallel_footprints.rs +++ b/crates/warp-core/tests/parallel_footprints.rs @@ -79,9 +79,10 @@ mod enforcement { use warp_core::{ 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, + EdgeRecord, EdgeSet, Engine, ExecutionGraphView, Footprint, FootprintViolation, + FootprintViolationWithPanic, GraphStore, GraphView, NodeId, NodeKey, NodeRecord, NodeSet, + PatternGraph, PortSet, RewriteRule, RuleExecutor, TickDelta, ViolationKind, WarpInstance, + WarpOp, }; // ============================================================================= @@ -848,6 +849,94 @@ mod enforcement { assert_eq!(*msg, "deliberate-12b"); } + // Evidence is retained only once a tick seals + // ============================================================================= + + const UNSEALED_NAME: &str = "test/observed-evidence-unsealed"; + + fn unsealed_executor( + view: &mut ExecutionGraphView<'_, '_>, + scope: &NodeId, + delta: &mut TickDelta, + ) { + let _ = view.node(scope); + // Footprint-valid but state-invalid: the node does not exist, so the + // merged patch cannot apply and the tick never seals. + delta.push(WarpOp::DeleteNode { + node: NodeKey { + warp_id: view.warp_id(), + local_id: make_node_id("observed-evidence-missing"), + }, + }); + } + + fn unsealed_footprint(view: GraphView<'_>, scope: &NodeId) -> Footprint { + let wid = view.warp_id(); + let missing = make_node_id("observed-evidence-missing"); + let mut n_read = NodeSet::default(); + let mut n_write = NodeSet::default(); + let mut a_write = AttachmentSet::default(); + n_read.insert_with_warp(wid, *scope); + n_write.insert_with_warp(wid, missing); + a_write.insert(AttachmentKey::node_alpha(NodeKey { + warp_id: wid, + local_id: missing, + })); + Footprint { + n_read, + n_write, + e_read: EdgeSet::default(), + e_write: EdgeSet::default(), + a_read: AttachmentSet::default(), + a_write, + b_in: PortSet::default(), + b_out: PortSet::default(), + factor_mask: 0, + } + } + + #[test] + fn evidence_from_a_tick_that_fails_after_merge_is_not_retained() { + let scope = make_node_id("observed-evidence-unsealed-scope"); + let mut store = GraphStore::default(); + store.insert_node( + scope, + NodeRecord { + ty: make_type_id("scope"), + }, + ); + let mut engine = Engine::new(store, scope); + engine + .register_rule(RewriteRule { + id: test_rule_id(UNSEALED_NAME), + name: UNSEALED_NAME, + left: PatternGraph { nodes: vec![] }, + matcher: always_match, + executor: RuleExecutor::observed(unsealed_executor), + compute_footprint: unsealed_footprint, + factor_mask: 0, + conflict_policy: ConflictPolicy::Abort, + join_fn: None, + }) + .expect("register"); + let tx = engine.begin(); + assert!(matches!( + engine.apply(tx, UNSEALED_NAME, &scope), + Ok(ApplyResult::Applied) + )); + + let result = catch_unwind(AssertUnwindSafe(|| engine.commit(tx))); + assert!( + matches!(result, Ok(Err(_))), + "deleting a missing node fails when the patch applies" + ); + assert!( + engine.last_execution_footprints().is_empty(), + "a tick that did not seal leaves no retained evidence" + ); + assert_eq!(engine.last_aborted_execution_footprints().len(), 1); + } + // ============================================================================= // t3_13: DeleteNode requires alpha attachment write in footprint // =============================================================================