1use super::*;
4use crate::events::{Event, EventKind};
5use crate::gate_evaluation::{protocol::Digest, snapshot::Identity};
6use std::collections::{BTreeMap, HashMap};
7
8const PREFIX: &str = "baseline-candidate-v1:";
9const MAX_EVIDENCE_BYTES: u64 = 128 * 1024;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(
13 tag = "outcome",
14 rename_all = "kebab-case",
15 rename_all_fields = "camelCase",
16 deny_unknown_fields
17)]
18pub enum ExpectedOutcome {
19 Passed,
20 Failed {
21 failure_id: String,
22 },
23 Diagnostic {
24 failure_id: String,
25 diagnostic: String,
26 },
27}
28
29impl ExpectedOutcome {
30 fn validate(&self) -> Result<()> {
31 let (failure, diagnostic) = match self {
32 Self::Passed => return Ok(()),
33 Self::Failed { failure_id } => (failure_id, None),
34 Self::Diagnostic {
35 failure_id,
36 diagnostic,
37 } => (failure_id, Some(diagnostic)),
38 };
39 if failure.trim().is_empty()
40 || failure.len() > 128
41 || diagnostic.is_some_and(|d| d.trim().is_empty() || d.len() > 1024)
42 {
43 return Err(invalid(
44 "pair expectations need a named failure and a bounded exact diagnostic",
45 ));
46 }
47 Ok(())
48 }
49
50 pub(super) fn matches(&self, case: &CaseEvidence) -> bool {
51 let Some(receipt) = &case.receipt else {
52 return false;
53 };
54 if receipt.checks_run == 0 {
55 return false;
56 }
57 match self {
58 Self::Passed => {
59 case.exit_code == Some(0)
60 && receipt.outcome == CheckOutcome::Passed
61 && receipt.failure_id.is_none()
62 && receipt.diagnostic.is_none()
63 }
64 Self::Failed { failure_id } => {
65 case.exit_code.is_some_and(|c| c != 0)
66 && receipt.outcome == CheckOutcome::Failed
67 && receipt.failure_id.as_ref() == Some(failure_id)
68 && receipt.diagnostic.is_none()
69 }
70 Self::Diagnostic {
71 failure_id,
72 diagnostic,
73 } => {
74 case.exit_code.is_some_and(|c| c != 0)
75 && receipt.outcome == CheckOutcome::Failed
76 && receipt.failure_id.as_ref() == Some(failure_id)
77 && receipt.diagnostic.as_ref() == Some(diagnostic)
78 }
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase", deny_unknown_fields)]
85pub struct PairSpec {
86 pub baseline_revision: String,
87 pub expected_baseline: ExpectedOutcome,
88 pub expected_candidate: ExpectedOutcome,
89 pub environment_label: String,
90 pub overlay_checker_on_baseline: bool,
91}
92
93impl PairSpec {
94 pub(super) fn validate(&self) -> Result<()> {
95 if !matches!(self.baseline_revision.len(), 40 | 64)
96 || !self
97 .baseline_revision
98 .bytes()
99 .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
100 || self.environment_label.trim().is_empty()
101 || self.environment_label.len() > 256
102 {
103 return Err(invalid(
104 "baselinePair requires a full lowercase commit ID and a bounded environment label",
105 ));
106 }
107 self.expected_baseline.validate()?;
108 self.expected_candidate.validate()
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct CaseBinding {
115 pub source: Identity,
117 pub environment: Digest,
118}
119
120#[derive(Debug, Serialize, Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct PairEvidence {
123 pub spec: PairSpec,
124 pub status: ControlStatus,
125 pub detail: String,
126 pub baseline: Option<CaseEvidence>,
127 pub candidate: Option<CaseEvidence>,
128 pub checker_overlay_sha256: Option<String>,
129}
130
131impl PairEvidence {
132 pub(super) fn pending(spec: &PairSpec) -> Self {
133 Self {
134 spec: spec.clone(),
135 status: ControlStatus::Inconclusive,
136 detail: "INCONCLUSIVE: pair has not run".into(),
137 baseline: None,
138 candidate: None,
139 checker_overlay_sha256: None,
140 }
141 }
142}
143
144pub(super) fn case_environment(
145 config: &MissionConfig,
146 scratch: &Path,
147 revision: &str,
148) -> HashMap<String, String> {
149 let env =
150 crate::contract_lint::lint_env(scratch, Some(revision), &config.contract_env_passthrough);
151 case_environment_values(env, scratch)
152}
153
154fn case_environment_values(
155 mut env: HashMap<String, String>,
156 scratch: &Path,
157) -> HashMap<String, String> {
158 env.insert(
159 "CARGO_TARGET_DIR".into(),
160 scratch.join("target").display().to_string(),
161 );
162 env.insert("PYTHONDONTWRITEBYTECODE".into(), "1".into());
163 env.insert(
164 "KRANZ_CONTROL_SCRATCH".into(),
165 scratch.display().to_string(),
166 );
167 env.insert(
168 "KRANZ_CONTROL_RESULT".into(),
169 scratch.join("result.json").display().to_string(),
170 );
171 env
172}
173
174pub(super) fn environment_identity(
175 config: &MissionConfig,
176 scratch: &Path,
177 env: &HashMap<String, String>,
178) -> Digest {
179 let scratch = scratch.to_string_lossy();
180 let normalized: BTreeMap<_, _> = env
181 .iter()
182 .map(|(k, v)| {
183 (
184 k,
185 if k == "KRANZ_BASE_SHA" {
186 "<separately-bound-revision>".into()
187 } else if k == "CARGO_HOME" {
188 "<per-case-credential-free-cargo-cache>".into()
189 } else {
190 v.replace(scratch.as_ref(), "<per-case-scratch>")
191 },
192 )
193 })
194 .collect();
195 let mut policy = config.worker.sandbox.clone();
196 if policy.enforce == SandboxEnforce::Off {
197 policy.enforce = SandboxEnforce::Fs;
198 }
199 policy.extra_write.clear();
200 Digest::of(
201 &serde_json::to_vec(&(
202 std::env::consts::OS,
203 std::env::consts::ARCH,
204 policy,
205 crate::agent_env::contract_cargo_cache_source(),
206 normalized,
207 ))
208 .expect("environment identity"),
209 )
210}
211
212fn current_environment(config: &MissionConfig) -> Digest {
213 let scratch = Path::new("/kranz-pair-observation-scratch");
216 let env = crate::agent_env::contract_command_env_preview(
217 scratch,
218 Some("current"),
219 &config.contract_env_passthrough,
220 );
221 let env = crate::contract_lint::with_git_hooks_disabled(env);
222 let mut env = case_environment_values(env, scratch);
223 if config.worker.sandbox.enforce == SandboxEnforce::FsNet {
224 env.insert("CARGO_NET_OFFLINE".into(), "true".into());
225 }
226 environment_identity(config, scratch, &env)
227}
228
229#[allow(clippy::too_many_arguments)]
230pub(super) fn evaluate_pair(
231 repo: &GitRepo,
232 paths: &MissionPaths,
233 revision: &str,
234 assertion: &Assertion,
235 config: &MissionConfig,
236 deadline: Instant,
237 cancelled: &AtomicBool,
238 evidence: &mut ControlEvidence,
239) -> Result<()> {
240 let spec = assertion
241 .negative_control
242 .as_ref()
243 .unwrap()
244 .baseline_pair
245 .as_ref()
246 .unwrap();
247 let pair = evidence.baseline_pair.as_mut().unwrap();
248 if repo.rev_parse(&format!("{}^{{commit}}", spec.baseline_revision))? != spec.baseline_revision
249 {
250 return Err(invalid("baseline revision is not an exact commit"));
251 }
252 pair.checker_overlay_sha256 = spec
253 .overlay_checker_on_baseline
254 .then(|| evidence.checker_sha256.clone());
255 pair.baseline = Some(run_case(
256 repo,
257 paths,
258 &spec.baseline_revision,
259 assertion,
260 &[],
261 spec.overlay_checker_on_baseline,
262 config,
263 deadline,
264 cancelled,
265 )?);
266 pair.candidate = Some(run_case(
267 repo,
268 paths,
269 revision,
270 assertion,
271 &[],
272 false,
273 config,
274 deadline,
275 cancelled,
276 )?);
277 let baseline = pair.baseline.as_ref().unwrap();
278 let candidate = pair.candidate.as_ref().unwrap();
279 let environments: Option<Vec<_>> = [
280 evidence.valid.as_ref(),
281 evidence.defective.as_ref(),
282 Some(baseline),
283 Some(candidate),
284 ]
285 .into_iter()
286 .map(|case| case?.binding.as_ref().map(|b| &b.environment))
287 .collect();
288 let comparable =
289 environments.is_some_and(|envs| envs.windows(2).all(|pair| pair[0] == pair[1]));
290 let same_candidate = candidate.binding.as_ref().is_some_and(|observed| {
291 [evidence.valid.as_ref(), evidence.defective.as_ref()]
292 .into_iter()
293 .all(|case| {
294 case.and_then(|c| c.binding.as_ref())
295 .is_some_and(|binding| binding.source == observed.source)
296 })
297 });
298 if comparable
299 && same_candidate
300 && spec.expected_baseline.matches(baseline)
301 && spec.expected_candidate.matches(candidate)
302 {
303 pair.status = ControlStatus::Verified;
304 pair.detail = "VERIFIED: approved expectations matched at both revisions after valid/defective controls passed. Environment configuration matches; host toolchain, cache and service state are not attested. Advisory observation only.".into();
305 } else {
306 pair.detail = "INCONCLUSIVE: source identities, expectations or environment configuration differ; inspect the actual receipts. Setup errors, missing receipts and zero checks do not establish the intended behavior.".into();
307 }
308 Ok(())
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315pub struct Observation {
316 pub binding: Option<CaseBinding>,
317 pub exit_code: Option<i32>,
318 pub receipt: Option<CheckReceipt>,
319 pub environment_names: Vec<String>,
320}
321impl From<&CaseEvidence> for Observation {
322 fn from(case: &CaseEvidence) -> Self {
323 Self {
324 binding: case.binding.clone(),
325 exit_code: case.exit_code,
326 receipt: case.receipt.clone(),
327 environment_names: case.environment_names.clone(),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333#[serde(rename_all = "camelCase")]
334pub struct Summary {
335 pub assertion_id: String,
336 pub assertion_sha256: String,
337 pub checker_sha256: String,
338 pub control_sha256: String,
339 pub recorded_at: String,
340 pub spec: PairSpec,
341 pub status: ControlStatus,
342 pub detail: String,
343 pub baseline: Option<Observation>,
344 pub candidate: Option<Observation>,
345 pub checker_overlay_sha256: Option<String>,
346}
347impl Summary {
348 fn from_evidence(evidence: &ControlEvidence) -> Option<Self> {
349 if evidence.version != 2 {
350 return None;
351 }
352 let pair = evidence.baseline_pair.as_ref()?;
353 Some(Self {
354 assertion_id: evidence.assertion_id.clone(),
355 assertion_sha256: evidence.assertion_sha256.clone(),
356 checker_sha256: evidence.checker_sha256.clone(),
357 control_sha256: evidence.control_sha256.clone(),
358 recorded_at: evidence.recorded_at.clone(),
359 spec: pair.spec.clone(),
360 status: pair.status,
361 detail: pair.detail.clone(),
362 baseline: pair.baseline.as_ref().map(Observation::from),
363 candidate: pair.candidate.as_ref().map(Observation::from),
364 checker_overlay_sha256: pair.checker_overlay_sha256.clone(),
365 })
366 }
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
370#[serde(rename_all = "camelCase", deny_unknown_fields)]
371pub struct Descriptor {
372 pub digest: Digest,
373 pub bytes: u64,
374 pub summary: Summary,
375}
376
377pub fn descriptor(detail: Option<&str>) -> Option<Descriptor> {
378 let text = detail?.strip_prefix(PREFIX)?;
379 if text.len() > MAX_EVIDENCE_BYTES as usize {
380 return None;
381 }
382 let descriptor: Descriptor = serde_json::from_str(text).ok()?;
383 (descriptor.bytes <= MAX_EVIDENCE_BYTES).then_some(descriptor)
384}
385
386pub(super) fn report(
387 evidence: &ControlEvidence,
388 retained: &Result<(String, Vec<u8>)>,
389) -> GateReport {
390 let (artefact, pass) = match retained.as_ref().ok().and_then(|(reference, bytes)| {
391 let retained: ControlEvidence = serde_json::from_slice(bytes).ok()?;
392 Some((reference, bytes, Summary::from_evidence(&retained)?))
393 }) {
394 Some((reference, bytes, summary)) => {
395 let pass = summary.status == ControlStatus::Verified;
396 let descriptor = Descriptor {
397 digest: Digest::of(bytes),
398 bytes: bytes.len() as u64,
399 summary,
400 };
401 (
402 ArtefactRef::new(reference).with_detail(format!(
403 "{PREFIX}{}",
404 serde_json::to_string(&descriptor).expect("pair descriptor")
405 )),
406 pass,
407 )
408 }
409 None => (
410 ArtefactRef::new("baseline/candidate evidence unavailable")
411 .with_detail("INCONCLUSIVE: pair evidence could not be retained (advisory)"),
412 false,
413 ),
414 };
415 GateReport {
416 name: format!("baseline-candidate:{}", evidence.assertion_id),
417 kind: GateKind::Deterministic,
418 outcome: if pass {
419 GateOutcome::pass(artefact)
420 } else {
421 GateOutcome::fail(artefact)
422 },
423 }
424}
425
426pub(crate) fn retained_bytes(
427 mission_dir: &Path,
428 reference: &str,
429 descriptor: &Descriptor,
430) -> Option<Vec<u8>> {
431 let path = reference.strip_prefix(crate::gate_results::FILE_REF_SCHEME)?;
432 crate::gate_evaluation::protocol::WirePath::try_from(path.to_string()).ok()?;
433 let file = crate::paths::open_read_nofollow(&mission_dir.join(path)).ok()?;
434 #[cfg(unix)]
435 {
436 use std::os::unix::fs::MetadataExt as _;
437 if file.metadata().ok()?.nlink() != 1 {
438 return None;
439 }
440 }
441 let bytes = crate::paths::read_regular_file_bounded(file, MAX_EVIDENCE_BYTES).ok()?;
442 (bytes.len() as u64 == descriptor.bytes
443 && Digest::of(bytes.as_bytes()) == descriptor.digest
444 && serde_json::from_str::<ControlEvidence>(&bytes)
445 .ok()
446 .and_then(|e| Summary::from_evidence(&e))
447 .is_some_and(|summary| identity(&summary) == identity(&descriptor.summary)))
448 .then(|| bytes.into_bytes())
449}
450
451#[derive(Debug, Serialize)]
452#[serde(rename_all = "camelCase")]
453pub struct Review {
454 pub seq: u64,
455 pub reference: String,
456 pub available: bool,
457 pub source_and_config_match: bool,
458 pub detail: String,
459 pub summary: Summary,
460}
461
462pub fn reviews(
465 mission_dir: &Path,
466 events: &[Event],
467 repo: Option<&GitRepo>,
468 assertions: &[Assertion],
469 config: &MissionConfig,
470) -> Vec<Review> {
471 reviews_with_budget(
472 mission_dir,
473 events,
474 repo,
475 assertions,
476 config,
477 &mut (128 * 1024 * 1024),
478 )
479}
480
481pub(crate) fn reviews_with_budget(
482 mission_dir: &Path,
483 events: &[Event],
484 repo: Option<&GitRepo>,
485 assertions: &[Assertion],
486 config: &MissionConfig,
487 budget: &mut usize,
488) -> Vec<Review> {
489 let environment = current_environment(config);
490 let mut snapshots = BTreeMap::new();
491 let mut reviews = Vec::new();
492 for event in events.iter().rev() {
493 let EventKind::GateResult {
494 gate,
495 artefact_ref,
496 artefact_detail,
497 ..
498 } = &event.kind
499 else {
500 continue;
501 };
502 if !gate.starts_with("baseline-candidate:") {
503 continue;
504 }
505 let Some(descriptor) = descriptor(artefact_detail.as_deref()) else {
506 continue;
507 };
508 if gate != &format!("baseline-candidate:{}", descriptor.summary.assertion_id) {
509 continue;
510 }
511 let length = descriptor.bytes as usize;
512 let available =
513 *budget >= length && retained_bytes(mission_dir, artefact_ref, &descriptor).is_some();
514 *budget = budget.saturating_sub(length);
515 let summary = descriptor.summary;
516 let may_capture = snapshots.len() < 32;
517 let source = snapshots
518 .entry(summary.spec.baseline_revision.clone())
519 .or_insert_with(|| {
520 if available && may_capture && summary.spec.validate().is_ok() {
521 repo.and_then(|repo| {
522 crate::gate_evaluation::snapshot::SourceSnapshot::capture(
523 repo,
524 &summary.spec.baseline_revision,
525 )
526 .ok()
527 })
528 .map(|s| s.identity)
529 } else {
530 None
531 }
532 });
533 let source_and_config_match = available
534 && assertions.iter().any(|a| {
535 a.id == summary.assertion_id
536 && identity(a) == summary.assertion_sha256
537 && a.negative_control
538 .as_ref()
539 .zip(repo)
540 .is_some_and(|(spec, repo)| {
541 check_inputs(repo.root(), &spec.checker_files).is_ok()
542 })
543 })
544 && summary
545 .candidate
546 .as_ref()
547 .and_then(|c| c.binding.as_ref())
548 .is_some_and(|binding| {
549 source.as_ref() == Some(&binding.source) && binding.environment == environment
550 });
551 let detail = if !available {
552 "UNAVAILABLE: retained pair evidence is missing or its digest differs."
553 } else if source_and_config_match {
554 "Source and environment configuration match this recorded observation. Toolchain, cache and service state remain unqualified; this is not permission to reuse a gate decision."
555 } else {
556 "HISTORICAL: source, approved check, or environment configuration changed or cannot be established."
557 };
558 reviews.push(Review {
559 seq: event.seq,
560 reference: artefact_ref.clone(),
561 available,
562 source_and_config_match,
563 detail: detail.into(),
564 summary,
565 });
566 }
567 reviews.reverse();
568 reviews
569}
570
571pub(crate) fn diagnostics(
572 mission_dir: &Path,
573 events: &[Event],
574 repo: &GitRepo,
575 assertions: &[Assertion],
576 config: &MissionConfig,
577) -> Vec<GateReport> {
578 let reviews = reviews(mission_dir, events, Some(repo), assertions, config);
579 let latest_event: BTreeMap<_, _> = events
581 .iter()
582 .filter_map(|event| match &event.kind {
583 EventKind::GateResult { gate, .. } if gate.starts_with("baseline-candidate:") => {
584 Some((gate.as_str(), event.seq))
585 }
586 _ => None,
587 })
588 .collect();
589 let mut latest = BTreeMap::new();
590 for review in reviews {
591 latest.insert(review.summary.assertion_id.clone(), review);
592 }
593 latest
594 .into_values()
595 .filter(|r| {
596 r.source_and_config_match
597 && latest_event
598 .get(format!("baseline-candidate:{}", r.summary.assertion_id).as_str())
599 == Some(&r.seq)
600 })
601 .map(|review| {
602 let event = events
603 .iter()
604 .find(|event| event.seq == review.seq)
605 .expect("review event");
606 let EventKind::GateResult {
607 artefact_detail, ..
608 } = &event.kind
609 else {
610 unreachable!()
611 };
612 let artefact = ArtefactRef::new(review.reference)
613 .with_detail(artefact_detail.clone().expect("descriptor"));
614 GateReport {
615 name: format!("baseline-candidate:{}", review.summary.assertion_id),
616 kind: GateKind::Deterministic,
617 outcome: if review.summary.status == ControlStatus::Verified {
618 GateOutcome::pass(artefact)
619 } else {
620 GateOutcome::fail(artefact)
621 },
622 }
623 })
624 .collect()
625}