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