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/// Suffix appended to a Job's name to compose its default receipt-
48/// ConfigMap name. The substrate convention is that any Job which
49/// emits a [`ReceiptEnvelope`] writes it to a ConfigMap in the Job's
50/// own namespace whose name is `<job_name>-receipt` unless the caller
51/// supplies an explicit override.
52///
53/// Load-bearing at three shipped derivation sites, each of which
54/// re-composed the `<name>-receipt` shape by hand pre-lift:
55/// - `tatara_reconciler::boundary::evaluate_job_attested` — the
56///   `JobAttested` postcondition's default `receiptConfigMap`
57///   (`<parsed.name>-receipt`);
58/// - `tatara_reconciler::boundary::evaluate_closed_loop_auth` — the
59///   `ClosedLoopAuth` postcondition's default `receiptConfigMap`
60///   (`<probe_job_name>-receipt`, where the probe Job itself
61///   defaults to `<process_name>-closed-loop-probe`);
62/// - `tatara_reconciler::render::export_receipt_configmap_name` — the
63///   export-worker Job's per-index receipt ConfigMap
64///   (`<process_name>-export-<index>-receipt`), which is
65///   structurally `<export_job_name(process_name, index)>-receipt`.
66///
67/// Pre-lift each site restated the suffix as a `format!` literal
68/// (`format!("{}-receipt", parsed.name)`,
69/// `format!("{job_name}-receipt")`, and
70/// `format!("{process_name}-export-{index}-receipt")`). A rename to
71/// `-attest` or a scheme change to `.receipt-cm` would have needed a
72/// grep-and-replace across the three production sites AND a
73/// coordinated update of every fleet-shipped operator override in the
74/// closed-loop-probe chart and the reconciler's own tests.
75/// Post-lift the suffix lives at ONE const on the receipt module;
76/// [`default_receipt_config_map_name`] composes it with a Job name;
77/// every default-derivation site AND the export-worker composer
78/// route through the same primitive so a future suffix change lands
79/// at this ONE const and every consumer picks it up mechanically.
80///
81/// Sibling suffix-const on the substrate: [`RECEIPT_VERSION`] pins
82/// the wire-format version string every parser gates on; this const
83/// pins the wire-K8s-name suffix every default-derivation site
84/// composes. Both are load-bearing constants that operators grep and
85/// dashboards template on — neither may drift from its published
86/// spelling silently.
87///
88/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
89/// `<job_name>-receipt` shape recurred at three production sites past
90/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold and is lifted to
91/// ONE substrate const + composer here. THEORY.md §III — the
92/// typescape; the substrate's own receipt-CM naming convention
93/// becomes a NAMED PRIMITIVE rather than a shape spelled out by hand
94/// at every derivation site.
95pub const RECEIPT_CM_SUFFIX: &str = "-receipt";
96
97/// Compose the canonical receipt-ConfigMap name for a Job named
98/// `job_name` — the substrate's default when no explicit
99/// `receiptConfigMap` override is supplied on a postcondition's
100/// params or when a renderer builds a Job whose receipt CM name is
101/// derived from the Job's own name.
102///
103/// Returns `<job_name>{RECEIPT_CM_SUFFIX}`. See [`RECEIPT_CM_SUFFIX`]
104/// for the full lift rationale and the three consumer sites the
105/// primitive owns.
106///
107/// Uses string concatenation rather than `format!` so the composition
108/// does not participate in the workspace's typed-emission ban migration
109/// (skip-format-ban CLAUDE.md note); the shape is fixed at
110/// `<job_name>` ++ [`RECEIPT_CM_SUFFIX`] and any future two-arg
111/// composer (e.g. `default_receipt_config_map_name_scoped(cluster,
112/// job)`) extends the primitive here, not the consumer sites.
113#[must_use]
114pub fn default_receipt_config_map_name(job_name: &str) -> String {
115    let mut out = String::with_capacity(job_name.len() + RECEIPT_CM_SUFFIX.len());
116    out.push_str(job_name);
117    out.push_str(RECEIPT_CM_SUFFIX);
118    out
119}
120
121/// Canonical `data` key on a `receipt`-carrying ConfigMap for the JSON
122/// wire form of a [`ReceiptEnvelope`] — the substrate's PRIMARY payload
123/// key. Peer to [`RECEIPT_YAML_KEY`] on the same wire-form axis;
124/// [`RECEIPT_CM_KEYS`] fixes the primary-first ordering the reader-side
125/// lookup gate binds to.
126///
127/// Load-bearing at four shipped production sites — two on the writer
128/// axis (`tatara-closed-loop-probe`'s per-run receipt-CM emit inserts
129/// BOTH keys so operators can `kubectl get cm -o yaml` and read the
130/// receipt without re-parsing the embedded JSON) and two on the reader
131/// axis (`tatara-reconciler::boundary::verify_receipt_cm`'s `data`-map
132/// lookup gate reads the primary FIRST, then falls back to the YAML
133/// twin). Pre-lift each side restated the two `&'static str` literals
134/// verbatim — the writer inserted `"receipt.json"` + `"receipt.yaml"`
135/// as inline `String` allocations, the reader chained
136/// `.and_then(|d| d.get("receipt.json")).or_else(|| … "receipt.yaml"))`
137/// as inline lookup literals — with NO shared owner binding the two
138/// keys' spelling OR the primary-first ordering that the reader-side
139/// gate encodes as a load-bearing invariant. A rename at ONE writer
140/// key or ONE reader key silently desynchronizes the twin (a probe
141/// writing `"receipt.jsonl"` while the reader still gates on
142/// `"receipt.json"` → the postcondition MALFORMED-reads a ConfigMap
143/// that carries a valid receipt at a drifted key); a swap of the
144/// primary/fallback ordering at the reader silently promotes YAML
145/// over the operator-canonical JSON form.
146///
147/// Post-lift the two keys live at ONE substrate-owned pair of
148/// constants; [`RECEIPT_CM_KEYS`] pins the primary-first ordering the
149/// reader-side lookup gate iterates through
150/// ([`extract_receipt_payload_json`] composes the gate); every writer
151/// insert AND every reader lookup routes through ONE substrate owner,
152/// so a future rename (e.g. `"receipt.jsonl"` on a NDJSON schema
153/// variant, `"receipt.cbor"` on a binary-form variant) OR a
154/// primary/fallback swap lands at ONE substrate site and every writer
155/// and reader picks up the change mechanically — the two-side drift
156/// trap becomes unrepresentable at the type / value binding.
157///
158/// The reader's primary-first ordering is the substrate's convention:
159/// the JSON form is the machine-canonical wire (the closed-loop probe
160/// emits `serde_json::to_string(envelope)` as the source-of-truth
161/// payload), the YAML twin is the operator-facing readable projection
162/// (`serde_yaml::to_string(envelope)`) — both round-trip through the
163/// SAME [`ReceiptEnvelope::parse_either`] parser, so the primary/fallback
164/// ordering is a payload-format preference, not a semantic distinction.
165/// A future third wire form (CBOR, MessagePack, sigstore-signed JSON)
166/// extends [`RECEIPT_CM_KEYS`] in the primary-first order the readers
167/// prefer, and rustc's `[…; N]` arity constant on the type binds the
168/// extension in lockstep with every consumer.
169///
170/// Sibling substrate-owned wire-form const on the same receipt axis:
171/// [`RECEIPT_CM_SUFFIX`] pins the ConfigMap-name suffix every
172/// default-derivation site composes; [`RECEIPT_VERSION`] pins the
173/// wire-format version string every parser gates on; the three
174/// consts together define the substrate's receipt-CM wire contract at
175/// ONE surface.
176///
177/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
178/// two payload-key literals recurred at FOUR production sites past
179/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold and are lifted
180/// to ONE substrate-owned pair of constants + ONE reader-side
181/// lookup-gate composer here. THEORY.md §V.1 — knowable platform;
182/// the (primary, fallback) ordering that the reader-side gate encodes
183/// becomes a NAMED PRIMITIVE ([`RECEIPT_CM_KEYS`]) rather than an
184/// implicit hand-coded chain reader consumers had to grok from a
185/// `.and_then(...).or_else(...)` pattern.
186pub const RECEIPT_JSON_KEY: &str = "receipt.json";
187
188/// Canonical `data` key on a `receipt`-carrying ConfigMap for the YAML
189/// wire form of a [`ReceiptEnvelope`] — the substrate's operator-facing
190/// FALLBACK payload key. Peer to [`RECEIPT_JSON_KEY`] on the same
191/// wire-form axis; [`RECEIPT_CM_KEYS`] fixes the primary-first ordering
192/// that pins the JSON form ahead of the YAML twin.
193///
194/// See [`RECEIPT_JSON_KEY`] for the full lift rationale and the four
195/// consumer sites this pair owns. The YAML form is the readable
196/// operator projection (`serde_yaml::to_string(envelope)`) the closed-
197/// loop probe writes alongside the JSON form so `kubectl get cm -o
198/// yaml` returns a human-readable payload without an inner JSON
199/// re-parse; the reader accepts it as a fallback when the JSON form is
200/// absent (older probe binaries, out-of-cluster hand-written receipts,
201/// operator-hand-written test fixtures).
202pub const RECEIPT_YAML_KEY: &str = "receipt.yaml";
203
204/// The primary-first closed-set of `data` keys the substrate's
205/// receipt-CM readers look up in order. [`RECEIPT_JSON_KEY`] takes
206/// precedence over [`RECEIPT_YAML_KEY`] — every reader-side lookup
207/// gate ([`extract_receipt_payload_json`] composes the canonical gate)
208/// iterates this table and returns the first hit as `&str`; every
209/// writer emits BOTH entries so the primary-first ordering the reader
210/// prefers matches the JSON form the probe wrote as its source-of-truth
211/// payload.
212///
213/// See [`RECEIPT_JSON_KEY`] for the full lift rationale and the four
214/// consumer sites this table owns. Sibling closed-set tables across
215/// the crate: [`ReceiptEnvelope::REQUIRED_PILLARS`], [`ReceiptKind::ALL`],
216/// [`crate::export::ReportFormat::ALL`], [`crate::phase::ProcessPhase::ALL`],
217/// [`crate::boundary::ConditionKind::ALL`], [`crate::intent::IntentKind::ALL`].
218pub const RECEIPT_CM_KEYS: [&str; 2] = [RECEIPT_JSON_KEY, RECEIPT_YAML_KEY];
219
220/// Operator-facing diagnostic message the substrate's receipt-CM
221/// reader-side lookup gate returns when neither [`RECEIPT_JSON_KEY`]
222/// nor [`RECEIPT_YAML_KEY`] is present as a string-valued entry on the
223/// ConfigMap's `data` map. Named through the substrate so the message
224/// stays coherent with [`RECEIPT_CM_KEYS`] — a rename at either key
225/// const would leave this message spelling the pre-rename literals
226/// verbatim, so both this const AND the two key consts live at ONE
227/// substrate site and any future re-shape sweeps them together.
228///
229/// Composed as a `&'static` byte-literal (not a `format!(...)`) so the
230/// composition does not participate in the workspace's typed-emission
231/// ban migration (skip-format-ban CLAUDE.md note); the shape is fixed
232/// at the two key literals' current spellings.
233pub const RECEIPT_CM_MISSING_KEY_MSG: &str =
234    "ConfigMap missing data['receipt.json' | 'receipt.yaml'] string key";
235
236/// Primary-first reader-side lookup gate for a receipt-CM's `data`
237/// map — the substrate primitive that owns the (primary, fallback)
238/// key-precedence chain every receipt-CM reader threads through.
239///
240/// Iterates [`RECEIPT_CM_KEYS`] in order and returns the first entry
241/// present as a string-valued JSON scalar; returns `None` when the
242/// `data` map is absent, when neither key is present, or when the
243/// entry at either key is a non-string JSON value (a JSON number,
244/// object, or array — none of which are a valid receipt-payload
245/// projection under the wire contract).
246///
247/// Load-bearing at ONE production reader-side site pre-lift
248/// (`tatara-reconciler::boundary::verify_receipt_cm`) whose 3-link
249/// `.and_then(|d| d.get(RECEIPT_JSON_KEY)).or_else(|| … RECEIPT_YAML_KEY
250/// )).and_then(|v| v.as_str())` chain restated the SAME two-key-
251/// with-string-scalar-projection shape as inline combinator plumbing
252/// — a shape the substrate now owns at ONE place so a future third
253/// wire form (a CBOR encoding, a sigstore-signed JSON form, a
254/// per-cluster payload rename) extends this primitive in lockstep
255/// with the [`RECEIPT_CM_KEYS`] table AND every reader consumer
256/// picks up the change mechanically. Sibling reader consumers
257/// (kenshi-runner's P3 test-suite receipt readback, shinka's per-
258/// migration receipt readback, any future per-Job attestation
259/// verifier) compose this ONE substrate primitive rather than
260/// re-authoring the primary/fallback chain per-consumer.
261///
262/// Lifetime: the returned `&str` borrows from the passed-in
263/// `serde_json::Value` — the reader's `data` reference must outlive
264/// the returned payload borrow. Every production reader-side consumer
265/// already holds the DynamicObject that owns the `Value` graph across
266/// the parse call that consumes this borrow, so the lifetime binding
267/// composes cleanly.
268///
269/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
270/// (primary-key, fallback-key, string-scalar-projection) 3-link
271/// combinator chain recurred at ONE production reader site with the
272/// (primary, fallback) ordering as a load-bearing invariant nowhere
273/// bound at the substrate — post-lift ONE substrate primitive owns
274/// the chain AND [`RECEIPT_CM_KEYS`] pins the ordering the primitive
275/// iterates through. THEORY.md §V.1 — knowable platform; the reader-
276/// side (primary, fallback) precedence becomes a NAMED PRIMITIVE the
277/// receipt-inspection surfaces (LSP hover, `tatara-check` receipt-
278/// inspect report, REPL) bind to for reading the wire contract from
279/// the substrate directly.
280#[must_use]
281pub fn extract_receipt_payload_json(data: Option<&serde_json::Value>) -> Option<&str> {
282    for key in RECEIPT_CM_KEYS {
283        if let Some(v) = data
284            .and_then(|d| d.get(key))
285            .and_then(serde_json::Value::as_str)
286        {
287            return Some(v);
288        }
289    }
290    None
291}
292
293/// Closed-set typed identifier for the four known [`ReceiptEnvelope::kind`]
294/// strings the substrate emits today — [`Self::ClosedLoopAuth`] →
295/// `"closed-loop-auth"`, [`Self::DbMigration`] → `"db-migration"`,
296/// [`Self::TestSuite`] → `"test-suite"`, [`Self::NixBuild`] →
297/// `"nix-build"` — as a Rust enum, so the (variant, canonical kebab-case
298/// kind, semantic role) triple binds at ONE site on the typed algebra
299/// rather than at the four byte-identical string-literal sites scattered
300/// across the closed-loop probe binary (`default_value` on
301/// `--receipt-kind`), the reconciler's receipt-parser tests, the
302/// `ephemeral_pipeline` integration test, and the future shinka /
303/// kenshi / nix-build Job authors that compose `ReceiptEnvelope::build`.
304///
305/// Pre-lift the four canonical kebab-case kinds lived as `&'static str`
306/// literal arguments at every author site (`ReceiptEnvelope::build(
307/// "closed-loop-auth", …)`) AND as docstring prose at this module's
308/// header (`Today's consumers: closed-loop-auth, db-migration,
309/// test-suite, nix-build`). The (canonical-string, semantic-role)
310/// pairing was load-bearing across ≥5 files yet enforced by per-site
311/// call-site discipline — a rename of `"closed-loop-auth"` →
312/// `"closed-loop"` at the probe binary's CLI default (the originator of
313/// every production receipt) silently desynchronizes from the docstring
314/// prose AND from the reconciler's test fixtures AND from any future
315/// kind-keyed dispatch (e.g. shinka's per-kind verifier registry) — the
316/// `kind` field is a `String` from the wire shape's perspective so the
317/// compiler cannot bind the literals together. Post-lift the canonical
318/// kebab-case strings live at ONE [`Self::as_str`] arm per variant;
319/// every author site composes the typed variant through
320/// `ReceiptEnvelope::build(ReceiptKind::ClosedLoopAuth, …)` (the typed
321/// → `String` `From` impl lets the existing `impl Into<String>` API
322/// surface accept the variant transparently) and a rename lands at ONE
323/// `as_str` arm here — no per-call-site grep + edit sweep, no silent
324/// drift between the docstring header and the wire literals.
325///
326/// The `kind` field on [`ReceiptEnvelope`] remains a `String` because
327/// the schema is open by design: operators register new `kind` strings
328/// for future consumers (operator-domain Job receipts) without bumping
329/// the wire version. The typed `ReceiptKind` is the closed-set *view*
330/// over that open String — every receipt the substrate itself emits
331/// projects through one of the four typed variants, and the typed
332/// projection [`ReceiptEnvelope::known_kind`] decodes any envelope's
333/// `kind` into `Some(ReceiptKind)` when it matches a known variant,
334/// `None` for operator-registered open kinds. The (open-String,
335/// closed-typed-view) split is the same shape `tatara-lisp`'s
336/// `Sexp::Sym` (open atoms) vs `MacroDefHead` (closed-set head
337/// markers) takes — open data through one type, closed dispatch
338/// through another, no `_` fallthrough where the closed set runs.
339///
340/// Adding a fifth kind (e.g. `Provenance` → `"provenance-attest"`)
341/// extends the enum AND the two projection arms ([`Self::as_str`],
342/// [`Self::from_str`] via the [`Self::ALL`] sweep) in lockstep — rustc
343/// binds the extension through exhaustiveness over the closed enum so
344/// a partial extension that forgets ONE projection becomes a compile
345/// error rather than a runtime drift where the new kind builds receipts
346/// but `known_kind()` returns `None` and the future kind-keyed verifier
347/// dispatch silently falls through.
348///
349/// Sibling closed-set [`Self::ALL`] lift across the crate:
350/// [`crate::export::ReportFormat::ALL`],
351/// [`crate::export::ExportTrigger::ALL`],
352/// [`crate::export::ReportPayloadShape::ALL`],
353/// [`crate::phase::ProcessPhase::ALL`],
354/// [`crate::signal::ProcessSignal::ALL`],
355/// [`crate::boundary::ConditionKind::ALL`],
356/// [`crate::lifetime::TeardownPolicy::ALL`],
357/// [`crate::lifetime::LifetimeKind::ALL`],
358/// [`crate::intent::IntentKind::ALL`],
359/// [`crate::lifetime_clock::TerminateReasonKind::ALL`].
360///
361/// Theory anchor: THEORY.md §III — the typescape; the substrate's own
362/// receipt kinds become a TYPE rather than four `&'static str` literals
363/// at every author site and a docstring header that drifts the moment
364/// any rename happens off-script. THEORY.md §V.3 — three-pillar
365/// attestation; the `kind` field is the *what-am-I* discriminator on
366/// every receipt that chains into a [`ProcessAttestation`], and the
367/// typed variant is the substrate's shared vocabulary for "which kind
368/// of work just got attested" — pre-lift each call site had to spell
369/// the kind by hand, post-lift each call site composes the typed
370/// constant and any consumer (future verifier, future dashboard, future
371/// LSP completion) sweeps [`Self::ALL`] to enumerate every known
372/// substrate-emitted receipt without grep.
373#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
374#[closed_set(via = "as_str", display, generate_unknown)]
375pub enum ReceiptKind {
376    /// Closed-loop auth probe — stamps that a system's bundled identity
377    /// issuer authenticated its bundled client. Emitted by
378    /// `tatara-closed-loop-probe`; the substrate primitive every
379    /// closed-loop-testable product composes (an issuer↔client pair,
380    /// future: identity providers, message brokers, databases that can
381    /// issue creds to themselves).
382    ClosedLoopAuth,
383    /// Schema/migration runs. shinka emits one per applied migration;
384    /// the pillars carry the diff hash so the chain shows exactly which
385    /// migration was applied where.
386    DbMigration,
387    /// Test suites — kenshi-runner et al. The `evidence` field carries
388    /// pass/fail counts; the pillars stamp the suite identity.
389    TestSuite,
390    /// Nix builds. Carries the store-path pillar as `artifact_hash`;
391    /// chains every reproducible build into the Process attestation
392    /// chain so a derivation's output is provable on its owning
393    /// Process.
394    NixBuild,
395}
396
397impl ReceiptKind {
398    /// The closed set of substrate-emitted receipt kinds — single
399    /// source of truth that drives the [`Self::from_str`] decode sweep
400    /// AND any future enumeration consumer (kind-keyed verifier
401    /// registry, dashboard completion list, `tatara-check` receipt-kind
402    /// enumeration). Adding a fifth variant (e.g. `Provenance` →
403    /// `"provenance-attest"`) lands at one `ALL` entry + one `as_str`
404    /// arm — exhaustively checked by the compiler (the `[Self; 4]`
405    /// array literal forces the arity) AND by the per-variant
406    /// truth-table tests below.
407    ///
408    /// Sibling closed-set lifts across the crate's typescape:
409    /// [`crate::export::ReportFormat::ALL`],
410    /// [`crate::phase::ProcessPhase::ALL`],
411    /// [`crate::boundary::ConditionKind::ALL`],
412    /// [`crate::intent::IntentKind::ALL`].
413    pub const ALL: [Self; 4] = [
414        Self::ClosedLoopAuth,
415        Self::DbMigration,
416        Self::TestSuite,
417        Self::NixBuild,
418    ];
419
420    /// Canonical kebab-case wire-format kind — the literal that lands
421    /// in [`ReceiptEnvelope::kind`] when this variant authors the
422    /// receipt. Pinned to four byte-exact strings the substrate has
423    /// already published (the closed-loop probe's `default_value` on
424    /// `--receipt-kind`, the reconciler tests' fixture builds, the
425    /// `ephemeral_pipeline` integration test's assertions) — renaming
426    /// any one is a wire-format change, not a typed-internal refactor,
427    /// and the `receipt_kind_canonical_names_pinned` truth-table test
428    /// fails first to keep the substrate honest. Used by
429    /// [`fmt::Display`] (single source of truth) and as the `String`
430    /// projection that `From<ReceiptKind> for String` ([`Self::into`])
431    /// composes so [`ReceiptEnvelope::build`]'s `impl Into<String>`
432    /// kind argument transparently accepts the typed variant.
433    #[must_use]
434    pub const fn as_str(self) -> &'static str {
435        match self {
436            Self::ClosedLoopAuth => "closed-loop-auth",
437            Self::DbMigration => "db-migration",
438            Self::TestSuite => "test-suite",
439            Self::NixBuild => "nix-build",
440        }
441    }
442}
443
444// `impl fmt::Display for ReceiptKind` + `impl FromStr for ReceiptKind`
445// + `impl tatara_lisp::ClosedSet for ReceiptKind` + `pub struct
446// UnknownReceiptKind(pub String)` are generated by
447// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
448// "as_str", display, generate_unknown)]` on the enum declaration above.
449// The auto-derived label `"receipt kind"` matches the prior hand-
450// rolled `#[error("unknown receipt kind: {0}")]` verbatim. The
451// inherent `as_str` projection stays load-bearing — the kebab-case
452// wire-format that matches `ReceiptEnvelope::kind`'s published literals
453// verbatim — while the trait method `label` gives generic consumers a
454// STABLE name across the workspace-wide closed-set implementors. The
455// open-by-design `ReceiptEnvelope::known_kind` projection routes the
456// `Err(UnknownReceiptKind)` arm into a `None` so operator-registered
457// open kinds stay open.
458
459impl From<ReceiptKind> for String {
460    /// Composes [`ReceiptKind::as_str`] into an owned `String` so
461    /// every `impl Into<String>` API surface ([`ReceiptEnvelope::build`]'s
462    /// `kind` parameter most notably) accepts the typed variant
463    /// transparently — the call site stays `build(kind, …)` and the
464    /// typed → wire bridge runs through ONE place.
465    fn from(k: ReceiptKind) -> Self {
466        k.as_str().to_owned()
467    }
468}
469
470impl From<ReceiptKind> for &'static str {
471    fn from(k: ReceiptKind) -> Self {
472        k.as_str()
473    }
474}
475
476/// One entry in the [`ReceiptEnvelope::REQUIRED_PILLARS`] closed-set
477/// table — the pair (diagnostic field name, wire-form accessor) that
478/// composes ONE required-pillar rejection through the shared
479/// [`require_nonempty`] peer. The alias gives the tuple a nameable
480/// type so downstream consumers (`tatara-check` receipt-inspector, an
481/// LSP hover on the const, per-pillar dashboard columns) bind to
482/// "one pillar's descriptor" as a first-class handle rather than
483/// re-typing the underlying `(&'static str, fn(&ReceiptEnvelope) ->
484/// &str)` tuple at every consumer.
485pub type RequiredPillar = (&'static str, fn(&ReceiptEnvelope) -> &str);
486
487/// Typed receipt envelope. Any Job in pleme-io that wants its result to
488/// chain into a Process's `status.attestation` writes one of these.
489#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
490#[serde(rename_all = "snake_case", deny_unknown_fields)]
491pub struct ReceiptEnvelope {
492    /// Must equal `RECEIPT_VERSION`. Mismatches reject the receipt.
493    pub version: String,
494    /// What this receipt proves. Known: `closed-loop-auth`, `db-migration`,
495    /// `test-suite`, `nix-build`. Operators may register new kinds —
496    /// the envelope is open.
497    pub kind: String,
498    /// Three-pillar root: `BLAKE3(domain ++ artifact ++ control ++ intent ++ previous)`.
499    pub composed_root: String,
500    /// Pillar 1: what the Job was *trying* to do (canonical intent).
501    pub intent_hash: String,
502    /// Pillar 2: what the Job *produced* (artifact / proof material).
503    pub artifact_hash: String,
504    /// Pillar 3: how the Job *verified* its work (controls / signatures /
505    /// auth steps). Empty string when there was no control step.
506    pub control_hash: String,
507    /// Timestamp the Job set when it wrote the receipt.
508    pub generated_at: DateTime<Utc>,
509    /// Optional owning-Process reference (`namespace/name`). When the
510    /// reconciler creates the Job it stamps this in via the downward
511    /// API; receipts without it still parse for ad-hoc / out-of-cluster
512    /// runs.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub process_ref: Option<String>,
515    /// Optional structured evidence. Free-form JSON. The reconciler does
516    /// not parse this — it's for human / downstream-tool inspection.
517    #[serde(default, skip_serializing_if = "is_null")]
518    pub evidence: serde_json::Value,
519}
520
521fn is_null(v: &serde_json::Value) -> bool {
522    v.is_null()
523}
524
525/// Why a receipt is rejected. Kept as a typed enum so callers can
526/// pattern-match on the failure mode and surface targeted operator
527/// messages.
528#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
529pub enum ReceiptError {
530    #[error("invalid JSON: {0}")]
531    InvalidJson(String),
532    #[error("invalid YAML: {0}")]
533    InvalidYaml(String),
534    #[error("version != {RECEIPT_VERSION} (got {0:?})")]
535    WrongVersion(String),
536    #[error("missing required field: {0}")]
537    MissingField(&'static str),
538    #[error("kind is empty")]
539    EmptyKind,
540    #[error("composed_root mismatch (got {got}, want {want})")]
541    RootMismatch { got: String, want: String },
542}
543
544impl ReceiptEnvelope {
545    /// Build a receipt envelope from typed pillars + kind. `generated_at`
546    /// defaults to `Utc::now()`.
547    pub fn build(
548        kind: impl Into<String>,
549        intent_hash: impl Into<String>,
550        artifact_hash: impl Into<String>,
551        control_hash: impl Into<String>,
552        previous_root: Option<&str>,
553    ) -> Self {
554        let intent_hash = intent_hash.into();
555        let artifact_hash = artifact_hash.into();
556        let control_hash = control_hash.into();
557        let composed_root = compose_root(
558            &artifact_hash,
559            empty_to_none(&control_hash),
560            &intent_hash,
561            previous_root,
562        );
563        Self {
564            version: RECEIPT_VERSION.into(),
565            kind: kind.into(),
566            composed_root,
567            intent_hash,
568            artifact_hash,
569            control_hash,
570            generated_at: Utc::now(),
571            process_ref: None,
572            evidence: serde_json::Value::Null,
573        }
574    }
575
576    /// Parse a receipt from a JSON string.
577    pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
578        let env: Self =
579            serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))?;
580        env.verify_shape()?;
581        Ok(env)
582    }
583
584    /// Parse a receipt from a YAML string. Useful for ConfigMaps that
585    /// store the payload in YAML form.
586    pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
587        let env: Self =
588            serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))?;
589        env.verify_shape()?;
590        Ok(env)
591    }
592
593    /// Parse via JSON first, then YAML if JSON fails. Lets a single
594    /// reader accept either wire form without the operator having to
595    /// declare it. Useful when the Job writes JSON and the reconciler
596    /// reads back through a kube DynamicObject whose `data` is YAML.
597    pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
598        match Self::parse_json(payload) {
599            Ok(env) => Ok(env),
600            Err(_) => Self::parse_yaml(payload),
601        }
602    }
603
604    /// Closed-set table of pillars that MUST be non-empty on every
605    /// well-formed receipt — the wire-form's structural invariant
606    /// [`Self::verify_shape`] enforces. Pre-lift the three checks
607    /// lived as three byte-identical `if self.<pillar>.is_empty() {
608    /// return Err(ReceiptError::MissingField("<pillar>")); }` two-arm
609    /// conditionals inline in `verify_shape` — one per pillar name,
610    /// each hand-writing the SAME (field-name, accessor, rejection)
611    /// triple with the pillar name repeated at BOTH the accessor
612    /// (`self.composed_root`) AND the diagnostic literal
613    /// (`"composed_root"`). Post-lift the three (field-name,
614    /// accessor) pairs live at ONE closed-set table here;
615    /// `verify_shape` composes ONE per-entry iteration that
616    /// dispatches through the shared [`require_nonempty`] free-fn
617    /// peer of [`empty_to_none`].
618    ///
619    /// Each entry is a [`RequiredPillar`] tuple whose named type gives
620    /// downstream consumers (a `tatara-check` receipt-inspector, an
621    /// LSP hover, a per-pillar dashboard column) a nameable handle
622    /// for "one pillar's (diagnostic-name, wire-form-accessor)
623    /// pairing" rather than an unnamed function-pointer tuple
624    /// re-typed at every consumer.
625    ///
626    /// The `control_hash` field is DELIBERATELY NOT in this table:
627    /// the substrate's second pillar carries an "empty means absent"
628    /// convention that [`Self::control_hash_opt`] + [`empty_to_none`]
629    /// project as a typed `Option::None`, so its emptiness is a
630    /// semantic bit rather than a validation failure. The pair
631    /// (`REQUIRED_PILLARS` — must be non-empty; `control_hash_opt` —
632    /// may be empty) is the substrate's typed answer to which
633    /// pillars are load-bearing vs. schema-optional. A future
634    /// re-shape that promotes a fourth required pillar (e.g. a
635    /// mandatory `signer_hash` on a signed-receipt schema variant)
636    /// lands as ONE new entry in this table + rustc's `[…; N]`
637    /// arity constant on the type binding the extension in lockstep
638    /// so a partial addition that forgets the diagnostic surface
639    /// becomes a compile error rather than a runtime drift.
640    ///
641    /// Sibling closed-set tables across the crate:
642    /// [`ReceiptKind::ALL`],
643    /// [`crate::export::ReportFormat::ALL`],
644    /// [`crate::phase::ProcessPhase::ALL`],
645    /// [`crate::boundary::ConditionKind::ALL`],
646    /// [`crate::intent::IntentKind::ALL`].
647    ///
648    /// Theory anchor: THEORY.md §VI.1 — generation over composition;
649    /// the three inline pillar-emptiness checks recurred at THREE
650    /// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
651    /// and are lifted to ONE closed-set table + ONE shared rejection
652    /// peer. THEORY.md §V.1 — knowable platform; the enumeration of
653    /// required-pillar field names lives at ONE surface a
654    /// documentation surface, an LSP hover, or a `tatara-check`
655    /// receipt-inspector binds to for enumerating the receipt's
656    /// structural invariants. THEORY.md §V.3 — three-pillar
657    /// attestation; the (mandatory, may-be-absent) split of the
658    /// three-pillar-plus-composed-root wire form is a typed
659    /// substrate contract, not a per-consumer discipline.
660    pub const REQUIRED_PILLARS: [RequiredPillar; 3] = [
661        ("composed_root", |e| e.composed_root.as_str()),
662        ("intent_hash", |e| e.intent_hash.as_str()),
663        ("artifact_hash", |e| e.artifact_hash.as_str()),
664    ];
665
666    /// Verify the schema-level invariants: correct version + non-empty
667    /// kind + non-empty pillar hashes (length-only, not BLAKE3-recompute).
668    /// The three required-pillar rejections dispatch through
669    /// [`Self::REQUIRED_PILLARS`] + [`require_nonempty`] so a future
670    /// fourth required pillar lands at ONE table entry rather than
671    /// as a fourth inline `if …is_empty() { return Err(…); }` copy.
672    pub fn verify_shape(&self) -> Result<(), ReceiptError> {
673        if self.version != RECEIPT_VERSION {
674            return Err(ReceiptError::WrongVersion(self.version.clone()));
675        }
676        if self.kind.is_empty() {
677            return Err(ReceiptError::EmptyKind);
678        }
679        for (field, accessor) in Self::REQUIRED_PILLARS {
680            require_nonempty(field, accessor(self))?;
681        }
682        // control_hash MAY be empty when there is no control step;
683        // the BLAKE3 compose treats empty as "absent" via Option —
684        // see `Self::control_hash_opt` + `empty_to_none`.
685        Ok(())
686    }
687
688    /// Verify that `composed_root` is consistent with the pillars.
689    /// `expected_previous_root` is the previous root in the Process's
690    /// attestation chain (or `None` for first attestation).
691    pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
692        let want = compose_root(
693            &self.artifact_hash,
694            self.control_hash_opt(),
695            &self.intent_hash,
696            expected_previous_root,
697        );
698        constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
699    }
700
701    /// Strict-equality check against an operator-provided expected root.
702    /// Returns the receipt's root unchanged on success.
703    pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
704        if let Some(want) = expected {
705            if want != self.composed_root {
706                return Err(ReceiptError::RootMismatch {
707                    got: self.composed_root.clone(),
708                    want: want.to_string(),
709                });
710            }
711        }
712        Ok(&self.composed_root)
713    }
714
715    /// Decode `self.kind` into the typed [`ReceiptKind`] variant when
716    /// the wire string matches one of the four substrate-emitted
717    /// canonical kebab-case kinds; `None` when the kind is an
718    /// operator-registered open string (the schema is open by design —
719    /// every receipt remains a valid receipt, but only typed kinds
720    /// participate in closed-set dispatch). The (open `String`,
721    /// closed-typed view) split lets future kind-keyed consumers
722    /// (verifier registries, dashboard completion, audit-trail
723    /// classifiers) sweep the typed variants without touching the
724    /// open-by-design wire shape. Lifted as the canonical decode site
725    /// so no consumer re-implements the `match self.kind.as_str()`
726    /// arm-by-arm — the closed-set sweep happens through
727    /// [`ReceiptKind::from_str`] at ONE site.
728    #[must_use]
729    pub fn known_kind(&self) -> Option<ReceiptKind> {
730        self.kind.parse().ok()
731    }
732
733    /// Lower into a `ProcessAttestation` — the canonical handoff so a
734    /// Job's typed receipt becomes evidence on a Process. `generation`
735    /// + `previous_root` come from the owning Process's prior
736    /// attestation (or 0 + None for the first cycle).
737    pub fn to_attestation(
738        &self,
739        generation: u64,
740        previous_root: Option<&str>,
741    ) -> ProcessAttestation {
742        ProcessAttestation::compose(
743            self.artifact_hash.clone(),
744            self.control_hash_opt().map(str::to_owned),
745            self.intent_hash.clone(),
746            previous_root.map(String::from),
747            generation,
748        )
749    }
750
751    /// Typed projection of the wire form's `control_hash` field —
752    /// `Some(hash)` when a control step ran, `None` when it did not.
753    ///
754    /// The wire form stamps `control_hash: String` (schema-open,
755    /// serde-friendly), but the substrate's `compose_root` +
756    /// `ProcessAttestation::compose` compositions both take an
757    /// `Option<&str>` / `Option<String>` and thread `None` through the
758    /// exact BLAKE3 bytes pattern an absent-pillar walk emits — an
759    /// empty `control_hash` and an absent-pillar receipt hash to the
760    /// SAME `composed_root`. That "empty means absent" convention
761    /// pre-lift lived at THREE sites inside this impl block —
762    /// [`Self::build`] (constructing the envelope from typed pillars),
763    /// [`Self::verify_root`] (recomposing the root against pillars for
764    /// wire-form verification), and [`Self::to_attestation`] (lowering
765    /// the receipt into a [`ProcessAttestation`] on a Process's
766    /// attestation chain) — each hand-writing the SAME
767    /// `if self.control_hash.is_empty() { None } else {
768    /// Some(self.control_hash.as_str()) }` two-arm conditional. Post-
769    /// lift the convention lives at ONE method here; the three
770    /// consumers each compose a ONE-LINE call:
771    ///   * `verify_root` → `self.control_hash_opt()` directly,
772    ///   * `to_attestation` → `self.control_hash_opt().map(str::to_owned)`
773    ///     for the `Option<String>` shape [`ProcessAttestation::compose`]
774    ///     binds,
775    ///   * `build` (which reads a local `control_hash: String` before
776    ///     the envelope is constructed) → the free-fn peer
777    ///     [`empty_to_none`] on the same borrowed string.
778    ///
779    /// Public because the projection is load-bearing operator-facing
780    /// contract: an authoring surface (an LSP hover, a
781    /// `tatara-check` report, a REPL `:receipt-inspect` command) that
782    /// wants to render "no control step" vs. "control_hash: <hash>"
783    /// binds to this method rather than pattern-matching on
784    /// `self.control_hash.is_empty()` at its own call site — a future
785    /// re-shape of the empty-means-absent convention (a sentinel-
786    /// string variant, an explicit `Option<String>` on the wire form
787    /// once the schema evolves, or a typed
788    /// `ControlStep::{Ran(hash), Skipped}` enum) lands at ONE method
789    /// here rather than at every consumer that inspects the pillar.
790    ///
791    /// Theory anchor: THEORY.md §V.1 — knowable platform; the
792    /// wire-vs-typed projection lives at ONE substrate method so a
793    /// consumer reads the pillar's typed-Option contract from the
794    /// receipt directly, not from three parallel inline conditionals
795    /// scattered across `build` / `verify_root` / `to_attestation`.
796    /// THEORY.md §VI.1 — generation over composition; the
797    /// `is_empty() ? None : Some(&self.control_hash)` two-arm
798    /// projection recurred at THREE inline sites past the ★★
799    /// PRIME-DIRECTIVE ≥ 2 duplication threshold and is lifted to ONE
800    /// owner here. THEORY.md §V.3 — three-pillar attestation; the
801    /// receipt's second pillar (control step) has ONE typed projection
802    /// site the composition primitives ([`compose_root`],
803    /// [`ProcessAttestation::compose`]) both bind against, so the
804    /// pillar's wire-vs-typed identity cannot drift across the three
805    /// consumers.
806    #[must_use]
807    pub fn control_hash_opt(&self) -> Option<&str> {
808        empty_to_none(&self.control_hash)
809    }
810}
811
812/// Project a wire-form pillar string onto its typed `Option<&str>`
813/// contract — `Some(s)` when `s` is non-empty, `None` when `s` is
814/// empty (the substrate's "no such pillar" convention that
815/// [`compose_root`] + [`ProcessAttestation::compose`] both thread as
816/// an absent-pillar walk through the BLAKE3 domain-tagged
817/// composition).
818///
819/// The free-fn peer of [`ReceiptEnvelope::control_hash_opt`] for
820/// call sites that hold a borrowed pillar string BEFORE a
821/// [`ReceiptEnvelope`] is constructed — namely
822/// [`ReceiptEnvelope::build`]'s inline `compose_root` call, which
823/// composes the pillar's typed-Option identity from the local
824/// `control_hash: String` intake before the envelope value exists.
825/// The two peers share ONE projection body (`(!s.is_empty()).
826/// then_some(s)`) so a future re-shape of the empty-means-absent
827/// convention (a sentinel-string variant, an explicit
828/// `Option<String>` on the wire form once the schema evolves)
829/// lands at ONE substrate primitive rather than at both the
830/// inherent method and its pre-construction free-fn peer.
831///
832/// Theory anchor: THEORY.md §VI.1 — generation over composition;
833/// the pre-construction peer of the pillar projection lives at ONE
834/// substrate primitive alongside the post-construction inherent
835/// method, so the two receipt-lifecycle stages (pre-envelope in
836/// [`ReceiptEnvelope::build`], post-envelope in every other
837/// consumer) share ONE typed projection.
838fn empty_to_none(s: &str) -> Option<&str> {
839    (!s.is_empty()).then_some(s)
840}
841
842/// Reject a required pillar whose wire form is empty with a typed
843/// [`ReceiptError::MissingField`] carrying `field` — the diagnostic
844/// literal the operator sees. Free-fn peer of [`empty_to_none`] on
845/// the same wire-form-emptiness axis, and rejection sibling of the
846/// [`ReceiptEnvelope::REQUIRED_PILLARS`] closed-set table
847/// [`ReceiptEnvelope::verify_shape`] dispatches through.
848///
849/// The two peers on the emptiness axis carry two different typed
850/// projections of the SAME wire-form bit:
851///   * [`empty_to_none`] — the "empty means absent" convention for
852///     the second pillar (control step); the substrate composes
853///     `Option::None` through `compose_root` so an empty
854///     `control_hash` and an absent-pillar receipt hash to the SAME
855///     `composed_root`.
856///   * [`require_nonempty`] — the "empty is a validation failure"
857///     convention for the three required pillars; the substrate
858///     rejects the receipt with a typed [`ReceiptError::MissingField`]
859///     carrying the offending field name so the operator's diagnostic
860///     surface (a reconciler event, a CLI stderr, a `tatara-check`
861///     receipt-inspect report) names the pillar directly.
862///
863/// The two peers are DELIBERATELY named as (`empty_to_none`,
864/// `require_nonempty`) rather than as a single overloaded projection
865/// so the two typed conventions (absence-as-Option vs.
866/// absence-as-Err) surface at the substrate's exported vocabulary
867/// as two distinct primitives — a per-caller misroute (composing
868/// `require_nonempty` on `control_hash` and getting a false
869/// `MissingField`, or composing `empty_to_none` on `intent_hash`
870/// and threading `None` through `compose_root` past a wire that
871/// should have rejected) is a name-typo, not a silent semantic
872/// swap.
873///
874/// Theory anchor: THEORY.md §VI.1 — generation over composition;
875/// the "empty is a required-pillar failure" three-line inline
876/// conditional recurred at THREE sites past the ★★ PRIME-DIRECTIVE
877/// ≥ 2 duplication threshold and is lifted to ONE substrate primitive
878/// composed through the [`ReceiptEnvelope::REQUIRED_PILLARS`] table.
879/// THEORY.md §V.1 — knowable platform; the two emptiness projections
880/// live at ONE typed vocabulary the receipt-inspection surfaces (LSP
881/// hover, `tatara-check` report, REPL) bind to for reading the
882/// receipt's structural contract from the substrate directly.
883fn require_nonempty(field: &'static str, value: &str) -> Result<(), ReceiptError> {
884    if value.is_empty() {
885        return Err(ReceiptError::MissingField(field));
886    }
887    Ok(())
888}
889
890const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
891
892/// Same composition as `ProcessAttestation::composed_hex` — kept local so
893/// `tatara_process::receipt::compose_root(...)` is a single line in
894/// downstream code without re-importing the attestation module.
895fn compose_root(
896    artifact: &str,
897    control: Option<&str>,
898    intent: &str,
899    previous: Option<&str>,
900) -> String {
901    let mut h = blake3::Hasher::new();
902    h.update(DOMAIN_TAG);
903    h.update(artifact.as_bytes());
904    h.update(b"\n");
905    h.update(control.unwrap_or("").as_bytes());
906    h.update(b"\n");
907    h.update(intent.as_bytes());
908    h.update(b"\n");
909    h.update(previous.unwrap_or("").as_bytes());
910    hex::encode(h.finalize().as_bytes())
911}
912
913fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
914    if a.len() != b.len() {
915        return false;
916    }
917    let mut acc: u8 = 0;
918    for (x, y) in a.iter().zip(b.iter()) {
919        acc |= x ^ y;
920    }
921    acc == 0
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    fn sample_payload() -> &'static str {
929        // Composed_root precomputed from compose_root("bbbb", Some("cccc"), "aaaa", None)
930        // (recomputed at test time to be canonical; this string is regenerated
931        // if the domain tag ever changes).
932        r#"{
933            "version": "tatara-receipt/v1",
934            "kind": "closed-loop-auth",
935            "composed_root": "RECOMPUTE",
936            "intent_hash":   "aaaa",
937            "artifact_hash": "bbbb",
938            "control_hash":  "cccc",
939            "generated_at":  "2026-05-19T12:00:00Z"
940        }"#
941    }
942
943    fn canonical_payload_json() -> String {
944        let root = compose_root("bbbb", Some("cccc"), "aaaa", None);
945        sample_payload().replace("RECOMPUTE", &root)
946    }
947
948    #[test]
949    fn build_produces_valid_envelope() {
950        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
951        assert_eq!(r.version, RECEIPT_VERSION);
952        assert_eq!(r.kind, "test-suite");
953        assert!(r.verify_shape().is_ok());
954        assert!(r.verify_root(None));
955    }
956
957    #[test]
958    fn build_empty_control_omits_from_root() {
959        let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
960        let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
961        assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
962
963        // And differs from a receipt with a real control hash.
964        let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
965        assert_ne!(with_empty.composed_root, with_control.composed_root);
966    }
967
968    #[test]
969    fn parse_json_round_trip() {
970        let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
971        assert_eq!(r.kind, "closed-loop-auth");
972        assert!(r.verify_root(None));
973    }
974
975    #[test]
976    fn parse_yaml_round_trip() {
977        let yaml = r#"
978version: tatara-receipt/v1
979kind: db-migration
980composed_root: ROOT
981intent_hash:   aaaa
982artifact_hash: bbbb
983control_hash:  cccc
984generated_at:  2026-05-19T12:00:00Z
985"#
986        .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
987        let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
988        assert_eq!(r.kind, "db-migration");
989        assert!(r.verify_root(None));
990    }
991
992    #[test]
993    fn parse_either_falls_back_to_yaml() {
994        let yaml = r#"
995version: tatara-receipt/v1
996kind: test-suite
997composed_root: ROOT
998intent_hash:   aaaa
999artifact_hash: bbbb
1000control_hash:  cccc
1001generated_at:  2026-05-19T12:00:00Z
1002"#
1003        .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
1004        assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
1005    }
1006
1007    #[test]
1008    fn wrong_version_rejected() {
1009        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
1010        env["version"] = "tatara-receipt/v2".into();
1011        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
1012        assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
1013    }
1014
1015    #[test]
1016    fn missing_field_rejected() {
1017        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
1018        env.as_object_mut().unwrap().remove("intent_hash");
1019        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
1020        assert!(matches!(err, ReceiptError::InvalidJson(_)));
1021    }
1022
1023    #[test]
1024    fn unknown_field_rejected() {
1025        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
1026        env["forged_extra"] = "should-fail".into();
1027        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
1028        assert!(matches!(err, ReceiptError::InvalidJson(_)));
1029    }
1030
1031    #[test]
1032    fn empty_kind_rejected_in_verify_shape() {
1033        let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
1034        r.kind = String::new();
1035        assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
1036    }
1037
1038    #[test]
1039    fn expect_root_matches_or_mismatches() {
1040        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1041        let root = r.composed_root.clone();
1042        assert!(r.expect_root(Some(&root)).is_ok());
1043        let err = r.expect_root(Some("nope")).unwrap_err();
1044        assert!(matches!(err, ReceiptError::RootMismatch { .. }));
1045        assert!(r.expect_root(None).is_ok());
1046    }
1047
1048    #[test]
1049    fn lower_to_attestation_chains_pillars() {
1050        let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
1051        let a = r.to_attestation(0, None);
1052        assert_eq!(a.intent_hash, "i");
1053        assert_eq!(a.artifact_hash, "a");
1054        assert_eq!(a.control_hash.as_deref(), Some("c"));
1055        // Both compose the same root.
1056        assert_eq!(a.composed_root, r.composed_root);
1057        assert!(a.verify());
1058
1059        let next = r.to_attestation(1, Some(&a.composed_root));
1060        assert_eq!(next.generation, 1);
1061        assert_eq!(
1062            next.previous_root.as_deref(),
1063            Some(a.composed_root.as_str())
1064        );
1065        // The composed_root differs because previous_root is included.
1066        assert_ne!(next.composed_root, a.composed_root);
1067    }
1068
1069    #[test]
1070    fn verify_root_detects_tamper() {
1071        let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
1072        assert!(r.verify_root(None));
1073        r.intent_hash = "tampered".into();
1074        assert!(!r.verify_root(None));
1075    }
1076
1077    #[test]
1078    fn process_ref_optional_and_round_trips() {
1079        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1080        r.process_ref = Some("demo-test/ephemeral".into());
1081        let s = serde_json::to_string(&r).unwrap();
1082        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
1083        assert_eq!(back.process_ref.as_deref(), Some("demo-test/ephemeral"));
1084    }
1085
1086    #[test]
1087    fn evidence_round_trips() {
1088        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1089        r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
1090        let s = serde_json::to_string(&r).unwrap();
1091        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
1092        assert_eq!(back.evidence["passed"], 12);
1093    }
1094
1095    // ── ReceiptKind closed-set truth-table ───────────────────────────
1096
1097    /// Structural well-formedness of [`ReceiptKind`] as a
1098    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1099    /// testkit lift that pins all three structural invariants (`ALL`
1100    /// is non-empty, every variant round-trips through
1101    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1102    /// outside the closed set) at ONE call site. Replaces the hand-
1103    /// derived `receipt_kind_all_enumerates_each_variant_exactly_once`
1104    /// + `receipt_kind_from_str_round_trips_canonical_names` + the
1105    /// empty-input arm of `receipt_kind_from_str_rejects_open_kinds`.
1106    /// `FromStr` delegates to
1107    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1108    /// exercises the same code path operators hit when parsing a wire
1109    /// `kind` field back to the typed kind.
1110    #[test]
1111    fn receipt_kind_is_well_formed_closed_set() {
1112        tatara_closed_set::assert_closed_set_well_formed::<ReceiptKind>();
1113    }
1114
1115    #[test]
1116    fn receipt_kind_canonical_names_pinned() {
1117        // Byte-exact wire-format pin — renaming any of these is a
1118        // wire-format change, not a typed-internal refactor.
1119        assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
1120        assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
1121        assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
1122        assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
1123    }
1124
1125    #[test]
1126    fn receipt_kind_from_str_rejects_open_kinds() {
1127        // Future / typo / wrong-case all surface a typed
1128        // UnknownReceiptKind carrying the offending input verbatim
1129        // (operator-facing diagnostic); the schema is open at the
1130        // wire layer, but the closed-set view is byte-exact. The
1131        // empty-input arm is pinned by
1132        // [`receipt_kind_is_well_formed_closed_set`] via the
1133        // `tatara_lisp::ClosedSet` testkit; the cases here pin the
1134        // verbatim-echo contract on the [`UnknownReceiptKind`] newtype,
1135        // which the trait's `make_unknown` can't see.
1136        for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
1137            let err = bad.parse::<ReceiptKind>().unwrap_err();
1138            assert_eq!(err, UnknownReceiptKind(bad.to_string()));
1139        }
1140    }
1141
1142    #[test]
1143    fn receipt_kind_display_delegates_to_as_str() {
1144        for k in ReceiptKind::ALL {
1145            assert_eq!(format!("{k}"), k.as_str());
1146        }
1147    }
1148
1149    #[test]
1150    fn receipt_kind_into_string_matches_as_str() {
1151        for k in ReceiptKind::ALL {
1152            let s: String = k.into();
1153            assert_eq!(s, k.as_str());
1154        }
1155    }
1156
1157    #[test]
1158    fn build_accepts_typed_receipt_kind() {
1159        // The typed → wire bridge: `build(ReceiptKind::X, …)` produces
1160        // a receipt whose `kind` field is exactly `X.as_str()`.
1161        for k in ReceiptKind::ALL {
1162            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
1163            assert_eq!(env.kind, k.as_str());
1164            assert!(env.verify_shape().is_ok());
1165            assert!(env.verify_root(None));
1166        }
1167    }
1168
1169    #[test]
1170    fn known_kind_decodes_built_receipts() {
1171        for k in ReceiptKind::ALL {
1172            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
1173            assert_eq!(env.known_kind(), Some(k));
1174        }
1175    }
1176
1177    #[test]
1178    fn known_kind_returns_none_for_open_kinds() {
1179        // Open-by-design: a custom operator-registered kind still
1180        // parses, still verifies, and still attests — it just doesn't
1181        // project through the closed-set typed view.
1182        let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
1183        assert_eq!(env.known_kind(), None);
1184        assert!(
1185            env.verify_shape().is_ok(),
1186            "open kind must remain a valid receipt"
1187        );
1188    }
1189
1190    // ── `control_hash_opt` / `empty_to_none` — the wire-form-to-typed
1191    //    projection of the second pillar (control step). Pre-lift the
1192    //    `is_empty() ? None : Some(&self.control_hash)` two-arm
1193    //    conditional lived at THREE inline sites — `build`,
1194    //    `verify_root`, `to_attestation` — each hand-writing the SAME
1195    //    projection with slightly-different ownership shapes
1196    //    (`Option<&str>` for the two composers, `Option<String>` for
1197    //    the attestation composer). Post-lift the projection lives at
1198    //    ONE inherent method + ONE free-fn peer for the pre-envelope
1199    //    call site. The tests below pin the substrate primitive's
1200    //    contract at its boundary so a regression at the projection
1201    //    surfaces here rather than as a silent `composed_root` drift
1202    //    at every consumer that composes the pillar.
1203
1204    #[test]
1205    fn empty_to_none_projects_empty_to_none_and_non_empty_to_some_verbatim() {
1206        // The pre-envelope free-fn peer of `control_hash_opt` — used
1207        // by `build` before the envelope exists. Pin BOTH arms of the
1208        // projection: an empty string projects to `None` (the "no
1209        // such pillar" convention that `compose_root` threads through
1210        // the absent-pillar BLAKE3 bytes pattern), and any non-empty
1211        // string projects to `Some(s)` byte-identical to the input.
1212        // A regression that (a) inverted the arms (folding `""` to
1213        // `Some("")` and every non-empty into `None`), (b) normalized
1214        // the payload (trimming whitespace, lowercasing hex), or (c)
1215        // introduced a sentinel-string special case (`"none"`, `"-"`,
1216        // etc.) would surface here rather than as a silent
1217        // `composed_root` shift at every consumer that composes the
1218        // pillar.
1219        assert_eq!(super::empty_to_none(""), None);
1220        assert_eq!(super::empty_to_none("c"), Some("c"));
1221        assert_eq!(super::empty_to_none("cccc"), Some("cccc"));
1222        // A whitespace-only string is NOT empty by the pillar's typed
1223        // contract — the substrate composes bytes verbatim through
1224        // BLAKE3, so a `" "` control hash IS a distinct pillar from
1225        // an absent one; the projection must preserve that
1226        // distinction.
1227        assert_eq!(super::empty_to_none(" "), Some(" "));
1228    }
1229
1230    #[test]
1231    fn control_hash_opt_matches_the_free_fn_peer_on_every_receipt() {
1232        // Post-envelope inherent method routes through the same
1233        // `empty_to_none` free-fn body — pin the equivalence across
1234        // both arms so a future regression that split the two
1235        // projections (e.g. the inherent method starts trimming, the
1236        // free-fn stays byte-verbatim) surfaces here rather than as a
1237        // `composed_root` mismatch between `build` (uses the free-fn
1238        // peer) and `verify_root` / `to_attestation` (use the
1239        // inherent method).
1240        let with_control = ReceiptEnvelope::build("test-suite", "i", "a", "cccc", None);
1241        assert_eq!(with_control.control_hash_opt(), Some("cccc"));
1242        assert_eq!(
1243            with_control.control_hash_opt(),
1244            super::empty_to_none(&with_control.control_hash),
1245        );
1246
1247        let no_control = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
1248        assert_eq!(no_control.control_hash_opt(), None);
1249        assert_eq!(
1250            no_control.control_hash_opt(),
1251            super::empty_to_none(&no_control.control_hash),
1252        );
1253    }
1254
1255    #[test]
1256    fn control_hash_opt_composes_the_same_root_the_three_consumers_bind() {
1257        // End-to-end pin at the receipt-lifecycle boundary — the
1258        // three consumers (`build`, `verify_root`, `to_attestation`)
1259        // must land on the SAME `composed_root` for a given pillar
1260        // tuple regardless of which projection body they route
1261        // through. Sweeps BOTH pillar arms (present control + empty
1262        // control) so a regression that mis-wired ONE consumer to
1263        // the pre-lift inline conditional or that changed the
1264        // projection at ONE site surfaces here rather than as a
1265        // silent divergence between `verify_root`'s decision and
1266        // `to_attestation`'s written `composed_root`.
1267        for control in ["", "control-hash-cccc"] {
1268            let env = ReceiptEnvelope::build("test-suite", "i", "a", control, None);
1269            // `verify_root` composes through the inherent method AND
1270            // through the same `compose_root(&artifact, control_opt,
1271            // &intent, previous)` skeleton `build` binds — so the
1272            // envelope must verify against its own composed root.
1273            assert!(
1274                env.verify_root(None),
1275                "verify_root failed for control={control:?}",
1276            );
1277            // `to_attestation` composes the same pillar tuple through
1278            // `ProcessAttestation::compose`'s `Option<String>` shape;
1279            // the attestation's `composed_root` must match the
1280            // envelope's `composed_root` byte-for-byte because both
1281            // compose the SAME BLAKE3 domain-tagged skeleton over
1282            // the SAME typed-Option pillar identity.
1283            let att = env.to_attestation(0, None);
1284            assert_eq!(
1285                att.composed_root, env.composed_root,
1286                "attestation root drift for control={control:?}",
1287            );
1288        }
1289    }
1290
1291    // ── `REQUIRED_PILLARS` / `require_nonempty` — the closed-set
1292    //    table + shared rejection peer that `verify_shape` composes
1293    //    the three required-pillar emptiness checks through. Pre-lift
1294    //    the three `if self.<pillar>.is_empty() { return Err(
1295    //    ReceiptError::MissingField("<pillar>")); }` two-arm
1296    //    conditionals lived inline in `verify_shape` — one per pillar
1297    //    name, each hand-writing the SAME (field-name, accessor,
1298    //    rejection) triple. Post-lift the three (field-name,
1299    //    accessor) pairs live at ONE `REQUIRED_PILLARS` const, and
1300    //    the rejection body lives at ONE `require_nonempty` peer. The
1301    //    tests below pin the substrate primitives' contract at their
1302    //    boundary so a regression at the projection surfaces here
1303    //    rather than as a silent shift in the receipt's structural
1304    //    validation semantics.
1305
1306    #[test]
1307    fn require_nonempty_rejects_empty_with_the_named_field_and_passes_non_empty_verbatim() {
1308        // The shared rejection peer of `empty_to_none` — used by
1309        // `verify_shape` through the `REQUIRED_PILLARS` sweep. Pin
1310        // BOTH arms of the projection: an empty value rejects with
1311        // `ReceiptError::MissingField(field)` carrying the literal
1312        // `field` byte-identically (so a rename at the table entry
1313        // reaches the operator's diagnostic surface — the reconciler
1314        // event, the CLI stderr, the `tatara-check` receipt-inspect
1315        // report), and any non-empty value passes with `Ok(())`
1316        // (regardless of the payload's shape — a whitespace-only `" "`
1317        // is NOT empty by the pillar's typed contract). A regression
1318        // that (a) mis-named the field on the rejection (leaking a
1319        // caller-controlled `&str` in place of the `&'static` diagnostic
1320        // literal), (b) rejected non-empty values (folding `" "` or
1321        // some other sentinel into `MissingField`), or (c) accepted
1322        // the empty payload silently would surface here rather than
1323        // as a silent semantic shift in `verify_shape`'s rejection
1324        // vocabulary.
1325        assert_eq!(
1326            super::require_nonempty("composed_root", ""),
1327            Err(ReceiptError::MissingField("composed_root")),
1328        );
1329        assert_eq!(
1330            super::require_nonempty("intent_hash", ""),
1331            Err(ReceiptError::MissingField("intent_hash")),
1332        );
1333        assert_eq!(super::require_nonempty("composed_root", "aaaa"), Ok(()));
1334        // Whitespace-only strings pass the rejection gate — the
1335        // substrate composes bytes verbatim through BLAKE3 so `" "`
1336        // IS a distinct pillar from an absent one; the rejection
1337        // must preserve that distinction.
1338        assert_eq!(super::require_nonempty("intent_hash", " "), Ok(()));
1339    }
1340
1341    #[test]
1342    fn required_pillars_table_is_pairwise_distinct_and_enumerates_the_three_names() {
1343        // The closed-set table `verify_shape` dispatches through.
1344        // Pin: (a) the arity is exactly THREE (rustc's `[…; 3]`
1345        // constant on the type binds this at compile time; the pin
1346        // here checks the runtime enumeration matches so a future
1347        // arity bump surfaces as a coordinated update rather than a
1348        // silent drift), (b) each entry's field name is a
1349        // pillar-unique string (a duplicate entry — the same pillar
1350        // listed twice — would evaluate the same rejection twice at
1351        // ONE run, hiding a distinct pillar's absence behind the
1352        // duplicate's success), (c) the three names match the
1353        // byte-exact wire literals the reconciler tests + operator
1354        // diagnostics have already published (`"composed_root"`,
1355        // `"intent_hash"`, `"artifact_hash"`) — renaming any of them
1356        // is a wire-diagnostic change, not a typed-internal refactor.
1357        let names: Vec<&'static str> = ReceiptEnvelope::REQUIRED_PILLARS
1358            .iter()
1359            .map(|(name, _)| *name)
1360            .collect();
1361        assert_eq!(names, vec!["composed_root", "intent_hash", "artifact_hash"]);
1362
1363        // Pairwise-distinct check — the table's arity is small
1364        // enough for a hand-authored O(n^2) sweep, and a duplicate
1365        // would defeat the whole point of the enumeration.
1366        for i in 0..names.len() {
1367            for j in (i + 1)..names.len() {
1368                assert_ne!(
1369                    names[i], names[j],
1370                    "REQUIRED_PILLARS[{i}] and [{j}] share field name {}",
1371                    names[i],
1372                );
1373            }
1374        }
1375
1376        // control_hash is DELIBERATELY not in the table (it carries
1377        // the "empty means absent" semantic bit — see
1378        // `control_hash_opt` + `empty_to_none`). Pin the exclusion so
1379        // a future well-meaning addition that promotes control_hash
1380        // to a required pillar surfaces here as a contract change
1381        // rather than as a silent rejection of receipts the
1382        // substrate's own compose_root treats as valid absent-pillar
1383        // walks.
1384        assert!(
1385            !names.contains(&"control_hash"),
1386            "control_hash must not be in REQUIRED_PILLARS — its emptiness \
1387             is the substrate's absent-pillar convention",
1388        );
1389    }
1390
1391    #[test]
1392    fn verify_shape_rejects_each_required_pillar_when_emptied_with_the_typed_field_name() {
1393        // End-to-end pin at the `verify_shape` boundary — each entry
1394        // in `REQUIRED_PILLARS` must surface a
1395        // `ReceiptError::MissingField(field)` carrying the entry's
1396        // OWN name when its accessor's value is empty. Sweeps the
1397        // table so a future fourth required pillar picks up the
1398        // rejection through the SAME per-entry iteration + the SAME
1399        // shared `require_nonempty` peer, and a mis-wired accessor
1400        // (an entry naming "intent_hash" whose accessor reads
1401        // `self.artifact_hash`) surfaces here as a mismatched typed
1402        // rejection rather than as a silent semantic drift at
1403        // production.
1404        for (field, accessor) in ReceiptEnvelope::REQUIRED_PILLARS {
1405            let mut env = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1406            // Empty ONLY the pillar under test by zeroing the field
1407            // through the wire-form struct's own mutable access
1408            // (which the `#[serde(deny_unknown_fields)]` wire shape
1409            // doesn't restrict at the Rust level).
1410            match field {
1411                "composed_root" => env.composed_root.clear(),
1412                "intent_hash" => env.intent_hash.clear(),
1413                "artifact_hash" => env.artifact_hash.clear(),
1414                other => panic!("unknown REQUIRED_PILLARS entry {other}"),
1415            }
1416            assert!(
1417                accessor(&env).is_empty(),
1418                "accessor for {field} did not read the emptied field",
1419            );
1420            let err = env
1421                .verify_shape()
1422                .expect_err("verify_shape must reject empty required pillar");
1423            assert_eq!(
1424                err,
1425                ReceiptError::MissingField(field),
1426                "verify_shape returned {err:?} — expected MissingField({field:?})",
1427            );
1428        }
1429    }
1430
1431    // ── RECEIPT_CM_SUFFIX + default_receipt_config_map_name ──────────
1432    //
1433    // Fail-before-pass-after pins for the substrate-level naming
1434    // convention that the reconciler's JobAttested + ClosedLoopAuth
1435    // evaluators AND the export-worker renderer all route through
1436    // for their default-derivation callsites. A regression that
1437    // renamed the suffix (e.g. `-receipt` → `-attest`, `-cm`, or
1438    // `.receipt`) OR that swapped the composition order at the
1439    // composer (e.g. `<suffix><job>` instead of `<job><suffix>`)
1440    // would silently misroute every default-derivation receipt-CM
1441    // read against a ConfigMap the Job never wrote to — the pins
1442    // here catch the drift at the primitive itself, before it
1443    // reaches any downstream consumer.
1444
1445    #[test]
1446    fn receipt_cm_suffix_pinned_to_dash_receipt() {
1447        // Byte-exact wire-format pin — renaming this is a wire-name
1448        // change, not a typed-internal refactor. Operators grep for
1449        // the `-receipt` suffix in kubectl output; dashboards and
1450        // export tooling template on it; the closed-loop-probe chart
1451        // publishes ConfigMaps at this suffix. A silent rename here
1452        // would desync all of them at once.
1453        assert_eq!(RECEIPT_CM_SUFFIX, "-receipt");
1454    }
1455
1456    #[test]
1457    fn default_receipt_config_map_name_appends_suffix_to_job_name() {
1458        // The canonical composition every default-derivation site
1459        // routed through pre-lift as `format!("{name}-receipt")`.
1460        assert_eq!(default_receipt_config_map_name("my-job"), "my-job-receipt");
1461        assert_eq!(
1462            default_receipt_config_map_name("probe-job"),
1463            "probe-job-receipt"
1464        );
1465    }
1466
1467    #[test]
1468    fn default_receipt_config_map_name_composes_through_the_suffix_const() {
1469        // Cross-primitive coherence pin — the composer's output must
1470        // equal `<job_name>{RECEIPT_CM_SUFFIX}` verbatim across a
1471        // sweep of shipped Job-name shapes (bare, hierarchical
1472        // export-index, closed-loop probe derivation, one-char, and
1473        // empty). A regression that inlined the suffix at the
1474        // composer (breaking the const's role as the ONE source of
1475        // truth) fails HERE at the shipped-shape sweep because the
1476        // pin re-reads the const at test time.
1477        for job_name in [
1478            "my-job",
1479            "r1-export-0",
1480            "attest-export-5",
1481            "closed-loop-attest-closed-loop-probe",
1482            "x",
1483            "",
1484        ] {
1485            let mut expected = String::new();
1486            expected.push_str(job_name);
1487            expected.push_str(RECEIPT_CM_SUFFIX);
1488            assert_eq!(
1489                default_receipt_config_map_name(job_name),
1490                expected,
1491                "default_receipt_config_map_name({job_name:?}) drifted from \
1492                 <job>++RECEIPT_CM_SUFFIX composition",
1493            );
1494        }
1495    }
1496
1497    // ── RECEIPT_JSON_KEY / RECEIPT_YAML_KEY / RECEIPT_CM_KEYS /
1498    //    RECEIPT_CM_MISSING_KEY_MSG / extract_receipt_payload_json ─────
1499    //
1500    // Fail-before-pass-after pins for the substrate-level (primary,
1501    // fallback) receipt-CM `data`-key pair AND the reader-side lookup
1502    // gate composer that every receipt-CM consumer routes through.
1503    // A regression that renamed either key OR that swapped the
1504    // primary/fallback ordering at the reader gate would silently
1505    // desynchronize writer/reader pairs across the workspace — the
1506    // pins here catch the drift at the primitives themselves, before
1507    // it reaches any downstream ConfigMap-fetch site.
1508    //
1509    // Pre-lift the two keys appeared as inline `&'static str` literals
1510    // at FOUR production sites (2 writer inserts + 2 reader lookups)
1511    // with no shared owner binding their spelling OR the primary-first
1512    // ordering that the reader-side gate encodes as a load-bearing
1513    // invariant.
1514
1515    #[test]
1516    fn receipt_cm_keys_pinned_to_wire_form_literals() {
1517        // Byte-exact wire-format pin — renaming either is a wire-name
1518        // change, not a typed-internal refactor. Operators grep for
1519        // these keys in kubectl output; the closed-loop-probe chart
1520        // publishes ConfigMaps carrying them; the reconciler's
1521        // JobAttested/ClosedLoopAuth evaluators gate on them. A silent
1522        // rename here would desync every writer/reader pair fleet-wide.
1523        assert_eq!(RECEIPT_JSON_KEY, "receipt.json");
1524        assert_eq!(RECEIPT_YAML_KEY, "receipt.yaml");
1525    }
1526
1527    #[test]
1528    fn receipt_cm_keys_table_pins_primary_first_ordering() {
1529        // The primary/fallback ordering is load-bearing — the reader
1530        // gate returns the FIRST hit, so JSON must precede YAML to
1531        // preserve the substrate's "JSON is machine-canonical, YAML
1532        // is operator-facing readable twin" contract. A regression
1533        // that reordered the table would silently promote YAML over
1534        // JSON — the payload STILL parses (both wire forms round-trip
1535        // through the same `ReceiptEnvelope::parse_either`), but the
1536        // reader now prefers the operator-facing form when both are
1537        // present, breaking the substrate's payload-form preference.
1538        assert_eq!(RECEIPT_CM_KEYS, [RECEIPT_JSON_KEY, RECEIPT_YAML_KEY]);
1539        assert_eq!(RECEIPT_CM_KEYS[0], RECEIPT_JSON_KEY);
1540        assert_eq!(RECEIPT_CM_KEYS[1], RECEIPT_YAML_KEY);
1541        assert_eq!(RECEIPT_CM_KEYS.len(), 2);
1542    }
1543
1544    #[test]
1545    fn receipt_cm_missing_key_msg_names_both_keys_in_primary_first_order() {
1546        // The diagnostic message the reader-side gate returns when
1547        // neither key is present must NAME both keys so the operator
1548        // reading a `ReceiptVerdict::Malformed(...)` event knows
1549        // exactly which `data.<key>` entries the reader looked up.
1550        // Pin the message contains BOTH key literals (a rename at
1551        // either key const would drift the message spelling silently
1552        // if the message were `format!`-composed at the callsite;
1553        // the substrate owns the message const alongside the two key
1554        // consts so a coordinated update lands here).
1555        assert!(
1556            RECEIPT_CM_MISSING_KEY_MSG.contains(RECEIPT_JSON_KEY),
1557            "missing-key diagnostic must name {RECEIPT_JSON_KEY}",
1558        );
1559        assert!(
1560            RECEIPT_CM_MISSING_KEY_MSG.contains(RECEIPT_YAML_KEY),
1561            "missing-key diagnostic must name {RECEIPT_YAML_KEY}",
1562        );
1563        // Primary before fallback in the diagnostic text — the
1564        // operator's mental model matches the reader's iteration order.
1565        let json_pos = RECEIPT_CM_MISSING_KEY_MSG
1566            .find(RECEIPT_JSON_KEY)
1567            .expect("json key present");
1568        let yaml_pos = RECEIPT_CM_MISSING_KEY_MSG
1569            .find(RECEIPT_YAML_KEY)
1570            .expect("yaml key present");
1571        assert!(
1572            json_pos < yaml_pos,
1573            "diagnostic must name {RECEIPT_JSON_KEY} before {RECEIPT_YAML_KEY}",
1574        );
1575    }
1576
1577    #[test]
1578    fn extract_receipt_payload_json_returns_none_when_data_absent() {
1579        // The reader-side gate handles the `data` map's own absence
1580        // gracefully — `obj.data.get("data")` returns `None` when the
1581        // ConfigMap carries no `data` map, and the gate must project
1582        // that to `None` rather than panic or return a spurious hit.
1583        assert_eq!(extract_receipt_payload_json(None), None);
1584    }
1585
1586    #[test]
1587    fn extract_receipt_payload_json_returns_none_when_neither_key_present() {
1588        // A ConfigMap with `data` but no receipt payload — the gate
1589        // returns `None` so the caller can project to the typed
1590        // `ReceiptVerdict::Malformed(RECEIPT_CM_MISSING_KEY_MSG)`.
1591        let data = serde_json::json!({ "unrelated.key": "value" });
1592        assert_eq!(extract_receipt_payload_json(Some(&data)), None);
1593    }
1594
1595    #[test]
1596    fn extract_receipt_payload_json_prefers_primary_over_fallback_when_both_present() {
1597        // Load-bearing primary-first invariant — when BOTH keys are
1598        // present (the normal writer emit shape), the reader must
1599        // return the JSON form. A regression that swapped the
1600        // iteration order would silently promote YAML over JSON with
1601        // NO observable failure at parse time (both round-trip through
1602        // `parse_either`), so the pin here catches the ordering drift
1603        // at the reader gate itself.
1604        let data = serde_json::json!({
1605            RECEIPT_JSON_KEY: "json-payload",
1606            RECEIPT_YAML_KEY: "yaml-payload",
1607        });
1608        assert_eq!(
1609            extract_receipt_payload_json(Some(&data)),
1610            Some("json-payload"),
1611            "reader must prefer {RECEIPT_JSON_KEY} over {RECEIPT_YAML_KEY} when both present",
1612        );
1613    }
1614
1615    #[test]
1616    fn extract_receipt_payload_json_falls_back_to_yaml_when_json_absent() {
1617        // Fallback arm — an older probe binary or a hand-authored
1618        // fixture that only wrote the YAML form still reads
1619        // successfully. Pins that the YAML entry is reachable through
1620        // the gate.
1621        let data = serde_json::json!({
1622            RECEIPT_YAML_KEY: "yaml-payload",
1623        });
1624        assert_eq!(
1625            extract_receipt_payload_json(Some(&data)),
1626            Some("yaml-payload"),
1627        );
1628    }
1629
1630    #[test]
1631    fn extract_receipt_payload_json_returns_first_key_when_only_primary_present() {
1632        // Primary-only arm — the writer emitted just the JSON form
1633        // (e.g. a future probe that dropped the YAML twin). Reader
1634        // still resolves the payload through the primary key.
1635        let data = serde_json::json!({
1636            RECEIPT_JSON_KEY: "json-payload",
1637        });
1638        assert_eq!(
1639            extract_receipt_payload_json(Some(&data)),
1640            Some("json-payload"),
1641        );
1642    }
1643
1644    #[test]
1645    fn extract_receipt_payload_json_rejects_non_string_scalar_values() {
1646        // The wire contract says receipt payload values are string
1647        // scalars — a JSON number, object, or array at either key is
1648        // NOT a valid payload. The gate returns `None` (the caller
1649        // then projects to a `Malformed` verdict) rather than a
1650        // spurious hit that would panic downstream in the parser.
1651        let data = serde_json::json!({
1652            RECEIPT_JSON_KEY: 42,
1653            RECEIPT_YAML_KEY: ["not", "a", "string"],
1654        });
1655        assert_eq!(extract_receipt_payload_json(Some(&data)), None);
1656    }
1657
1658    #[test]
1659    fn extract_receipt_payload_json_skips_non_string_primary_and_falls_back_to_string_fallback() {
1660        // Mixed case — the primary key exists but carries a non-string
1661        // value (a malformed writer, a partially-migrated wire form),
1662        // and the fallback key carries a valid string payload. The
1663        // gate treats the non-string primary as absent for the
1664        // string-scalar projection contract and returns the string
1665        // fallback. This preserves availability at the reader when a
1666        // writer half-populated the primary.
1667        let data = serde_json::json!({
1668            RECEIPT_JSON_KEY: { "nested": "object" },
1669            RECEIPT_YAML_KEY: "yaml-payload",
1670        });
1671        assert_eq!(
1672            extract_receipt_payload_json(Some(&data)),
1673            Some("yaml-payload"),
1674        );
1675    }
1676
1677    #[test]
1678    fn extract_receipt_payload_json_matches_hand_authored_pre_lift_chain_bytewise() {
1679        // Byte-identity pin against the pre-lift 3-link combinator
1680        // chain that `verify_receipt_cm` composed inline:
1681        //     data.and_then(|d| d.get(RECEIPT_JSON_KEY))
1682        //         .or_else(|| data.and_then(|d| d.get(RECEIPT_YAML_KEY)))
1683        //         .and_then(|v| v.as_str())
1684        // Sweeps the four (primary-present × fallback-present)
1685        // combinations so a regression at the primitive that broke
1686        // the byte identity with the pre-lift shape surfaces here
1687        // rather than as a subtle divergence at ONE quadrant.
1688        for (json_val, yaml_val) in [
1689            (Some("json"), Some("yaml")),
1690            (Some("json"), None::<&str>),
1691            (None::<&str>, Some("yaml")),
1692            (None::<&str>, None::<&str>),
1693        ] {
1694            let mut map = serde_json::Map::new();
1695            if let Some(j) = json_val {
1696                map.insert(RECEIPT_JSON_KEY.into(), serde_json::Value::String(j.into()));
1697            }
1698            if let Some(y) = yaml_val {
1699                map.insert(RECEIPT_YAML_KEY.into(), serde_json::Value::String(y.into()));
1700            }
1701            let data = serde_json::Value::Object(map);
1702            let via_primitive = extract_receipt_payload_json(Some(&data));
1703            let via_pre_lift_chain = data
1704                .get(RECEIPT_JSON_KEY)
1705                .or_else(|| data.get(RECEIPT_YAML_KEY))
1706                .and_then(serde_json::Value::as_str);
1707            assert_eq!(
1708                via_primitive, via_pre_lift_chain,
1709                "extract_receipt_payload_json diverged from pre-lift chain at \
1710                 (json={json_val:?}, yaml={yaml_val:?})",
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn default_receipt_config_map_name_matches_prior_hand_authored_format_shape() {
1717        // Path-uniformity pin against the three pre-lift `format!`
1718        // literals — each callsite spelled the shape a slightly
1719        // different way (`format!("{}-receipt", parsed.name)` /
1720        // `format!("{job_name}-receipt")` /
1721        // `format!("{process_name}-export-{index}-receipt")`) but
1722        // all three composed the SAME `<job>-receipt` byte sequence
1723        // once evaluated. The lift preserves that byte identity so
1724        // no downstream ConfigMap grep or fleet-shipped operator
1725        // override changes meaning. A regression at the primitive
1726        // that broke the byte identity (e.g. inserted a separator,
1727        // uppercased the suffix, dropped the leading dash) would
1728        // fail HERE against the pre-lift `format!` literal for a
1729        // hand-picked Job-name that carries no ambiguity around
1730        // separators.
1731        let job_name = "svc-abc-export-3";
1732        let pre_lift = format!("{job_name}-receipt");
1733        let post_lift = default_receipt_config_map_name(job_name);
1734        assert_eq!(
1735            pre_lift, post_lift,
1736            "post-lift primitive drifted from pre-lift `format!(\"{{name}}-receipt\")` byte shape",
1737        );
1738    }
1739}