Skip to content

Pre-arm scope-simulation preview: what fires and what it severs (JEF-675) - #310

Merged
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-675-pre-arm-collateral-preview-what-fires-and-what-it-severs-if
Aug 1, 2026
Merged

Pre-arm scope-simulation preview: what fires and what it severs (JEF-675)#310
thejefflarson merged 1 commit into
mainfrom
thejefflarson/jef-675-pre-arm-collateral-preview-what-fires-and-what-it-severs-if

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

Summary

A read-only projection (ADR-0021, ADR-0016) that answers, before an operator flips mode: enforce: what fires, and what live traffic does it sever, if enforceScope were this candidate scope right now?

  • Backendrespond::actuator::scope_preview::preview_scope is a pure function that classifies this pass's standing, mechanically-eligible cuts (reversible, additive-live, live-corroborated/promoted) against a candidate ActuationScope, reusing the exact per-mitigation BlastRadius the live blast gate (predict_blast_radius/decide) already computes every pass. A new state::ScopePreviewStore captures (Mitigation, BlastRadius) pairs at the same point in Engine::process the gate itself computes them, so the preview can never drift from the numbers the gate acted on, and never recomputes reachability itself.
  • Honest empty scope — a candidate scope with no namespaces/labels partitions every eligible cut into "held, out of scope" — never "unscoped, matches everything". This required a small addition to ActuationScope: endpoints_within is the strict endpoint match in_scope already ran behind its production "empty scope = unscoped" shortcut; the preview calls it directly so an empty candidate can never be misread as the enforce-everywhere wildcard ADR-0021 forbids.
  • Collateral unknown, never implied safereachability_incomplete surfaces as its own collateral_unknown flag on a would-fire cut, distinct from (and never collapsed into) an empty alive_collateral.
  • Dashboard — a new GET /api/scope_preview.json?namespaces=&labels= route (inherits the same OIDC enforcement as every other /api/*.json route via the existing generic /api/ route-class match), a scope_preview view-model + props module, and a self-contained ScopePreviewPanel (its own on-demand fetch, not part of the 5s tab poll) mounted inside the Action tab.

Key design decision

predict_blast_radius was already a pure, side-effect-free function over (&Mitigation, &SecurityGraph, &HealthReport) — no extraction was needed to reach the live blast gate's collateral computation read-only. What was missing was a way to read that per-mitigation result from outside the single pass where the live SecurityGraph/HealthReport borrow exists — hence ScopePreviewStore, a per-pass snapshot handle following the same pattern as Findings/ReversionLog.

Scope of the preview

The preview isolates exactly the enforceScope axis (ADR-0021's single scope dial). It intentionally does not gate on which action classes are currently armed (the ordered arming ladder, ADR-0035) — that's a separate, already-visible knob; folding it in would make the preview answer a moving-target question instead of the one the ticket asks.

Test plan

  • npm --prefix engine/web run build, eslint, tsc --noEmit, vitest run (117 tests, incl. 6 new for ScopePreviewPanel — honest empty, would-fire/held partition, collateral-unknown, XSS-inert render)
  • cargo fmt
  • cargo clippy --all-targets -- -D warnings — clean
  • cargo nextest run --workspace — 1132 passed, 2 skipped
  • Added engine::scope_preview_tests::computing_the_preview_never_applies_or_arms_the_cut_it_previews — an engine-level acceptance test proving computing/reading the preview never calls Actuator::apply/revert and never touches the applied-action log, even when the preview itself correctly classifies a real, justified standing cut as "would fire" for the candidate scope
  • Pure-function unit tests for the classification (in/out of scope, empty-scope honesty, collateral-unknown, ineligible cuts excluded) + a view-model test suite
  • Ran /soundcheck:pr-review — no Critical/High findings (GET-only, auth-inherited, zero-egress, no injection surface, untrusted echoed strings render via existing JSX auto-escape/serde-props-as-contract, verified by an added XSS-inert test)
  • Ran /simplify — merged an import grouping, and eliminated a redundant per-pass Mitigation+BlastRadius clone in Engine::process (now consumes active_mitigations via into_iter().zip(blasts) instead of cloning into a separate snapshot)

Closes JEF-675.

…it severs

Adds a read-only projection (ADR-0021, ADR-0016) that answers "what fires, and
what live traffic does it sever, if I arm mode: enforce on this enforceScope
right now" — before the operator flips the switch that arms the live cut
across the whole scope at once.

Backend: a pure `preview_scope` function (respond::actuator::scope_preview)
classifies this pass's standing, mechanically-eligible cuts against a
CANDIDATE ActuationScope, reusing the SAME per-mitigation BlastRadius the
live blast gate already computes each pass (captured into a new
ScopePreviewStore snapshot at the exact point predict_blast_radius runs, so
the preview can never drift from the numbers the gate acted on). An empty
candidate scope naturally partitions every cut as held/out-of-scope — an
honest zero, never "unscoped" — because the preview calls the new
ActuationScope::endpoints_within (the strict endpoint match `in_scope`
already ran behind its unscoped shortcut), not the production in_scope
shortcut that treats an empty scope as unscoped.

Key design decision: predict_blast_radius was ALREADY a pure, side-effect-
free function — no extraction was needed to reach the live blast gate's
collateral computation read-only. What was missing was a way to read that
per-mitigation result from outside the one pass where the live graph/health
borrow exists, hence ScopePreviewStore.

Dashboard: a new GET /api/scope_preview.json?namespaces=&labels= route
(auth-gated identically to every other /api/*.json route), a scope_preview
view-model + props module, and a self-contained ScopePreviewPanel (own
fetch, not part of the 5s tab poll) mounted inside the Action tab.

Tests: pure-function unit tests for the classification (in/out of scope,
collateral-unknown never implied safe, ineligible cuts excluded, honest
empty scope), a view-model test suite, and an engine-level acceptance test
that processes a real justified cut through a spy actuator and asserts
computing the preview never calls apply/revert and never touches the
applied-action log, even though the preview correctly reports the cut as
would-fire. Frontend: vitest coverage including an XSS-inert render check.

Closes JEF-675.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
@thejefflarson
thejefflarson force-pushed the thejefflarson/jef-675-pre-arm-collateral-preview-what-fires-and-what-it-severs-if branch from 78c4d11 to 01270b7 Compare August 1, 2026 09:51
@thejefflarson

Copy link
Copy Markdown
Owner Author

Integration note (architect): rebased onto main after #309 landed. Shared-surface conflicts (DashboardState divergence+scope_preview fields, state/mod.rs and view-model re-export lists, the four dashboard_preview scenarios, api_json_tests/enforce_tests empty-state builders, run_loop handle wiring, and the two new /api/*.json routes) resolved as union-of-additions — every new route, field, and prop preserved.

To honor the 1,000-line cap (CLAUDE.md; the file_size_guard test) on the shared engine/src/engine/mod.rs, which all three dashboard PRs in this wave append to, the cohesive boot-time journal replay (Engine::replay_journal, ~125 lines) is extracted into a new sibling module engine/src/engine/restore.rs — mirroring the existing adj_pass.rs/adj_gate.rs split pattern. Behavior-neutral code move. Integrated mod.rs is now 848 lines, leaving headroom for #311. Full cargo nextest (1113 pass incl. the file-size guard), clippy, fmt, and the web typecheck/lint/test all green locally.

@thejefflarson
thejefflarson enabled auto-merge (squash) August 1, 2026 09:51
@thejefflarson
thejefflarson merged commit 7e75ac2 into main Aug 1, 2026
5 of 6 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-675-pre-arm-collateral-preview-what-fires-and-what-it-severs-if branch August 1, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant