Skip to main content

affidavit/
architecture.rs

1//! Deterministic enterprise-architecture qualification standing.
2//!
3//! This module certifies evidence bindings. It never grants consequential
4//! execution authority; BRCE remains the exclusive DO boundary.
5//!
6//! Hardening invariants (v26.9.26, schema v2):
7//! - every digest is `algorithm:value` (lowercase algorithm, printable-ASCII
8//!   body with no whitespace or invisible characters). Malformed digests are
9//!   refused by [`certify`] *and* re-checked by [`verify_integrity`], so a
10//!   resealed receipt carrying a malformed or empty digest is refused on
11//!   replay and on [`from_json_verified`];
12//! - evidence and artifact digest sets are canonicalized (sorted, deduplicated)
13//!   so duplicate delivery or reordering of the same evidence yields the same
14//!   receipt digest, and a non-canonical receipt is refused on replay;
15//! - standing and chain fields must agree: `SUPERSEDED` exists only as a
16//!   retirement record carrying both `prior_receipt_digest` (the retired
17//!   receipt) and `superseded_by_receipt_digest` (its successor); `CANDIDATE`
18//!   and `REFUSED` carry no chain fields; `QUALIFIED` may carry only
19//!   `prior_receipt_digest` (it is then the successor of that receipt);
20//! - supersession ([`supersede`]) yields a QUALIFIED successor for the new SBB
21//!   and a SUPERSEDED retirement record for the replaced SBB, so a consumer
22//!   selecting `QUALIFIED` by ABB gets the current SBB, never the stale one;
23//! - [`ArchitectureStandingLedger`] admits receipts, refuses orphan or forked
24//!   chain links, and answers "current qualified SBB by ABB" as
25//!   machine-readable JSON.
26//!
27//! Integrity is not authenticity: `receipt_digest` is an unkeyed blake3
28//! self-digest, so anyone may recompute it after editing fields. Forged
29//! evidence is refused by re-deriving evidence digests from the observed
30//! evidence bytes ([`QualificationEvidence`], [`verify_evidence`]); producer
31//! authentication (signatures/keys) is out of scope for this module.
32//!
33//! [`certify`]: ArchitectureQualificationReceipt::certify
34//! [`verify_integrity`]: ArchitectureQualificationReceipt::verify_integrity
35//! [`from_json_verified`]: ArchitectureQualificationReceipt::from_json_verified
36//! [`supersede`]: ArchitectureQualificationReceipt::supersede
37//! [`verify_evidence`]: ArchitectureQualificationReceipt::verify_evidence
38
39use serde::{Deserialize, Serialize};
40use std::collections::BTreeMap;
41
42/// Schema identifier bound into every architecture qualification receipt.
43pub const ARCHITECTURE_RECEIPT_SCHEMA: &str = "affidavit.architecture-qualification.v2";
44
45/// Schema identifier of the machine-readable standing query answer.
46pub const ARCHITECTURE_QUERY_SCHEMA: &str = "affidavit.architecture-standing-query.v1";
47
48/// Standing that an architecture qualification receipt can carry.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
50#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
51pub enum ArchitectureStanding {
52    /// No admitted evidence; never certifiable.
53    Unknown,
54    /// Proposed but not yet qualified.
55    Candidate,
56    /// Qualified against the exact ABB/contract/SBB subject.
57    Qualified,
58    /// Qualification refused.
59    Refused,
60    /// Retirement record of a receipt whose SBB was replaced by a successor
61    /// SBB; chained to both the retired receipt and its successor.
62    Superseded,
63}
64
65/// Independent evidence producer family (charter DoD item 7).
66#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum EvidenceSource {
69    /// autofde-lab qualification runs.
70    AutofdeLab,
71    /// xaas conformance/process observations.
72    Xaas,
73    /// Runtime/process observations of the deployed SBB.
74    Runtime,
75}
76
77/// One piece of independent qualification evidence, bound to the exact bytes
78/// that were observed. Its [`binding_digest`] is what a receipt records.
79///
80/// [`binding_digest`]: QualificationEvidence::binding_digest
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct QualificationEvidence {
84    /// Producer family.
85    pub source: EvidenceSource,
86    /// Digest of the producer that emitted the evidence.
87    pub producer_digest: String,
88    /// `blake3:` digest of the observed evidence bytes.
89    pub content_digest: String,
90}
91
92impl QualificationEvidence {
93    /// Observe evidence bytes: the content digest is derived from the bytes,
94    /// never asserted by the caller.
95    pub fn observe(
96        source: EvidenceSource,
97        producer_digest: impl Into<String>,
98        bytes: &[u8],
99    ) -> Result<Self, ArchitectureRefusal> {
100        let producer_digest = producer_digest.into();
101        validate_digest("evidence.producer_digest", &producer_digest)?;
102        if bytes.is_empty() {
103            return Err(ArchitectureRefusal::MissingEvidence);
104        }
105        Ok(Self {
106            source,
107            producer_digest,
108            content_digest: blake3_digest(bytes),
109        })
110    }
111
112    /// Digest binding source, producer and content; recorded in receipts.
113    pub fn binding_digest(&self) -> String {
114        let bytes = serde_json::to_vec(self).expect("evidence is serializable");
115        blake3_digest(&bytes)
116    }
117
118    /// Refuse unless `bytes` are exactly the observed evidence.
119    pub fn verify_bytes(&self, bytes: &[u8]) -> Result<(), ArchitectureRefusal> {
120        validate_digest("evidence.producer_digest", &self.producer_digest)?;
121        if bytes.is_empty() || self.content_digest != blake3_digest(bytes) {
122            return Err(ArchitectureRefusal::ForgedEvidence);
123        }
124        Ok(())
125    }
126}
127
128/// Evidence receipt binding an ABB, contract and SBB to exact digests.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct ArchitectureQualificationReceipt {
132    /// Schema identifier; must equal [`ARCHITECTURE_RECEIPT_SCHEMA`].
133    pub schema: String,
134    /// Architecture building block digest.
135    pub abb_digest: String,
136    /// Architecture contract digest.
137    pub contract_digest: String,
138    /// Solution building block digest.
139    pub sbb_digest: String,
140    /// Exact subject digest (e.g. commit or artifact identity).
141    pub exact_subject_digest: String,
142    /// Canonical (sorted, deduplicated) qualification evidence digests.
143    pub qualification_evidence_digests: Vec<String>,
144    /// Digest of the producer that emitted the evidence.
145    pub producer_digest: String,
146    /// Canonical (sorted, deduplicated) artifact digests.
147    pub artifact_digests: Vec<String>,
148    /// Standing carried by this receipt.
149    pub standing: ArchitectureStanding,
150    /// Always false: affidavit never confers DO authority.
151    pub confers_do_authority: bool,
152    /// For a QUALIFIED successor: the receipt it replaces. For a SUPERSEDED
153    /// retirement record: the receipt being retired.
154    pub prior_receipt_digest: Option<String>,
155    /// For a SUPERSEDED retirement record: the successor receipt.
156    pub superseded_by_receipt_digest: Option<String>,
157    /// blake3 digest over the receipt with this field cleared.
158    pub receipt_digest: String,
159}
160
161/// Result of replacing an SBB: the QUALIFIED successor and the SUPERSEDED
162/// retirement record of the replaced receipt.
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
164pub struct Supersession {
165    /// SUPERSEDED record retiring the prior receipt.
166    pub retired: ArchitectureQualificationReceipt,
167    /// QUALIFIED receipt for the replacement SBB.
168    pub successor: ArchitectureQualificationReceipt,
169}
170
171/// Typed refusals for architecture qualification.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum ArchitectureRefusal {
174    /// Exact subject digest missing.
175    MissingExactSubject,
176    /// No qualification evidence.
177    MissingEvidence,
178    /// Attempt to certify `UNKNOWN` standing.
179    UnknownPromotion,
180    /// Contract digest differs from the expected one.
181    MutableOrChangedContract,
182    /// SBB digest differs from the expected one.
183    MutableOrChangedSbb,
184    /// ABB or subject digest belongs to a different subject.
185    CrossSubjectReuse,
186    /// Receipt claims DO authority.
187    DoAuthorityForbidden,
188    /// Recomputed digest or canonical form does not match.
189    ReplayMismatch,
190    /// A digest field is not `algorithm:value`.
191    MalformedDigest {
192        /// Field that carried the malformed digest.
193        field: &'static str,
194    },
195    /// Schema identifier is not [`ARCHITECTURE_RECEIPT_SCHEMA`].
196    SchemaMismatch,
197    /// Supersession did not change the SBB.
198    NotAReplacement,
199    /// Chain link does not point at the given prior receipt.
200    ChainBroken,
201    /// Standing and chain fields disagree (e.g. SUPERSEDED without a chain,
202    /// or a chain field on a CANDIDATE/REFUSED receipt).
203    StandingChainMismatch,
204    /// Only a QUALIFIED receipt can be superseded.
205    NotSupersedable,
206    /// The receipt was already superseded by a different successor.
207    AlreadySuperseded,
208    /// A chain link references a receipt absent from the ledger.
209    OrphanChainLink,
210    /// Evidence digests do not match the observed evidence bytes.
211    ForgedEvidence,
212    /// Serialized receipt could not be parsed.
213    Malformed,
214}
215
216impl core::fmt::Display for ArchitectureRefusal {
217    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
218        match self {
219            Self::MalformedDigest { field } => write!(f, "MALFORMED_DIGEST[{field}]"),
220            other => write!(f, "{other:?}"),
221        }
222    }
223}
224
225impl std::error::Error for ArchitectureRefusal {}
226
227fn blake3_digest(bytes: &[u8]) -> String {
228    format!("blake3:{}", blake3::hash(bytes).to_hex())
229}
230
231fn split_digest(value: &str) -> Option<(&str, &str)> {
232    let (algorithm, body) = value.split_once(':')?;
233    let algorithm_ok = !algorithm.is_empty()
234        && algorithm
235            .bytes()
236            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-');
237    // Printable ASCII only: rejects whitespace, control characters and
238    // invisible Unicode such as U+200B.
239    let body_ok = !body.is_empty() && body.bytes().all(|b| b.is_ascii_graphic());
240    (algorithm_ok && body_ok).then_some((algorithm, body))
241}
242
243fn validate_digest(field: &'static str, value: &str) -> Result<(), ArchitectureRefusal> {
244    split_digest(value)
245        .map(|_| ())
246        .ok_or(ArchitectureRefusal::MalformedDigest { field })
247}
248
249/// A receipt link must be a well-formed `blake3:<64 lowercase hex>` digest.
250fn validate_receipt_link(field: &'static str, value: &str) -> Result<(), ArchitectureRefusal> {
251    let ok = value.strip_prefix("blake3:").is_some_and(|hex| {
252        hex.len() == 64
253            && hex
254                .bytes()
255                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
256    });
257    if ok {
258        Ok(())
259    } else {
260        Err(ArchitectureRefusal::MalformedDigest { field })
261    }
262}
263
264fn canonical_set(
265    field: &'static str,
266    digests: Vec<String>,
267) -> Result<Vec<String>, ArchitectureRefusal> {
268    for digest in &digests {
269        validate_digest(field, digest)?;
270    }
271    let mut digests = digests;
272    digests.sort();
273    digests.dedup();
274    Ok(digests)
275}
276
277fn is_canonical(digests: &[String]) -> bool {
278    digests.windows(2).all(|pair| pair[0] < pair[1])
279}
280
281impl ArchitectureQualificationReceipt {
282    /// Certify a qualification receipt. Refuses malformed digests, missing
283    /// subject/evidence, `UNKNOWN` promotion and direct `SUPERSEDED`
284    /// certification (supersession only arises from [`supersede`]).
285    ///
286    /// [`supersede`]: ArchitectureQualificationReceipt::supersede
287    #[allow(clippy::too_many_arguments)]
288    pub fn certify(
289        abb_digest: impl Into<String>,
290        contract_digest: impl Into<String>,
291        sbb_digest: impl Into<String>,
292        exact_subject_digest: impl Into<String>,
293        qualification_evidence_digests: Vec<String>,
294        producer_digest: impl Into<String>,
295        artifact_digests: Vec<String>,
296        standing: ArchitectureStanding,
297    ) -> Result<Self, ArchitectureRefusal> {
298        let abb_digest = abb_digest.into();
299        let contract_digest = contract_digest.into();
300        let sbb_digest = sbb_digest.into();
301        let exact_subject_digest = exact_subject_digest.into();
302        let producer_digest = producer_digest.into();
303
304        if exact_subject_digest.is_empty() {
305            return Err(ArchitectureRefusal::MissingExactSubject);
306        }
307        if qualification_evidence_digests.is_empty() {
308            return Err(ArchitectureRefusal::MissingEvidence);
309        }
310        match standing {
311            ArchitectureStanding::Unknown => return Err(ArchitectureRefusal::UnknownPromotion),
312            ArchitectureStanding::Superseded => {
313                return Err(ArchitectureRefusal::StandingChainMismatch)
314            }
315            _ => {}
316        }
317        validate_digest("abb_digest", &abb_digest)?;
318        validate_digest("contract_digest", &contract_digest)?;
319        validate_digest("sbb_digest", &sbb_digest)?;
320        validate_digest("exact_subject_digest", &exact_subject_digest)?;
321        validate_digest("producer_digest", &producer_digest)?;
322        let qualification_evidence_digests = canonical_set(
323            "qualification_evidence_digests",
324            qualification_evidence_digests,
325        )?;
326        let artifact_digests = canonical_set("artifact_digests", artifact_digests)?;
327
328        let mut receipt = Self {
329            schema: ARCHITECTURE_RECEIPT_SCHEMA.to_string(),
330            abb_digest,
331            contract_digest,
332            sbb_digest,
333            exact_subject_digest,
334            qualification_evidence_digests,
335            producer_digest,
336            artifact_digests,
337            standing,
338            confers_do_authority: false,
339            prior_receipt_digest: None,
340            superseded_by_receipt_digest: None,
341            receipt_digest: String::new(),
342        };
343        receipt.reseal();
344        Ok(receipt)
345    }
346
347    /// Certify from observed evidence items: the recorded evidence digests
348    /// are their [`QualificationEvidence::binding_digest`]s.
349    #[allow(clippy::too_many_arguments)]
350    pub fn certify_from_evidence(
351        abb_digest: impl Into<String>,
352        contract_digest: impl Into<String>,
353        sbb_digest: impl Into<String>,
354        exact_subject_digest: impl Into<String>,
355        evidence: &[QualificationEvidence],
356        producer_digest: impl Into<String>,
357        artifact_digests: Vec<String>,
358        standing: ArchitectureStanding,
359    ) -> Result<Self, ArchitectureRefusal> {
360        for item in evidence {
361            validate_digest("evidence.producer_digest", &item.producer_digest)?;
362            validate_receipt_link("evidence.content_digest", &item.content_digest)?;
363        }
364        Self::certify(
365            abb_digest,
366            contract_digest,
367            sbb_digest,
368            exact_subject_digest,
369            evidence
370                .iter()
371                .map(QualificationEvidence::binding_digest)
372                .collect(),
373            producer_digest,
374            artifact_digests,
375            standing,
376        )
377    }
378
379    fn reseal(&mut self) {
380        self.receipt_digest = digest_without_receipt(self);
381    }
382
383    /// Verify the receipt's own integrity independent of any expected subject:
384    /// schema, DO ceiling, standing, digest well-formedness, standing/chain
385    /// agreement, evidence presence, canonical form and self-digest.
386    pub fn verify_integrity(&self) -> Result<(), ArchitectureRefusal> {
387        if self.schema != ARCHITECTURE_RECEIPT_SCHEMA {
388            return Err(ArchitectureRefusal::SchemaMismatch);
389        }
390        if self.confers_do_authority {
391            return Err(ArchitectureRefusal::DoAuthorityForbidden);
392        }
393        if self.standing == ArchitectureStanding::Unknown {
394            return Err(ArchitectureRefusal::UnknownPromotion);
395        }
396        if self.exact_subject_digest.is_empty() {
397            return Err(ArchitectureRefusal::MissingExactSubject);
398        }
399        if self.qualification_evidence_digests.is_empty() {
400            return Err(ArchitectureRefusal::MissingEvidence);
401        }
402        validate_digest("abb_digest", &self.abb_digest)?;
403        validate_digest("contract_digest", &self.contract_digest)?;
404        validate_digest("sbb_digest", &self.sbb_digest)?;
405        validate_digest("exact_subject_digest", &self.exact_subject_digest)?;
406        validate_digest("producer_digest", &self.producer_digest)?;
407        for digest in &self.qualification_evidence_digests {
408            validate_digest("qualification_evidence_digests", digest)?;
409        }
410        for digest in &self.artifact_digests {
411            validate_digest("artifact_digests", digest)?;
412        }
413        if let Some(prior) = &self.prior_receipt_digest {
414            validate_receipt_link("prior_receipt_digest", prior)?;
415        }
416        if let Some(successor) = &self.superseded_by_receipt_digest {
417            validate_receipt_link("superseded_by_receipt_digest", successor)?;
418        }
419        let chain_ok = match self.standing {
420            ArchitectureStanding::Superseded => match (
421                &self.prior_receipt_digest,
422                &self.superseded_by_receipt_digest,
423            ) {
424                (Some(prior), Some(successor)) => prior != successor,
425                _ => false,
426            },
427            ArchitectureStanding::Qualified => self.superseded_by_receipt_digest.is_none(),
428            _ => self.prior_receipt_digest.is_none() && self.superseded_by_receipt_digest.is_none(),
429        };
430        if !chain_ok {
431            return Err(ArchitectureRefusal::StandingChainMismatch);
432        }
433        if !is_canonical(&self.qualification_evidence_digests)
434            || !is_canonical(&self.artifact_digests)
435        {
436            return Err(ArchitectureRefusal::ReplayMismatch);
437        }
438        if self.receipt_digest != digest_without_receipt(self) {
439            return Err(ArchitectureRefusal::ReplayMismatch);
440        }
441        Ok(())
442    }
443
444    /// Replay the receipt against the expected exact ABB/contract/SBB/subject.
445    /// Integrity only: see the module doc on authenticity.
446    pub fn verify_replay(
447        &self,
448        expected_abb_digest: &str,
449        expected_contract_digest: &str,
450        expected_sbb_digest: &str,
451        expected_subject_digest: &str,
452    ) -> Result<(), ArchitectureRefusal> {
453        self.verify_integrity()?;
454        if self.abb_digest != expected_abb_digest
455            || self.exact_subject_digest != expected_subject_digest
456        {
457            return Err(ArchitectureRefusal::CrossSubjectReuse);
458        }
459        if self.contract_digest != expected_contract_digest {
460            return Err(ArchitectureRefusal::MutableOrChangedContract);
461        }
462        if self.sbb_digest != expected_sbb_digest {
463            return Err(ArchitectureRefusal::MutableOrChangedSbb);
464        }
465        Ok(())
466    }
467
468    /// Re-derive the evidence set from observed evidence bytes. Refuses with
469    /// [`ArchitectureRefusal::ForgedEvidence`] when any bytes do not match
470    /// their content digest, or when the recorded evidence set differs from
471    /// the set derived from the observations.
472    pub fn verify_evidence(
473        &self,
474        observed: &[(QualificationEvidence, &[u8])],
475    ) -> Result<(), ArchitectureRefusal> {
476        self.verify_integrity()?;
477        for (item, bytes) in observed {
478            item.verify_bytes(bytes)?;
479        }
480        let mut derived: Vec<String> = observed
481            .iter()
482            .map(|(item, _)| item.binding_digest())
483            .collect();
484        derived.sort();
485        derived.dedup();
486        if derived != self.qualification_evidence_digests {
487            return Err(ArchitectureRefusal::ForgedEvidence);
488        }
489        Ok(())
490    }
491
492    /// Replace this receipt's SBB. The prior receipt must be intact and
493    /// QUALIFIED, and the SBB must actually change (a different digest body,
494    /// not only a different algorithm label). Returns the QUALIFIED successor
495    /// and the SUPERSEDED retirement record of `self`.
496    pub fn supersede(
497        &self,
498        new_sbb_digest: impl Into<String>,
499        new_subject_digest: impl Into<String>,
500        evidence: Vec<String>,
501    ) -> Result<Supersession, ArchitectureRefusal> {
502        self.verify_integrity()?;
503        if self.standing != ArchitectureStanding::Qualified {
504            return Err(ArchitectureRefusal::NotSupersedable);
505        }
506        let new_sbb_digest = new_sbb_digest.into();
507        if same_sbb(&new_sbb_digest, &self.sbb_digest) {
508            return Err(ArchitectureRefusal::NotAReplacement);
509        }
510        let mut successor = Self::certify(
511            self.abb_digest.clone(),
512            self.contract_digest.clone(),
513            new_sbb_digest,
514            new_subject_digest,
515            evidence,
516            self.producer_digest.clone(),
517            self.artifact_digests.clone(),
518            ArchitectureStanding::Qualified,
519        )?;
520        successor.prior_receipt_digest = Some(self.receipt_digest.clone());
521        successor.reseal();
522
523        let mut retired = self.clone();
524        retired.standing = ArchitectureStanding::Superseded;
525        retired.prior_receipt_digest = Some(self.receipt_digest.clone());
526        retired.superseded_by_receipt_digest = Some(successor.receipt_digest.clone());
527        retired.reseal();
528
529        Ok(Supersession { retired, successor })
530    }
531
532    /// Verify that `self` is an intact QUALIFIED successor link to an intact
533    /// QUALIFIED `prior` receipt of the same ABB and contract.
534    pub fn verify_chain(&self, prior: &Self) -> Result<(), ArchitectureRefusal> {
535        prior.verify_integrity()?;
536        self.verify_integrity()?;
537        if self.prior_receipt_digest.as_deref() != Some(prior.receipt_digest.as_str())
538            || self.standing != ArchitectureStanding::Qualified
539        {
540            return Err(ArchitectureRefusal::ChainBroken);
541        }
542        if prior.standing != ArchitectureStanding::Qualified {
543            return Err(ArchitectureRefusal::NotSupersedable);
544        }
545        if self.abb_digest != prior.abb_digest {
546            return Err(ArchitectureRefusal::CrossSubjectReuse);
547        }
548        if self.contract_digest != prior.contract_digest {
549            return Err(ArchitectureRefusal::MutableOrChangedContract);
550        }
551        if same_sbb(&self.sbb_digest, &prior.sbb_digest) {
552            return Err(ArchitectureRefusal::NotAReplacement);
553        }
554        Ok(())
555    }
556
557    /// Serialize to canonical JSON.
558    pub fn to_json(&self) -> String {
559        serde_json::to_string(self).expect("architecture receipt is serializable")
560    }
561
562    /// Parse a serialized receipt (unknown fields refused) and verify its
563    /// integrity before returning it.
564    pub fn from_json_verified(json: &str) -> Result<Self, ArchitectureRefusal> {
565        let receipt: Self =
566            serde_json::from_str(json).map_err(|_| ArchitectureRefusal::Malformed)?;
567        receipt.verify_integrity()?;
568        Ok(receipt)
569    }
570}
571
572impl Supersession {
573    /// Verify both halves against the intact prior receipt: the successor is
574    /// a valid chain link, and the retirement record retires exactly `prior`
575    /// (identical content, SUPERSEDED, pointing at prior and successor).
576    pub fn verify(
577        &self,
578        prior: &ArchitectureQualificationReceipt,
579    ) -> Result<(), ArchitectureRefusal> {
580        self.successor.verify_chain(prior)?;
581        self.retired.verify_integrity()?;
582        let r = &self.retired;
583        if r.standing != ArchitectureStanding::Superseded
584            || r.prior_receipt_digest.as_deref() != Some(prior.receipt_digest.as_str())
585            || r.superseded_by_receipt_digest.as_deref()
586                != Some(self.successor.receipt_digest.as_str())
587        {
588            return Err(ArchitectureRefusal::ChainBroken);
589        }
590        let same_content = r.abb_digest == prior.abb_digest
591            && r.contract_digest == prior.contract_digest
592            && r.sbb_digest == prior.sbb_digest
593            && r.exact_subject_digest == prior.exact_subject_digest
594            && r.qualification_evidence_digests == prior.qualification_evidence_digests
595            && r.producer_digest == prior.producer_digest
596            && r.artifact_digests == prior.artifact_digests;
597        if !same_content {
598            return Err(ArchitectureRefusal::ChainBroken);
599        }
600        Ok(())
601    }
602}
603
604fn same_sbb(a: &str, b: &str) -> bool {
605    if a == b {
606        return true;
607    }
608    match (split_digest(a), split_digest(b)) {
609        (Some((_, body_a)), Some((_, body_b))) => body_a == body_b,
610        _ => false,
611    }
612}
613
614fn digest_without_receipt(receipt: &ArchitectureQualificationReceipt) -> String {
615    let mut projection = receipt.clone();
616    projection.receipt_digest.clear();
617    let bytes = serde_json::to_vec(&projection).expect("architecture receipt is serializable");
618    blake3_digest(&bytes)
619}
620
621/// Admitted architecture receipts with chain enforcement and standing queries
622/// (charter DoD item 8). Every admitted receipt is integrity-checked; chain
623/// links must reference receipts already admitted; a receipt is retired at
624/// most once.
625#[derive(Debug, Clone, Default)]
626pub struct ArchitectureStandingLedger {
627    receipts: BTreeMap<String, ArchitectureQualificationReceipt>,
628    /// retired receipt digest -> successor receipt digest
629    retired_by: BTreeMap<String, String>,
630}
631
632impl ArchitectureStandingLedger {
633    /// Empty ledger.
634    pub fn new() -> Self {
635        Self::default()
636    }
637
638    /// Number of admitted receipts.
639    pub fn len(&self) -> usize {
640        self.receipts.len()
641    }
642
643    /// True when no receipt is admitted.
644    pub fn is_empty(&self) -> bool {
645        self.receipts.is_empty()
646    }
647
648    /// Admit an unchained receipt (QUALIFIED/CANDIDATE/REFUSED with no chain
649    /// fields). Chained receipts must arrive through [`admit_supersession`].
650    /// Re-admitting an identical receipt is idempotent.
651    ///
652    /// [`admit_supersession`]: ArchitectureStandingLedger::admit_supersession
653    pub fn admit(
654        &mut self,
655        receipt: ArchitectureQualificationReceipt,
656    ) -> Result<(), ArchitectureRefusal> {
657        receipt.verify_integrity()?;
658        if receipt.prior_receipt_digest.is_some() {
659            let prior = receipt.prior_receipt_digest.as_deref().unwrap_or_default();
660            return Err(if self.receipts.contains_key(prior) {
661                ArchitectureRefusal::ChainBroken
662            } else {
663                ArchitectureRefusal::OrphanChainLink
664            });
665        }
666        self.receipts
667            .insert(receipt.receipt_digest.clone(), receipt);
668        Ok(())
669    }
670
671    /// Admit a supersession: the prior receipt must already be admitted and
672    /// not yet retired, and both halves must verify against it. Nothing is
673    /// admitted unless everything verifies.
674    pub fn admit_supersession(
675        &mut self,
676        supersession: Supersession,
677    ) -> Result<(), ArchitectureRefusal> {
678        let prior_digest = supersession
679            .successor
680            .prior_receipt_digest
681            .clone()
682            .ok_or(ArchitectureRefusal::ChainBroken)?;
683        let prior = self
684            .receipts
685            .get(&prior_digest)
686            .ok_or(ArchitectureRefusal::OrphanChainLink)?;
687        supersession.verify(prior)?;
688        if let Some(existing) = self.retired_by.get(&prior_digest) {
689            if existing == &supersession.successor.receipt_digest {
690                return Ok(());
691            }
692            return Err(ArchitectureRefusal::AlreadySuperseded);
693        }
694        self.retired_by
695            .insert(prior_digest, supersession.successor.receipt_digest.clone());
696        let Supersession { retired, successor } = supersession;
697        self.receipts
698            .insert(successor.receipt_digest.clone(), successor);
699        self.receipts
700            .insert(retired.receipt_digest.clone(), retired);
701        Ok(())
702    }
703
704    /// Effective standing of an admitted receipt: SUPERSEDED once retired,
705    /// otherwise its own standing. `None` when not admitted.
706    pub fn standing_of(&self, receipt_digest: &str) -> Option<ArchitectureStanding> {
707        let receipt = self.receipts.get(receipt_digest)?;
708        if self.retired_by.contains_key(receipt_digest) {
709            Some(ArchitectureStanding::Superseded)
710        } else {
711            Some(receipt.standing)
712        }
713    }
714
715    /// Receipts currently QUALIFIED for `abb_digest` (not retired, not
716    /// retirement records), ordered by receipt digest.
717    pub fn current_qualified(&self, abb_digest: &str) -> Vec<&ArchitectureQualificationReceipt> {
718        self.receipts
719            .values()
720            .filter(|r| {
721                r.abb_digest == abb_digest
722                    && r.standing == ArchitectureStanding::Qualified
723                    && !self.retired_by.contains_key(&r.receipt_digest)
724            })
725            .collect()
726    }
727
728    /// Machine-readable answer to "which SBBs currently stand QUALIFIED for
729    /// this ABB, and which were superseded".
730    pub fn query_json(&self, abb_digest: &str) -> String {
731        let current: Vec<serde_json::Value> = self
732            .current_qualified(abb_digest)
733            .into_iter()
734            .map(|r| {
735                serde_json::json!({
736                    "sbb_digest": r.sbb_digest,
737                    "contract_digest": r.contract_digest,
738                    "exact_subject_digest": r.exact_subject_digest,
739                    "receipt_digest": r.receipt_digest,
740                    "prior_receipt_digest": r.prior_receipt_digest,
741                })
742            })
743            .collect();
744        let superseded: Vec<serde_json::Value> = self
745            .receipts
746            .values()
747            .filter(|r| {
748                r.abb_digest == abb_digest && r.standing == ArchitectureStanding::Superseded
749            })
750            .map(|r| {
751                serde_json::json!({
752                    "sbb_digest": r.sbb_digest,
753                    "retired_receipt_digest": r.prior_receipt_digest,
754                    "superseded_by_receipt_digest": r.superseded_by_receipt_digest,
755                    "record_digest": r.receipt_digest,
756                })
757            })
758            .collect();
759        serde_json::json!({
760            "schema": ARCHITECTURE_QUERY_SCHEMA,
761            "abb_digest": abb_digest,
762            "confers_do_authority": false,
763            "current_qualified": current,
764            "superseded": superseded,
765        })
766        .to_string()
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    fn receipt() -> ArchitectureQualificationReceipt {
775        ArchitectureQualificationReceipt::certify(
776            "sha256:abb",
777            "sha256:contract",
778            "sha256:sbb",
779            "sha256:subject",
780            vec!["sha256:evidence".into()],
781            "sha256:producer",
782            vec!["sha256:artifact".into()],
783            ArchitectureStanding::Qualified,
784        )
785        .unwrap()
786    }
787
788    #[test]
789    fn qualification_receipt_is_deterministic_and_non_authoritative() {
790        let first = receipt();
791        let second = receipt();
792        assert_eq!(first, second);
793        assert!(!first.confers_do_authority);
794        first
795            .verify_replay(
796                "sha256:abb",
797                "sha256:contract",
798                "sha256:sbb",
799                "sha256:subject",
800            )
801            .unwrap();
802    }
803
804    #[test]
805    fn changed_contract_sbb_and_cross_subject_reuse_are_refused() {
806        let receipt = receipt();
807
808        assert_eq!(
809            receipt.verify_replay(
810                "sha256:abb",
811                "sha256:changed",
812                "sha256:sbb",
813                "sha256:subject"
814            ),
815            Err(ArchitectureRefusal::MutableOrChangedContract)
816        );
817        assert_eq!(
818            receipt.verify_replay(
819                "sha256:abb",
820                "sha256:contract",
821                "sha256:changed",
822                "sha256:subject"
823            ),
824            Err(ArchitectureRefusal::MutableOrChangedSbb)
825        );
826        assert_eq!(
827            receipt.verify_replay(
828                "sha256:other",
829                "sha256:contract",
830                "sha256:sbb",
831                "sha256:other-subject"
832            ),
833            Err(ArchitectureRefusal::CrossSubjectReuse)
834        );
835    }
836
837    #[test]
838    fn unknown_and_missing_evidence_cannot_acquire_standing() {
839        assert_eq!(
840            ArchitectureQualificationReceipt::certify(
841                "sha256:abb",
842                "sha256:contract",
843                "sha256:sbb",
844                "sha256:subject",
845                vec!["sha256:evidence".into()],
846                "sha256:producer",
847                vec![],
848                ArchitectureStanding::Unknown
849            ),
850            Err(ArchitectureRefusal::UnknownPromotion)
851        );
852
853        assert_eq!(
854            ArchitectureQualificationReceipt::certify(
855                "sha256:abb",
856                "sha256:contract",
857                "sha256:sbb",
858                "sha256:subject",
859                vec![],
860                "sha256:producer",
861                vec![],
862                ArchitectureStanding::Qualified
863            ),
864            Err(ArchitectureRefusal::MissingEvidence)
865        );
866    }
867
868    #[test]
869    fn replacement_qualifies_successor_and_supersedes_the_prior() {
870        let current = receipt();
871        let s = current
872            .supersede(
873                "sha256:sbb-b",
874                "sha256:subject-b",
875                vec!["sha256:evidence-b".into()],
876            )
877            .unwrap();
878
879        assert_eq!(s.successor.standing, ArchitectureStanding::Qualified);
880        assert_eq!(s.successor.sbb_digest, "sha256:sbb-b");
881        assert_eq!(
882            s.successor.prior_receipt_digest.as_deref(),
883            Some(current.receipt_digest.as_str())
884        );
885        assert_eq!(s.retired.standing, ArchitectureStanding::Superseded);
886        assert_eq!(s.retired.sbb_digest, "sha256:sbb");
887        assert_eq!(
888            s.retired.superseded_by_receipt_digest.as_deref(),
889            Some(s.successor.receipt_digest.as_str())
890        );
891        assert!(!s.successor.confers_do_authority && !s.retired.confers_do_authority);
892        s.verify(&current).unwrap();
893    }
894}