Skip to main content

tatara_process/
receipt.rs

1//! `tatara-receipt/v1` — the typed receipt envelope every pleme-io Job
2//! emits to prove its work was done.
3//!
4//! Today's consumers (and the only ones supported on `tatara-receipt/v1`):
5//! - **closed-loop auth probes** — `kind = "closed-loop-auth"`. Stamps
6//!   that a system's bundled identity issuer authenticated its bundled
7//!   client. The substrate primitive every closed-loop-testable product
8//!   composes (Akeyless gator↔gateway, future: identity providers,
9//!   message brokers, databases that can issue creds to themselves).
10//! - **schema/migration runs** — `kind = "db-migration"`. shinka emits
11//!   one per applied migration; pillars carry the diff hash.
12//! - **test suites** — `kind = "test-suite"`. kenshi-runner et al.
13//! - **nix builds** — `kind = "nix-build"`. Carries the store-path
14//!   pillar as `artifact_hash`.
15//! - Anything else — operators register new `kind` strings; the
16//!   schema is open by design (the *shape* is fixed; the kind is data).
17//!
18//! Lives in `tatara-process` so `ReceiptEnvelope → ProcessAttestation`
19//! is a local typed bridge — the reconciler's verifier and any future
20//! Process consumer share one parse.
21//!
22//! Wire format (snake_case to match the existing ConfigMap payload
23//! shape the akeyless-closed-loop-probe chart writes):
24//!
25//! ```yaml
26//! version: tatara-receipt/v1
27//! kind: closed-loop-auth
28//! composed_root: <26-char hex>
29//! intent_hash:   <hex>
30//! artifact_hash: <hex>
31//! control_hash:  <hex>
32//! generated_at:  2026-05-19T22:00:00Z
33//! process_ref:   "akeyless-test/ephemeral-akeyless"   # optional
34//! evidence:      { ... }                              # optional, free-form
35//! ```
36
37use chrono::{DateTime, Utc};
38use schemars::JsonSchema;
39use serde::{Deserialize, Serialize};
40
41use crate::attestation::ProcessAttestation;
42
43/// Canonical version string. Bump → `tatara-receipt/v2` if the wire
44/// shape changes; parsers refuse anything else for the v1 reader.
45pub const RECEIPT_VERSION: &str = "tatara-receipt/v1";
46
47/// Closed-set typed identifier for the four known [`ReceiptEnvelope::kind`]
48/// strings the substrate emits today — [`Self::ClosedLoopAuth`] →
49/// `"closed-loop-auth"`, [`Self::DbMigration`] → `"db-migration"`,
50/// [`Self::TestSuite`] → `"test-suite"`, [`Self::NixBuild`] →
51/// `"nix-build"` — as a Rust enum, so the (variant, canonical kebab-case
52/// kind, semantic role) triple binds at ONE site on the typed algebra
53/// rather than at the four byte-identical string-literal sites scattered
54/// across the closed-loop probe binary (`default_value` on
55/// `--receipt-kind`), the reconciler's receipt-parser tests, the
56/// `ephemeral_pipeline` integration test, and the future shinka /
57/// kenshi / nix-build Job authors that compose `ReceiptEnvelope::build`.
58///
59/// Pre-lift the four canonical kebab-case kinds lived as `&'static str`
60/// literal arguments at every author site (`ReceiptEnvelope::build(
61/// "closed-loop-auth", …)`) AND as docstring prose at this module's
62/// header (`Today's consumers: closed-loop-auth, db-migration,
63/// test-suite, nix-build`). The (canonical-string, semantic-role)
64/// pairing was load-bearing across ≥5 files yet enforced by per-site
65/// call-site discipline — a rename of `"closed-loop-auth"` →
66/// `"closed-loop"` at the probe binary's CLI default (the originator of
67/// every production receipt) silently desynchronizes from the docstring
68/// prose AND from the reconciler's test fixtures AND from any future
69/// kind-keyed dispatch (e.g. shinka's per-kind verifier registry) — the
70/// `kind` field is a `String` from the wire shape's perspective so the
71/// compiler cannot bind the literals together. Post-lift the canonical
72/// kebab-case strings live at ONE [`Self::as_str`] arm per variant;
73/// every author site composes the typed variant through
74/// `ReceiptEnvelope::build(ReceiptKind::ClosedLoopAuth, …)` (the typed
75/// → `String` `From` impl lets the existing `impl Into<String>` API
76/// surface accept the variant transparently) and a rename lands at ONE
77/// `as_str` arm here — no per-call-site grep + edit sweep, no silent
78/// drift between the docstring header and the wire literals.
79///
80/// The `kind` field on [`ReceiptEnvelope`] remains a `String` because
81/// the schema is open by design: operators register new `kind` strings
82/// for future consumers (operator-domain Job receipts) without bumping
83/// the wire version. The typed `ReceiptKind` is the closed-set *view*
84/// over that open String — every receipt the substrate itself emits
85/// projects through one of the four typed variants, and the typed
86/// projection [`ReceiptEnvelope::known_kind`] decodes any envelope's
87/// `kind` into `Some(ReceiptKind)` when it matches a known variant,
88/// `None` for operator-registered open kinds. The (open-String,
89/// closed-typed-view) split is the same shape `tatara-lisp`'s
90/// `Sexp::Sym` (open atoms) vs `MacroDefHead` (closed-set head
91/// markers) takes — open data through one type, closed dispatch
92/// through another, no `_` fallthrough where the closed set runs.
93///
94/// Adding a fifth kind (e.g. `Provenance` → `"provenance-attest"`)
95/// extends the enum AND the two projection arms ([`Self::as_str`],
96/// [`Self::from_str`] via the [`Self::ALL`] sweep) in lockstep — rustc
97/// binds the extension through exhaustiveness over the closed enum so
98/// a partial extension that forgets ONE projection becomes a compile
99/// error rather than a runtime drift where the new kind builds receipts
100/// but `known_kind()` returns `None` and the future kind-keyed verifier
101/// dispatch silently falls through.
102///
103/// Sibling closed-set [`Self::ALL`] lift across the crate:
104/// [`crate::export::ReportFormat::ALL`],
105/// [`crate::export::ExportTrigger::ALL`],
106/// [`crate::export::ReportPayloadShape::ALL`],
107/// [`crate::phase::ProcessPhase::ALL`],
108/// [`crate::signal::ProcessSignal::ALL`],
109/// [`crate::boundary::ConditionKind::ALL`],
110/// [`crate::lifetime::TeardownPolicy::ALL`],
111/// [`crate::lifetime::LifetimeKind::ALL`],
112/// [`crate::intent::IntentKind::ALL`],
113/// [`crate::lifetime_clock::TerminateReasonKind::ALL`].
114///
115/// Theory anchor: THEORY.md §III — the typescape; the substrate's own
116/// receipt kinds become a TYPE rather than four `&'static str` literals
117/// at every author site and a docstring header that drifts the moment
118/// any rename happens off-script. THEORY.md §V.3 — three-pillar
119/// attestation; the `kind` field is the *what-am-I* discriminator on
120/// every receipt that chains into a [`ProcessAttestation`], and the
121/// typed variant is the substrate's shared vocabulary for "which kind
122/// of work just got attested" — pre-lift each call site had to spell
123/// the kind by hand, post-lift each call site composes the typed
124/// constant and any consumer (future verifier, future dashboard, future
125/// LSP completion) sweeps [`Self::ALL`] to enumerate every known
126/// substrate-emitted receipt without grep.
127#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
128#[closed_set(via = "as_str", display, generate_unknown)]
129pub enum ReceiptKind {
130    /// Closed-loop auth probe — stamps that a system's bundled identity
131    /// issuer authenticated its bundled client. Emitted by
132    /// `tatara-closed-loop-probe`; the substrate primitive every
133    /// closed-loop-testable product composes (Akeyless gator↔gateway,
134    /// future: identity providers, message brokers, databases that can
135    /// issue creds to themselves).
136    ClosedLoopAuth,
137    /// Schema/migration runs. shinka emits one per applied migration;
138    /// the pillars carry the diff hash so the chain shows exactly which
139    /// migration was applied where.
140    DbMigration,
141    /// Test suites — kenshi-runner et al. The `evidence` field carries
142    /// pass/fail counts; the pillars stamp the suite identity.
143    TestSuite,
144    /// Nix builds. Carries the store-path pillar as `artifact_hash`;
145    /// chains every reproducible build into the Process attestation
146    /// chain so a derivation's output is provable on its owning
147    /// Process.
148    NixBuild,
149}
150
151impl ReceiptKind {
152    /// The closed set of substrate-emitted receipt kinds — single
153    /// source of truth that drives the [`Self::from_str`] decode sweep
154    /// AND any future enumeration consumer (kind-keyed verifier
155    /// registry, dashboard completion list, `tatara-check` receipt-kind
156    /// enumeration). Adding a fifth variant (e.g. `Provenance` →
157    /// `"provenance-attest"`) lands at one `ALL` entry + one `as_str`
158    /// arm — exhaustively checked by the compiler (the `[Self; 4]`
159    /// array literal forces the arity) AND by the per-variant
160    /// truth-table tests below.
161    ///
162    /// Sibling closed-set lifts across the crate's typescape:
163    /// [`crate::export::ReportFormat::ALL`],
164    /// [`crate::phase::ProcessPhase::ALL`],
165    /// [`crate::boundary::ConditionKind::ALL`],
166    /// [`crate::intent::IntentKind::ALL`].
167    pub const ALL: [Self; 4] = [
168        Self::ClosedLoopAuth,
169        Self::DbMigration,
170        Self::TestSuite,
171        Self::NixBuild,
172    ];
173
174    /// Canonical kebab-case wire-format kind — the literal that lands
175    /// in [`ReceiptEnvelope::kind`] when this variant authors the
176    /// receipt. Pinned to four byte-exact strings the substrate has
177    /// already published (the closed-loop probe's `default_value` on
178    /// `--receipt-kind`, the reconciler tests' fixture builds, the
179    /// `ephemeral_pipeline` integration test's assertions) — renaming
180    /// any one is a wire-format change, not a typed-internal refactor,
181    /// and the `receipt_kind_canonical_names_pinned` truth-table test
182    /// fails first to keep the substrate honest. Used by
183    /// [`fmt::Display`] (single source of truth) and as the `String`
184    /// projection that `From<ReceiptKind> for String` ([`Self::into`])
185    /// composes so [`ReceiptEnvelope::build`]'s `impl Into<String>`
186    /// kind argument transparently accepts the typed variant.
187    #[must_use]
188    pub const fn as_str(self) -> &'static str {
189        match self {
190            Self::ClosedLoopAuth => "closed-loop-auth",
191            Self::DbMigration => "db-migration",
192            Self::TestSuite => "test-suite",
193            Self::NixBuild => "nix-build",
194        }
195    }
196}
197
198// `impl fmt::Display for ReceiptKind` + `impl FromStr for ReceiptKind`
199// + `impl tatara_lisp::ClosedSet for ReceiptKind` + `pub struct
200// UnknownReceiptKind(pub String)` are generated by
201// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
202// "as_str", display, generate_unknown)]` on the enum declaration above.
203// The auto-derived label `"receipt kind"` matches the prior hand-
204// rolled `#[error("unknown receipt kind: {0}")]` verbatim. The
205// inherent `as_str` projection stays load-bearing — the kebab-case
206// wire-format that matches `ReceiptEnvelope::kind`'s published literals
207// verbatim — while the trait method `label` gives generic consumers a
208// STABLE name across the workspace-wide closed-set implementors. The
209// open-by-design `ReceiptEnvelope::known_kind` projection routes the
210// `Err(UnknownReceiptKind)` arm into a `None` so operator-registered
211// open kinds stay open.
212
213impl From<ReceiptKind> for String {
214    /// Composes [`ReceiptKind::as_str`] into an owned `String` so
215    /// every `impl Into<String>` API surface ([`ReceiptEnvelope::build`]'s
216    /// `kind` parameter most notably) accepts the typed variant
217    /// transparently — the call site stays `build(kind, …)` and the
218    /// typed → wire bridge runs through ONE place.
219    fn from(k: ReceiptKind) -> Self {
220        k.as_str().to_owned()
221    }
222}
223
224impl From<ReceiptKind> for &'static str {
225    fn from(k: ReceiptKind) -> Self {
226        k.as_str()
227    }
228}
229
230/// Typed receipt envelope. Any Job in pleme-io that wants its result to
231/// chain into a Process's `status.attestation` writes one of these.
232#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
233#[serde(rename_all = "snake_case", deny_unknown_fields)]
234pub struct ReceiptEnvelope {
235    /// Must equal `RECEIPT_VERSION`. Mismatches reject the receipt.
236    pub version: String,
237    /// What this receipt proves. Known: `closed-loop-auth`, `db-migration`,
238    /// `test-suite`, `nix-build`. Operators may register new kinds —
239    /// the envelope is open.
240    pub kind: String,
241    /// Three-pillar root: `BLAKE3(domain ++ artifact ++ control ++ intent ++ previous)`.
242    pub composed_root: String,
243    /// Pillar 1: what the Job was *trying* to do (canonical intent).
244    pub intent_hash: String,
245    /// Pillar 2: what the Job *produced* (artifact / proof material).
246    pub artifact_hash: String,
247    /// Pillar 3: how the Job *verified* its work (controls / signatures /
248    /// auth steps). Empty string when there was no control step.
249    pub control_hash: String,
250    /// Timestamp the Job set when it wrote the receipt.
251    pub generated_at: DateTime<Utc>,
252    /// Optional owning-Process reference (`namespace/name`). When the
253    /// reconciler creates the Job it stamps this in via the downward
254    /// API; receipts without it still parse for ad-hoc / out-of-cluster
255    /// runs.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub process_ref: Option<String>,
258    /// Optional structured evidence. Free-form JSON. The reconciler does
259    /// not parse this — it's for human / downstream-tool inspection.
260    #[serde(default, skip_serializing_if = "is_null")]
261    pub evidence: serde_json::Value,
262}
263
264fn is_null(v: &serde_json::Value) -> bool {
265    v.is_null()
266}
267
268/// Why a receipt is rejected. Kept as a typed enum so callers can
269/// pattern-match on the failure mode and surface targeted operator
270/// messages.
271#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
272pub enum ReceiptError {
273    #[error("invalid JSON: {0}")]
274    InvalidJson(String),
275    #[error("invalid YAML: {0}")]
276    InvalidYaml(String),
277    #[error("version != {RECEIPT_VERSION} (got {0:?})")]
278    WrongVersion(String),
279    #[error("missing required field: {0}")]
280    MissingField(&'static str),
281    #[error("kind is empty")]
282    EmptyKind,
283    #[error("composed_root mismatch (got {got}, want {want})")]
284    RootMismatch { got: String, want: String },
285}
286
287impl ReceiptEnvelope {
288    /// Build a receipt envelope from typed pillars + kind. `generated_at`
289    /// defaults to `Utc::now()`.
290    pub fn build(
291        kind: impl Into<String>,
292        intent_hash: impl Into<String>,
293        artifact_hash: impl Into<String>,
294        control_hash: impl Into<String>,
295        previous_root: Option<&str>,
296    ) -> Self {
297        let intent_hash = intent_hash.into();
298        let artifact_hash = artifact_hash.into();
299        let control_hash = control_hash.into();
300        let composed_root = compose_root(
301            &artifact_hash,
302            if control_hash.is_empty() {
303                None
304            } else {
305                Some(control_hash.as_str())
306            },
307            &intent_hash,
308            previous_root,
309        );
310        Self {
311            version: RECEIPT_VERSION.into(),
312            kind: kind.into(),
313            composed_root,
314            intent_hash,
315            artifact_hash,
316            control_hash,
317            generated_at: Utc::now(),
318            process_ref: None,
319            evidence: serde_json::Value::Null,
320        }
321    }
322
323    /// Parse a receipt from a JSON string.
324    pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
325        let env: Self =
326            serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))?;
327        env.verify_shape()?;
328        Ok(env)
329    }
330
331    /// Parse a receipt from a YAML string. Useful for ConfigMaps that
332    /// store the payload in YAML form.
333    pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
334        let env: Self =
335            serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))?;
336        env.verify_shape()?;
337        Ok(env)
338    }
339
340    /// Parse via JSON first, then YAML if JSON fails. Lets a single
341    /// reader accept either wire form without the operator having to
342    /// declare it. Useful when the Job writes JSON and the reconciler
343    /// reads back through a kube DynamicObject whose `data` is YAML.
344    pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
345        match Self::parse_json(payload) {
346            Ok(env) => Ok(env),
347            Err(_) => Self::parse_yaml(payload),
348        }
349    }
350
351    /// Verify the schema-level invariants: correct version + non-empty
352    /// kind + non-empty pillar hashes (length-only, not BLAKE3-recompute).
353    pub fn verify_shape(&self) -> Result<(), ReceiptError> {
354        if self.version != RECEIPT_VERSION {
355            return Err(ReceiptError::WrongVersion(self.version.clone()));
356        }
357        if self.kind.is_empty() {
358            return Err(ReceiptError::EmptyKind);
359        }
360        if self.composed_root.is_empty() {
361            return Err(ReceiptError::MissingField("composed_root"));
362        }
363        if self.intent_hash.is_empty() {
364            return Err(ReceiptError::MissingField("intent_hash"));
365        }
366        if self.artifact_hash.is_empty() {
367            return Err(ReceiptError::MissingField("artifact_hash"));
368        }
369        // control_hash MAY be empty when there is no control step;
370        // the BLAKE3 compose treats empty as "absent" via Option.
371        Ok(())
372    }
373
374    /// Verify that `composed_root` is consistent with the pillars.
375    /// `expected_previous_root` is the previous root in the Process's
376    /// attestation chain (or `None` for first attestation).
377    pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
378        let want = compose_root(
379            &self.artifact_hash,
380            if self.control_hash.is_empty() {
381                None
382            } else {
383                Some(self.control_hash.as_str())
384            },
385            &self.intent_hash,
386            expected_previous_root,
387        );
388        constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
389    }
390
391    /// Strict-equality check against an operator-provided expected root.
392    /// Returns the receipt's root unchanged on success.
393    pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
394        if let Some(want) = expected {
395            if want != self.composed_root {
396                return Err(ReceiptError::RootMismatch {
397                    got: self.composed_root.clone(),
398                    want: want.to_string(),
399                });
400            }
401        }
402        Ok(&self.composed_root)
403    }
404
405    /// Decode `self.kind` into the typed [`ReceiptKind`] variant when
406    /// the wire string matches one of the four substrate-emitted
407    /// canonical kebab-case kinds; `None` when the kind is an
408    /// operator-registered open string (the schema is open by design —
409    /// every receipt remains a valid receipt, but only typed kinds
410    /// participate in closed-set dispatch). The (open `String`,
411    /// closed-typed view) split lets future kind-keyed consumers
412    /// (verifier registries, dashboard completion, audit-trail
413    /// classifiers) sweep the typed variants without touching the
414    /// open-by-design wire shape. Lifted as the canonical decode site
415    /// so no consumer re-implements the `match self.kind.as_str()`
416    /// arm-by-arm — the closed-set sweep happens through
417    /// [`ReceiptKind::from_str`] at ONE site.
418    #[must_use]
419    pub fn known_kind(&self) -> Option<ReceiptKind> {
420        self.kind.parse().ok()
421    }
422
423    /// Lower into a `ProcessAttestation` — the canonical handoff so a
424    /// Job's typed receipt becomes evidence on a Process. `generation`
425    /// + `previous_root` come from the owning Process's prior
426    /// attestation (or 0 + None for the first cycle).
427    pub fn to_attestation(
428        &self,
429        generation: u64,
430        previous_root: Option<&str>,
431    ) -> ProcessAttestation {
432        ProcessAttestation::compose(
433            self.artifact_hash.clone(),
434            if self.control_hash.is_empty() {
435                None
436            } else {
437                Some(self.control_hash.clone())
438            },
439            self.intent_hash.clone(),
440            previous_root.map(String::from),
441            generation,
442        )
443    }
444}
445
446const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
447
448/// Same composition as `ProcessAttestation::composed_hex` — kept local so
449/// `tatara_process::receipt::compose_root(...)` is a single line in
450/// downstream code without re-importing the attestation module.
451fn compose_root(
452    artifact: &str,
453    control: Option<&str>,
454    intent: &str,
455    previous: Option<&str>,
456) -> String {
457    let mut h = blake3::Hasher::new();
458    h.update(DOMAIN_TAG);
459    h.update(artifact.as_bytes());
460    h.update(b"\n");
461    h.update(control.unwrap_or("").as_bytes());
462    h.update(b"\n");
463    h.update(intent.as_bytes());
464    h.update(b"\n");
465    h.update(previous.unwrap_or("").as_bytes());
466    hex::encode(h.finalize().as_bytes())
467}
468
469fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
470    if a.len() != b.len() {
471        return false;
472    }
473    let mut acc: u8 = 0;
474    for (x, y) in a.iter().zip(b.iter()) {
475        acc |= x ^ y;
476    }
477    acc == 0
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn sample_payload() -> &'static str {
485        // Composed_root precomputed from compose_root("bbbb", Some("cccc"), "aaaa", None)
486        // (recomputed at test time to be canonical; this string is regenerated
487        // if the domain tag ever changes).
488        r#"{
489            "version": "tatara-receipt/v1",
490            "kind": "closed-loop-auth",
491            "composed_root": "RECOMPUTE",
492            "intent_hash":   "aaaa",
493            "artifact_hash": "bbbb",
494            "control_hash":  "cccc",
495            "generated_at":  "2026-05-19T12:00:00Z"
496        }"#
497    }
498
499    fn canonical_payload_json() -> String {
500        let root = compose_root("bbbb", Some("cccc"), "aaaa", None);
501        sample_payload().replace("RECOMPUTE", &root)
502    }
503
504    #[test]
505    fn build_produces_valid_envelope() {
506        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
507        assert_eq!(r.version, RECEIPT_VERSION);
508        assert_eq!(r.kind, "test-suite");
509        assert!(r.verify_shape().is_ok());
510        assert!(r.verify_root(None));
511    }
512
513    #[test]
514    fn build_empty_control_omits_from_root() {
515        let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
516        let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
517        assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
518
519        // And differs from a receipt with a real control hash.
520        let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
521        assert_ne!(with_empty.composed_root, with_control.composed_root);
522    }
523
524    #[test]
525    fn parse_json_round_trip() {
526        let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
527        assert_eq!(r.kind, "closed-loop-auth");
528        assert!(r.verify_root(None));
529    }
530
531    #[test]
532    fn parse_yaml_round_trip() {
533        let yaml = r#"
534version: tatara-receipt/v1
535kind: db-migration
536composed_root: ROOT
537intent_hash:   aaaa
538artifact_hash: bbbb
539control_hash:  cccc
540generated_at:  2026-05-19T12:00:00Z
541"#
542        .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
543        let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
544        assert_eq!(r.kind, "db-migration");
545        assert!(r.verify_root(None));
546    }
547
548    #[test]
549    fn parse_either_falls_back_to_yaml() {
550        let yaml = r#"
551version: tatara-receipt/v1
552kind: test-suite
553composed_root: ROOT
554intent_hash:   aaaa
555artifact_hash: bbbb
556control_hash:  cccc
557generated_at:  2026-05-19T12:00:00Z
558"#
559        .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
560        assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
561    }
562
563    #[test]
564    fn wrong_version_rejected() {
565        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
566        env["version"] = "tatara-receipt/v2".into();
567        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
568        assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
569    }
570
571    #[test]
572    fn missing_field_rejected() {
573        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
574        env.as_object_mut().unwrap().remove("intent_hash");
575        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
576        assert!(matches!(err, ReceiptError::InvalidJson(_)));
577    }
578
579    #[test]
580    fn unknown_field_rejected() {
581        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
582        env["forged_extra"] = "should-fail".into();
583        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
584        assert!(matches!(err, ReceiptError::InvalidJson(_)));
585    }
586
587    #[test]
588    fn empty_kind_rejected_in_verify_shape() {
589        let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
590        r.kind = String::new();
591        assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
592    }
593
594    #[test]
595    fn expect_root_matches_or_mismatches() {
596        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
597        let root = r.composed_root.clone();
598        assert!(r.expect_root(Some(&root)).is_ok());
599        let err = r.expect_root(Some("nope")).unwrap_err();
600        assert!(matches!(err, ReceiptError::RootMismatch { .. }));
601        assert!(r.expect_root(None).is_ok());
602    }
603
604    #[test]
605    fn lower_to_attestation_chains_pillars() {
606        let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
607        let a = r.to_attestation(0, None);
608        assert_eq!(a.intent_hash, "i");
609        assert_eq!(a.artifact_hash, "a");
610        assert_eq!(a.control_hash.as_deref(), Some("c"));
611        // Both compose the same root.
612        assert_eq!(a.composed_root, r.composed_root);
613        assert!(a.verify());
614
615        let next = r.to_attestation(1, Some(&a.composed_root));
616        assert_eq!(next.generation, 1);
617        assert_eq!(
618            next.previous_root.as_deref(),
619            Some(a.composed_root.as_str())
620        );
621        // The composed_root differs because previous_root is included.
622        assert_ne!(next.composed_root, a.composed_root);
623    }
624
625    #[test]
626    fn verify_root_detects_tamper() {
627        let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
628        assert!(r.verify_root(None));
629        r.intent_hash = "tampered".into();
630        assert!(!r.verify_root(None));
631    }
632
633    #[test]
634    fn process_ref_optional_and_round_trips() {
635        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
636        r.process_ref = Some("akeyless-test/ephemeral".into());
637        let s = serde_json::to_string(&r).unwrap();
638        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
639        assert_eq!(back.process_ref.as_deref(), Some("akeyless-test/ephemeral"));
640    }
641
642    #[test]
643    fn evidence_round_trips() {
644        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
645        r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
646        let s = serde_json::to_string(&r).unwrap();
647        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
648        assert_eq!(back.evidence["passed"], 12);
649    }
650
651    // ── ReceiptKind closed-set truth-table ───────────────────────────
652
653    /// Structural well-formedness of [`ReceiptKind`] as a
654    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
655    /// testkit lift that pins all three structural invariants (`ALL`
656    /// is non-empty, every variant round-trips through
657    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
658    /// outside the closed set) at ONE call site. Replaces the hand-
659    /// derived `receipt_kind_all_enumerates_each_variant_exactly_once`
660    /// + `receipt_kind_from_str_round_trips_canonical_names` + the
661    /// empty-input arm of `receipt_kind_from_str_rejects_open_kinds`.
662    /// `FromStr` delegates to
663    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
664    /// exercises the same code path operators hit when parsing a wire
665    /// `kind` field back to the typed kind.
666    #[test]
667    fn receipt_kind_is_well_formed_closed_set() {
668        tatara_closed_set::assert_closed_set_well_formed::<ReceiptKind>();
669    }
670
671    #[test]
672    fn receipt_kind_canonical_names_pinned() {
673        // Byte-exact wire-format pin — renaming any of these is a
674        // wire-format change, not a typed-internal refactor.
675        assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
676        assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
677        assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
678        assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
679    }
680
681    #[test]
682    fn receipt_kind_from_str_rejects_open_kinds() {
683        // Future / typo / wrong-case all surface a typed
684        // UnknownReceiptKind carrying the offending input verbatim
685        // (operator-facing diagnostic); the schema is open at the
686        // wire layer, but the closed-set view is byte-exact. The
687        // empty-input arm is pinned by
688        // [`receipt_kind_is_well_formed_closed_set`] via the
689        // `tatara_lisp::ClosedSet` testkit; the cases here pin the
690        // verbatim-echo contract on the [`UnknownReceiptKind`] newtype,
691        // which the trait's `make_unknown` can't see.
692        for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
693            let err = bad.parse::<ReceiptKind>().unwrap_err();
694            assert_eq!(err, UnknownReceiptKind(bad.to_string()));
695        }
696    }
697
698    #[test]
699    fn receipt_kind_display_delegates_to_as_str() {
700        for k in ReceiptKind::ALL {
701            assert_eq!(format!("{k}"), k.as_str());
702        }
703    }
704
705    #[test]
706    fn receipt_kind_into_string_matches_as_str() {
707        for k in ReceiptKind::ALL {
708            let s: String = k.into();
709            assert_eq!(s, k.as_str());
710        }
711    }
712
713    #[test]
714    fn build_accepts_typed_receipt_kind() {
715        // The typed → wire bridge: `build(ReceiptKind::X, …)` produces
716        // a receipt whose `kind` field is exactly `X.as_str()`.
717        for k in ReceiptKind::ALL {
718            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
719            assert_eq!(env.kind, k.as_str());
720            assert!(env.verify_shape().is_ok());
721            assert!(env.verify_root(None));
722        }
723    }
724
725    #[test]
726    fn known_kind_decodes_built_receipts() {
727        for k in ReceiptKind::ALL {
728            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
729            assert_eq!(env.known_kind(), Some(k));
730        }
731    }
732
733    #[test]
734    fn known_kind_returns_none_for_open_kinds() {
735        // Open-by-design: a custom operator-registered kind still
736        // parses, still verifies, and still attests — it just doesn't
737        // project through the closed-set typed view.
738        let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
739        assert_eq!(env.known_kind(), None);
740        assert!(
741            env.verify_shape().is_ok(),
742            "open kind must remain a valid receipt"
743        );
744    }
745}