Skip to content

feat(engine): wire IncidentDecision + the deterministic action menu into the live ledger (JEF-570) - #299

Merged
thejefflarson merged 5 commits into
mainfrom
thejefflarson/jef-570-incidentdecision-deterministic-action-menu-grounding-guards
Jul 29, 2026
Merged

feat(engine): wire IncidentDecision + the deterministic action menu into the live ledger (JEF-570)#299
thejefflarson merged 5 commits into
mainfrom
thejefflarson/jef-570-incidentdecision-deterministic-action-menu-grounding-guards

Conversation

@thejefflarson

Copy link
Copy Markdown
Owner

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

  • 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) so the ~60 existing prompt-content tests are untouched.
  • model_call.rsModelAdjudicator::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 (the grandfathered zero-anchor backstop) via a round-trip through the legacy Verdict shape it operates on — no reimplementation.
  • 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) 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.rsMenuLine/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, not just asserted)

Shadow stays the DEFAULT posture; ADR-0021 per-class arming and enforceScope 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: engine + behavior).

DECISION NEEDED (scope, per the ticket's own escape hatch)

  1. Journal schema v2 is deferred. D8's typed IncidentDecision variant + the fingerprint/cut_signature double 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-auto containment_for proposal 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.
  2. The D6 fallback-trigger boundary. ADR-0034's 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 decisive_attack_with_empty_cuts_still_gets_the_fallback_proposal and decisive_no_attack_produces_no_proposal_at_all.

Test plan

  • npm --prefix engine/web run build
  • cargo fmt --check
  • cargo clippy --all-targets -- -D warnings (workspace)
  • cargo test --workspace — 1022 engine lib tests + behavior crate tests green
  • Self-review against soundcheck:pr-review (no Critical/High findings)
  • /simplify single-pass cleanup applied (deduped menu normalization)

Claude-Session: https://claude.ai/code/session_01VtjoJttCvBY4dzCoE4f9vP

thejefflarson and others added 2 commits July 28, 2026 20:57
…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>
@thejefflarson

Copy link
Copy Markdown
Owner Author

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 17af55f and ran it: respond::decisions_tests::a_downstream_cut_persists_across_a_pass_with_no_decision FAILS there (the standing downstream QuarantineWorkload cut is severed and rebuilt from containment_for, landing in retired → self-revert tears down the live NetworkPolicy), and PASSES on 190e3a0. The regression guard a_decisive_no_attack_still_retires_a_standing_cut passes on both — the carry-forward did not make cuts un-retirable. D7 asymmetry holds: fresh Uncertain / no-decision is inert; decisive NoAttack or chain-clear retires; decisive Attack-with-empty-cuts (D1) retires + offers the non-auto fallback.

An additional freshness check I ran down: adj_pass prunes the Engine-local decision map every pass (self.decisions.retain(|k,_| current_entries.contains(k)), adj_pass.rs:433), so a cleared-then-reappearing entry re-judges rather than replaying a stale decisive Attack. No stale-cut re-arm path.

Other invariants re-verified

  • D6 desired-set: deterministic quarantine_targets auto-insert deleted for breach-relevant chains (kept only on the internal-only path); containment_for demoted to adjudicated=false fallback that never auto-applies; model cuts enter only on a decisive Attack with a still-proven breach-relevant chain. Confirmed in reconcile.
  • Skeptic default: non-member contain degrades the whole decision to Uncertain/no-cut (tested end-to-end).
  • Rails: decide.rs/arm.rs/PROTECTOR_MODE default untouched (zero diff); shadow-default rail test confirms a decisive model-chosen Attack only proposes — nothing armed, nothing applied. Zero new outbound path.
  • Independently ran: cargo fmt --check clean, cargo clippy --all-targets -D warnings clean, cargo test -p protector --lib = 1024 passed. All changed files < 1000 lines (max 954).

DECISION — Journal schema v2 (D8) deferral: ACCEPTED

The per-entry decision map is Engine-local and not persisted. On restart every breach-relevant entry falls back to the non-auto containment_for proposal until freshly re-judged. This is strictly more conservative — no cut can auto-apply on a decision it never re-derived this run — and ADR-0034 D8 itself already accepts the ~20-min cold-start re-judge cost. It weakens no invariant and is moot under the shipped shadow-default posture. Merging as-is.

Recorded follow-up (required before enforce-mode arming is expected to survive a restart): journal schema v2 — the typed IncidentDecision journal variant + the fingerprint / cut_signature double replay-lock (ADR-0034 D8). Until it lands, a restart in enforce mode drops standing cuts to proposals (safe, but operationally reopens a contained path until re-judged).

@thejefflarson
thejefflarson enabled auto-merge (squash) July 29, 2026 04:26
@thejefflarson
thejefflarson disabled auto-merge July 29, 2026 04:38
@thejefflarson

Copy link
Copy Markdown
Owner Author

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

e2e (a required check, branch protection is strict) fails deterministically at step 7/11 "HARD MODE":

timed out after 120s waiting for: engine cuts the corroborated entry web

Steps 1-6 pass, including the shadow propose-only rail. This is not infra flake and not introduced by the branch update — on main step 7 passes; this PR is what changes the behavior it asserts.

Root cause — the e2e encodes a contract this PR intentionally retires

scripts/e2e.sh step 7 asserts that in enforce mode, live corroboration alone, with no model, auto-cuts the entry web and pivot store (ADR-0009's asymmetric action bar):

wait_until "engine cuts the corroborated entry web" 120 managed_np_for web
pass "engine quarantined role=web — live corroboration met the asymmetric action bar (ADR-0009)"

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 (adjudicated=false) fallbacks. Step 7 injects no model (the model env is only added in phases 10-11; CI has no Ollama, so the model-driven phases E/F/G are skipped). So reconcile correctly applies nothing, and the assertion times out. The Rust tests were updated to the new contract; scripts/e2e.sh was not.

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 scripts/e2e.sh steps 7-8 to the model-gated contract, e.g. one of:

  1. Make the enforce-mode auto-cut assertions model-gated — inject a decisive-Attack model endpoint (or a CI stub judge) into step 7's deploy_protector, mirroring phases 10-11, and gate them like E/F/G when no model is reachable; or
  2. Rewrite steps 7-8 to assert propose-only without a model (a managed NetworkPolicy is proposed, adjudicated=false, never applied), and move the actual auto-cut + self-revert assertions into the Ollama-gated model phases.

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.

thejefflarson and others added 2 commits July 28, 2026 21:48
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
@thejefflarson
thejefflarson merged commit cf49d48 into main Jul 29, 2026
5 checks passed
@thejefflarson
thejefflarson deleted the thejefflarson/jef-570-incidentdecision-deterministic-action-menu-grounding-guards branch July 29, 2026 04:58
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