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}