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}