Pre-arm scope-simulation preview: what fires and what it severs (JEF-675) - #310
Conversation
…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
78c4d11 to
01270b7
Compare
|
Integration note (architect): rebased onto main after #309 landed. Shared-surface conflicts (DashboardState To honor the 1,000-line cap (CLAUDE.md; the |
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, ifenforceScopewere this candidate scope right now?respond::actuator::scope_preview::preview_scopeis a pure function that classifies this pass's standing, mechanically-eligible cuts (reversible, additive-live, live-corroborated/promoted) against a candidateActuationScope, reusing the exact per-mitigationBlastRadiusthe live blast gate (predict_blast_radius/decide) already computes every pass. A newstate::ScopePreviewStorecaptures(Mitigation, BlastRadius)pairs at the same point inEngine::processthe gate itself computes them, so the preview can never drift from the numbers the gate acted on, and never recomputes reachability itself.ActuationScope:endpoints_withinis the strict endpoint matchin_scopealready 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.reachability_incompletesurfaces as its owncollateral_unknownflag on a would-fire cut, distinct from (and never collapsed into) an emptyalive_collateral.GET /api/scope_preview.json?namespaces=&labels=route (inherits the same OIDC enforcement as every other/api/*.jsonroute via the existing generic/api/route-class match), ascope_previewview-model + props module, and a self-containedScopePreviewPanel(its own on-demand fetch, not part of the 5s tab poll) mounted inside the Action tab.Key design decision
predict_blast_radiuswas 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 liveSecurityGraph/HealthReportborrow exists — henceScopePreviewStore, a per-pass snapshot handle following the same pattern asFindings/ReversionLog.Scope of the preview
The preview isolates exactly the
enforceScopeaxis (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 forScopePreviewPanel— honest empty, would-fire/held partition, collateral-unknown, XSS-inert render)cargo fmtcargo clippy --all-targets -- -D warnings— cleancargo nextest run --workspace— 1132 passed, 2 skippedengine::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 callsActuator::apply/revertand 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/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)/simplify— merged an import grouping, and eliminated a redundant per-passMitigation+BlastRadiusclone inEngine::process(now consumesactive_mitigationsviainto_iter().zip(blasts)instead of cloning into a separate snapshot)Closes JEF-675.