Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
44aebb0
feat(warp-core): footprint enforcement guard (Phase 6B)
flyingrobots Jan 24, 2026
0d0231b
fix(footprints): complete footprint declarations for all rules
flyingrobots Jan 24, 2026
c84251b
docs: update documentation for footprint enforcement (Phase 6B)
flyingrobots Jan 24, 2026
001e140
fix(warp-core): scope guard metadata per warp
flyingrobots Jan 25, 2026
9ba6bbe
refactor(echo-dind-tests): share view op id derivation
flyingrobots Jan 25, 2026
b410f08
fix(echo-dry-tests): always declare port scope read
flyingrobots Jan 25, 2026
c04bb9c
refactor(warp-core): remove ExecItemKind dead code
flyingrobots Jan 25, 2026
4cbb82f
refactor(warp-core): remove TickDelta::ops_len
flyingrobots Jan 25, 2026
145b78c
test(warp-core): gate footprint enforcement suite
flyingrobots Jan 25, 2026
912d336
refactor(warp-core): index slice theorem node ids
flyingrobots Jan 25, 2026
2dbab3a
fix(warp-core): classify inbox rules as system
flyingrobots Jan 25, 2026
2795575
fix(echo-dry-tests): align motion footprint reads
flyingrobots Jan 25, 2026
5b12540
docs(echo-dind-tests): clarify view op id helper
flyingrobots Jan 25, 2026
dd54075
docs: drop stale line numbers in tour de code
flyingrobots Jan 25, 2026
7bd2529
test(echo-dry-tests): assert motion footprint boundaries
flyingrobots Jan 25, 2026
c2017be
docs(changelog): clarify test count and md060
flyingrobots Jan 25, 2026
9ca5cf4
docs(echo-dind-tests): explain state footprint read
flyingrobots Jan 25, 2026
4ecf8c8
feat(warp-core): poison deltas and guard panics
flyingrobots Jan 25, 2026
b4aa989
test(warp-core): align slice theorem commit hash
flyingrobots Jan 25, 2026
f814f9e
docs(adr): list enforcement files in phase 6B plan
flyingrobots Jan 25, 2026
7ca1541
docs(adr): fix footprint_guard location
flyingrobots Jan 25, 2026
a00e637
docs(book): clarify footprint guard subset
flyingrobots Jan 25, 2026
92c5e3b
docs(book): qualify enforcement overhead
flyingrobots Jan 25, 2026
258c2e5
docs: fix dind harness guard notes
flyingrobots Jan 25, 2026
efaedea
docs(glossary): clarify ExecItemKind enforcement
flyingrobots Jan 25, 2026
b1c9381
docs(notes): correct footprint guard overhead
flyingrobots Jan 25, 2026
c44e3e9
docs(study): reflect panic_any in director's cut
flyingrobots Jan 25, 2026
c55a515
docs(study): align enforced execution pseudocode
flyingrobots Jan 25, 2026
867b717
docs(study): fix visual atlas enforcement notes
flyingrobots Jan 25, 2026
c2904c0
docs(study): qualify footprint guard gating
flyingrobots Jan 25, 2026
6a4ea77
docs(study): tighten callouts and gate enforcement
flyingrobots Jan 25, 2026
530e0be
docs(study): clarify catch_unwind vs write checks
flyingrobots Jan 25, 2026
0da5a2f
docs(study): drop GraphView size claim
flyingrobots Jan 25, 2026
6134812
fix(echo-dind-tests): make view op ids length-agnostic
flyingrobots Jan 25, 2026
af79eb8
test(echo-dry-tests): harden motion footprint assertions
flyingrobots Jan 25, 2026
f0d8e7e
feat(warp-core): forbid unsafe_graph with enforcement
flyingrobots Jan 25, 2026
cb5c039
docs(study): tighten enforcement tour anchors
flyingrobots Jan 25, 2026
7d60f6f
docs(changelog): expand Phase 6B enforcement notes
flyingrobots Jan 25, 2026
0962ebc
fix(review): address PR #261 feedback items
flyingrobots Jan 25, 2026
c170ff5
docs(study): update stale ops_len reference to len()
flyingrobots Jan 25, 2026
edc5fa0
docs: address PR #261 documentation feedback
flyingrobots Jan 25, 2026
ea106e5
refactor(engine): extract helpers from apply_reserved_rewrites
flyingrobots Jan 25, 2026
3c1efe5
fix(delta): filter no-op ops before delta/diff comparison
flyingrobots Jan 25, 2026
8891af1
refactor: remove clippy too_many_lines suppressions
flyingrobots Jan 25, 2026
48cd423
docs(demo_rules): add rustdoc for PORT_RULE_NAME constant
flyingrobots Jan 25, 2026
79d6b46
fix(review): address PR feedback items
flyingrobots Jan 25, 2026
ad7f6d1
fix(review): address PR feedback round 2
flyingrobots Jan 25, 2026
6cf1c19
fix(review): address PR feedback round 3
flyingrobots Jan 25, 2026
7770dc7
fix(ci): remove HashSet mention from footprint_guard doc comment
flyingrobots Jan 25, 2026
359cb27
docs(visual-atlas): clarify unsafe_graph disables enforcement
flyingrobots Jan 25, 2026
186fcb3
refactor(snapshot): replace AttachmentArrays tuple with named struct
flyingrobots Jan 25, 2026
f57c5af
fix(graph): DeleteNode must not cascade edges
flyingrobots Jan 25, 2026
339f057
docs: update documentation for delete_node cascade removal
flyingrobots Jan 25, 2026
d43d7da
docs: add DeleteNode cascade removal to CHANGELOG
flyingrobots Jan 25, 2026
a80f232
fix(rustdoc): remove broken link to delete_node_cascade
flyingrobots Jan 25, 2026
6cf2a78
fix(review): address code review feedback
flyingrobots Jan 25, 2026
6a897a4
fix(review): address code review feedback
flyingrobots Jan 25, 2026
5f66ec8
fix(review): add cross-warp detection, track_caller, and unconditiona…
flyingrobots Jan 25, 2026
249f770
fix(review): update OpTargets doc, cfg-gate checks, fix UpsertWarpIns…
flyingrobots Jan 26, 2026
47e52a2
docs(footprint): document FootprintViolationWithPanic in module docs
flyingrobots Jan 26, 2026
95ffd82
fix(snapshot_accum): add parent/root validation to apply_open_portal
flyingrobots Jan 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .markdownlint.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
{
"MD007": { "indent": 4 },
"MD013": false,
"MD049": false,
"MD033": {
"allowed_elements": ["u8", "br", "p", "img", "a", "strong", "sub"]
},
"MD041": false,
"MD046": {
"style": "fenced"
}
},
"MD060": false
}
101 changes: 99 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,100 @@

## Unreleased

### Added - Phase 6B: Footprint Enforcement (ADR-0007)

- **FootprintGuard runtime enforcement** (`boaw/exec.rs`): `catch_unwind`-based guard validates
that rewrite-rule executors only read/write resources declared in their `Footprint`. Active in
debug builds and opt-in for release (`footprint_enforce_release` feature). Disabled by
`unsafe_graph` feature.

- **`GraphView::new_guarded()`** (`graph_view.rs`): Read-side enforcement intercepts `node()`,
`edges_from()`, `has_edge()`, `node_attachment()`, `edge_attachment()` calls against declared
read sets.

- **`ExecItem::new()` constructor** (`boaw/exec.rs`): Private `kind: ExecItemKind` field
(cfg-gated) distinguishes `User` vs `System` rules for instance-op authorization.

- **`FootprintViolation` / `ViolationKind` public types** (`footprint.rs`): Typed panic payloads
for ergonomic test assertions — `NodeReadNotDeclared`, `NodeWriteNotDeclared`,
`EdgeReadNotDeclared`, `EdgeWriteNotDeclared`, `AttachmentReadNotDeclared`,
`AttachmentWriteNotDeclared`, `CrossWarpEmission`, `UnauthorizedInstanceOp`, `OpWarpUnknown`.

- **`check_op()` post-hoc write validation** (`boaw/exec.rs`): Validates emitted `WarpOp`s against
declared write sets. Edge mutations (`UpsertEdge`/`DeleteEdge`) are validated by
`op_write_targets()` in `footprint_guard.rs` to require only the `from` node in `n_write`
(the `to` node is intentionally not required). Rationale: `GraphStore` maintains both
`edges_from` and `edges_to`, but adjacency mutation attribution is recorded against the
source node (`from`) only.

- **Slice-theorem proof tests** (`tests/boaw_footprints.rs`): 15 initial integration tests proving
enforcement catches drift, cross-warp violations, instance-op escalation, and
write-violation-overrides-panic invariant.

#### Feature Flag Semantics

- **Debug builds**: enforcement enabled by default (`debug_assertions` on).
- **Release builds**: enforcement disabled unless `footprint_enforce_release` is enabled.
- **`unsafe_graph`**: unconditionally disables enforcement (guards + validation), even in release.
Builds with both `footprint_enforce_release` and `unsafe_graph` are rejected at compile time.
Intended use: performance benchmarking or fuzzing where safety checks are deliberately bypassed.

#### Panic Recovery Semantics

- `execute_item_enforced` wraps executor calls in `catch_unwind`, performs read enforcement
via `GraphView::new_guarded`, and post-hoc write enforcement via `check_op()`.
- A `FootprintViolation` (triggered via `panic_any` in `footprint_guard.rs`) produces a
`PoisonedDelta` rather than a recoverable `Result`. The violating item's execution is aborted
and the worker returns `WorkerResult::Poisoned` immediately (fail-fast). Other workers may
continue until merge, but the tick will abort regardless.
- At the engine layer, poisoned deltas abort the tick via `std::panic::resume_unwind()`: in the
`delta_validate` path, non-poisoned deltas are processed until a `PoisonedDelta` is encountered,
triggering `MergeError::PoisonedDelta` and `resume_unwind()` (via `into_panic()`); in the
non-`delta_validate` path, the iterator is flattened via `.map()` until an `Err(poisoned)`
causes immediate `resume_unwind()`. Abort is immediate with no cleanup once `resume_unwind()`
is invoked. No partial commits occur.

#### Performance Impact

- See `docs/notes/boaw-perf-baseline.md` for measured overhead.
- Debug-mode enforcement measured <5% overhead for small footprints and ~15% for larger
footprints with frequent reads. Release builds are zero-overhead when enforcement is cfg-gated.
- Baseline focused on `FootprintGuard` write validation in `boaw/exec.rs` and read-side checks in
`GraphView::new_guarded()`. Enforcement remains opt-in for release.

#### Known Limitations (Phase 6B)

- **Cross-warp enforcement**: `check_op()` rejects cross-warp emissions except for
`ExecItemKind::System` instance-level ops. System rules are built-in executors such as inbox
handling (`DISPATCH_INBOX_RULE_NAME`, `ACK_PENDING_RULE_NAME`). Instance-level ops are
`WarpOp` variants that modify warp instances (`UpsertWarpInstance`, `DeleteWarpInstance`).
System items are created via `ExecItem::new_system()` (cfg-gated `pub(crate)`). Portal-based
cross-warp permissions are planned for Phase 7.
- **Footprint ergonomics**: current `Footprint` API requires verbose `NodeSet`/`EdgeSet`/`AttachmentSet`
construction; builder/derive helpers are planned for Phase 6C+.
- **Over-declaration**: overly broad write sets reduce parallelism; there is no automated detection
or warning for over-declared writes yet.
- **Guard metadata trade-offs**: guard metadata is assembled from a `HashMap` per tick; alternatives
(e.g., vector indexing) are unbenchmarked.
- **Poison invariant**: poisoned deltas are dropped and abort the tick; recovery or partial salvage
remains undefined pending a stronger typestate API. Key symbols by category:
- **Types**: `FootprintGuard`, `FootprintViolation`
- **Constructors/Methods**: `GraphView::new_guarded`, `ExecItem::new`
- **Functions**: `check_op`
- **Tests/Examples**: `tests/boaw_footprints.rs`

See ADR-0007 for full context.

### Changed - DeleteNode No Longer Cascades Edges

- **BREAKING**: `GraphStore::delete_node()` now returns `Err(DeleteNodeError::NodeNotIsolated)`
if the node has any attached edges. Previously, edges were silently deleted.
- **New API**: `delete_node_isolated()` explicitly requires the node to have no edges.
- **Footprint enforcement**: `op_write_targets(DeleteNode)` now includes the alpha attachment
in the write set, ensuring attachment cleanup is properly declared.
- **Rationale**: Implicit cascade deletion violated the principle of least surprise and made
footprint declarations ambiguous. Callers must now explicitly delete edges before nodes.

### Added - SPEC-0004: Worldlines & Playback

- **`worldline.rs`**: Worldline types for history tracking
Expand Down Expand Up @@ -81,9 +175,12 @@
- **P1: OOM prevention** (`materialization/frame_v2.rs`): Bound `entry_count` by remaining payload size in `decode_v2_packet` to prevent malicious allocation
- **P1: Fork guard** (`provenance_store.rs`): Added `WorldlineAlreadyExists` error variant; `fork()` rejects duplicate worldline IDs
- **P1: Dangling edge validation** (`worldline.rs`): `UpsertEdge` now verifies `from`/`to` nodes exist in store before applying
- **P1: Silent skip → Result** (`boaw/exec.rs`): `execute_work_queue` returns `Result<Vec<TickDelta>, WarpId>` instead of panicking on missing store; caller maps to `EngineError::InternalCorruption`
- **P1: Silent skip → Result** (`boaw/exec.rs`): `execute_work_queue` returns `Vec<WorkerResult>` with variants `Success(TickDelta)`, `Poisoned(PoisonedDelta)`, `MissingStore(WarpId)`; caller maps `MissingStore` to `EngineError::UnknownWarp`
- **P1: Guard metadata scoping** (`engine_impl.rs`): Guard metadata (enforcement tracking of read/write footprints and violation markers) now keys by warp-scoped `NodeKey` (`WarpId + NodeId`), fixing cross-warp collisions that produced false positives/negatives when different warps reused the same local IDs; detected via multi-warp enforcement tests (e.g., slice theorem replay).
- **P2: Tilde-pin bytes dep** (`crates/warp-benches/Cargo.toml`): `bytes = "~1.11"` for minor-version stability
- **P2: Markdownlint MD060** (`.markdownlint.json`): Removed global MD060 disable (all tables are well-formed; no false positives to suppress)
- **P2: Markdownlint MD060** (`.markdownlint.json`): Global MD060 disable retained to avoid table false positives (revisit once tables are normalized)
- **P2: Port rule footprint** (`crates/echo-dry-tests/src/demo_rules.rs`): Always declare scope node read to prevent enforcement panics when node is missing
- **P2: Motion rule footprint** (`crates/echo-dry-tests/src/demo_rules.rs`): Always declare scope node read to prevent enforcement panics when node is missing
- **P2: Test hardening** (`tests/`): Real `compute_commit_hash_v2` in all test worldline setups, u8 truncation guards (`num_ticks <= 127`), updated playback tests to match corrected `publish_truth` indexing
- **Trivial: Phase 6B benchmark** (`boaw_baseline.rs`): Added `bench_work_queue` exercising full `build_work_units → execute_work_queue` pipeline across multi-warp setups
- **Trivial: Perf baseline stats** (`docs/notes/boaw-perf-baseline.md`): Expanded statistical context note with sample size, CI methodology, and Criterion report location
Expand Down
119 changes: 83 additions & 36 deletions crates/echo-dind-tests/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,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, EdgeRecord, EdgeSet, Footprint, GraphStore, GraphView, Hash,
NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, RewriteRule, TickDelta, TypeId, WarpId,
WarpOp,
AttachmentValue, ConflictPolicy, EdgeId, EdgeRecord, EdgeSet, Footprint, GraphStore, GraphView,
Hash, NodeId, NodeKey, NodeRecord, NodeSet, PatternGraph, RewriteRule, TickDelta, TypeId,
WarpId, WarpOp,
};

const TYPE_VIEW_OP: &str = "sys/view/op";
Expand Down Expand Up @@ -62,7 +62,15 @@ pub fn route_push_rule() -> RewriteRule {
emit_route_push(s.warp_id(), delta, args.path);
}
},
compute_footprint: |s, scope| footprint_for_state_node(s, scope, "sim/state/routePath"),
compute_footprint: |s, scope| {
// Only declare full footprint if args decode succeeds (mirrors executor).
if decode_op_args::<ops::route_push::Args>(s, scope, ops::route_push::decode_vars)
.is_none()
{
return minimal_decode_footprint(s, scope);
}
footprint_for_state_node(s, scope, "sim/state/routePath")
},
factor_mask: 0,
conflict_policy: ConflictPolicy::Abort,
join_fn: None,
Expand All @@ -85,7 +93,15 @@ pub fn set_theme_rule() -> RewriteRule {
emit_set_theme(s.warp_id(), delta, args.mode);
}
},
compute_footprint: |s, scope| footprint_for_state_node(s, scope, "sim/state/theme"),
compute_footprint: |s, scope| {
// Only declare full footprint if args decode succeeds (mirrors executor).
if decode_op_args::<ops::set_theme::Args>(s, scope, ops::set_theme::decode_vars)
.is_none()
{
return minimal_decode_footprint(s, scope);
}
footprint_for_state_node(s, scope, "sim/state/theme")
},
factor_mask: 0,
conflict_policy: ConflictPolicy::Abort,
join_fn: None,
Expand Down Expand Up @@ -136,7 +152,23 @@ pub fn toast_rule() -> RewriteRule {
);
}
},
compute_footprint: |s, scope| footprint_for_state_node(s, scope, "sim/view"),
compute_footprint: |s, scope| {
// Only declare full footprint if args decode succeeds (mirrors executor).
if decode_op_args::<ops::toast::Args>(s, scope, ops::toast::decode_vars).is_none() {
return minimal_decode_footprint(s, scope);
}

// emit_view_op_delta_scoped creates view node, op node, edge, and attachment
let view_id = make_node_id("sim/view");
let (op_id, edge_id) = view_op_ids_for_scope(scope);

echo_dry_tests::FootprintBuilder::from_view(s)
.reads_node_with_alpha(*scope)
.writes_nodes([view_id, op_id])
.writes_edge(edge_id)
.writes_node_alpha(op_id)
.build()
},
factor_mask: 0,
conflict_policy: ConflictPolicy::Abort,
join_fn: None,
Expand Down Expand Up @@ -181,19 +213,10 @@ pub fn drop_ball_rule() -> RewriteRule {
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.
let ball_key = NodeKey {
warp_id: s.warp_id(),
local_id: make_node_id("ball"),
};
let mut n_write = NodeSet::default();
n_write.insert(ball_key);
let mut a_write = AttachmentSet::default();
a_write.insert(AttachmentKey::node_alpha(ball_key));
Footprint {
n_write,
a_write,
..Default::default()
}
let ball_id = make_node_id("ball");
echo_dry_tests::FootprintBuilder::from_view(s)
.writes_node_with_alpha(ball_id)
.build()
},
factor_mask: 0,
conflict_policy: ConflictPolicy::Abort,
Expand Down Expand Up @@ -249,15 +272,9 @@ pub fn ball_physics_rule() -> RewriteRule {
}
},
compute_footprint: |s, scope| {
let mut a_write = AttachmentSet::default();
a_write.insert(AttachmentKey::node_alpha(NodeKey {
warp_id: s.warp_id(),
local_id: *scope,
}));
Footprint {
a_write,
..Default::default()
}
echo_dry_tests::FootprintBuilder::from_view(s)
.reads_writes_node_alpha(*scope)
.build()
},
factor_mask: 0,
conflict_policy: ConflictPolicy::Abort,
Expand Down Expand Up @@ -334,6 +351,16 @@ impl<'a> MotionV2View<'a> {
}
}

/// Returns a minimal footprint for decode-only access.
///
/// Used when `decode_op_args` fails: declares only the reads attempted during
/// decoding (scope node + its attachment) with no writes.
fn minimal_decode_footprint(view: GraphView<'_>, scope: &NodeId) -> Footprint {
echo_dry_tests::FootprintBuilder::from_view(view)
.reads_node_with_alpha(*scope)
.build()
}

/// Compute the footprint for a state node operation.
pub fn footprint_for_state_node(
view: GraphView<'_>,
Expand Down Expand Up @@ -364,6 +391,15 @@ pub fn footprint_for_state_node(
e_write.insert_with_warp(warp_id, make_edge_id("edge:sim/state"));
e_write.insert_with_warp(warp_id, make_edge_id(&format!("edge:{state_node_path}")));

// Target node may also be read (toggle_nav reads current value), so we
// conservatively declare the alpha read for all callers. Trade-off:
// route_push/set_theme over-declare reads and may slightly over-serialize,
// but all callers use ConflictPolicy::Abort so the write footprint already
// forces serialization and the scheduling impact is minimal.
a_read.insert(AttachmentKey::node_alpha(NodeKey {
warp_id,
local_id: target_id,
}));
a_write.insert(AttachmentKey::node_alpha(NodeKey {
warp_id,
local_id: target_id,
Expand Down Expand Up @@ -532,11 +568,24 @@ fn emit_toggle_nav(view: GraphView<'_>, delta: &mut TickDelta) {
});
}

/// Emit ops for a view operation with scope-derived deterministic sequencing.
/// Derives deterministic view op IDs from an intent scope.
///
/// Uses the triggering intent's scope (NodeId) to derive a unique view op ID.
/// This ensures determinism under parallel execution since the same intent
/// always produces the same view op ID regardless of worker assignment.
/// Returns `(op_node_id, edge_id)` computed from the scope's hex encoding.
/// Used by both `compute_footprint` and `emit_view_op_delta_scoped` to ensure
/// footprint declarations match actual writes under parallel execution.
fn view_op_ids_for_scope(scope: &NodeId) -> (NodeId, EdgeId) {
use std::fmt::Write as _;
// Size-agnostic: derives hex length from actual byte slice
let mut scope_hex = String::with_capacity(scope.0.len() * 2);
for &b in scope.0.iter() {
write!(&mut scope_hex, "{b:02x}").expect("write to String cannot fail");
}
(
make_node_id(&format!("sim/view/op:{scope_hex}")),
make_edge_id(&format!("edge:view/op:{scope_hex}")),
)
}

fn emit_view_op_delta_scoped(
warp_id: WarpId,
delta: &mut TickDelta,
Expand All @@ -556,10 +605,8 @@ fn emit_view_op_delta_scoped(
});
// Derive view op ID from the intent's scope (NodeId) for deterministic sequencing.
// The scope is content-addressed and unique per intent, ensuring no collisions.
// Use all 32 bytes of scope as hex for a collision-free identifier.
let scope_hex: String = scope.0.iter().map(|b| format!("{:02x}", b)).collect();
let op_id = make_node_id(&format!("sim/view/op:{}", scope_hex));
let edge_id = make_edge_id(&format!("edge:view/op:{}", scope_hex));
// Use all scope bytes as hex for a collision-free identifier.
let (op_id, edge_id) = view_op_ids_for_scope(scope);
delta.push(WarpOp::UpsertNode {
node: NodeKey {
warp_id,
Expand Down
Loading