feat(engine): wire IncidentDecision + the deterministic action menu into the live ledger (JEF-570) - #299
Conversation
…nto the live ledger (JEF-570)
Lands ADR-0034 (target-choice, not a mechanism-menu) on top of the merged
incident/ module (JEF-609): the model now names the compromised on-path
node(s); determinism resolves each to its narrowest legal cut.
- prompt.rs: splices the deterministic containment-options menu
(incident::Menu::render) LAST, immediately before a new output
instruction asking for {assessment, reason, contain} instead of the old
4-value {verdict, reason} JSON. Evidence-framing sections are unchanged;
the menu is part of the full-state prompt, so prompt_cache_key/the
ADR-0023 delta gate cover it unchanged. build_delta_prompt_with_menu_asn
is a new, additive entry point (build_delta_prompt_asn/build_judgment_prompt*
keep their existing signatures, rendering an empty menu, for the ~60
existing prompt-content tests).
- model_call.rs: ModelAdjudicator::judge now parses via
incident::parse_incident_decision (tolerant, skeptic default) against
the caller-built menu, chains the D5 grounding guards, and reuses
guard_unsupported_exploitable (grandfathered zero-anchor backstop) via a
round-trip through the legacy Verdict shape it operates on.
- Adjudicator::judge now takes the entry's Menu and returns an
IncidentDecision. IncidentDecision::to_verdict() bridges to the legacy
Verdict so the ADR-0023 cache / re-judge gate / breaker+backoff /
journal Breach line / notifier / dashboard keep consuming exactly what
they already do, unchanged.
- adj_pass.rs: builds each entry's menu (unioned across its objective
chains), dispatches judge() with it, and records this pass's DECISIVE
IncidentDecision into a new Engine-owned map (never persisted — see
scope note below) that carries forward across a cache-hit/backoff pass
(D7's retirement asymmetry: a fresh Uncertain retires nothing).
- respond/mod.rs::reconcile now takes that per-entry decision map. Desired
set = model-chosen cuts whose entry still has a proven chain, plus the
containment_for FALLBACK (stamped adjudicated=false, never auto-applied)
for a breach-relevant entry with no decisive Attack-with-cuts decision.
The deterministic quarantine_targets desired-set insertion is DELETED
for breach-relevant chains (kept, unchanged, for a non-breach-relevant
chain's JEF-284 condition-2 targets — outside the north star's two
lanes, ADR-0032 §6, untouched by this ticket).
- incident/menu.rs, incident/mod.rs: MenuLine/ChosenCut gain a `cut: Link`
field (JEF-570 needed the concrete Link to build a ledger Mitigation;
the pre-merged module only carried the signature string) and a shared
`normalize` helper (build_menu and the new per-entry union both use it).
Rails verified (tested): shadow stays the DEFAULT posture; ADR-0021
per-class arming and enforceScope are untouched (decide() unchanged);
blast-radius/alive-collateral gate untouched; reversible/additive +
self-revert untouched; zero-egress (no new outbound path); untrusted text
in the menu stays fenced / fixed mechanism strings only; presentation
still never gates (ADR-0016, untouched).
Tests: unit tests for the fold (engine::tests, journal_tests — mechanical
Adjudicator-signature updates across 8 existing test doubles), the
reconcile desired-set (respond/decisions_tests.rs: fallback stamped
non-auto even when corroborated, model cuts clear the gate, a confident
NoAttack proposes nothing, D1 attack-with-empty-cuts still falls back, a
non-member reply degrades end-to-end), retirement asymmetry and the
shadow-default rail (engine::tests, two new integration tests), and the
pre-existing containment/quarantine test suites updated for the new
model-gated desired set (respond/tests.rs, pivot_quarantine_tests.rs).
1022 lib tests green; cargo fmt clean; cargo clippy --all-targets
-D warnings clean (workspace).
DECISION NEEDED (scope, flagged per the ticket's own escape hatch):
journal schema v2 (D8's typed IncidentDecision variant + the
fingerprint/cut_signature double replay-lock) is DEFERRED to a follow-up
ticket. This PR's per-entry decision map is Engine-local and NOT
persisted, so a restart starts every entry with no decision — every
breach-relevant entry falls back to the non-auto containment_for proposal
until it is freshly re-judged (strictly MORE conservative than D8's
target, never less safe: no cut can ever auto-apply on a replayed
decision it never actually re-derived this run). The verdict CACHE
(JEF-301) still re-seeds from the journal exactly as before, so a restart
still skips a redundant model call for an unchanged entry — only the
CUTS require a fresh live decision, not the assessment.
Also flagged: the D6 fallback-trigger boundary condition — the ADR text
literally scopes the fallback to "no decisive decision" but D1 explicitly
routes an `Attack` decision with an empty `contain` to the fallback too.
Resolved by triggering the fallback whenever there is no decisive
model-chosen cut (absent, Uncertain, or Attack-with-empty-contain) and
NEVER for a decisive NoAttack (a confident clear proposes nothing at
all) — implemented and covered by
respond::decisions_tests::decisive_attack_with_empty_cuts_still_gets_the_fallback_proposal
and ::decisive_no_attack_produces_no_proposal_at_all.
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…rtain (JEF-570)
HIGH severity D7 retirement-asymmetry violation caught by security review: the
`_ =>` fallback arm rebuilt an entry's desired set from `containment_for`
alone whenever this pass had no decisive decision (no decision at all, or a
fresh Uncertain). Since a previously model-chosen cut's signature generally
differs from containment_for's own default — every downstream
QuarantineWorkload does — that cut silently dropped out of `desired`, landed
in `retired`, and the caller's self-revert loop (engine/mod.rs) actually
reverted the live isolation NetworkPolicy. A model wobble/timeout/the
documented cold-start window after a restart would sever every standing
downstream cut at once, reopening the attacker's path in enforce mode —
exactly what D7 forbids ("a fresh Uncertain retires nothing").
Splits the old catch-all arm by decisiveness:
- No decision at all, or a fresh Uncertain: INERT (new). Carry every
mitigation already active for the entry forward UNCHANGED
(carry_forward_or_fallback), re-justified against this pass's chain so a
genuine chain-clear next pass still retires it structurally. Only offers
the containment_for fallback when there is no standing cut to carry.
- Decisive Attack naming no cut (D1): unchanged — a decisive omission
retires the standing cut and offers only the fallback.
- Decisive NoAttack / decisive Attack with cuts: unchanged.
New tests (respond/decisions_tests.rs), confirmed to fail against the prior
code and pass against the fix:
- a_downstream_cut_persists_across_a_pass_with_no_decision — a downstream
QuarantineWorkload cut (signature != the entry's containment_for default)
chosen on pass N stays active after a pass N+1 with no decision at all.
- a_decisive_no_attack_still_retires_a_standing_cut — the regression guard:
the carry-forward must not make a cut un-retirable; a later decisive
NoAttack still clears it.
1024 lib tests green (was 1022); cargo fmt clean; cargo clippy --all-targets
-D warnings clean (workspace).
Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Architect review — JEF-570 (ADR-0032/0034 wiring)Independently re-verified on the diff (not the self-report). Approving for merge. Safety-critical D7 re-verification (done, not trusted)I brought the new D7 test onto the pre-fix commit An additional freshness check I ran down: Other invariants re-verified
DECISION — Journal schema v2 (D8) deferral: ACCEPTEDThe per-entry decision map is Engine-local and not persisted. On restart every breach-relevant entry falls back to the non-auto Recorded follow-up (required before enforce-mode arming is expected to survive a restart): journal schema v2 — the typed |
…ministic-action-menu-grounding-guards
HOLD — required e2e check genuinely red (supersedes my prior approval)My earlier comment approved on the Rust review; the e2e job then failed and changes the disposition. This is HELD, not merged. What failed
Steps 1-6 pass, including the shadow propose-only rail. This is not infra flake and not introduced by the branch update — on Root cause — the e2e encodes a contract this PR intentionally retires
ADR-0034 D6 — which this PR implements — deletes exactly that: the deterministic desired-set insertion for breach-relevant chains is removed, and the desired set becomes model-chosen cuts + non-auto ( Architect determination (recorded)ADR-0034 supersedes ADR-0009's corroboration-alone auto-cut: under enforce mode with no live model, determinism only proposes — nothing auto-cuts. The engine code here is faithful to ADR-0032/0034. The e2e is the stale artifact and must be brought into line. This is required scope for "wire the cut into the LIVE containment path" — the e2e is the live-path exercise. Required to unblock (fix, don't bypass — no admin merge)Rework
Option 1 preserves an end-to-end auto-cut+self-revert assertion (stronger) but needs a CI-reachable judge; option 2 keeps CI model-free but drops CI's only live auto-cut coverage. I'd prefer option 1 with a deterministic CI stub judge so the live containment path stays covered in CI. Add a one-line note to ADR-0034 Consequences that D6 retires ADR-0009's no-model corroboration-auto-cut. Everything else on this PR verified clean (D6/D7 including the empirically-reproduced pre-fix D7 failure, skeptic default, rails untouched, 1024 lib tests / clippy / fmt green, all files < 1000 lines). Only the e2e blocks. |
scripts/e2e.sh step 7 still encoded the OLD ADR-0009 contract — asserting that live corroboration ALONE, with no model, auto-cuts the entry (and the remotely-exploitable pivot). This PR's D6 deliberately deletes that deterministic auto-cut: the desired active set is now model-chosen cuts + non-auto (adjudicated=false) containment_for fallbacks. CI has no Ollama (steps 10-11 skip), so with no model reconcile correctly applies nothing — step 7's "engine cuts the corroborated entry web" assertion timed out. - Step 7 (renamed): HARD MODE + live corroboration + NO model is now PROPOSE-only. Asserts no NetworkPolicy is applied for web OR store, and that the engine logs a "mitigation proposed" line for web's containment_for fallback (two new log-line predicates, mitigation_proposed_for_web / mitigation_retired_for_web, mirroring the existing chains_proven two-stage-grep pattern so field order in the log formatter can't break the match). - Step 8 (renamed): the fallback proposal is chain-gated — removing the durable allow retires it (a "mitigation retired" log line), exactly like an applied cut would revert. Reasserts no NetworkPolicy exists (nothing was ever applied to revert). - Step 6 + the managed_np_roles doc comment: reworded off "asymmetric action bar (ADR-0009)" / "corroboration would flip auto-eligible" — corroboration alone no longer drives an auto-cut for either the entry or the (now model-only) pivot proposal. - Steps 9-11 (log4j/model phases) are UNCHANGED — they already tested the right thing (no model ⇒ propose-only; a model's own verdict ⇒ cut) and needed no changes for D6. - docs/adr/0034-cut-choice-contract.md: one-line Consequences note — ADR-0034 supersedes ADR-0009's corroboration-alone auto-cut. The model-driven auto-cut/self-revert cycle (steps 10-11) remains this e2e's only apply/revert coverage against a REAL cluster + a real model (Ollama-gated, skipped in CI) — but the SAME Engine::process apply path is now ALSO exercised unconditionally in CI by a new Rust integration test, engine::tests::a_model_chosen_cut_auto_applies_in_enforce_mode (enforce mode + judgement armed + a stub Adjudicator's decisive Attack ⇒ the dry-run actuator's active_count() > 0), alongside the existing shadow contrast a_model_chosen_cut_only_proposes_in_shadow_by_default. Validation: docker is unreachable in this sandbox (k3d needs a live daemon), so scripts/e2e.sh could not be run locally — the assertions were updated precisely against a full read of the script's pre/post-D6 behavior and cross-checked against engine::model::config()'s documented "no PROTECTOR_ENGINE_MODEL ⇒ NullAdjudicator, deterministic-only" contract, which is exactly step 7's configuration. CI will validate; will iterate if still red. 1025 lib tests green (was 1024); cargo fmt clean; cargo clippy --all-targets -D warnings clean (workspace). Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…cision-deterministic-action-menu-grounding-guards' into thejefflarson/jef-570-incidentdecision-deterministic-action-menu-grounding-guards
Closes JEF-570.
Summary
Lands ADR-0034 (the model names the compromised node(s); determinism resolves the narrowest cut) on top of the merged
incident/module (JEF-609, #296). This is the highest-risk ticket of the sprint — it wires the model's cut choice into the live containment/ledger path — so the change is deliberately conservative: every existing rail (shadow-default, ADR-0021 arming, blast-radius gate, reversible/additive-only + self-revert, zero-egress, ADR-0023 cache/gate/breaker) is left byte-for-byte intact, verified by tests, not just asserted.Wiring
incident::Menu::render) LAST, immediately before a new output instruction asking for{assessment, reason, contain}instead of the old 4-value{verdict, reason}JSON. Evidence-framing sections are unchanged; the menu is part of the full-state prompt, soprompt_cache_key/the ADR-0023 delta gate cover it unchanged.build_delta_prompt_with_menu_asnis a new, additive entry point —build_delta_prompt_asn/build_judgment_prompt*keep their existing signatures (rendering an empty menu) so the ~60 existing prompt-content tests are untouched.ModelAdjudicator::judgenow parses viaincident::parse_incident_decision(tolerant, skeptic default) against the caller-built menu, chains the D5 grounding guards, and reusesguard_unsupported_exploitable(the grandfathered zero-anchor backstop) via a round-trip through the legacyVerdictshape it operates on — no reimplementation.Adjudicator::judgenow takes the entry'sMenuand returns anIncidentDecision.IncidentDecision::to_verdict()bridges to the legacyVerdictso the ADR-0023 cache / re-judge gate / breaker+backoff / journalBreachline / notifier / dashboard keep consuming exactly what they already do, unchanged.judge()with it, and records this pass's DECISIVEIncidentDecisioninto a new Engine-owned map (never persisted — see scope note) that carries forward across a cache-hit/backoff pass: D7's retirement asymmetry (a freshUncertainretires nothing).containment_forFALLBACK (stampedadjudicated=false, never auto-applied) for a breach-relevant entry with no decisive Attack-with-cuts decision. The deterministicquarantine_targetsdesired-set insertion is deleted for breach-relevant chains (kept, unchanged, for a non-breach-relevant chain's JEF-284 condition-2 targets — outside the north star's two lanes, ADR-0032 §6, untouched by this ticket).MenuLine/ChosenCutgain acut: Linkfield (JEF-570 needed the concreteLinkto build a ledgerMitigation; the pre-merged module only carried the signature string) and a sharednormalizehelper (build_menuand the new per-entry union both use it).Rails verified (tested, not just asserted)
Shadow stays the DEFAULT posture; ADR-0021 per-class arming and
enforceScopeuntouched (decide()unchanged); blast-radius/alive-collateral gate untouched; reversible/additive + self-revert untouched; zero-egress (no new outbound path); untrusted text in the menu stays fenced / fixed mechanism strings only; presentation still never gates (ADR-0016, untouched).Tests
Unit tests for the fold (
engine::tests,journal_tests— mechanicalAdjudicator-signature updates across 8 existing test doubles), the reconcile desired-set (respond/decisions_tests.rs: fallback stamped non-auto even when corroborated, model cuts clear the gate, a confidentNoAttackproposes nothing, D1 attack-with-empty-cuts still falls back, a non-member reply degrades end-to-end), retirement asymmetry and the shadow-default rail (engine::tests, two new integration tests), and the pre-existing containment/quarantine test suites updated for the new model-gated desired set (respond/tests.rs,pivot_quarantine_tests.rs).1022 lib tests green;
cargo fmtclean;cargo clippy --all-targets -- -D warningsclean (workspace:engine+behavior).DECISION NEEDED (scope, per the ticket's own escape hatch)
IncidentDecisionvariant + the fingerprint/cut_signaturedouble replay-lock is a follow-up ticket. This PR's per-entry decision map is Engine-local and NOT persisted, so a restart starts every entry with no decision — every breach-relevant entry falls back to the non-autocontainment_forproposal until it is freshly re-judged. This is strictly MORE conservative than D8's target, never less safe: no cut can ever auto-apply on a replayed decision it never actually re-derived this run. The verdict CACHE (JEF-301) still re-seeds from the journal exactly as before, so a restart still skips a redundant model call for an unchanged entry — only the cuts require a fresh live decision, not the assessment.Attackdecision with an emptycontainto the fallback too. Resolved by triggering the fallback whenever there is no decisive model-chosen cut (absent,Uncertain, orAttack-with-empty-contain) and NEVER for a decisiveNoAttack(a confident clear proposes nothing at all) — implemented and covered bydecisive_attack_with_empty_cuts_still_gets_the_fallback_proposalanddecisive_no_attack_produces_no_proposal_at_all.Test plan
npm --prefix engine/web run buildcargo fmt --checkcargo clippy --all-targets -- -D warnings(workspace)cargo test --workspace— 1022 engine lib tests + behavior crate tests greensoundcheck:pr-review(no Critical/High findings)/simplifysingle-pass cleanup applied (deduped menu normalization)Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP