Skip to main content

kranz_engine/contract_controls/
pair.rs

1//! Actual revision pairs reuse the control runner. These are advisory,
2//! source-bound observations, never independent attestations or consent.
3use 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    /// Captured before any fixture or checker overlay.
116    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    // No filesystem mutation or child execution is needed to reconstruct the
214    // configuration. Tool binaries, caches and remote services are not pinned.
215    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/// A narrow dossier carried to gates and human views. It contains no worker
312/// transcript or command output. The retained artifact carries bounded output.
313#[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
462/// Availability is digest checked; freshness is conservative. Matching source
463/// and configuration does not prove that external services or tools stood still.
464pub 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    // A newer missing/malformed artifact must not fall back to an older pass.
580    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}