Skip to main content

kranz_engine/
gate.rs

1//! The first-class gate interface (ticket
2//! `.kranz/tickets/gate-plugin-interface.md`, KRZ-311; scored-gates addendum
3//! KRZ-315).
4//!
5//! Until this module every gate in the engine was bespoke — contract command
6//! assertions, scrutiny validators, the empty-deliverable final gate,
7//! [`crate::merge_gate`], [`crate::workspace_gate`], preflight — and each
8//! invented its own pass/fail shape, so nothing could register, order, or
9//! record gates uniformly. This module is the shared contract: a [`Gate`] is
10//! ordered, typed, independently registrable into a [`GatePipeline`], and
11//! every evaluation returns a [`GateOutcome`] — an authoritative
12//! [`GateVerdict`] plus an [`ArtefactRef`] handle to the evidence behind it.
13//!
14//! WHY the ordering is structural: deterministic gates (exit codes, lints,
15//! scans) are cheap and reproducible, while model-judged gates spend tokens
16//! and want the deterministic evidence in hand first. A [`GatePipeline`]
17//! therefore cannot represent "model gate ahead of deterministic gate" at
18//! all — it stores the two kinds in separate sections and evaluates every
19//! deterministic gate (in registration order) before any model-judged one
20//! (also in registration order). There is no insertion-position API to get
21//! wrong, and registration can only ever choose an order WITHIN a section.
22//!
23//! WHY the score is optional and inert (KRZ-315): a gate may report a
24//! confidence score and the threshold it judged against so a later slice
25//! (`gate-confidence-score`) can persist and query low-confidence verdicts.
26//! The score never decides anything here — [`GateOutcome`] has no
27//! constructor that derives a verdict from a score, so the verdict a gate
28//! states IS the verdict. Boolean-only gates simply never name the field.
29//!
30//! WHY artefacts are references, not events: persisting outcomes as
31//! first-class events is `gate-results-first-class-events` (KRZ-312). This
32//! module fixes only the handle shape so that slice needs no rework here.
33//!
34//! Ownership is unchanged by this interface: who WRITES a gate's config is a
35//! property of the reader, not the registry. The merge-gate suite stays
36//! base-branch-owned (its bytes are read from the live base sha in
37//! [`crate::merge`]), so a mission cannot weaken or reorder the gates that
38//! judge its own diff.
39
40/// Whether a gate's verdict comes from a reproducible check or from model
41/// judgement. The kind selects the gate's pipeline section — deterministic
42/// gates always evaluate first (see the module docs).
43///
44/// Serde kebab-case (the Role/GrantKind idiom): the persisted `gate.result`
45/// event (KRZ-312) carries the kind as its ladder-SECTION discriminator —
46/// kind and section are one-to-one by pipeline construction, so the event
47/// needs no separate section field.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum GateKind {
51    /// Exit codes, lints, scans: cheap, reproducible, no model spend.
52    Deterministic,
53    /// A model session's judgement (scrutiny, review): spends tokens and may
54    /// carry a confidence score.
55    ModelJudged,
56}
57
58/// The authoritative outcome of one gate evaluation: pass or fail, as
59/// stated by the gate — never derived from [`GateOutcome::score`].
60///
61/// Serde kebab-case for the persisted `gate.result` event (KRZ-312).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum GateVerdict {
65    Pass,
66    Fail,
67}
68
69/// Which evaluation surface ran the pipeline a `gate.result` event came from
70/// (KRZ-312). The same gate id is evaluated more than once per mission —
71/// once at plan approval and once at the final gate — so a gate id plus
72/// ladder position alone cannot name ONE evaluation; the surface does. It is
73/// recorded on the event itself rather than inferred from neighbouring
74/// events: a replay that reconstructs the ladder from the log alone must not
75/// depend on emission-order conventions that a later refactor could move.
76#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum GateSurface {
79    /// Plan-approval contract gates (`orchestrator::approve_plan`): the
80    /// defect-class floor including `passes-on-base`, evaluated against the
81    /// pristine base tree.
82    Approval,
83    /// Final-gate contract gates (`orchestrator::final_gate`): the static
84    /// floor re-checked against the active tree, plus any configured pack's
85    /// deterministic gates registered after it.
86    FinalGate,
87}
88
89/// A gate-supplied confidence score and the threshold the gate judged it
90/// against (KRZ-315). Purely evidentiary: nothing in this module reads it.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct GateScore {
93    /// The gate's confidence in its own verdict (conventionally 0.0..=1.0;
94    /// the scale is the gate's to define).
95    pub score: f64,
96    /// The threshold the gate judged the score against — below it, a later
97    /// slice routes the verdict to a human.
98    pub threshold: f64,
99}
100
101/// A handle to the evidence behind a verdict.
102///
103/// This is the reference a later slice persists as a first-class event
104/// (KRZ-312); it is deliberately just a handle plus optional captured
105/// content, not an event itself.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ArtefactRef {
108    /// Stable handle naming where the evidence lives: a repo-relative path,
109    /// the command line that ran, a session id — whatever re-finds it.
110    pub reference: String,
111    /// Captured content worth keeping verbatim (a failing command's output,
112    /// a validator's reply excerpt). `None` when the reference alone is the
113    /// evidence.
114    pub detail: Option<String>,
115}
116
117impl ArtefactRef {
118    pub fn new(reference: impl Into<String>) -> Self {
119        Self {
120            reference: reference.into(),
121            detail: None,
122        }
123    }
124
125    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
126        self.detail = Some(detail.into());
127        self
128    }
129}
130
131/// What one gate evaluation produced: an authoritative verdict, the
132/// artefact behind it, and an optional confidence score.
133#[derive(Debug, Clone, PartialEq)]
134pub struct GateOutcome {
135    pub verdict: GateVerdict,
136    pub artefact: ArtefactRef,
137    /// Optional gate-supplied score (KRZ-315). `None` for boolean-only
138    /// gates; never consulted to compute `verdict`.
139    pub score: Option<GateScore>,
140    /// The stable Flight Rules standards rule ids this evaluation joined
141    /// (ticket `flight-rules-finding-provenance`, KRZ-343; design D-H): the
142    /// linkage persisted as `ruleIds` on the gate's `gate.result` event so
143    /// the coverage matrix can answer "which approved rules did this
144    /// mechanism evaluate" without parsing prose. Empty for every gate with
145    /// no standards linkage — boolean-only gates are byte-identical on the
146    /// wire (the event omits the empty list).
147    pub rule_ids: Vec<String>,
148}
149
150impl GateOutcome {
151    /// A passing outcome. There is deliberately no constructor that takes a
152    /// score and computes a verdict — the verdict is always stated.
153    pub fn pass(artefact: ArtefactRef) -> Self {
154        Self {
155            verdict: GateVerdict::Pass,
156            artefact,
157            score: None,
158            rule_ids: Vec::new(),
159        }
160    }
161
162    /// A failing outcome; see [`GateOutcome::pass`] on verdicts vs scores.
163    pub fn fail(artefact: ArtefactRef) -> Self {
164        Self {
165            verdict: GateVerdict::Fail,
166            artefact,
167            score: None,
168            rule_ids: Vec::new(),
169        }
170    }
171
172    /// Attach a confidence score + threshold without touching the verdict.
173    pub fn with_score(mut self, score: f64, threshold: f64) -> Self {
174        self.score = Some(GateScore { score, threshold });
175        self
176    }
177
178    /// Name the standards rules this evaluation joined (KRZ-343) — stable
179    /// rule ids, never rule prose. Like the score, the linkage is purely
180    /// evidentiary: it never changes the verdict.
181    pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
182        self.rule_ids = rule_ids;
183        self
184    }
185
186    pub fn passed(&self) -> bool {
187        self.verdict == GateVerdict::Pass
188    }
189}
190
191/// A gate: an ordered, typed, independently registrable check.
192///
193/// Gates capture everything they need at construction (paths, suites,
194/// executors, sessions) so registration is uniform; `evaluate` takes no
195/// shared context because a shell-command gate and a scrutiny gate share no
196/// honest input type.
197pub trait Gate {
198    /// Stable identity for reports and (later) persisted events.
199    fn name(&self) -> &str;
200    /// Selects the pipeline section; deterministic gates evaluate first.
201    fn kind(&self) -> GateKind;
202    /// Run the check and return its outcome.
203    fn evaluate(&self) -> GateOutcome;
204}
205
206/// One gate's outcome, annotated with the identity and kind the pipeline
207/// registered it under.
208#[derive(Debug, Clone)]
209pub struct GateReport {
210    pub name: String,
211    pub kind: GateKind,
212    pub outcome: GateOutcome,
213}
214
215/// An ordered gate sequence with the deterministic/model-judged ordering
216/// encoded in its storage: two sections, evaluated deterministic-first, so a
217/// pipeline with a model gate ahead of a deterministic gate is
218/// unrepresentable rather than merely rejected.
219#[derive(Default)]
220pub struct GatePipeline {
221    deterministic: Vec<Box<dyn Gate>>,
222    model_judged: Vec<Box<dyn Gate>>,
223}
224
225impl GatePipeline {
226    pub fn new() -> Self {
227        Self::default()
228    }
229
230    /// Register a gate. Its declared [`Gate::kind`] selects the section —
231    /// registration chooses an order within a section, never a position
232    /// across sections.
233    pub fn register(&mut self, gate: Box<dyn Gate>) -> &mut Self {
234        match gate.kind() {
235            GateKind::Deterministic => self.deterministic.push(gate),
236            GateKind::ModelJudged => self.model_judged.push(gate),
237        }
238        self
239    }
240
241    pub fn len(&self) -> usize {
242        self.deterministic.len() + self.model_judged.len()
243    }
244
245    pub fn is_empty(&self) -> bool {
246        self.len() == 0
247    }
248
249    /// Evaluate every gate in pipeline order: all deterministic gates in
250    /// registration order, then all model-judged gates in registration
251    /// order. Every gate runs and every outcome is returned — stopping at
252    /// the first failure (as the merge-gate suite does internally) is a
253    /// gate's or caller's policy, not the registry's.
254    pub fn evaluate(&self) -> Vec<GateReport> {
255        self.deterministic
256            .iter()
257            .chain(self.model_judged.iter())
258            .map(|gate| GateReport {
259                name: gate.name().to_string(),
260                kind: gate.kind(),
261                outcome: gate.evaluate(),
262            })
263            .collect()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::cell::RefCell;
271    use std::rc::Rc;
272
273    /// A scripted gate: records the order it was evaluated in and returns a
274    /// fixed boolean outcome.
275    struct ScriptedGate {
276        name: &'static str,
277        kind: GateKind,
278        verdict: GateVerdict,
279        calls: Rc<RefCell<Vec<&'static str>>>,
280    }
281
282    impl Gate for ScriptedGate {
283        fn name(&self) -> &str {
284            self.name
285        }
286        fn kind(&self) -> GateKind {
287            self.kind
288        }
289        fn evaluate(&self) -> GateOutcome {
290            self.calls.borrow_mut().push(self.name);
291            match self.verdict {
292                GateVerdict::Pass => GateOutcome::pass(ArtefactRef::new(self.name)),
293                GateVerdict::Fail => GateOutcome::fail(ArtefactRef::new(self.name)),
294            }
295        }
296    }
297
298    fn scripted(
299        name: &'static str,
300        kind: GateKind,
301        calls: &Rc<RefCell<Vec<&'static str>>>,
302    ) -> Box<dyn Gate> {
303        Box::new(ScriptedGate {
304            name,
305            kind,
306            verdict: GateVerdict::Pass,
307            calls: Rc::clone(calls),
308        })
309    }
310
311    /// The ordering rule: registering model-judged gates FIRST must still
312    /// evaluate them after every deterministic gate — the pipeline has no
313    /// representation for "model ahead of deterministic".
314    #[test]
315    fn gate_plugin_model_gates_cannot_precede_deterministic_gates() {
316        let calls = Rc::new(RefCell::new(Vec::new()));
317        let mut pipeline = GatePipeline::new();
318        pipeline
319            .register(scripted("model-a", GateKind::ModelJudged, &calls))
320            .register(scripted("det-a", GateKind::Deterministic, &calls))
321            .register(scripted("model-b", GateKind::ModelJudged, &calls))
322            .register(scripted("det-b", GateKind::Deterministic, &calls));
323        assert_eq!(pipeline.len(), 4);
324
325        let reports = pipeline.evaluate();
326
327        let expected = ["det-a", "det-b", "model-a", "model-b"];
328        assert_eq!(
329            reports
330                .iter()
331                .map(|report| report.name.as_str())
332                .collect::<Vec<_>>(),
333            expected,
334            "deterministic section first, registration order within each section"
335        );
336        assert_eq!(
337            reports.iter().map(|report| report.kind).collect::<Vec<_>>(),
338            [
339                GateKind::Deterministic,
340                GateKind::Deterministic,
341                GateKind::ModelJudged,
342                GateKind::ModelJudged,
343            ]
344        );
345        assert_eq!(
346            *calls.borrow(),
347            expected,
348            "evaluation ran in pipeline order"
349        );
350        assert!(reports.iter().all(|report| report.outcome.passed()));
351    }
352
353    /// A boolean-only gate: its `evaluate` never names score or threshold.
354    struct BooleanGate;
355
356    impl Gate for BooleanGate {
357        fn name(&self) -> &str {
358            "boolean-gate"
359        }
360        fn kind(&self) -> GateKind {
361            GateKind::Deterministic
362        }
363        fn evaluate(&self) -> GateOutcome {
364            GateOutcome::pass(ArtefactRef::new("lint.log"))
365        }
366    }
367
368    #[test]
369    fn gate_plugin_boolean_gate_runs_without_a_score() {
370        let mut pipeline = GatePipeline::new();
371        pipeline.register(Box::new(BooleanGate));
372        let reports = pipeline.evaluate();
373        assert_eq!(reports.len(), 1);
374        assert!(reports[0].outcome.passed());
375        assert_eq!(reports[0].outcome.score, None);
376        assert_eq!(reports[0].outcome.artefact.reference, "lint.log");
377    }
378
379    #[test]
380    fn gate_plugin_verdict_is_never_derived_from_the_score() {
381        let confident_failure = GateOutcome::fail(ArtefactRef::new("review")).with_score(0.99, 0.5);
382        assert!(
383            !confident_failure.passed(),
384            "a high score must not flip a stated Fail"
385        );
386        let nervous_pass = GateOutcome::pass(ArtefactRef::new("review")).with_score(0.1, 0.9);
387        assert!(
388            nervous_pass.passed(),
389            "a low score must not flip a stated Pass"
390        );
391    }
392
393    #[test]
394    fn gate_plugin_scored_gate_carries_score_and_threshold() {
395        let outcome = GateOutcome::pass(ArtefactRef::new("scrutiny")).with_score(0.42, 0.75);
396        let score = outcome.score.expect("score recorded");
397        assert_eq!(score.score, 0.42);
398        assert_eq!(score.threshold, 0.75);
399        assert!(outcome.passed());
400    }
401}