Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 0 deletions docs/adr/0034-cut-choice-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ ladder, and entry-exclusion all survive as the resolver + fallback.)
1.7B's over-cut mass is acceptable.
- **Model unavailable** ⇒ fallback proposals only; standing cuts persist until chain-clear
(§7's deliberate asymmetry).
- **ADR-0034 supersedes ADR-0009's corroboration-alone auto-cut**: under enforce mode with no
live model, determinism only *proposes* (the `containment_for` fallback, stamped
`adjudicated=false`) — nothing auto-cuts on live corroboration by itself anymore. Only a
model's decisive `attack` naming a node (§1/§6) arms an auto-eligible cut now.
- Refines [ADR-0032](0032-model-is-incident-responder.md) §3 (mechanism-menu → target-choice)
and its 4-value output (→ 3-value). Judge tier remains [ADR-0033] (pending T2b).
- Deferred: edge-granular downstream cuts (no actuator lever — the schema versions forward via
Expand Down
15 changes: 10 additions & 5 deletions engine/src/engine/adj_gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ impl Engine {
/// (re-judge) vs subtractive (the prior verdict holds), and the baseline itself (the gate
/// serves its verdict on a subtractive hold). Built before the cache lookup so the cached-on
/// and sent prompt bytes can never drift.
// 8 args (JEF-565 added `downstream`): each is a distinct, already-computed piece of this
// pass's per-entry state (no natural sub-grouping that wouldn't just be a wrapper struct for
// its own sake — see the same call already made in `run_loop.rs`/`supply_chain/mod.rs`).
// 9 args (JEF-570 added `menu`): each is a distinct, already-computed piece of this pass's
// per-entry state (no natural sub-grouping that wouldn't just be a wrapper struct for its
// own sake — see the same call already made in `run_loop.rs`/`supply_chain/mod.rs`).
#[allow(clippy::too_many_arguments)]
pub(super) fn prepare_pending(
&self,
Expand All @@ -66,19 +66,23 @@ impl Engine {
idxs: &[usize],
graph: &graph::SecurityGraph,
asn: &crate::engine::observe::asn::AsnDb,
menu: reason::adjudicate::incident::Menu,
) -> (PendingEntry, bool, Option<state::VerdictBaseline>) {
let baseline = self.verdicts.baseline_for(entry_key);
let delta = reason::adjudicate::build_delta_prompt_asn(
let delta = reason::adjudicate::build_delta_prompt_with_menu_asn(
&entry,
&objectives,
graph,
asn,
baseline.as_ref().map(|b| &b.surface),
&downstream,
&menu,
);
// The verdict-cache key is the FULL-STATE hash (excludes the "Changes since…" section) so
// an identical full state always keys identically regardless of the delta — see
// `build_delta_prompt_asn` for why (ADR-0023's fingerprint↔delta-gate resolution).
// `build_delta_prompt_asn` for why (ADR-0023's fingerprint↔delta-gate resolution). The
// menu is part of that full state (JEF-570): a mapping change is a prompt change is a
// re-judge (ADR-0034 D4).
let fingerprint = delta.cache_key;
let chain = reason::adjudicate::chain_shape_hash(&objectives);
let pending = PendingEntry {
Expand All @@ -92,6 +96,7 @@ impl Engine {
chain,
surface: delta.surface,
idxs: idxs.to_vec(),
menu,
};
(pending, delta.additive, baseline)
}
Expand Down
2 changes: 2 additions & 0 deletions engine/src/engine/adj_gate_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fn pending(entry: &str, fingerprint: &str) -> PendingEntry {
chain: "ch".into(),
surface: JudgedSurface::default(),
idxs: vec![0],
menu: reason::adjudicate::incident::Menu::default(),
}
}

Expand Down Expand Up @@ -315,6 +316,7 @@ fn downstream_only_cve_appearing_is_additive_and_forces_a_rejudge() {
chain: "ch".into(),
surface: current_build.surface,
idxs: vec![0],
menu: reason::adjudicate::incident::Menu::default(),
};
assert!(
matches!(
Expand Down
100 changes: 86 additions & 14 deletions engine/src/engine/adj_pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,41 @@
//! This is a behavior-neutral code move: it mutates exactly the state the inline block did
//! (`verdicts`, `journal`, `notifier`, `findings`, `metrics`) and stamps verdicts onto the
//! passed-in `chains` in place. The caller re-publishes the enriched chains afterward.
//!
//! **ADR-0034 (JEF-570):** each entry's model call now returns an
//! [`incident::IncidentDecision`] (a 3-value assessment + the engine-resolved cuts it chose
//! from the entry's deterministic menu, built in Phase 1), not the bare legacy
//! [`reason::adjudicate::Verdict`]. The pass folds it two ways: `to_verdict()` derives the
//! `Verdict` every existing rail here — the cache, the re-judge gate, the journal `Breach`
//! line, the notifier, the display — keeps consuming unchanged (ADR-0023 stays intact), while
//! the decision itself is collected and returned so the caller can hand it to
//! [`super::respond::MitigationLedger::reconcile`] (D6/D7): the ledger's desired set is now the
//! model-chosen cuts, not a deterministic insertion.

use futures::StreamExt;
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};

use super::{
Engine, PendingEntry, adj_gate, churn_diag, graph, journal, model, notify, reason, state,
Engine, PendingEntry, adj_gate, churn_diag, graph, journal, model, notify, observe, reason,
state,
};
use reason::adjudicate::incident;

impl Engine {
/// Run the four-phase adjudication pass over this pass's breach-relevant chains (see the
/// module docs). Stamps each entry's verdict onto `chains` in place (for the timer path's
/// `chain.emit()` log and the `from_chain` fallback) and writes the resolved verdict to the
/// shared store the instant it's decided, so the findings snapshot resolves it immediately.
/// `pass_now` is the pass's single injected clock, shared with every backoff/breaker decision.
/// Returns this pass's per-entry cut-choice decision (ADR-0034), keyed by entry key, for the
/// ledger reconcile.
pub(super) async fn run_adjudication_pass(
&mut self,
chains: &mut [reason::proof::ProvenChain],
graph: &graph::SecurityGraph,
health: &observe::health::HealthReport,
pass_now: std::time::Instant,
) {
) -> BTreeMap<String, incident::IncidentDecision> {
// Adjudicate (ADR-0013): the model is the JUDGE of every breach-relevant PATH,
// always. The deterministic proof winnows to the paths an internet-facing
// workload can actually reach (internet → entry → objective); the model then
Expand Down Expand Up @@ -118,11 +133,18 @@ impl Engine {
// block for (see `downstream_workloads`).
let downstream = downstream_workloads(&entry, idxs, chains);

// ADR-0034 D4 (JEF-570): the deterministic cut-choice menu for this entry, unioned
// across every one of its objective-chains (see `entry_menu`) — the SAME menu the
// prompt's containment-options section renders and the model's `contain` reply
// resolves against.
let menu = entry_menu(idxs, chains, graph, health);

// Build the entry's delta-aware pending record (prompt + fingerprint + projected
// surface) and read its baseline — see [`Engine::prepare_pending`] (ADR-0023 / JEF-350
// / JEF-387). `additive` says whether the delta since the baseline is additive.
let (pending, additive, baseline) =
self.prepare_pending(entry_key, entry, objectives, downstream, idxs, graph, &asn);
let (pending, additive, baseline) = self.prepare_pending(
entry_key, entry, objectives, downstream, idxs, graph, &asn, menu,
);
// The layered re-judge gate (JEF-390 LRU / JEF-391 delta hold / JEF-234 breaker +
// backoff / re-judge), decided WITHOUT a model call — see [`adj_gate`].
match adj_gate::classify_adjudication(
Expand Down Expand Up @@ -164,40 +186,44 @@ impl Engine {
// other entries' adjudication in the same pass.
let judged_results: Vec<(
PendingEntry,
reason::adjudicate::Verdict,
incident::IncidentDecision,
std::time::Duration,
)> = {
let adjudicator = &self.adjudicator;
let graph_ref = graph;
futures::stream::iter(to_judge.into_iter().map(|pending| async move {
let started = std::time::Instant::now();
let verdict = adjudicator
let decision = adjudicator
.judge(
&pending.entry,
&pending.objectives,
graph_ref,
&pending.prompt,
&pending.downstream,
&pending.menu,
)
.await;
(pending, verdict, started.elapsed())
(pending, decision, started.elapsed())
}))
.buffer_unordered(model::model_concurrency())
.collect()
.await
};

// Phase 3 — fold each fresh verdict back into the per-entry store (sequential; each
// step mutates the engine). Cache a decisive verdict + clear its backoff/close the
// breaker; arm backoff on an Uncertain; record the model-call latency + outcome. This
// is the SAME bookkeeping the old sequential loop did per fresh call — only the
// dispatch shape (concurrent, above) changed.
for (pending, verdict, elapsed) in judged_results {
// Phase 3 — fold each fresh decision back into the per-entry store (sequential; each
// step mutates the engine). Cache a decisive verdict (derived from the decision,
// ADR-0034) + clear its backoff/close the breaker; arm backoff on an Uncertain; record
// the model-call latency + outcome. This is the SAME bookkeeping the old sequential loop
// did per fresh call — only the dispatch shape (concurrent, above) changed.
for (pending, decision, elapsed) in judged_results {
// Time the (slow, CPU-bound) model call so its latency tail is observable in
// shadow (JEF-100). Recorded for every fresh call; `result` labels the outcome.
self.metrics
.model_latency_ms
.record(elapsed.as_secs_f64() * 1000.0, &[]);
// The legacy 4-value Verdict every rail below still consumes unchanged (ADR-0023's
// cache/gate/journal/notify/display — see `IncidentDecision::to_verdict`).
let verdict = decision.to_verdict();
// An Uncertain is usually a transient model outage — re-judge later rather than
// pin the failure into the cache. Logged at info: on a slow CPU model nearly every
// verdict lands here, and a silent inconclusive is indistinguishable from "the
Expand All @@ -209,10 +235,19 @@ impl Engine {
// breaker's failure run, so the next pass does NOT re-judge it immediately.
self.verdicts
.record_inconclusive(&pending.entry_key, pass_now);
// ADR-0034 D7: a fresh Uncertain retires nothing and cuts nothing — leave
// whatever decision (if any) is already recorded for this entry standing,
// rather than clearing it. Inert both ways.
"unavailable"
}
decisive => {
tracing::info!(entry = %pending.entry.0, objectives = pending.objectives.len(), verdict = ?decisive, "adjudicated entry");
// ADR-0034 D6/D7: record THIS pass's decisive cut-choice decision — the
// ledger reconcile reads it back below. Overwrites any prior decision for
// this entry, so a fresh decisive decision that DROPS a cut is exactly what
// retires it (the ledger's own active-vs-desired diff does the rest).
self.decisions
.insert(pending.entry_key.clone(), decision.clone());
self.verdicts.cache_decisive(
&pending.entry_key,
pending.fingerprint.clone(),
Expand Down Expand Up @@ -388,6 +423,16 @@ impl Engine {
"adjudication pass (model calls = judged)"
);
}
// ADR-0034 D6/D7 (JEF-570): drop decisions for entries that no longer exist this pass
// (mirrors `self.verdicts.retain_present` above) — a stale decision must never outlive
// the entry it was judged for. Every entry STILL present keeps its last DECISIVE
// decision even on a cache-hit/held/backoff pass this cycle (no fresh call ran), which
// is exactly D7's retirement asymmetry: only a fresh decisive decision (Phase 3 above)
// or the entry disappearing here changes what the ledger sees; a this-pass Uncertain
// (skipped/backoff) never clears it.
self.decisions
.retain(|k, _| current_entries.contains(k.as_str()));
self.decisions.clone()
}
}

Expand All @@ -414,3 +459,30 @@ fn downstream_workloads(
nodes.dedup();
nodes
}

/// The deterministic cut-choice menu for one entry (ADR-0034 D4, JEF-570), unioned across
/// EVERY one of its objective-chains — [`incident::build_menu`] itself takes just one
/// [`reason::proof::ProvenChain`] (an (entry, objective) pair), but the model is judged once
/// PER ENTRY over every objective it reaches, so the menu it's shown must be the union of what
/// each of those chains would offer. Pure data merge (no decision logic duplicated): re-sorts +
/// dedups exactly as `build_menu` does internally, so a node covered by more than one chain
/// (e.g. two objectives sharing a downstream pivot) still appears exactly once.
fn entry_menu(
idxs: &[usize],
chains: &[reason::proof::ProvenChain],
graph: &graph::SecurityGraph,
health: &observe::health::HealthReport,
) -> incident::Menu {
let mut selectable = Vec::new();
let mut uncontainable = Vec::new();
for &i in idxs {
let m = incident::build_menu(&chains[i], graph, health);
selectable.extend(m.selectable);
uncontainable.extend(m.uncontainable);
}
incident::normalize_menu(&mut selectable, &mut uncontainable);
incident::Menu {
selectable,
uncontainable,
}
}
1 change: 1 addition & 0 deletions engine/src/engine/churn_diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ mod tests {
chain: "ch1".into(),
surface: crate::engine::reason::adjudicate::JudgedSurface::default(),
idxs: vec![],
menu: crate::engine::reason::adjudicate::incident::Menu::default(),
};

let buf = Arc::new(Mutex::new(Vec::new()));
Expand Down
16 changes: 10 additions & 6 deletions engine/src/engine/journal_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ use super::*;
use crate::engine::graph::attack::AttackRef;
use crate::engine::graph::{NodeKey, SecurityGraph};
use crate::engine::reason::adjudicate::Verdict;
use crate::engine::reason::adjudicate::incident::{IncidentDecision, Menu};
use crate::engine::respond::actuator::DryRunActuator;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use super::tests::{
CountingAdjudicator, FixedAdjudicator, engine_with, engine_with_adjudicator, exposed_snapshot,
CountingAdjudicator, FixedAdjudicator, decision_of, engine_with, engine_with_adjudicator,
exposed_snapshot,
};

/// A unique temp journal path for a test, without a temp-file crate.
Expand Down Expand Up @@ -239,9 +241,10 @@ impl reason::adjudicate::Adjudicator for AlwaysUncertain {
_graph: &SecurityGraph,
_prompt: &str,
_downstream: &[NodeKey],
) -> Verdict {
_menu: &Menu,
) -> IncidentDecision {
self.0.fetch_add(1, Ordering::SeqCst);
Verdict::Uncertain("model unavailable".into())
IncidentDecision::uncertain("model unavailable")
}
}

Expand Down Expand Up @@ -285,12 +288,13 @@ impl reason::adjudicate::Adjudicator for RecoversAfter {
_graph: &SecurityGraph,
_prompt: &str,
_downstream: &[NodeKey],
) -> Verdict {
_menu: &Menu,
) -> IncidentDecision {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
if n < self.down_for {
Verdict::Uncertain("model unavailable".into())
IncidentDecision::uncertain("model unavailable")
} else {
Verdict::Exploitable("RCE reaches the secret".into())
decision_of(Verdict::Exploitable("RCE reaches the secret".into()))
}
}
}
Expand Down
Loading