Skip to main content

devscout_rs/graph/compiler_facts/
admit.rs

1// The pure admission core: no I/O, no process, no filesystem access. Every
2// refusal test in this crate calls this function directly or indirectly
3// through the CLI, so its check order is fixed and documented here rather
4// than left to fall out of the code -- a predictable "first failing check
5// wins" story every refusal test asserts against.
6
7use super::artifact::{parse_header, CandidateHeader, Coverage};
8use super::context_summary;
9use super::expectations::AdmissionExpectations;
10use super::reasons::RefusalReason;
11
12/// A candidate that passed every admission check, still carrying its
13/// original bytes unchanged -- the one field publication ever writes.
14#[derive(Debug, Clone)]
15pub struct AdmittedFacts {
16    /// The parsed header.
17    pub header: CandidateHeader,
18    /// The original candidate bytes, verbatim. Never re-serialized: this is
19    /// what makes a lossless round trip possible without this module
20    /// modeling the bulk payload at all.
21    pub bytes: Vec<u8>,
22}
23
24// A unit id claimed both processed and missing is the artifact
25// contradicting its own bookkeeping -- the shape of internal incoherence
26// this crate can detect without engine cooperation.
27fn inventory_is_coherent(header: &CandidateHeader) -> bool {
28    !header
29        .units_processed
30        .iter()
31        .any(|unit| header.units_missing.contains(unit))
32}
33
34// Fails closed rather than silently passing when either side is absent: a
35// recognised context envelope is expected to always carry a header summary
36// this admission path can recompute and verify, so a producer that stops
37// emitting one, or whose embedded envelope this path cannot read compilation
38// identities/fingerprints from at all, is refused on this class rather than
39// having it quietly stop checking anything at all.
40fn context_summary_matches(header: &CandidateHeader) -> bool {
41    match (
42        &header.context_fingerprint_header,
43        &header.context_compilations,
44    ) {
45        (Some(header_fp), Some(compilations)) => {
46            *header_fp == context_summary::recompute(compilations)
47        }
48        _ => false,
49    }
50}
51
52// Checked after `inventory_is_coherent`, before `identity_check`: a
53// candidate's own occurrence payload contradicting itself or its own
54// embedded envelope is a broken-run concern (the candidate cannot be
55// trusted at all), not a checkout-vs-candidate identity mismatch. First
56// failing entry, first failing class wins -- fixed order, doc-commented the
57// same way `identity_check`'s is.
58fn occurrence_self_consistency(header: &CandidateHeader) -> Option<RefusalReason> {
59    if header.occurrence_sites.is_some() != header.occurrences_capability_provided {
60        return Some(RefusalReason::OccurrenceCapabilityMismatch);
61    }
62
63    let sites = header.occurrence_sites.as_ref()?;
64    // `context_compilations: None` here is already caught by
65    // `ContextFingerprintMismatch` a moment later in `identity_check`, so
66    // this loop is deliberately skipped rather than duplicating that
67    // refusal under a different token.
68    let compilations = header.context_compilations.as_ref()?;
69
70    for site in sites {
71        match compilations.iter().find(|c| c.identity == site.identity) {
72            None => return Some(RefusalReason::OccurrenceCompilationUnknown),
73            Some(matched) if matched.fingerprint.is_none() => {
74                return Some(RefusalReason::OccurrenceCompilationUnsupported)
75            }
76            Some(_) => {}
77        }
78    }
79
80    None
81}
82
83fn identity_check(
84    header: &CandidateHeader,
85    expected: &AdmissionExpectations,
86) -> Option<RefusalReason> {
87    if header.contract_version != expected.contract_version {
88        return Some(RefusalReason::ContractVersionMismatch);
89    }
90    if header.producer_name != expected.producer_name {
91        return Some(RefusalReason::ProducerMismatch);
92    }
93    if header.engine_revision != expected.engine_revision {
94        return Some(RefusalReason::EngineRevisionMismatch);
95    }
96    if header.profile != expected.profile {
97        return Some(RefusalReason::ProfileMismatch);
98    }
99    if header.dependency_fingerprint != expected.dependency_fingerprint {
100        return Some(RefusalReason::DependencyFingerprintMismatch);
101    }
102    if header.context_schema_version != expected.context_schema_version {
103        return Some(RefusalReason::ContextEnvelopeVersionUnrecognised);
104    }
105    if !context_summary_matches(header) {
106        return Some(RefusalReason::ContextFingerprintMismatch);
107    }
108    if let Some(expected_head) = &expected.head_sha {
109        if header.source_head_sha.as_deref() != Some(expected_head.as_str()) {
110            return Some(RefusalReason::SourceSnapshotMismatch);
111        }
112    }
113    None
114}
115
116/// Admits or refuses a candidate compiler-facts artifact.
117///
118/// Check order, fixed: malformed encoding, then the terminal completion
119/// record, then per-unit inventory coherence, then occurrence self-
120/// consistency, then contract version, producer identity, engine revision,
121/// profile, dependency fingerprint, the embedded context-envelope version,
122/// the context fingerprint, and the source snapshot -- and only once every
123/// one of those has passed does a structurally valid, declared-incomplete
124/// artifact still succeed, carrying its own incomplete [`Coverage`] rather
125/// than being refused.
126pub fn admit(
127    candidate_bytes: &[u8],
128    expected: &AdmissionExpectations,
129) -> Result<AdmittedFacts, RefusalReason> {
130    let header = parse_header(candidate_bytes)?;
131
132    if !header.completion_terminal {
133        return Err(RefusalReason::MissingCompletionRecord);
134    }
135    if !inventory_is_coherent(&header) {
136        return Err(RefusalReason::IncoherentInventory);
137    }
138    if let Some(reason) = occurrence_self_consistency(&header) {
139        return Err(reason);
140    }
141    if let Some(reason) = identity_check(&header, expected) {
142        return Err(reason);
143    }
144
145    Ok(AdmittedFacts {
146        header,
147        bytes: candidate_bytes.to_vec(),
148    })
149}
150
151impl AdmittedFacts {
152    /// The admitted artifact's own coverage state.
153    pub fn coverage(&self) -> &Coverage {
154        &self.header.coverage
155    }
156}