Skip to main content

kranz_engine/
gate_results.rs

1//! Persisting gate evaluations as first-class `gate.result` events, and
2//! resolving the artefact references those events carry (ticket
3//! `.kranz/tickets/gate-results-first-class-events`, KRZ-312 — the
4//! governance evidence layer's last substrate gap before provenance-replay).
5//!
6//! Two halves, deliberately small:
7//!
8//! 1. **Emission shape** — [`gate_result_events`] converts one evaluated
9//!    [`crate::gate::GatePipeline`]'s reports into `gate.result` payloads, one event per
10//!    gate, in pipeline order, assigning each gate its ladder position
11//!    (zero-based index within its kind/section). This is the ONLY place
12//!    that mapping lives, so the approval and final-gate surfaces can never
13//!    drift apart in how they number the ladder.
14//! 2. **Resolution** — [`resolve_artefact`] classifies a stored artefact
15//!    reference against the mission dir as resolved/unresolved WITHOUT ever
16//!    failing: the ticket's discipline is that a reference whose bytes are
17//!    gone (a cleaned `runs/`, a discarded scratch checkout) resolves to
18//!    "unresolved", never to an error that blocks replay.
19//!
20//! WHY the reference shape has a scheme at all: today's gates produce
21//! gate-local handles — a description (`contract gate vacuous-filter`), a
22//! command line, a tracked suite path — whose evidence is inherently textual
23//! and travels in the event payload itself. When the evidence IS a file, the
24//! reference must say so unambiguously or a resolver cannot tell
25//! "cargo test --workspace" from a path; `file:` marks a mission-relative
26//! path (e.g. `file:runs/r-1.jsonl`, the same relative shape
27//! [`crate::paths::MissionPaths::transcript_rel`] records). Anything without
28//! the scheme is the gate-local handle verbatim — the ticket's "inherently
29//! textual" case — and classifies as [`ArtefactResolution::Inline`]: there
30//! are no bytes to lose, the event payload IS the evidence.
31//!
32//! WHY mission-relative, never absolute (ticket text): an absolute host path
33//! makes the log unreadable on any other machine and leaks the host layout
34//! into the audit record; the mission dir is the anchor every reader already
35//! has. References pointing outside it (`..`, absolute) can never resolve
36//! honestly, so they classify as unresolved rather than erroring.
37//!
38//! Scrubbing: nothing here truncates or redacts. The event-log append
39//! boundary already scans and redacts every string payload
40//! (`EventLog::append_redacting`), so captured output reaches the log
41//! scrubbed exactly like `orchestrator.decision` details do.
42
43use crate::events::EventKind;
44use crate::gate::{GateKind, GateReport, GateSurface};
45use std::path::{Component, Path, PathBuf};
46
47/// The scheme marking an artefact reference as a mission-relative file path
48/// (`file:runs/r-1.jsonl`). References without it are gate-local handles —
49/// the evidence is textual and lives in the event payload.
50pub const FILE_REF_SCHEME: &str = "file:";
51
52/// Build a file-backed artefact reference from a mission-relative path.
53/// Callers pass the relative path (the `runs/<id>.jsonl` idiom); the scheme
54/// is glued on here so the marker exists in exactly one spelling.
55pub fn file_artefact_ref(mission_relative: &str) -> String {
56    format!("{FILE_REF_SCHEME}{mission_relative}")
57}
58
59/// The resolution of a stored artefact reference against the mission dir.
60/// A classification, never an error: every constructor path through
61/// [`resolve_artefact`] is total.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum ArtefactResolution {
64    /// A `file:` reference whose mission-relative bytes are present; the
65    /// payload is the absolute path to read them from.
66    Resolved { path: PathBuf },
67    /// A `file:` reference whose bytes are gone (a cleaned `runs/`, a pruned
68    /// mission, a discarded scratch checkout) — or whose path could never
69    /// resolve honestly inside a mission dir (absolute, `..`, symlinked).
70    /// The payload is the path that was probed, for diagnostics.
71    Unresolved { path: PathBuf },
72    /// No `file:` scheme: the reference is the gate-local handle itself (a
73    /// command line, a description, a tracked suite path). Its evidence is
74    /// textual and travels in the event payload — there is nothing on disk
75    /// to re-find, so there is nothing that can go missing.
76    Inline,
77}
78
79/// Classify a stored artefact reference against `mission_dir`. Total by
80/// construction: missing bytes, unreadable entries, escape-shaped paths,
81/// and io errors all classify as [`ArtefactResolution::Unresolved`] (or
82/// [`ArtefactResolution::Inline`] when there is nothing to resolve) — a
83/// replay over a pruned mission must degrade to "evidence no longer on
84/// disk", never fail.
85///
86/// A symlinked artefact classifies as unresolved rather than being followed:
87/// the mission tree is no-follow territory (P1 mission-path-no-follow), and
88/// the resolver only ever classifies — readers re-open through the pinned
89/// mission capability when they need the bytes.
90pub fn resolve_artefact(mission_dir: &Path, reference: &str) -> ArtefactResolution {
91    let Some(relative) = reference.strip_prefix(FILE_REF_SCHEME) else {
92        return ArtefactResolution::Inline;
93    };
94    let rel_path = Path::new(relative);
95    // Only Normal/CurDir components can name something inside the mission
96    // dir; RootDir/Prefix/ParentDir are escape shapes that must never be
97    // joined and probed.
98    let honest = !relative.is_empty()
99        && rel_path
100            .components()
101            .all(|c| matches!(c, Component::Normal(_) | Component::CurDir));
102    let path = mission_dir.join(rel_path);
103    let resolved = honest
104        && std::fs::symlink_metadata(&path)
105            .map(|m| m.file_type().is_file())
106            .unwrap_or(false);
107    if resolved {
108        ArtefactResolution::Resolved { path }
109    } else {
110        ArtefactResolution::Unresolved { path }
111    }
112}
113
114/// Convert one evaluated pipeline's reports into `gate.result` payloads: one
115/// event per gate, in pipeline (evaluation) order, each carrying its ladder
116/// position. The index counts WITHIN the gate's kind/section — registration
117/// order is evaluation order per section (gate.rs) — so (surface, kind,
118/// index) names one evaluation exactly and sorting a replayed ladder on it
119/// reproduces pipeline order.
120///
121/// The reports' artefact strings pass through verbatim: gate-local handles
122/// stay gate-local (the textual case), and a gate that names a file uses
123/// [`file_artefact_ref`] at construction so the scheme survives into the
124/// log.
125pub fn gate_result_events(surface: GateSurface, reports: &[GateReport]) -> Vec<EventKind> {
126    let mut deterministic_index = 0u32;
127    let mut model_judged_index = 0u32;
128    reports
129        .iter()
130        .map(|report| {
131            let index = match report.kind {
132                GateKind::Deterministic => {
133                    let index = deterministic_index;
134                    deterministic_index += 1;
135                    index
136                }
137                GateKind::ModelJudged => {
138                    let index = model_judged_index;
139                    model_judged_index += 1;
140                    index
141                }
142            };
143            EventKind::GateResult {
144                gate: report.name.clone(),
145                surface,
146                kind: report.kind,
147                index,
148                verdict: report.outcome.verdict,
149                artefact_ref: report.outcome.artefact.reference.clone(),
150                artefact_detail: report.outcome.artefact.detail.clone(),
151                score: report.outcome.score.map(|score| score.score),
152                threshold: report.outcome.score.map(|score| score.threshold),
153                rule_ids: report.outcome.rule_ids.clone(),
154            }
155        })
156        .collect()
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::gate::{ArtefactRef, GateOutcome, GateVerdict};
163
164    fn report(name: &str, kind: GateKind, verdict: GateVerdict) -> GateReport {
165        let outcome = match verdict {
166            GateVerdict::Pass => GateOutcome::pass(ArtefactRef::new(format!("ref {name}"))),
167            GateVerdict::Fail => {
168                GateOutcome::fail(ArtefactRef::new(format!("ref {name}")).with_detail("boom"))
169            }
170        };
171        GateReport {
172            name: name.to_string(),
173            kind,
174            outcome,
175        }
176    }
177
178    /// The ladder mapping: one event per report in pipeline order, with the
179    /// index counting within each kind/section — a model-judged gate's index
180    /// restarts at zero even when deterministic gates precede it (the two
181    /// sections are numbered independently, exactly as GatePipeline stores
182    /// them).
183    #[test]
184    fn gate_result_events_assign_per_section_indices_in_pipeline_order() {
185        let mut det_pass = report("det-a", GateKind::Deterministic, GateVerdict::Pass);
186        det_pass.outcome.score = Some(crate::gate::GateScore {
187            score: 0.9,
188            threshold: 0.5,
189        });
190        let reports = vec![
191            det_pass,
192            report("det-b", GateKind::Deterministic, GateVerdict::Fail),
193            report("model-a", GateKind::ModelJudged, GateVerdict::Pass),
194        ];
195
196        let events = gate_result_events(GateSurface::FinalGate, &reports);
197        assert_eq!(events.len(), 3);
198        let shape: Vec<(String, GateKind, u32, GateVerdict)> = events
199            .iter()
200            .map(|event| match event {
201                EventKind::GateResult {
202                    gate,
203                    kind,
204                    index,
205                    verdict,
206                    ..
207                } => (gate.clone(), *kind, *index, *verdict),
208                _ => panic!("wrong variant"),
209            })
210            .collect();
211        assert_eq!(
212            shape,
213            vec![
214                (
215                    "det-a".to_string(),
216                    GateKind::Deterministic,
217                    0,
218                    GateVerdict::Pass
219                ),
220                (
221                    "det-b".to_string(),
222                    GateKind::Deterministic,
223                    1,
224                    GateVerdict::Fail
225                ),
226                (
227                    "model-a".to_string(),
228                    GateKind::ModelJudged,
229                    0,
230                    GateVerdict::Pass
231                ),
232            ]
233        );
234        // Verbatim passthrough: artefact handle, captured detail, and the
235        // score pair arrive exactly as the gate stated them.
236        match &events[0] {
237            EventKind::GateResult {
238                artefact_ref,
239                artefact_detail,
240                score,
241                threshold,
242                ..
243            } => {
244                assert_eq!(artefact_ref, "ref det-a");
245                assert_eq!(*artefact_detail, None);
246                assert_eq!(*score, Some(0.9));
247                assert_eq!(*threshold, Some(0.5));
248            }
249            _ => panic!("wrong variant"),
250        }
251        match &events[1] {
252            EventKind::GateResult {
253                artefact_detail, ..
254            } => assert_eq!(artefact_detail.as_deref(), Some("boom")),
255            _ => panic!("wrong variant"),
256        }
257    }
258
259    /// An empty pipeline emits no events (the no-command-assertions case at
260    /// approval, or no contract + no pack at the final gate).
261    #[test]
262    fn gate_result_events_empty_pipeline_emits_nothing() {
263        assert!(gate_result_events(GateSurface::Approval, &[]).is_empty());
264    }
265
266    /// A `file:` reference whose mission-relative bytes exist resolves —
267    /// and the payload path is under the mission dir.
268    #[test]
269    fn gate_result_event_file_ref_resolves_when_bytes_exist() {
270        let tmp = tempfile::TempDir::new().unwrap();
271        let mission_dir = tmp.path();
272        std::fs::create_dir_all(mission_dir.join("runs")).unwrap();
273        std::fs::write(mission_dir.join("runs").join("r-1.jsonl"), b"{}").unwrap();
274
275        let resolved = resolve_artefact(mission_dir, &file_artefact_ref("runs/r-1.jsonl"));
276        match resolved {
277            ArtefactResolution::Resolved { path } => {
278                assert_eq!(path, mission_dir.join("runs/r-1.jsonl"))
279            }
280            other => panic!("expected resolved, got {other:?}"),
281        }
282    }
283
284    /// The ticket's central discipline: a reference whose bytes are gone
285    /// (the runs/ dir was cleaned, the mission pruned) reports UNRESOLVED —
286    /// a classification, never an error.
287    #[test]
288    fn gate_result_event_file_ref_is_unresolved_when_bytes_are_gone() {
289        let tmp = tempfile::TempDir::new().unwrap();
290        let mission_dir = tmp.path();
291        // The mission dir exists but runs/ was cleaned: nothing to find.
292        assert_eq!(
293            resolve_artefact(mission_dir, &file_artefact_ref("runs/r-1.jsonl")),
294            ArtefactResolution::Unresolved {
295                path: mission_dir.join("runs/r-1.jsonl")
296            }
297        );
298        // A directory at the referenced path is not evidence bytes either.
299        std::fs::create_dir_all(mission_dir.join("runs")).unwrap();
300        assert!(matches!(
301            resolve_artefact(mission_dir, &file_artefact_ref("runs")),
302            ArtefactResolution::Unresolved { .. }
303        ));
304    }
305
306    /// Escape-shaped references (absolute, parent-traversing, empty) can
307    /// never resolve honestly inside a mission dir: unresolved, and the
308    /// probe never touches the filesystem outside the anchor.
309    #[test]
310    fn gate_result_event_file_ref_escape_shapes_are_unresolved() {
311        let tmp = tempfile::TempDir::new().unwrap();
312        let mission_dir = tmp.path();
313        for reference in [
314            file_artefact_ref("../outside.jsonl"),
315            file_artefact_ref("runs/../../escape"),
316            file_artefact_ref("/etc/passwd"),
317            file_artefact_ref(""),
318        ] {
319            assert!(
320                matches!(
321                    resolve_artefact(mission_dir, &reference),
322                    ArtefactResolution::Unresolved { .. }
323                ),
324                "{reference} must classify unresolved"
325            );
326        }
327    }
328
329    /// Gate-local handles (a command line, a description, a tracked suite
330    /// path) carry no scheme: the evidence is textual and lives in the event
331    /// payload, so there is nothing on disk that could go missing.
332    #[test]
333    fn gate_result_event_textual_ref_is_inline() {
334        let tmp = tempfile::TempDir::new().unwrap();
335        for reference in [
336            "contract gate vacuous-filter",
337            "cargo test --workspace",
338            ".kranz/merge-gates.json",
339        ] {
340            assert_eq!(
341                resolve_artefact(tmp.path(), reference),
342                ArtefactResolution::Inline,
343                "{reference} must classify inline"
344            );
345        }
346    }
347
348    /// A symlinked artefact is unresolved, never followed — the mission
349    /// tree's no-follow posture applies to evidence reads too.
350    #[cfg(unix)]
351    #[test]
352    fn gate_result_event_symlinked_artefact_is_unresolved() {
353        use std::os::unix::fs::symlink;
354        let tmp = tempfile::TempDir::new().unwrap();
355        let mission_dir = tmp.path().join("mission");
356        std::fs::create_dir_all(mission_dir.join("runs")).unwrap();
357        let elsewhere = tmp.path().join("elsewhere.jsonl");
358        std::fs::write(&elsewhere, b"{}").unwrap();
359        symlink(&elsewhere, mission_dir.join("runs").join("r-1.jsonl")).unwrap();
360
361        assert!(matches!(
362            resolve_artefact(&mission_dir, &file_artefact_ref("runs/r-1.jsonl")),
363            ArtefactResolution::Unresolved { .. }
364        ));
365    }
366}