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
136 changes: 136 additions & 0 deletions engine/src/engine/adj_gate.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
//! The per-entry adjudication RE-JUDGE gate — the pure, cheap decision (no model call) of what
//! to do with one breach-relevant entry this pass. Extracted from the orchestrator to keep it
//! under the file-size cap (CLAUDE.md) and to hold the layered gate in one readable place.
//!
//! The gate layers, in order (first match wins):
//! 1. **Exact-fingerprint LRU hit (JEF-390)** — the model's input is byte-identical to a
//! recently-judged state; serve that decisive verdict, no model call.
//! 2. **Purely-subtractive delta hold (ADR-0023, JEF-391)** — a fingerprint miss but nothing was
//! ADDED to the entry's surface since its last DECISIVE verdict (something was only removed —
//! a pod vanished, a peer aged out). The prior decisive verdict still holds (its surface only
//! shrank; removal can only reduce breach risk), so serve it without a fresh call. This is
//! what stops the ephemeral-churn ping-pong at its root. Fails toward re-judging: a
//! non-additive delta always has a baseline (a missing baseline is additive → first judgment),
//! so a stray absent baseline re-judges rather than skips.
//! 3. **Breaker / backoff skip (JEF-234)** — the model looks down (global breaker) or this entry
//! is in inconclusive-adjudication backoff; synthesize an Uncertain and send nothing.
//! 4. Otherwise **re-judge** — a genuine cache miss with new (additive) surface.

use super::graph;
use super::state;
use super::{Engine, PendingEntry, reason};

/// The classification outcome for one entry this pass — decided WITHOUT calling the model.
#[cfg_attr(test, derive(Debug))]
pub(super) enum AdjGate {
/// Serve a decisive verdict with no model call: an exact-fingerprint LRU hit (JEF-390) or a
/// purely-subtractive delta hold (JEF-391). `held` is true only for the delta hold, so the
/// pass log can show how much churn the delta gate absorbed.
Resolved {
verdict: reason::adjudicate::Verdict,
held: bool,
},
/// Skip the model this pass and carry the prior display forward (JEF-234 breaker / backoff).
Skipped(reason::adjudicate::Verdict),
/// Queue for a fresh model call — a genuine re-judge.
Judge,
}

impl Engine {
/// Build one breach-relevant entry's [`PendingEntry`] for this pass: read its delta-aware
/// baseline (ADR-0023), build the model's complete prompt WITH the "Changes since…" delta
/// section, derive the verdict-cache key from that prompt (JEF-350) and the churn fingerprints
/// (JEF-387), and project this pass's surface (snapshotted as the next baseline on a decisive
/// verdict). Returns the pending record, whether the delta since the baseline is ADDITIVE
/// (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.
pub(super) fn prepare_pending(
&self,
entry_key: &str,
entry: graph::NodeKey,
objectives: Vec<(graph::NodeKey, graph::attack::AttackRef)>,
idxs: &[usize],
graph: &graph::SecurityGraph,
asn: &crate::engine::observe::asn::AsnDb,
) -> (PendingEntry, bool, Option<state::VerdictBaseline>) {
let baseline = self.verdicts.baseline_for(entry_key);
let delta = reason::adjudicate::build_delta_prompt_asn(
&entry,
&objectives,
graph,
asn,
baseline.as_ref().map(|b| &b.surface),
);
// 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).
let fingerprint = delta.cache_key;
let chain = reason::adjudicate::chain_shape_hash(&objectives);
let pending = PendingEntry {
entry_key: entry_key.to_string(),
entry,
objectives,
prompt: delta.prompt,
fingerprint,
sections: delta.sections,
chain,
surface: delta.surface,
idxs: idxs.to_vec(),
};
(pending, delta.additive, baseline)
}
}

/// Classify one breach-relevant entry's re-judge decision (see the module docs for the layered
/// gate). Reads only the verdict store (no other engine state), so it is a free function over
/// [`state::VerdictStore`] — directly unit-testable without a full engine. `additive` and
/// `baseline` come from the delta build (ADR-0023): `additive` is false only when a decisive
/// baseline exists AND nothing was added since it. `now` is the pass's single injected clock
/// (shared with the JEF-234 backoff). The subtractive-hold path warms the LRU under the current
/// fingerprint so the settled steady state HITS next pass.
pub(super) fn classify_adjudication(
verdicts: &state::VerdictStore,
pending: &PendingEntry,
additive: bool,
baseline: Option<&state::VerdictBaseline>,
now: std::time::Instant,
) -> AdjGate {
use reason::adjudicate::Verdict;
// 1. Exact-fingerprint LRU hit (JEF-390): byte-identical input, serve the cached verdict.
if let Some(verdict) = verdicts.cached_for(&pending.entry_key, &pending.fingerprint) {
return AdjGate::Resolved {
verdict,
held: false,
};
}
// 2. Purely-subtractive / unchanged delta since a decisive baseline (JEF-391): the prior
// verdict holds. `!additive` implies a baseline exists; a defensive absent baseline falls
// through to a re-judge (never suppress a judgment on possibly-new surface).
if !additive && let Some(b) = baseline {
verdicts.cache_decisive(
&pending.entry_key,
pending.fingerprint.clone(),
b.verdict.clone(),
);
return AdjGate::Resolved {
verdict: b.verdict.clone(),
held: true,
};
}
// 3. JEF-234 breaker / backoff: the model looks down — skip and carry the display forward.
if verdicts.breaker_open(now) {
return AdjGate::Skipped(Verdict::Uncertain(
"model unavailable (breaker open)".into(),
));
}
if verdicts.entry_backing_off(&pending.entry_key, now) {
return AdjGate::Skipped(Verdict::Uncertain("model unavailable (backing off)".into()));
}
// 4. A genuine cache miss with new (additive) surface — re-judge.
AdjGate::Judge
}

#[cfg(test)]
#[path = "adj_gate_tests.rs"]
mod tests;
125 changes: 125 additions & 0 deletions engine/src/engine/adj_gate_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//! Tests for the layered adjudication re-judge gate (ADR-0023 / JEF-391, over JEF-390 / JEF-234).
//! `classify_adjudication` reads only the verdict store, so these drive it directly with a real
//! [`state::VerdictStore`] and a hand-built [`PendingEntry`] — no full engine. Extracted to a
//! sibling file to keep `adj_gate.rs` under the file-size cap (CLAUDE.md).

use std::time::Instant;

use super::*;
use crate::engine::graph::NodeKey;
use crate::engine::graph::attack::EXPLOIT_PUBLIC_FACING;
use crate::engine::reason::adjudicate::{JudgedSurface, PromptSections, Verdict};

/// A minimal [`PendingEntry`] for `entry`/`fingerprint` — the only fields the gate reads. The
/// prompt/sections/surface/objectives are irrelevant to the classification decision.
fn pending(entry: &str, fingerprint: &str) -> PendingEntry {
PendingEntry {
entry_key: entry.to_string(),
entry: NodeKey(entry.to_string()),
objectives: vec![(NodeKey("secret/app/x".into()), EXPLOIT_PUBLIC_FACING)],
prompt: "unused".into(),
fingerprint: fingerprint.to_string(),
sections: PromptSections {
runtime: "r".into(),
cves: "c".into(),
secrets: "s".into(),
posture: "p".into(),
objectives: "o".into(),
entry: "e".into(),
},
chain: "ch".into(),
surface: JudgedSurface::default(),
idxs: vec![0],
}
}

fn baseline(verdict: Verdict) -> state::VerdictBaseline {
state::VerdictBaseline {
surface: JudgedSurface::default(),
verdict,
}
}

/// First judgment: no baseline ⇒ the delta build reports ADDITIVE, so a fresh (empty) store
/// re-judges — there is nothing decisive to serve yet.
#[test]
fn first_judgment_no_baseline_judges() {
let store = state::VerdictStore::new();
let p = pending("entry", "fp1");
assert!(matches!(
classify_adjudication(&store, &p, true, None, Instant::now()),
AdjGate::Judge
));
}

/// An ADDITIVE delta against a decisive baseline re-judges (something NEW must be evaluated) —
/// it is NOT served from the baseline, even though a baseline exists.
#[test]
fn additive_delta_rejudges() {
let store = state::VerdictStore::new();
let p = pending("entry", "fp-new");
let base = baseline(Verdict::Refuted("prior".into()));
assert!(matches!(
classify_adjudication(&store, &p, true, Some(&base), Instant::now()),
AdjGate::Judge
));
}

/// A PURELY SUBTRACTIVE delta (nothing added since the baseline) HOLDS the prior decisive
/// verdict — no fresh model call — and warms the LRU under the current fingerprint so the
/// settled state HITS next pass.
#[test]
fn subtractive_delta_holds_prior_verdict() {
let store = state::VerdictStore::new();
let p = pending("entry", "fp-shrunk");
let base = baseline(Verdict::Exploitable("held".into()));

let out = classify_adjudication(&store, &p, false, Some(&base), Instant::now());
match out {
AdjGate::Resolved { verdict, held } => {
assert!(
held,
"a subtractive hold is a HELD serve, not a plain LRU hit"
);
assert_eq!(verdict, Verdict::Exploitable("held".into()));
}
other => panic!("expected a held serve, got {other:?}"),
}
// The hold warmed the LRU: the same fingerprint now HITS directly (no model call).
assert_eq!(
store.cached_for("entry", "fp-shrunk"),
Some(Verdict::Exploitable("held".into())),
"the held verdict is cached under the current fingerprint"
);
}

/// Fail-safe: `!additive` with NO baseline (should be unreachable — a missing baseline is
/// additive) RE-JUDGES rather than serving nothing. Never suppress a judgment on possibly-new
/// surface.
#[test]
fn not_additive_without_baseline_still_rejudges() {
let store = state::VerdictStore::new();
let p = pending("entry", "fp");
assert!(matches!(
classify_adjudication(&store, &p, false, None, Instant::now()),
AdjGate::Judge
));
}

/// An exact-fingerprint LRU hit (JEF-390) serves the cached verdict as a plain hit (`held =
/// false`), taking precedence over the delta gate.
#[test]
fn exact_fingerprint_hit_serves_unheld() {
let store = state::VerdictStore::new();
store.cache_decisive("entry", "fp-seen".into(), Verdict::Refuted("cached".into()));
let p = pending("entry", "fp-seen");
// Even with an additive delta, the exact-state cache hit wins (identical input ⇒ identical
// verdict).
match classify_adjudication(&store, &p, true, None, Instant::now()) {
AdjGate::Resolved { verdict, held } => {
assert!(!held, "an exact LRU hit is not a subtractive hold");
assert_eq!(verdict, Verdict::Refuted("cached".into()));
}
other => panic!("expected an LRU hit, got {other:?}"),
}
}
8 changes: 5 additions & 3 deletions engine/src/engine/churn_diag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@
//!
//! Field meanings the collector relies on:
//! - `entry` — the entry key: the per-entry timeline key.
//! - `fp` — the whole-prompt hash (the verdict-cache key). UNCHANGED from the entry's prior
//! line ⇒ an Uncertain-retry (JEF-234: model verdict churn, not prompt). CHANGED ⇒ prompt
//! churn, attributed to whichever `sec_*` field moved.
//! - `fp` — the FULL-STATE prompt hash (the verdict-cache key; excludes the delta-only
//! "Changes since…" section, JEF-391). UNCHANGED from the entry's prior line ⇒ an
//! Uncertain-retry (JEF-234: model verdict churn, not prompt). CHANGED ⇒ state churn,
//! attributed to whichever `sec_*` field moved.
//! - `chain` — the objective/technique-SET shape hash: entries with the same shape group.
//! - `sec_*` — the six per-section fingerprints; the one that changed between two consecutive
//! lines for an entry IS the attributed cause.
Expand Down Expand Up @@ -105,6 +106,7 @@ mod tests {
entry: "e1".into(),
},
chain: "ch1".into(),
surface: crate::engine::reason::adjudicate::JudgedSurface::default(),
idxs: vec![],
};

Expand Down
Loading