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 (an issuer↔client pair, 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 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:   "demo-test/ephemeral-demo"   # 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 (an issuer↔client pair,
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            empty_to_none(&control_hash),
303            &intent_hash,
304            previous_root,
305        );
306        Self {
307            version: RECEIPT_VERSION.into(),
308            kind: kind.into(),
309            composed_root,
310            intent_hash,
311            artifact_hash,
312            control_hash,
313            generated_at: Utc::now(),
314            process_ref: None,
315            evidence: serde_json::Value::Null,
316        }
317    }
318
319    /// Parse a receipt from a JSON string.
320    pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
321        let env: Self =
322            serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))?;
323        env.verify_shape()?;
324        Ok(env)
325    }
326
327    /// Parse a receipt from a YAML string. Useful for ConfigMaps that
328    /// store the payload in YAML form.
329    pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
330        let env: Self =
331            serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))?;
332        env.verify_shape()?;
333        Ok(env)
334    }
335
336    /// Parse via JSON first, then YAML if JSON fails. Lets a single
337    /// reader accept either wire form without the operator having to
338    /// declare it. Useful when the Job writes JSON and the reconciler
339    /// reads back through a kube DynamicObject whose `data` is YAML.
340    pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
341        match Self::parse_json(payload) {
342            Ok(env) => Ok(env),
343            Err(_) => Self::parse_yaml(payload),
344        }
345    }
346
347    /// Verify the schema-level invariants: correct version + non-empty
348    /// kind + non-empty pillar hashes (length-only, not BLAKE3-recompute).
349    pub fn verify_shape(&self) -> Result<(), ReceiptError> {
350        if self.version != RECEIPT_VERSION {
351            return Err(ReceiptError::WrongVersion(self.version.clone()));
352        }
353        if self.kind.is_empty() {
354            return Err(ReceiptError::EmptyKind);
355        }
356        if self.composed_root.is_empty() {
357            return Err(ReceiptError::MissingField("composed_root"));
358        }
359        if self.intent_hash.is_empty() {
360            return Err(ReceiptError::MissingField("intent_hash"));
361        }
362        if self.artifact_hash.is_empty() {
363            return Err(ReceiptError::MissingField("artifact_hash"));
364        }
365        // control_hash MAY be empty when there is no control step;
366        // the BLAKE3 compose treats empty as "absent" via Option.
367        Ok(())
368    }
369
370    /// Verify that `composed_root` is consistent with the pillars.
371    /// `expected_previous_root` is the previous root in the Process's
372    /// attestation chain (or `None` for first attestation).
373    pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
374        let want = compose_root(
375            &self.artifact_hash,
376            self.control_hash_opt(),
377            &self.intent_hash,
378            expected_previous_root,
379        );
380        constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
381    }
382
383    /// Strict-equality check against an operator-provided expected root.
384    /// Returns the receipt's root unchanged on success.
385    pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
386        if let Some(want) = expected {
387            if want != self.composed_root {
388                return Err(ReceiptError::RootMismatch {
389                    got: self.composed_root.clone(),
390                    want: want.to_string(),
391                });
392            }
393        }
394        Ok(&self.composed_root)
395    }
396
397    /// Decode `self.kind` into the typed [`ReceiptKind`] variant when
398    /// the wire string matches one of the four substrate-emitted
399    /// canonical kebab-case kinds; `None` when the kind is an
400    /// operator-registered open string (the schema is open by design —
401    /// every receipt remains a valid receipt, but only typed kinds
402    /// participate in closed-set dispatch). The (open `String`,
403    /// closed-typed view) split lets future kind-keyed consumers
404    /// (verifier registries, dashboard completion, audit-trail
405    /// classifiers) sweep the typed variants without touching the
406    /// open-by-design wire shape. Lifted as the canonical decode site
407    /// so no consumer re-implements the `match self.kind.as_str()`
408    /// arm-by-arm — the closed-set sweep happens through
409    /// [`ReceiptKind::from_str`] at ONE site.
410    #[must_use]
411    pub fn known_kind(&self) -> Option<ReceiptKind> {
412        self.kind.parse().ok()
413    }
414
415    /// Lower into a `ProcessAttestation` — the canonical handoff so a
416    /// Job's typed receipt becomes evidence on a Process. `generation`
417    /// + `previous_root` come from the owning Process's prior
418    /// attestation (or 0 + None for the first cycle).
419    pub fn to_attestation(
420        &self,
421        generation: u64,
422        previous_root: Option<&str>,
423    ) -> ProcessAttestation {
424        ProcessAttestation::compose(
425            self.artifact_hash.clone(),
426            self.control_hash_opt().map(str::to_owned),
427            self.intent_hash.clone(),
428            previous_root.map(String::from),
429            generation,
430        )
431    }
432
433    /// Typed projection of the wire form's `control_hash` field —
434    /// `Some(hash)` when a control step ran, `None` when it did not.
435    ///
436    /// The wire form stamps `control_hash: String` (schema-open,
437    /// serde-friendly), but the substrate's `compose_root` +
438    /// `ProcessAttestation::compose` compositions both take an
439    /// `Option<&str>` / `Option<String>` and thread `None` through the
440    /// exact BLAKE3 bytes pattern an absent-pillar walk emits — an
441    /// empty `control_hash` and an absent-pillar receipt hash to the
442    /// SAME `composed_root`. That "empty means absent" convention
443    /// pre-lift lived at THREE sites inside this impl block —
444    /// [`Self::build`] (constructing the envelope from typed pillars),
445    /// [`Self::verify_root`] (recomposing the root against pillars for
446    /// wire-form verification), and [`Self::to_attestation`] (lowering
447    /// the receipt into a [`ProcessAttestation`] on a Process's
448    /// attestation chain) — each hand-writing the SAME
449    /// `if self.control_hash.is_empty() { None } else {
450    /// Some(self.control_hash.as_str()) }` two-arm conditional. Post-
451    /// lift the convention lives at ONE method here; the three
452    /// consumers each compose a ONE-LINE call:
453    ///   * `verify_root` → `self.control_hash_opt()` directly,
454    ///   * `to_attestation` → `self.control_hash_opt().map(str::to_owned)`
455    ///     for the `Option<String>` shape [`ProcessAttestation::compose`]
456    ///     binds,
457    ///   * `build` (which reads a local `control_hash: String` before
458    ///     the envelope is constructed) → the free-fn peer
459    ///     [`empty_to_none`] on the same borrowed string.
460    ///
461    /// Public because the projection is load-bearing operator-facing
462    /// contract: an authoring surface (an LSP hover, a
463    /// `tatara-check` report, a REPL `:receipt-inspect` command) that
464    /// wants to render "no control step" vs. "control_hash: <hash>"
465    /// binds to this method rather than pattern-matching on
466    /// `self.control_hash.is_empty()` at its own call site — a future
467    /// re-shape of the empty-means-absent convention (a sentinel-
468    /// string variant, an explicit `Option<String>` on the wire form
469    /// once the schema evolves, or a typed
470    /// `ControlStep::{Ran(hash), Skipped}` enum) lands at ONE method
471    /// here rather than at every consumer that inspects the pillar.
472    ///
473    /// Theory anchor: THEORY.md §V.1 — knowable platform; the
474    /// wire-vs-typed projection lives at ONE substrate method so a
475    /// consumer reads the pillar's typed-Option contract from the
476    /// receipt directly, not from three parallel inline conditionals
477    /// scattered across `build` / `verify_root` / `to_attestation`.
478    /// THEORY.md §VI.1 — generation over composition; the
479    /// `is_empty() ? None : Some(&self.control_hash)` two-arm
480    /// projection recurred at THREE inline sites past the ★★
481    /// PRIME-DIRECTIVE ≥ 2 duplication threshold and is lifted to ONE
482    /// owner here. THEORY.md §V.3 — three-pillar attestation; the
483    /// receipt's second pillar (control step) has ONE typed projection
484    /// site the composition primitives ([`compose_root`],
485    /// [`ProcessAttestation::compose`]) both bind against, so the
486    /// pillar's wire-vs-typed identity cannot drift across the three
487    /// consumers.
488    #[must_use]
489    pub fn control_hash_opt(&self) -> Option<&str> {
490        empty_to_none(&self.control_hash)
491    }
492}
493
494/// Project a wire-form pillar string onto its typed `Option<&str>`
495/// contract — `Some(s)` when `s` is non-empty, `None` when `s` is
496/// empty (the substrate's "no such pillar" convention that
497/// [`compose_root`] + [`ProcessAttestation::compose`] both thread as
498/// an absent-pillar walk through the BLAKE3 domain-tagged
499/// composition).
500///
501/// The free-fn peer of [`ReceiptEnvelope::control_hash_opt`] for
502/// call sites that hold a borrowed pillar string BEFORE a
503/// [`ReceiptEnvelope`] is constructed — namely
504/// [`ReceiptEnvelope::build`]'s inline `compose_root` call, which
505/// composes the pillar's typed-Option identity from the local
506/// `control_hash: String` intake before the envelope value exists.
507/// The two peers share ONE projection body (`(!s.is_empty()).
508/// then_some(s)`) so a future re-shape of the empty-means-absent
509/// convention (a sentinel-string variant, an explicit
510/// `Option<String>` on the wire form once the schema evolves)
511/// lands at ONE substrate primitive rather than at both the
512/// inherent method and its pre-construction free-fn peer.
513///
514/// Theory anchor: THEORY.md §VI.1 — generation over composition;
515/// the pre-construction peer of the pillar projection lives at ONE
516/// substrate primitive alongside the post-construction inherent
517/// method, so the two receipt-lifecycle stages (pre-envelope in
518/// [`ReceiptEnvelope::build`], post-envelope in every other
519/// consumer) share ONE typed projection.
520fn empty_to_none(s: &str) -> Option<&str> {
521    (!s.is_empty()).then_some(s)
522}
523
524const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
525
526/// Same composition as `ProcessAttestation::composed_hex` — kept local so
527/// `tatara_process::receipt::compose_root(...)` is a single line in
528/// downstream code without re-importing the attestation module.
529fn compose_root(
530    artifact: &str,
531    control: Option<&str>,
532    intent: &str,
533    previous: Option<&str>,
534) -> String {
535    let mut h = blake3::Hasher::new();
536    h.update(DOMAIN_TAG);
537    h.update(artifact.as_bytes());
538    h.update(b"\n");
539    h.update(control.unwrap_or("").as_bytes());
540    h.update(b"\n");
541    h.update(intent.as_bytes());
542    h.update(b"\n");
543    h.update(previous.unwrap_or("").as_bytes());
544    hex::encode(h.finalize().as_bytes())
545}
546
547fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
548    if a.len() != b.len() {
549        return false;
550    }
551    let mut acc: u8 = 0;
552    for (x, y) in a.iter().zip(b.iter()) {
553        acc |= x ^ y;
554    }
555    acc == 0
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn sample_payload() -> &'static str {
563        // Composed_root precomputed from compose_root("bbbb", Some("cccc"), "aaaa", None)
564        // (recomputed at test time to be canonical; this string is regenerated
565        // if the domain tag ever changes).
566        r#"{
567            "version": "tatara-receipt/v1",
568            "kind": "closed-loop-auth",
569            "composed_root": "RECOMPUTE",
570            "intent_hash":   "aaaa",
571            "artifact_hash": "bbbb",
572            "control_hash":  "cccc",
573            "generated_at":  "2026-05-19T12:00:00Z"
574        }"#
575    }
576
577    fn canonical_payload_json() -> String {
578        let root = compose_root("bbbb", Some("cccc"), "aaaa", None);
579        sample_payload().replace("RECOMPUTE", &root)
580    }
581
582    #[test]
583    fn build_produces_valid_envelope() {
584        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
585        assert_eq!(r.version, RECEIPT_VERSION);
586        assert_eq!(r.kind, "test-suite");
587        assert!(r.verify_shape().is_ok());
588        assert!(r.verify_root(None));
589    }
590
591    #[test]
592    fn build_empty_control_omits_from_root() {
593        let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
594        let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
595        assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
596
597        // And differs from a receipt with a real control hash.
598        let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
599        assert_ne!(with_empty.composed_root, with_control.composed_root);
600    }
601
602    #[test]
603    fn parse_json_round_trip() {
604        let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
605        assert_eq!(r.kind, "closed-loop-auth");
606        assert!(r.verify_root(None));
607    }
608
609    #[test]
610    fn parse_yaml_round_trip() {
611        let yaml = r#"
612version: tatara-receipt/v1
613kind: db-migration
614composed_root: ROOT
615intent_hash:   aaaa
616artifact_hash: bbbb
617control_hash:  cccc
618generated_at:  2026-05-19T12:00:00Z
619"#
620        .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
621        let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
622        assert_eq!(r.kind, "db-migration");
623        assert!(r.verify_root(None));
624    }
625
626    #[test]
627    fn parse_either_falls_back_to_yaml() {
628        let yaml = r#"
629version: tatara-receipt/v1
630kind: test-suite
631composed_root: ROOT
632intent_hash:   aaaa
633artifact_hash: bbbb
634control_hash:  cccc
635generated_at:  2026-05-19T12:00:00Z
636"#
637        .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
638        assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
639    }
640
641    #[test]
642    fn wrong_version_rejected() {
643        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
644        env["version"] = "tatara-receipt/v2".into();
645        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
646        assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
647    }
648
649    #[test]
650    fn missing_field_rejected() {
651        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
652        env.as_object_mut().unwrap().remove("intent_hash");
653        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
654        assert!(matches!(err, ReceiptError::InvalidJson(_)));
655    }
656
657    #[test]
658    fn unknown_field_rejected() {
659        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
660        env["forged_extra"] = "should-fail".into();
661        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
662        assert!(matches!(err, ReceiptError::InvalidJson(_)));
663    }
664
665    #[test]
666    fn empty_kind_rejected_in_verify_shape() {
667        let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
668        r.kind = String::new();
669        assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
670    }
671
672    #[test]
673    fn expect_root_matches_or_mismatches() {
674        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
675        let root = r.composed_root.clone();
676        assert!(r.expect_root(Some(&root)).is_ok());
677        let err = r.expect_root(Some("nope")).unwrap_err();
678        assert!(matches!(err, ReceiptError::RootMismatch { .. }));
679        assert!(r.expect_root(None).is_ok());
680    }
681
682    #[test]
683    fn lower_to_attestation_chains_pillars() {
684        let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
685        let a = r.to_attestation(0, None);
686        assert_eq!(a.intent_hash, "i");
687        assert_eq!(a.artifact_hash, "a");
688        assert_eq!(a.control_hash.as_deref(), Some("c"));
689        // Both compose the same root.
690        assert_eq!(a.composed_root, r.composed_root);
691        assert!(a.verify());
692
693        let next = r.to_attestation(1, Some(&a.composed_root));
694        assert_eq!(next.generation, 1);
695        assert_eq!(
696            next.previous_root.as_deref(),
697            Some(a.composed_root.as_str())
698        );
699        // The composed_root differs because previous_root is included.
700        assert_ne!(next.composed_root, a.composed_root);
701    }
702
703    #[test]
704    fn verify_root_detects_tamper() {
705        let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
706        assert!(r.verify_root(None));
707        r.intent_hash = "tampered".into();
708        assert!(!r.verify_root(None));
709    }
710
711    #[test]
712    fn process_ref_optional_and_round_trips() {
713        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
714        r.process_ref = Some("demo-test/ephemeral".into());
715        let s = serde_json::to_string(&r).unwrap();
716        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
717        assert_eq!(back.process_ref.as_deref(), Some("demo-test/ephemeral"));
718    }
719
720    #[test]
721    fn evidence_round_trips() {
722        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
723        r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
724        let s = serde_json::to_string(&r).unwrap();
725        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
726        assert_eq!(back.evidence["passed"], 12);
727    }
728
729    // ── ReceiptKind closed-set truth-table ───────────────────────────
730
731    /// Structural well-formedness of [`ReceiptKind`] as a
732    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
733    /// testkit lift that pins all three structural invariants (`ALL`
734    /// is non-empty, every variant round-trips through
735    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
736    /// outside the closed set) at ONE call site. Replaces the hand-
737    /// derived `receipt_kind_all_enumerates_each_variant_exactly_once`
738    /// + `receipt_kind_from_str_round_trips_canonical_names` + the
739    /// empty-input arm of `receipt_kind_from_str_rejects_open_kinds`.
740    /// `FromStr` delegates to
741    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
742    /// exercises the same code path operators hit when parsing a wire
743    /// `kind` field back to the typed kind.
744    #[test]
745    fn receipt_kind_is_well_formed_closed_set() {
746        tatara_closed_set::assert_closed_set_well_formed::<ReceiptKind>();
747    }
748
749    #[test]
750    fn receipt_kind_canonical_names_pinned() {
751        // Byte-exact wire-format pin — renaming any of these is a
752        // wire-format change, not a typed-internal refactor.
753        assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
754        assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
755        assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
756        assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
757    }
758
759    #[test]
760    fn receipt_kind_from_str_rejects_open_kinds() {
761        // Future / typo / wrong-case all surface a typed
762        // UnknownReceiptKind carrying the offending input verbatim
763        // (operator-facing diagnostic); the schema is open at the
764        // wire layer, but the closed-set view is byte-exact. The
765        // empty-input arm is pinned by
766        // [`receipt_kind_is_well_formed_closed_set`] via the
767        // `tatara_lisp::ClosedSet` testkit; the cases here pin the
768        // verbatim-echo contract on the [`UnknownReceiptKind`] newtype,
769        // which the trait's `make_unknown` can't see.
770        for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
771            let err = bad.parse::<ReceiptKind>().unwrap_err();
772            assert_eq!(err, UnknownReceiptKind(bad.to_string()));
773        }
774    }
775
776    #[test]
777    fn receipt_kind_display_delegates_to_as_str() {
778        for k in ReceiptKind::ALL {
779            assert_eq!(format!("{k}"), k.as_str());
780        }
781    }
782
783    #[test]
784    fn receipt_kind_into_string_matches_as_str() {
785        for k in ReceiptKind::ALL {
786            let s: String = k.into();
787            assert_eq!(s, k.as_str());
788        }
789    }
790
791    #[test]
792    fn build_accepts_typed_receipt_kind() {
793        // The typed → wire bridge: `build(ReceiptKind::X, …)` produces
794        // a receipt whose `kind` field is exactly `X.as_str()`.
795        for k in ReceiptKind::ALL {
796            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
797            assert_eq!(env.kind, k.as_str());
798            assert!(env.verify_shape().is_ok());
799            assert!(env.verify_root(None));
800        }
801    }
802
803    #[test]
804    fn known_kind_decodes_built_receipts() {
805        for k in ReceiptKind::ALL {
806            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
807            assert_eq!(env.known_kind(), Some(k));
808        }
809    }
810
811    #[test]
812    fn known_kind_returns_none_for_open_kinds() {
813        // Open-by-design: a custom operator-registered kind still
814        // parses, still verifies, and still attests — it just doesn't
815        // project through the closed-set typed view.
816        let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
817        assert_eq!(env.known_kind(), None);
818        assert!(
819            env.verify_shape().is_ok(),
820            "open kind must remain a valid receipt"
821        );
822    }
823
824    // ── `control_hash_opt` / `empty_to_none` — the wire-form-to-typed
825    //    projection of the second pillar (control step). Pre-lift the
826    //    `is_empty() ? None : Some(&self.control_hash)` two-arm
827    //    conditional lived at THREE inline sites — `build`,
828    //    `verify_root`, `to_attestation` — each hand-writing the SAME
829    //    projection with slightly-different ownership shapes
830    //    (`Option<&str>` for the two composers, `Option<String>` for
831    //    the attestation composer). Post-lift the projection lives at
832    //    ONE inherent method + ONE free-fn peer for the pre-envelope
833    //    call site. The tests below pin the substrate primitive's
834    //    contract at its boundary so a regression at the projection
835    //    surfaces here rather than as a silent `composed_root` drift
836    //    at every consumer that composes the pillar.
837
838    #[test]
839    fn empty_to_none_projects_empty_to_none_and_non_empty_to_some_verbatim() {
840        // The pre-envelope free-fn peer of `control_hash_opt` — used
841        // by `build` before the envelope exists. Pin BOTH arms of the
842        // projection: an empty string projects to `None` (the "no
843        // such pillar" convention that `compose_root` threads through
844        // the absent-pillar BLAKE3 bytes pattern), and any non-empty
845        // string projects to `Some(s)` byte-identical to the input.
846        // A regression that (a) inverted the arms (folding `""` to
847        // `Some("")` and every non-empty into `None`), (b) normalized
848        // the payload (trimming whitespace, lowercasing hex), or (c)
849        // introduced a sentinel-string special case (`"none"`, `"-"`,
850        // etc.) would surface here rather than as a silent
851        // `composed_root` shift at every consumer that composes the
852        // pillar.
853        assert_eq!(super::empty_to_none(""), None);
854        assert_eq!(super::empty_to_none("c"), Some("c"));
855        assert_eq!(super::empty_to_none("cccc"), Some("cccc"));
856        // A whitespace-only string is NOT empty by the pillar's typed
857        // contract — the substrate composes bytes verbatim through
858        // BLAKE3, so a `" "` control hash IS a distinct pillar from
859        // an absent one; the projection must preserve that
860        // distinction.
861        assert_eq!(super::empty_to_none(" "), Some(" "));
862    }
863
864    #[test]
865    fn control_hash_opt_matches_the_free_fn_peer_on_every_receipt() {
866        // Post-envelope inherent method routes through the same
867        // `empty_to_none` free-fn body — pin the equivalence across
868        // both arms so a future regression that split the two
869        // projections (e.g. the inherent method starts trimming, the
870        // free-fn stays byte-verbatim) surfaces here rather than as a
871        // `composed_root` mismatch between `build` (uses the free-fn
872        // peer) and `verify_root` / `to_attestation` (use the
873        // inherent method).
874        let with_control = ReceiptEnvelope::build("test-suite", "i", "a", "cccc", None);
875        assert_eq!(with_control.control_hash_opt(), Some("cccc"));
876        assert_eq!(
877            with_control.control_hash_opt(),
878            super::empty_to_none(&with_control.control_hash),
879        );
880
881        let no_control = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
882        assert_eq!(no_control.control_hash_opt(), None);
883        assert_eq!(
884            no_control.control_hash_opt(),
885            super::empty_to_none(&no_control.control_hash),
886        );
887    }
888
889    #[test]
890    fn control_hash_opt_composes_the_same_root_the_three_consumers_bind() {
891        // End-to-end pin at the receipt-lifecycle boundary — the
892        // three consumers (`build`, `verify_root`, `to_attestation`)
893        // must land on the SAME `composed_root` for a given pillar
894        // tuple regardless of which projection body they route
895        // through. Sweeps BOTH pillar arms (present control + empty
896        // control) so a regression that mis-wired ONE consumer to
897        // the pre-lift inline conditional or that changed the
898        // projection at ONE site surfaces here rather than as a
899        // silent divergence between `verify_root`'s decision and
900        // `to_attestation`'s written `composed_root`.
901        for control in ["", "control-hash-cccc"] {
902            let env = ReceiptEnvelope::build("test-suite", "i", "a", control, None);
903            // `verify_root` composes through the inherent method AND
904            // through the same `compose_root(&artifact, control_opt,
905            // &intent, previous)` skeleton `build` binds — so the
906            // envelope must verify against its own composed root.
907            assert!(
908                env.verify_root(None),
909                "verify_root failed for control={control:?}",
910            );
911            // `to_attestation` composes the same pillar tuple through
912            // `ProcessAttestation::compose`'s `Option<String>` shape;
913            // the attestation's `composed_root` must match the
914            // envelope's `composed_root` byte-for-byte because both
915            // compose the SAME BLAKE3 domain-tagged skeleton over
916            // the SAME typed-Option pillar identity.
917            let att = env.to_attestation(0, None);
918            assert_eq!(
919                att.composed_root, env.composed_root,
920                "attestation root drift for control={control:?}",
921            );
922        }
923    }
924}