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;
42use crate::three_pillar;
43
44/// Canonical version string. Bump → `tatara-receipt/v2` if the wire
45/// shape changes; parsers refuse anything else for the v1 reader.
46pub const RECEIPT_VERSION: &str = "tatara-receipt/v1";
47
48/// Suffix appended to a Job's name to compose its default receipt-
49/// ConfigMap name. The substrate convention is that any Job which
50/// emits a [`ReceiptEnvelope`] writes it to a ConfigMap in the Job's
51/// own namespace whose name is `<job_name>-receipt` unless the caller
52/// supplies an explicit override.
53///
54/// Load-bearing at three shipped derivation sites, each of which
55/// re-composed the `<name>-receipt` shape by hand pre-lift:
56/// - `tatara_reconciler::boundary::evaluate_job_attested` — the
57///   `JobAttested` postcondition's default `receiptConfigMap`
58///   (`<parsed.name>-receipt`);
59/// - `tatara_reconciler::boundary::evaluate_closed_loop_auth` — the
60///   `ClosedLoopAuth` postcondition's default `receiptConfigMap`
61///   (`<probe_job_name>-receipt`, where the probe Job itself
62///   defaults to `<process_name>-closed-loop-probe`);
63/// - `tatara_reconciler::render::export_receipt_configmap_name` — the
64///   export-worker Job's per-index receipt ConfigMap
65///   (`<process_name>-export-<index>-receipt`), which is
66///   structurally `<export_job_name(process_name, index)>-receipt`.
67///
68/// Pre-lift each site restated the suffix as a `format!` literal
69/// (`format!("{}-receipt", parsed.name)`,
70/// `format!("{job_name}-receipt")`, and
71/// `format!("{process_name}-export-{index}-receipt")`). A rename to
72/// `-attest` or a scheme change to `.receipt-cm` would have needed a
73/// grep-and-replace across the three production sites AND a
74/// coordinated update of every fleet-shipped operator override in the
75/// closed-loop-probe chart and the reconciler's own tests.
76/// Post-lift the suffix lives at ONE const on the receipt module;
77/// [`default_receipt_config_map_name`] composes it with a Job name;
78/// every default-derivation site AND the export-worker composer
79/// route through the same primitive so a future suffix change lands
80/// at this ONE const and every consumer picks it up mechanically.
81///
82/// Sibling suffix-const on the substrate: [`RECEIPT_VERSION`] pins
83/// the wire-format version string every parser gates on; this const
84/// pins the wire-K8s-name suffix every default-derivation site
85/// composes. Both are load-bearing constants that operators grep and
86/// dashboards template on — neither may drift from its published
87/// spelling silently.
88///
89/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
90/// `<job_name>-receipt` shape recurred at three production sites past
91/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold and is lifted to
92/// ONE substrate const + composer here. THEORY.md §III — the
93/// typescape; the substrate's own receipt-CM naming convention
94/// becomes a NAMED PRIMITIVE rather than a shape spelled out by hand
95/// at every derivation site.
96pub const RECEIPT_CM_SUFFIX: &str = "-receipt";
97
98/// Compose the canonical receipt-ConfigMap name for a Job named
99/// `job_name` — the substrate's default when no explicit
100/// `receiptConfigMap` override is supplied on a postcondition's
101/// params or when a renderer builds a Job whose receipt CM name is
102/// derived from the Job's own name.
103///
104/// Returns `<job_name>{RECEIPT_CM_SUFFIX}`. See [`RECEIPT_CM_SUFFIX`]
105/// for the full lift rationale and the three consumer sites the
106/// primitive owns.
107///
108/// Uses string concatenation rather than `format!` so the composition
109/// does not participate in the workspace's typed-emission ban migration
110/// (skip-format-ban CLAUDE.md note); the shape is fixed at
111/// `<job_name>` ++ [`RECEIPT_CM_SUFFIX`] and any future two-arg
112/// composer (e.g. `default_receipt_config_map_name_scoped(cluster,
113/// job)`) extends the primitive here, not the consumer sites.
114#[must_use]
115pub fn default_receipt_config_map_name(job_name: &str) -> String {
116    let mut out = String::with_capacity(job_name.len() + RECEIPT_CM_SUFFIX.len());
117    out.push_str(job_name);
118    out.push_str(RECEIPT_CM_SUFFIX);
119    out
120}
121
122/// Resolve the receipt-ConfigMap name a `JobAttested` /
123/// `ClosedLoopAuth` postcondition (or any future postcondition that
124/// consumes a receipt) reads — honoring an operator-supplied
125/// `receiptConfigMap:` override verbatim when present, otherwise
126/// falling back to the substrate's canonical
127/// [`default_receipt_config_map_name`] composer over `job_name`.
128///
129/// Pre-lift the SAME 4-line `<params>.receipt_config_map.clone()
130/// .unwrap_or_else(|| default_receipt_config_map_name(&<job_name>))`
131/// chain was hand-authored at TWO workspace-wide consumer sites past
132/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, each feeding
133/// the postcondition's optional-override slot into the substrate's
134/// default-derivation composer:
135///
136/// * `tatara-reconciler::boundary::evaluate_job_attested` — the
137///   `JobAttested` postcondition's fallback derivation, threading
138///   `parsed.receipt_config_map` (optional operator override) +
139///   `parsed.name` (the Job name the postcondition attests).
140/// * `tatara-reconciler::boundary::evaluate_closed_loop_auth` — the
141///   `ClosedLoopAuth` postcondition's fallback derivation, threading
142///   `parsed.receipt_config_map` + the locally-derived `job_name`
143///   (`parsed.job_name.clone().unwrap_or_else(|| format!(
144///   "{process_name}-closed-loop-probe"))`).
145///
146/// Both sites walked the SAME 3-link chain — take the optional
147/// override, clone through the `Some` arm, fall back to the
148/// substrate default composer on `None` — differing only in the
149/// second operand (`&parsed.name` on the JobAttested axis, `&job_name`
150/// on the closed-loop axis). Post-lift each callsite reads
151/// `resolve_receipt_config_map_name(parsed.receipt_config_map.as_deref(),
152/// &<job_name>)` and the override-then-fallback resolution rule
153/// lives at ONE substrate owner.
154///
155/// Return-form axis: owned `String` — matches every downstream
156/// receipt-CM fetch site's `ns: &str, cm_name: &str` signature after
157/// the caller borrows the composed name. The `override_name:
158/// Option<&str>` input takes the borrow form so a consumer holding
159/// an `Option<String>` (the shipped `parsed.receipt_config_map` slot
160/// shape) reaches the substrate via `.as_deref()` without a
161/// speculative pre-clone at the callsite; the substrate performs the
162/// single owned-allocation only on the `Some` arm's projection.
163///
164/// The `Some` arm is byte-preserving — an operator who supplies an
165/// explicit `receiptConfigMap: ""` (empty string) gets the empty
166/// string back, matching the pre-lift `.clone().unwrap_or_else(...)`
167/// chain's semantics. This is intentional: the substrate defers
168/// empty-string validation to the downstream fetch site (which
169/// surfaces the error as `Satisfaction::Unsatisfied("<label> …
170/// receipt ConfigMap <ns>/ missing")` — the operator-visible symptom
171/// of the misconfiguration lands at the postcondition evaluator,
172/// not silently swallowed by a substrate-side non-empty filter).
173///
174/// A future normalization (a per-fleet suffix override injected via
175/// env var, a namespace-prefixed derivation for cluster-hosted receipt
176/// stores, a debug-build assertion rejecting the empty-override
177/// corner) lands at THIS ONE substrate primitive and every downstream
178/// postcondition evaluator inherits the upgrade mechanically — no
179/// per-site edit at the JobAttested / ClosedLoopAuth pair or at
180/// future postconditions consuming a receipt-CM override.
181///
182/// Sibling on the receipt-CM naming axis to
183/// [`default_receipt_config_map_name`] (unconditional composer, no
184/// override) — this primitive extends that composer with the
185/// optional-override projection every postcondition evaluator
186/// wraps around the composer's output.
187///
188/// Theory anchor: THEORY.md §III — the typescape; the
189/// override-then-fallback resolution rule for a postcondition-facing
190/// wire-name becomes a NAMED PRIMITIVE rather than a chain spelled
191/// out at every postcondition site.
192#[must_use]
193pub fn resolve_receipt_config_map_name(override_name: Option<&str>, job_name: &str) -> String {
194    match override_name {
195        Some(name) => name.to_string(),
196        None => default_receipt_config_map_name(job_name),
197    }
198}
199
200/// Canonical `data` key on a `receipt`-carrying ConfigMap for the JSON
201/// wire form of a [`ReceiptEnvelope`] — the substrate's PRIMARY payload
202/// key. Peer to [`RECEIPT_YAML_KEY`] on the same wire-form axis;
203/// [`RECEIPT_CM_KEYS`] fixes the primary-first ordering the reader-side
204/// lookup gate binds to.
205///
206/// Load-bearing at four shipped production sites — two on the writer
207/// axis (`tatara-closed-loop-probe`'s per-run receipt-CM emit inserts
208/// BOTH keys so operators can `kubectl get cm -o yaml` and read the
209/// receipt without re-parsing the embedded JSON) and two on the reader
210/// axis (`tatara-reconciler::boundary::verify_receipt_cm`'s `data`-map
211/// lookup gate reads the primary FIRST, then falls back to the YAML
212/// twin). Pre-lift each side restated the two `&'static str` literals
213/// verbatim — the writer inserted `"receipt.json"` + `"receipt.yaml"`
214/// as inline `String` allocations, the reader chained
215/// `.and_then(|d| d.get("receipt.json")).or_else(|| … "receipt.yaml"))`
216/// as inline lookup literals — with NO shared owner binding the two
217/// keys' spelling OR the primary-first ordering that the reader-side
218/// gate encodes as a load-bearing invariant. A rename at ONE writer
219/// key or ONE reader key silently desynchronizes the twin (a probe
220/// writing `"receipt.jsonl"` while the reader still gates on
221/// `"receipt.json"` → the postcondition MALFORMED-reads a ConfigMap
222/// that carries a valid receipt at a drifted key); a swap of the
223/// primary/fallback ordering at the reader silently promotes YAML
224/// over the operator-canonical JSON form.
225///
226/// Post-lift the two keys live at ONE substrate-owned pair of
227/// constants; [`RECEIPT_CM_KEYS`] pins the primary-first ordering the
228/// reader-side lookup gate iterates through
229/// ([`extract_receipt_payload_json`] composes the gate); every writer
230/// insert AND every reader lookup routes through ONE substrate owner,
231/// so a future rename (e.g. `"receipt.jsonl"` on a NDJSON schema
232/// variant, `"receipt.cbor"` on a binary-form variant) OR a
233/// primary/fallback swap lands at ONE substrate site and every writer
234/// and reader picks up the change mechanically — the two-side drift
235/// trap becomes unrepresentable at the type / value binding.
236///
237/// The reader's primary-first ordering is the substrate's convention:
238/// the JSON form is the machine-canonical wire (the closed-loop probe
239/// emits `serde_json::to_string(envelope)` as the source-of-truth
240/// payload), the YAML twin is the operator-facing readable projection
241/// (`serde_yaml::to_string(envelope)`) — both round-trip through the
242/// SAME [`ReceiptEnvelope::parse_either`] parser, so the primary/fallback
243/// ordering is a payload-format preference, not a semantic distinction.
244/// A future third wire form (CBOR, MessagePack, sigstore-signed JSON)
245/// extends [`RECEIPT_CM_KEYS`] in the primary-first order the readers
246/// prefer, and rustc's `[…; N]` arity constant on the type binds the
247/// extension in lockstep with every consumer.
248///
249/// Sibling substrate-owned wire-form const on the same receipt axis:
250/// [`RECEIPT_CM_SUFFIX`] pins the ConfigMap-name suffix every
251/// default-derivation site composes; [`RECEIPT_VERSION`] pins the
252/// wire-format version string every parser gates on; the three
253/// consts together define the substrate's receipt-CM wire contract at
254/// ONE surface.
255///
256/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
257/// two payload-key literals recurred at FOUR production sites past
258/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold and are lifted
259/// to ONE substrate-owned pair of constants + ONE reader-side
260/// lookup-gate composer here. THEORY.md §V.1 — knowable platform;
261/// the (primary, fallback) ordering that the reader-side gate encodes
262/// becomes a NAMED PRIMITIVE ([`RECEIPT_CM_KEYS`]) rather than an
263/// implicit hand-coded chain reader consumers had to grok from a
264/// `.and_then(...).or_else(...)` pattern.
265pub const RECEIPT_JSON_KEY: &str = "receipt.json";
266
267/// Canonical `data` key on a `receipt`-carrying ConfigMap for the YAML
268/// wire form of a [`ReceiptEnvelope`] — the substrate's operator-facing
269/// FALLBACK payload key. Peer to [`RECEIPT_JSON_KEY`] on the same
270/// wire-form axis; [`RECEIPT_CM_KEYS`] fixes the primary-first ordering
271/// that pins the JSON form ahead of the YAML twin.
272///
273/// See [`RECEIPT_JSON_KEY`] for the full lift rationale and the four
274/// consumer sites this pair owns. The YAML form is the readable
275/// operator projection (`serde_yaml::to_string(envelope)`) the closed-
276/// loop probe writes alongside the JSON form so `kubectl get cm -o
277/// yaml` returns a human-readable payload without an inner JSON
278/// re-parse; the reader accepts it as a fallback when the JSON form is
279/// absent (older probe binaries, out-of-cluster hand-written receipts,
280/// operator-hand-written test fixtures).
281pub const RECEIPT_YAML_KEY: &str = "receipt.yaml";
282
283/// The primary-first closed-set of `data` keys the substrate's
284/// receipt-CM readers look up in order. [`RECEIPT_JSON_KEY`] takes
285/// precedence over [`RECEIPT_YAML_KEY`] — every reader-side lookup
286/// gate ([`extract_receipt_payload_json`] composes the canonical gate)
287/// iterates this table and returns the first hit as `&str`; every
288/// writer emits BOTH entries so the primary-first ordering the reader
289/// prefers matches the JSON form the probe wrote as its source-of-truth
290/// payload.
291///
292/// See [`RECEIPT_JSON_KEY`] for the full lift rationale and the four
293/// consumer sites this table owns. Sibling closed-set tables across
294/// the crate: [`ReceiptEnvelope::REQUIRED_PILLARS`], [`ReceiptKind::ALL`],
295/// [`crate::export::ReportFormat::ALL`], [`crate::phase::ProcessPhase::ALL`],
296/// [`crate::boundary::ConditionKind::ALL`], [`crate::intent::IntentKind::ALL`].
297pub const RECEIPT_CM_KEYS: [&str; 2] = [RECEIPT_JSON_KEY, RECEIPT_YAML_KEY];
298
299/// Operator-facing diagnostic message the substrate's receipt-CM
300/// reader-side lookup gate returns when neither [`RECEIPT_JSON_KEY`]
301/// nor [`RECEIPT_YAML_KEY`] is present as a string-valued entry on the
302/// ConfigMap's `data` map. Named through the substrate so the message
303/// stays coherent with [`RECEIPT_CM_KEYS`] — a rename at either key
304/// const would leave this message spelling the pre-rename literals
305/// verbatim, so both this const AND the two key consts live at ONE
306/// substrate site and any future re-shape sweeps them together.
307///
308/// Composed as a `&'static` byte-literal (not a `format!(...)`) so the
309/// composition does not participate in the workspace's typed-emission
310/// ban migration (skip-format-ban CLAUDE.md note); the shape is fixed
311/// at the two key literals' current spellings.
312pub const RECEIPT_CM_MISSING_KEY_MSG: &str =
313    "ConfigMap missing data['receipt.json' | 'receipt.yaml'] string key";
314
315/// Primary-first reader-side lookup gate for a receipt-CM's `data`
316/// map — the substrate primitive that owns the (primary, fallback)
317/// key-precedence chain every receipt-CM reader threads through.
318///
319/// Iterates [`RECEIPT_CM_KEYS`] in order and returns the first entry
320/// present as a string-valued JSON scalar; returns `None` when the
321/// `data` map is absent, when neither key is present, or when the
322/// entry at either key is a non-string JSON value (a JSON number,
323/// object, or array — none of which are a valid receipt-payload
324/// projection under the wire contract).
325///
326/// Load-bearing at ONE production reader-side site pre-lift
327/// (`tatara-reconciler::boundary::verify_receipt_cm`) whose 3-link
328/// `.and_then(|d| d.get(RECEIPT_JSON_KEY)).or_else(|| … RECEIPT_YAML_KEY
329/// )).and_then(|v| v.as_str())` chain restated the SAME two-key-
330/// with-string-scalar-projection shape as inline combinator plumbing
331/// — a shape the substrate now owns at ONE place so a future third
332/// wire form (a CBOR encoding, a sigstore-signed JSON form, a
333/// per-cluster payload rename) extends this primitive in lockstep
334/// with the [`RECEIPT_CM_KEYS`] table AND every reader consumer
335/// picks up the change mechanically. Sibling reader consumers
336/// (kenshi-runner's P3 test-suite receipt readback, shinka's per-
337/// migration receipt readback, any future per-Job attestation
338/// verifier) compose this ONE substrate primitive rather than
339/// re-authoring the primary/fallback chain per-consumer.
340///
341/// Lifetime: the returned `&str` borrows from the passed-in
342/// `serde_json::Value` — the reader's `data` reference must outlive
343/// the returned payload borrow. Every production reader-side consumer
344/// already holds the DynamicObject that owns the `Value` graph across
345/// the parse call that consumes this borrow, so the lifetime binding
346/// composes cleanly.
347///
348/// Theory anchor: THEORY.md §VI.1 — generation over composition; the
349/// (primary-key, fallback-key, string-scalar-projection) 3-link
350/// combinator chain recurred at ONE production reader site with the
351/// (primary, fallback) ordering as a load-bearing invariant nowhere
352/// bound at the substrate — post-lift ONE substrate primitive owns
353/// the chain AND [`RECEIPT_CM_KEYS`] pins the ordering the primitive
354/// iterates through. THEORY.md §V.1 — knowable platform; the reader-
355/// side (primary, fallback) precedence becomes a NAMED PRIMITIVE the
356/// receipt-inspection surfaces (LSP hover, `tatara-check` receipt-
357/// inspect report, REPL) bind to for reading the wire contract from
358/// the substrate directly.
359#[must_use]
360pub fn extract_receipt_payload_json(data: Option<&serde_json::Value>) -> Option<&str> {
361    for key in RECEIPT_CM_KEYS {
362        if let Some(v) = data
363            .and_then(|d| d.get(key))
364            .and_then(serde_json::Value::as_str)
365        {
366            return Some(v);
367        }
368    }
369    None
370}
371
372/// Closed-set typed identifier for the four known [`ReceiptEnvelope::kind`]
373/// strings the substrate emits today — [`Self::ClosedLoopAuth`] →
374/// `"closed-loop-auth"`, [`Self::DbMigration`] → `"db-migration"`,
375/// [`Self::TestSuite`] → `"test-suite"`, [`Self::NixBuild`] →
376/// `"nix-build"` — as a Rust enum, so the (variant, canonical kebab-case
377/// kind, semantic role) triple binds at ONE site on the typed algebra
378/// rather than at the four byte-identical string-literal sites scattered
379/// across the closed-loop probe binary (`default_value` on
380/// `--receipt-kind`), the reconciler's receipt-parser tests, the
381/// `ephemeral_pipeline` integration test, and the future shinka /
382/// kenshi / nix-build Job authors that compose `ReceiptEnvelope::build`.
383///
384/// Pre-lift the four canonical kebab-case kinds lived as `&'static str`
385/// literal arguments at every author site (`ReceiptEnvelope::build(
386/// "closed-loop-auth", …)`) AND as docstring prose at this module's
387/// header (`Today's consumers: closed-loop-auth, db-migration,
388/// test-suite, nix-build`). The (canonical-string, semantic-role)
389/// pairing was load-bearing across ≥5 files yet enforced by per-site
390/// call-site discipline — a rename of `"closed-loop-auth"` →
391/// `"closed-loop"` at the probe binary's CLI default (the originator of
392/// every production receipt) silently desynchronizes from the docstring
393/// prose AND from the reconciler's test fixtures AND from any future
394/// kind-keyed dispatch (e.g. shinka's per-kind verifier registry) — the
395/// `kind` field is a `String` from the wire shape's perspective so the
396/// compiler cannot bind the literals together. Post-lift the canonical
397/// kebab-case strings live at ONE [`Self::as_str`] arm per variant;
398/// every author site composes the typed variant through
399/// `ReceiptEnvelope::build(ReceiptKind::ClosedLoopAuth, …)` (the typed
400/// → `String` `From` impl lets the existing `impl Into<String>` API
401/// surface accept the variant transparently) and a rename lands at ONE
402/// `as_str` arm here — no per-call-site grep + edit sweep, no silent
403/// drift between the docstring header and the wire literals.
404///
405/// The `kind` field on [`ReceiptEnvelope`] remains a `String` because
406/// the schema is open by design: operators register new `kind` strings
407/// for future consumers (operator-domain Job receipts) without bumping
408/// the wire version. The typed `ReceiptKind` is the closed-set *view*
409/// over that open String — every receipt the substrate itself emits
410/// projects through one of the four typed variants, and the typed
411/// projection [`ReceiptEnvelope::known_kind`] decodes any envelope's
412/// `kind` into `Some(ReceiptKind)` when it matches a known variant,
413/// `None` for operator-registered open kinds. The (open-String,
414/// closed-typed-view) split is the same shape `tatara-lisp`'s
415/// `Sexp::Sym` (open atoms) vs `MacroDefHead` (closed-set head
416/// markers) takes — open data through one type, closed dispatch
417/// through another, no `_` fallthrough where the closed set runs.
418///
419/// Adding a fifth kind (e.g. `Provenance` → `"provenance-attest"`)
420/// extends the enum AND the two projection arms ([`Self::as_str`],
421/// [`Self::from_str`] via the [`Self::ALL`] sweep) in lockstep — rustc
422/// binds the extension through exhaustiveness over the closed enum so
423/// a partial extension that forgets ONE projection becomes a compile
424/// error rather than a runtime drift where the new kind builds receipts
425/// but `known_kind()` returns `None` and the future kind-keyed verifier
426/// dispatch silently falls through.
427///
428/// Sibling closed-set [`Self::ALL`] lift across the crate:
429/// [`crate::export::ReportFormat::ALL`],
430/// [`crate::export::ExportTrigger::ALL`],
431/// [`crate::export::ReportPayloadShape::ALL`],
432/// [`crate::phase::ProcessPhase::ALL`],
433/// [`crate::signal::ProcessSignal::ALL`],
434/// [`crate::boundary::ConditionKind::ALL`],
435/// [`crate::lifetime::TeardownPolicy::ALL`],
436/// [`crate::lifetime::LifetimeKind::ALL`],
437/// [`crate::intent::IntentKind::ALL`],
438/// [`crate::lifetime_clock::TerminateReasonKind::ALL`].
439///
440/// Theory anchor: THEORY.md §III — the typescape; the substrate's own
441/// receipt kinds become a TYPE rather than four `&'static str` literals
442/// at every author site and a docstring header that drifts the moment
443/// any rename happens off-script. THEORY.md §V.3 — three-pillar
444/// attestation; the `kind` field is the *what-am-I* discriminator on
445/// every receipt that chains into a [`ProcessAttestation`], and the
446/// typed variant is the substrate's shared vocabulary for "which kind
447/// of work just got attested" — pre-lift each call site had to spell
448/// the kind by hand, post-lift each call site composes the typed
449/// constant and any consumer (future verifier, future dashboard, future
450/// LSP completion) sweeps [`Self::ALL`] to enumerate every known
451/// substrate-emitted receipt without grep.
452#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
453#[closed_set(via = "as_str", display, generate_unknown)]
454pub enum ReceiptKind {
455    /// Closed-loop auth probe — stamps that a system's bundled identity
456    /// issuer authenticated its bundled client. Emitted by
457    /// `tatara-closed-loop-probe`; the substrate primitive every
458    /// closed-loop-testable product composes (an issuer↔client pair,
459    /// future: identity providers, message brokers, databases that can
460    /// issue creds to themselves).
461    ClosedLoopAuth,
462    /// Schema/migration runs. shinka emits one per applied migration;
463    /// the pillars carry the diff hash so the chain shows exactly which
464    /// migration was applied where.
465    DbMigration,
466    /// Test suites — kenshi-runner et al. The `evidence` field carries
467    /// pass/fail counts; the pillars stamp the suite identity.
468    TestSuite,
469    /// Nix builds. Carries the store-path pillar as `artifact_hash`;
470    /// chains every reproducible build into the Process attestation
471    /// chain so a derivation's output is provable on its owning
472    /// Process.
473    NixBuild,
474    /// Export-worker shipments. Emitted by `tatara-export-worker` when
475    /// a declared [`crate::export::ExportSpec`] finishes shipping its
476    /// artifact to the chosen [`crate::export::VectorChannel`]; the
477    /// three pillars are the canonical `ExportSpec` (intent), the
478    /// shipped event bytes (artifact), and the canonical
479    /// `ExportOutcome` (control). Sibling to [`Self::ClosedLoopAuth`]
480    /// on the "typed receipt kind emitted by a bundled Job" axis —
481    /// both variants share the same [`ReceiptEnvelope`] wire shape,
482    /// the same [`crate::three_pillar`] compose contract, and the same
483    /// `tatara-reconciler::boundary::JobAttested` reader-side verifier
484    /// (which advances the Process out of `Releasing` iff the
485    /// composed_root matches).
486    ///
487    /// Wire form `"tatara.export"` (dot-separated rather than
488    /// kebab-case; predates the closed-set lift). The typed
489    /// projection binds the wire literal at ONE [`Self::as_str`] arm
490    /// so a future rename lands at that arm alone rather than at
491    /// every author site + docstring restatement.
492    Export,
493}
494
495impl ReceiptKind {
496    /// The closed set of substrate-emitted receipt kinds — single
497    /// source of truth that drives the [`Self::from_str`] decode sweep
498    /// AND any future enumeration consumer (kind-keyed verifier
499    /// registry, dashboard completion list, `tatara-check` receipt-kind
500    /// enumeration). Adding a sixth variant (e.g. `Provenance` →
501    /// `"provenance-attest"`) lands at one `ALL` entry + one `as_str`
502    /// arm — exhaustively checked by the compiler (the `[Self; 5]`
503    /// array literal forces the arity) AND by the per-variant
504    /// truth-table tests below.
505    ///
506    /// Sibling closed-set lifts across the crate's typescape:
507    /// [`crate::export::ReportFormat::ALL`],
508    /// [`crate::phase::ProcessPhase::ALL`],
509    /// [`crate::boundary::ConditionKind::ALL`],
510    /// [`crate::intent::IntentKind::ALL`].
511    pub const ALL: [Self; 5] = [
512        Self::ClosedLoopAuth,
513        Self::DbMigration,
514        Self::TestSuite,
515        Self::NixBuild,
516        Self::Export,
517    ];
518
519    /// Canonical kebab-case wire-format kind — the literal that lands
520    /// in [`ReceiptEnvelope::kind`] when this variant authors the
521    /// receipt. Pinned to four byte-exact strings the substrate has
522    /// already published (the closed-loop probe's `default_value` on
523    /// `--receipt-kind`, the reconciler tests' fixture builds, the
524    /// `ephemeral_pipeline` integration test's assertions) — renaming
525    /// any one is a wire-format change, not a typed-internal refactor,
526    /// and the `receipt_kind_canonical_names_pinned` truth-table test
527    /// fails first to keep the substrate honest. Used by
528    /// [`fmt::Display`] (single source of truth) and as the `String`
529    /// projection that `From<ReceiptKind> for String` ([`Self::into`])
530    /// composes so [`ReceiptEnvelope::build`]'s `impl Into<String>`
531    /// kind argument transparently accepts the typed variant.
532    #[must_use]
533    pub const fn as_str(self) -> &'static str {
534        match self {
535            Self::ClosedLoopAuth => "closed-loop-auth",
536            Self::DbMigration => "db-migration",
537            Self::TestSuite => "test-suite",
538            Self::NixBuild => "nix-build",
539            Self::Export => "tatara.export",
540        }
541    }
542}
543
544// `impl fmt::Display for ReceiptKind` + `impl FromStr for ReceiptKind`
545// + `impl tatara_lisp::ClosedSet for ReceiptKind` + `pub struct
546// UnknownReceiptKind(pub String)` are generated by
547// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(via =
548// "as_str", display, generate_unknown)]` on the enum declaration above.
549// The auto-derived label `"receipt kind"` matches the prior hand-
550// rolled `#[error("unknown receipt kind: {0}")]` verbatim. The
551// inherent `as_str` projection stays load-bearing — the kebab-case
552// wire-format that matches `ReceiptEnvelope::kind`'s published literals
553// verbatim — while the trait method `label` gives generic consumers a
554// STABLE name across the workspace-wide closed-set implementors. The
555// open-by-design `ReceiptEnvelope::known_kind` projection routes the
556// `Err(UnknownReceiptKind)` arm into a `None` so operator-registered
557// open kinds stay open.
558
559impl From<ReceiptKind> for String {
560    /// Composes [`ReceiptKind::as_str`] into an owned `String` so
561    /// every `impl Into<String>` API surface ([`ReceiptEnvelope::build`]'s
562    /// `kind` parameter most notably) accepts the typed variant
563    /// transparently — the call site stays `build(kind, …)` and the
564    /// typed → wire bridge runs through ONE place.
565    fn from(k: ReceiptKind) -> Self {
566        k.as_str().to_owned()
567    }
568}
569
570impl From<ReceiptKind> for &'static str {
571    fn from(k: ReceiptKind) -> Self {
572        k.as_str()
573    }
574}
575
576/// One entry in the [`ReceiptEnvelope::REQUIRED_PILLARS`] closed-set
577/// table — the pair (diagnostic field name, wire-form accessor) that
578/// composes ONE required-pillar rejection through the shared
579/// [`require_nonempty`] peer. The alias gives the tuple a nameable
580/// type so downstream consumers (`tatara-check` receipt-inspector, an
581/// LSP hover on the const, per-pillar dashboard columns) bind to
582/// "one pillar's descriptor" as a first-class handle rather than
583/// re-typing the underlying `(&'static str, fn(&ReceiptEnvelope) ->
584/// &str)` tuple at every consumer.
585pub type RequiredPillar = (&'static str, fn(&ReceiptEnvelope) -> &str);
586
587/// Typed receipt envelope. Any Job in pleme-io that wants its result to
588/// chain into a Process's `status.attestation` writes one of these.
589#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
590#[serde(rename_all = "snake_case", deny_unknown_fields)]
591pub struct ReceiptEnvelope {
592    /// Must equal `RECEIPT_VERSION`. Mismatches reject the receipt.
593    pub version: String,
594    /// What this receipt proves. Known: `closed-loop-auth`, `db-migration`,
595    /// `test-suite`, `nix-build`. Operators may register new kinds —
596    /// the envelope is open.
597    pub kind: String,
598    /// Three-pillar root: `BLAKE3(domain ++ artifact ++ control ++ intent ++ previous)`.
599    pub composed_root: String,
600    /// Pillar 1: what the Job was *trying* to do (canonical intent).
601    pub intent_hash: String,
602    /// Pillar 2: what the Job *produced* (artifact / proof material).
603    pub artifact_hash: String,
604    /// Pillar 3: how the Job *verified* its work (controls / signatures /
605    /// auth steps). Empty string when there was no control step.
606    pub control_hash: String,
607    /// Timestamp the Job set when it wrote the receipt.
608    pub generated_at: DateTime<Utc>,
609    /// Optional owning-Process reference (`namespace/name`). When the
610    /// reconciler creates the Job it stamps this in via the downward
611    /// API; receipts without it still parse for ad-hoc / out-of-cluster
612    /// runs.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub process_ref: Option<String>,
615    /// Optional structured evidence. Free-form JSON. The reconciler does
616    /// not parse this — it's for human / downstream-tool inspection.
617    #[serde(default, skip_serializing_if = "is_null")]
618    pub evidence: serde_json::Value,
619}
620
621fn is_null(v: &serde_json::Value) -> bool {
622    v.is_null()
623}
624
625/// Wire-form encoding a [`ReceiptEnvelope`] payload was serialized
626/// in — the closed set of on-disk shapes the reader accepts.
627///
628/// Substrate primitive that closes the "which parser did this
629/// payload use" corner at ONE typed enum: every dispatcher that
630/// selects a parser (the shared [`ReceiptEnvelope::parse`] entry,
631/// the [`ReceiptEnvelope::parse_json`] / [`ReceiptEnvelope::parse_yaml`]
632/// wrappers, and the JSON-first-YAML-fallback
633/// [`ReceiptEnvelope::parse_either`]) routes through one arm of this
634/// enum, so a future wire form (e.g. a `Cbor` variant for a binary
635/// emit path, an `MsgPack` variant for a bandwidth-tight probe) lands
636/// as ONE variant + ONE arm of [`Self::parse_raw`]. Peer to
637/// [`ReceiptKind`] on the "one closed-set typed enum per wire-format
638/// axis" pattern — [`ReceiptKind`] closes the *semantic* kind axis
639/// (what the receipt claims), this enum closes the *encoding* axis
640/// (how the payload was written down).
641///
642/// The wrap-variant selection on the [`ReceiptError`] side
643/// ([`ReceiptError::InvalidJson`] vs [`ReceiptError::InvalidYaml`])
644/// travels with the wire-form arm here, so operators reading a
645/// failure surface see the encoding that failed without the caller
646/// having to hand-thread a per-form string label. See
647/// [`Self::error_variant`] for the closed-set mapping.
648#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
649pub enum ReceiptWireForm {
650    /// Compact JSON — the closed-loop-probe binary's default emit
651    /// form and the shape `serde_json::to_string(&env)` produces.
652    Json,
653    /// YAML — the shape a ConfigMap authored via `kubectl apply -f`
654    /// carries, and what `serde_yaml::to_string(&env)` produces.
655    Yaml,
656}
657
658impl ReceiptWireForm {
659    /// The closed set of wire-form encodings the reader accepts —
660    /// single source of truth that drives the JSON-first-YAML-fallback
661    /// sweep at [`ReceiptEnvelope::parse_either`] AND any future
662    /// enumeration consumer (a per-form metrics tag walker, a
663    /// `tatara-check` receipt-form auditor, a CLI `--wire-form` flag
664    /// completion list). Adding a third variant (e.g. `Cbor` for the
665    /// binary-emit corner already anticipated at [`Self`]'s enum
666    /// docstring, or `MsgPack` for a bandwidth-tight probe) lands as
667    /// ONE new variant + ONE arm on [`Self::parse_raw`] + ONE arm on
668    /// [`Self::as_str`] + ONE entry in [`Self::ALL`] — exhaustively
669    /// checked by the compiler (the `[Self; N]` array literal forces
670    /// the arity) AND by the per-variant truth-table tests below AND
671    /// by `parse_either`'s ALL-driven sweep (which auto-picks up the
672    /// new form without a per-consumer edit at the fallback chain).
673    ///
674    /// Sibling closed-set tables across the crate's typescape — this
675    /// entry closes the fourth axis they jointly own (semantic kind /
676    /// export report / process phase / boundary condition / intent
677    /// kind / **wire-form encoding**):
678    /// [`ReceiptKind::ALL`],
679    /// [`crate::export::ReportFormat::ALL`],
680    /// [`crate::phase::ProcessPhase::ALL`],
681    /// [`crate::boundary::ConditionKind::ALL`],
682    /// [`crate::intent::IntentKind::ALL`].
683    ///
684    /// The array order is JSON THEN YAML — matching the historical
685    /// [`ReceiptEnvelope::parse_either`] `.or_else` chain that tried
686    /// JSON first and YAML on JSON failure, so operator-facing log
687    /// lines reading the LAST-form error variant continue to see the
688    /// same fallback-form error (`ReceiptError::InvalidYaml`) on a
689    /// receipt payload that both forms reject. A regression that
690    /// reorders the entries surfaces at
691    /// [`tests::receipt_wire_form_all_matches_declaration_order_json_then_yaml`]
692    /// rather than as silent operator-facing skew across every
693    /// fallback log line.
694    pub const ALL: [Self; 2] = [Self::Json, Self::Yaml];
695
696    /// Deserialize `payload` with this wire-form's serde reader,
697    /// wrapping the parser's `Display` in the matching per-form
698    /// [`ReceiptError`] variant. Does NOT run
699    /// [`ReceiptEnvelope::verify_shape`] — the shared
700    /// [`ReceiptEnvelope::parse`] owner composes that postpass so
701    /// every wrapper picks it up mechanically.
702    fn parse_raw(self, payload: &str) -> Result<ReceiptEnvelope, ReceiptError> {
703        match self {
704            Self::Json => {
705                serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))
706            }
707            Self::Yaml => {
708                serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))
709            }
710        }
711    }
712
713    /// Stable wire-form label — the short lowercase identifier
714    /// (`"json"` / `"yaml"`) an operator-facing log line, a
715    /// per-form metrics tag, or a future CLI flag surface can print
716    /// or match against. Pins the closed-set spelling at ONE table
717    /// so a downstream rename lands here rather than at every
718    /// consumer that hand-composed `"json"` / `"yaml"` inline.
719    #[must_use]
720    pub const fn as_str(self) -> &'static str {
721        match self {
722            Self::Json => "json",
723            Self::Yaml => "yaml",
724        }
725    }
726}
727
728/// Why a receipt is rejected. Kept as a typed enum so callers can
729/// pattern-match on the failure mode and surface targeted operator
730/// messages.
731#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
732pub enum ReceiptError {
733    #[error("invalid JSON: {0}")]
734    InvalidJson(String),
735    #[error("invalid YAML: {0}")]
736    InvalidYaml(String),
737    #[error("version != {RECEIPT_VERSION} (got {0:?})")]
738    WrongVersion(String),
739    #[error("missing required field: {0}")]
740    MissingField(&'static str),
741    #[error("kind is empty")]
742    EmptyKind,
743    #[error("composed_root mismatch (got {got}, want {want})")]
744    RootMismatch { got: String, want: String },
745}
746
747impl ReceiptEnvelope {
748    /// Build a receipt envelope from typed pillars + kind. `generated_at`
749    /// defaults to `Utc::now()`.
750    pub fn build(
751        kind: impl Into<String>,
752        intent_hash: impl Into<String>,
753        artifact_hash: impl Into<String>,
754        control_hash: impl Into<String>,
755        previous_root: Option<&str>,
756    ) -> Self {
757        let intent_hash = intent_hash.into();
758        let artifact_hash = artifact_hash.into();
759        let control_hash = control_hash.into();
760        let composed_root = three_pillar::compose_root(
761            &artifact_hash,
762            empty_to_none(&control_hash),
763            &intent_hash,
764            previous_root,
765        );
766        Self {
767            version: RECEIPT_VERSION.into(),
768            kind: kind.into(),
769            composed_root,
770            intent_hash,
771            artifact_hash,
772            control_hash,
773            generated_at: Utc::now(),
774            process_ref: None,
775            evidence: serde_json::Value::Null,
776        }
777    }
778
779    /// Parse `payload` as a [`ReceiptWireForm`]-tagged encoding and
780    /// validate the result's shape — the ONE parse-then-verify chain
781    /// every [`Self::parse_json`] / [`Self::parse_yaml`] /
782    /// [`Self::parse_either`] wrapper composes through, and the entry
783    /// consumers that dispatch on a typed wire-form value (an
784    /// operator-supplied "receipt payload is <form>" annotation, a
785    /// future CLI flag on `tatara-check` selecting the reader) call
786    /// directly.
787    ///
788    /// Pre-lift the parse + `verify_shape` chain was hand-authored at
789    /// TWO byte-identical sites past the ★★ PRIME-DIRECTIVE ≥ 2
790    /// duplication threshold — [`Self::parse_json`] and
791    /// [`Self::parse_yaml`] each restated the same
792    /// `<parser>(payload).map_err(|e| ReceiptError::<Variant>(
793    /// e.to_string()))?;` + `env.verify_shape()?; Ok(env)` shape with
794    /// only the parser + wrap-variant swapped. Post-lift the two
795    /// per-form deserialize+wrap pairings live at ONE closed-set
796    /// dispatch inside [`ReceiptWireForm::parse_raw`]; this owner
797    /// composes THAT dispatch with the shared `verify_shape` postpass
798    /// so the two wrappers, [`Self::parse_either`], and any future
799    /// wire-form consumer (adding e.g. `ReceiptWireForm::Cbor` for a
800    /// binary emit path) land at ONE arm without re-authoring the
801    /// verify chain.
802    pub fn parse(payload: &str, form: ReceiptWireForm) -> Result<Self, ReceiptError> {
803        let env = form.parse_raw(payload)?;
804        env.verify_shape()?;
805        Ok(env)
806    }
807
808    /// Parse a receipt from a JSON string.
809    pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
810        Self::parse(payload, ReceiptWireForm::Json)
811    }
812
813    /// Parse a receipt from a YAML string. Useful for ConfigMaps that
814    /// store the payload in YAML form.
815    pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
816        Self::parse(payload, ReceiptWireForm::Yaml)
817    }
818
819    /// Parse via JSON first, then YAML if JSON fails. Lets a single
820    /// reader accept either wire form without the operator having to
821    /// declare it. Useful when the Job writes JSON and the reconciler
822    /// reads back through a kube DynamicObject whose `data` is YAML.
823    ///
824    /// Routes the fallback sweep through the closed-set table
825    /// [`ReceiptWireForm::ALL`] so a future third wire-form variant
826    /// (`Cbor`, `MsgPack`) picks up the fallback automatically — the
827    /// per-callsite `Self::parse(payload, Wire::A).or_else(|_|
828    /// Self::parse(payload, Wire::B))` chain would otherwise need a
829    /// third `.or_else` link at THIS site the moment the enum grew.
830    /// On full failure, the returned error is the LAST attempted
831    /// form's error variant (byte-for-byte identical to the pre-lift
832    /// `.or_else` chain, which discarded the JSON error and returned
833    /// the YAML error) — pinned at
834    /// [`tests::parse_either_preserves_last_form_error_variant_on_full_failure`].
835    pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
836        let mut last_err: Option<ReceiptError> = None;
837        for form in ReceiptWireForm::ALL {
838            match Self::parse(payload, form) {
839                Ok(env) => return Ok(env),
840                Err(e) => last_err = Some(e),
841            }
842        }
843        // `ReceiptWireForm::ALL: [Self; 2]` is non-empty at the type
844        // level, so the loop assigns `last_err` on every full-failure
845        // path. The `expect` documents the invariant a future zero-
846        // arity mistake at the ALL table would surface with.
847        Err(last_err.expect("ReceiptWireForm::ALL is non-empty"))
848    }
849
850    /// Closed-set table of pillars that MUST be non-empty on every
851    /// well-formed receipt — the wire-form's structural invariant
852    /// [`Self::verify_shape`] enforces. Pre-lift the three checks
853    /// lived as three byte-identical `if self.<pillar>.is_empty() {
854    /// return Err(ReceiptError::MissingField("<pillar>")); }` two-arm
855    /// conditionals inline in `verify_shape` — one per pillar name,
856    /// each hand-writing the SAME (field-name, accessor, rejection)
857    /// triple with the pillar name repeated at BOTH the accessor
858    /// (`self.composed_root`) AND the diagnostic literal
859    /// (`"composed_root"`). Post-lift the three (field-name,
860    /// accessor) pairs live at ONE closed-set table here;
861    /// `verify_shape` composes ONE per-entry iteration that
862    /// dispatches through the shared [`require_nonempty`] free-fn
863    /// peer of [`empty_to_none`].
864    ///
865    /// Each entry is a [`RequiredPillar`] tuple whose named type gives
866    /// downstream consumers (a `tatara-check` receipt-inspector, an
867    /// LSP hover, a per-pillar dashboard column) a nameable handle
868    /// for "one pillar's (diagnostic-name, wire-form-accessor)
869    /// pairing" rather than an unnamed function-pointer tuple
870    /// re-typed at every consumer.
871    ///
872    /// The `control_hash` field is DELIBERATELY NOT in this table:
873    /// the substrate's second pillar carries an "empty means absent"
874    /// convention that [`Self::control_hash_opt`] + [`empty_to_none`]
875    /// project as a typed `Option::None`, so its emptiness is a
876    /// semantic bit rather than a validation failure. The pair
877    /// (`REQUIRED_PILLARS` — must be non-empty; `control_hash_opt` —
878    /// may be empty) is the substrate's typed answer to which
879    /// pillars are load-bearing vs. schema-optional. A future
880    /// re-shape that promotes a fourth required pillar (e.g. a
881    /// mandatory `signer_hash` on a signed-receipt schema variant)
882    /// lands as ONE new entry in this table + rustc's `[…; N]`
883    /// arity constant on the type binding the extension in lockstep
884    /// so a partial addition that forgets the diagnostic surface
885    /// becomes a compile error rather than a runtime drift.
886    ///
887    /// Sibling closed-set tables across the crate:
888    /// [`ReceiptKind::ALL`],
889    /// [`crate::export::ReportFormat::ALL`],
890    /// [`crate::phase::ProcessPhase::ALL`],
891    /// [`crate::boundary::ConditionKind::ALL`],
892    /// [`crate::intent::IntentKind::ALL`].
893    ///
894    /// Theory anchor: THEORY.md §VI.1 — generation over composition;
895    /// the three inline pillar-emptiness checks recurred at THREE
896    /// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
897    /// and are lifted to ONE closed-set table + ONE shared rejection
898    /// peer. THEORY.md §V.1 — knowable platform; the enumeration of
899    /// required-pillar field names lives at ONE surface a
900    /// documentation surface, an LSP hover, or a `tatara-check`
901    /// receipt-inspector binds to for enumerating the receipt's
902    /// structural invariants. THEORY.md §V.3 — three-pillar
903    /// attestation; the (mandatory, may-be-absent) split of the
904    /// three-pillar-plus-composed-root wire form is a typed
905    /// substrate contract, not a per-consumer discipline.
906    pub const REQUIRED_PILLARS: [RequiredPillar; 3] = [
907        ("composed_root", |e| e.composed_root.as_str()),
908        ("intent_hash", |e| e.intent_hash.as_str()),
909        ("artifact_hash", |e| e.artifact_hash.as_str()),
910    ];
911
912    /// Verify the schema-level invariants: correct version + non-empty
913    /// kind + non-empty pillar hashes (length-only, not BLAKE3-recompute).
914    /// The three required-pillar rejections dispatch through
915    /// [`Self::REQUIRED_PILLARS`] + [`require_nonempty`] so a future
916    /// fourth required pillar lands at ONE table entry rather than
917    /// as a fourth inline `if …is_empty() { return Err(…); }` copy.
918    pub fn verify_shape(&self) -> Result<(), ReceiptError> {
919        if self.version != RECEIPT_VERSION {
920            return Err(ReceiptError::WrongVersion(self.version.clone()));
921        }
922        if self.kind.is_empty() {
923            return Err(ReceiptError::EmptyKind);
924        }
925        for (field, accessor) in Self::REQUIRED_PILLARS {
926            require_nonempty(field, accessor(self))?;
927        }
928        // control_hash MAY be empty when there is no control step;
929        // the BLAKE3 compose treats empty as "absent" via Option —
930        // see `Self::control_hash_opt` + `empty_to_none`.
931        Ok(())
932    }
933
934    /// Verify that `composed_root` is consistent with the pillars.
935    /// `expected_previous_root` is the previous root in the Process's
936    /// attestation chain (or `None` for first attestation).
937    pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
938        let want = three_pillar::compose_root(
939            &self.artifact_hash,
940            self.control_hash_opt(),
941            &self.intent_hash,
942            expected_previous_root,
943        );
944        three_pillar::constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
945    }
946
947    /// Strict-equality check against an operator-provided expected root.
948    /// Returns the receipt's root unchanged on success.
949    pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
950        if let Some(want) = expected {
951            if want != self.composed_root {
952                return Err(ReceiptError::RootMismatch {
953                    got: self.composed_root.clone(),
954                    want: want.to_string(),
955                });
956            }
957        }
958        Ok(&self.composed_root)
959    }
960
961    /// Decode `self.kind` into the typed [`ReceiptKind`] variant when
962    /// the wire string matches one of the four substrate-emitted
963    /// canonical kebab-case kinds; `None` when the kind is an
964    /// operator-registered open string (the schema is open by design —
965    /// every receipt remains a valid receipt, but only typed kinds
966    /// participate in closed-set dispatch). The (open `String`,
967    /// closed-typed view) split lets future kind-keyed consumers
968    /// (verifier registries, dashboard completion, audit-trail
969    /// classifiers) sweep the typed variants without touching the
970    /// open-by-design wire shape. Lifted as the canonical decode site
971    /// so no consumer re-implements the `match self.kind.as_str()`
972    /// arm-by-arm — the closed-set sweep happens through
973    /// [`ReceiptKind::from_str`] at ONE site.
974    #[must_use]
975    pub fn known_kind(&self) -> Option<ReceiptKind> {
976        self.kind.parse().ok()
977    }
978
979    /// Lower into a `ProcessAttestation` — the canonical handoff so a
980    /// Job's typed receipt becomes evidence on a Process. `generation`
981    /// + `previous_root` come from the owning Process's prior
982    /// attestation (or 0 + None for the first cycle).
983    pub fn to_attestation(
984        &self,
985        generation: u64,
986        previous_root: Option<&str>,
987    ) -> ProcessAttestation {
988        ProcessAttestation::compose(
989            self.artifact_hash.clone(),
990            self.control_hash_opt().map(str::to_owned),
991            self.intent_hash.clone(),
992            previous_root.map(String::from),
993            generation,
994        )
995    }
996
997    /// Typed projection of the wire form's `control_hash` field —
998    /// `Some(hash)` when a control step ran, `None` when it did not.
999    ///
1000    /// The wire form stamps `control_hash: String` (schema-open,
1001    /// serde-friendly), but the substrate's `three_pillar::compose_root`
1002    /// + `ProcessAttestation::compose` compositions both take an
1003    /// `Option<&str>` / `Option<String>` and thread `None` through the
1004    /// exact BLAKE3 bytes pattern an absent-pillar walk emits — an
1005    /// empty `control_hash` and an absent-pillar receipt hash to the
1006    /// SAME `composed_root`. That "empty means absent" convention
1007    /// pre-lift lived at THREE sites inside this impl block —
1008    /// [`Self::build`] (constructing the envelope from typed pillars),
1009    /// [`Self::verify_root`] (recomposing the root against pillars for
1010    /// wire-form verification), and [`Self::to_attestation`] (lowering
1011    /// the receipt into a [`ProcessAttestation`] on a Process's
1012    /// attestation chain) — each hand-writing the SAME
1013    /// `if self.control_hash.is_empty() { None } else {
1014    /// Some(self.control_hash.as_str()) }` two-arm conditional. Post-
1015    /// lift the convention lives at ONE method here; the three
1016    /// consumers each compose a ONE-LINE call:
1017    ///   * `verify_root` → `self.control_hash_opt()` directly,
1018    ///   * `to_attestation` → `self.control_hash_opt().map(str::to_owned)`
1019    ///     for the `Option<String>` shape [`ProcessAttestation::compose`]
1020    ///     binds,
1021    ///   * `build` (which reads a local `control_hash: String` before
1022    ///     the envelope is constructed) → the free-fn peer
1023    ///     [`empty_to_none`] on the same borrowed string.
1024    ///
1025    /// Public because the projection is load-bearing operator-facing
1026    /// contract: an authoring surface (an LSP hover, a
1027    /// `tatara-check` report, a REPL `:receipt-inspect` command) that
1028    /// wants to render "no control step" vs. "control_hash: <hash>"
1029    /// binds to this method rather than pattern-matching on
1030    /// `self.control_hash.is_empty()` at its own call site — a future
1031    /// re-shape of the empty-means-absent convention (a sentinel-
1032    /// string variant, an explicit `Option<String>` on the wire form
1033    /// once the schema evolves, or a typed
1034    /// `ControlStep::{Ran(hash), Skipped}` enum) lands at ONE method
1035    /// here rather than at every consumer that inspects the pillar.
1036    ///
1037    /// Theory anchor: THEORY.md §V.1 — knowable platform; the
1038    /// wire-vs-typed projection lives at ONE substrate method so a
1039    /// consumer reads the pillar's typed-Option contract from the
1040    /// receipt directly, not from three parallel inline conditionals
1041    /// scattered across `build` / `verify_root` / `to_attestation`.
1042    /// THEORY.md §VI.1 — generation over composition; the
1043    /// `is_empty() ? None : Some(&self.control_hash)` two-arm
1044    /// projection recurred at THREE inline sites past the ★★
1045    /// PRIME-DIRECTIVE ≥ 2 duplication threshold and is lifted to ONE
1046    /// owner here. THEORY.md §V.3 — three-pillar attestation; the
1047    /// receipt's second pillar (control step) has ONE typed projection
1048    /// site the composition primitives
1049    /// ([`three_pillar::compose_root`], [`ProcessAttestation::compose`])
1050    /// both bind against, so the pillar's wire-vs-typed identity
1051    /// cannot drift across the three consumers.
1052    #[must_use]
1053    pub fn control_hash_opt(&self) -> Option<&str> {
1054        empty_to_none(&self.control_hash)
1055    }
1056}
1057
1058/// Project a wire-form pillar string onto its typed `Option<&str>`
1059/// contract — `Some(s)` when `s` is non-empty, `None` when `s` is
1060/// empty (the substrate's "no such pillar" convention that
1061/// [`three_pillar::compose_root`] + [`ProcessAttestation::compose`]
1062/// both thread as an absent-pillar walk through the BLAKE3
1063/// domain-tagged composition).
1064///
1065/// The free-fn peer of [`ReceiptEnvelope::control_hash_opt`] for
1066/// call sites that hold a borrowed pillar string BEFORE a
1067/// [`ReceiptEnvelope`] is constructed — namely
1068/// [`ReceiptEnvelope::build`]'s inline `compose_root` call, which
1069/// composes the pillar's typed-Option identity from the local
1070/// `control_hash: String` intake before the envelope value exists.
1071/// The two peers share ONE projection body (`(!s.is_empty()).
1072/// then_some(s)`) so a future re-shape of the empty-means-absent
1073/// convention (a sentinel-string variant, an explicit
1074/// `Option<String>` on the wire form once the schema evolves)
1075/// lands at ONE substrate primitive rather than at both the
1076/// inherent method and its pre-construction free-fn peer.
1077///
1078/// Theory anchor: THEORY.md §VI.1 — generation over composition;
1079/// the pre-construction peer of the pillar projection lives at ONE
1080/// substrate primitive alongside the post-construction inherent
1081/// method, so the two receipt-lifecycle stages (pre-envelope in
1082/// [`ReceiptEnvelope::build`], post-envelope in every other
1083/// consumer) share ONE typed projection.
1084fn empty_to_none(s: &str) -> Option<&str> {
1085    (!s.is_empty()).then_some(s)
1086}
1087
1088/// Reject a required pillar whose wire form is empty with a typed
1089/// [`ReceiptError::MissingField`] carrying `field` — the diagnostic
1090/// literal the operator sees. Free-fn peer of [`empty_to_none`] on
1091/// the same wire-form-emptiness axis, and rejection sibling of the
1092/// [`ReceiptEnvelope::REQUIRED_PILLARS`] closed-set table
1093/// [`ReceiptEnvelope::verify_shape`] dispatches through.
1094///
1095/// The two peers on the emptiness axis carry two different typed
1096/// projections of the SAME wire-form bit:
1097///   * [`empty_to_none`] — the "empty means absent" convention for
1098///     the second pillar (control step); the substrate composes
1099///     `Option::None` through `compose_root` so an empty
1100///     `control_hash` and an absent-pillar receipt hash to the SAME
1101///     `composed_root`.
1102///   * [`require_nonempty`] — the "empty is a validation failure"
1103///     convention for the three required pillars; the substrate
1104///     rejects the receipt with a typed [`ReceiptError::MissingField`]
1105///     carrying the offending field name so the operator's diagnostic
1106///     surface (a reconciler event, a CLI stderr, a `tatara-check`
1107///     receipt-inspect report) names the pillar directly.
1108///
1109/// The two peers are DELIBERATELY named as (`empty_to_none`,
1110/// `require_nonempty`) rather than as a single overloaded projection
1111/// so the two typed conventions (absence-as-Option vs.
1112/// absence-as-Err) surface at the substrate's exported vocabulary
1113/// as two distinct primitives — a per-caller misroute (composing
1114/// `require_nonempty` on `control_hash` and getting a false
1115/// `MissingField`, or composing `empty_to_none` on `intent_hash`
1116/// and threading `None` through `compose_root` past a wire that
1117/// should have rejected) is a name-typo, not a silent semantic
1118/// swap.
1119///
1120/// Theory anchor: THEORY.md §VI.1 — generation over composition;
1121/// the "empty is a required-pillar failure" three-line inline
1122/// conditional recurred at THREE sites past the ★★ PRIME-DIRECTIVE
1123/// ≥ 2 duplication threshold and is lifted to ONE substrate primitive
1124/// composed through the [`ReceiptEnvelope::REQUIRED_PILLARS`] table.
1125/// THEORY.md §V.1 — knowable platform; the two emptiness projections
1126/// live at ONE typed vocabulary the receipt-inspection surfaces (LSP
1127/// hover, `tatara-check` report, REPL) bind to for reading the
1128/// receipt's structural contract from the substrate directly.
1129fn require_nonempty(field: &'static str, value: &str) -> Result<(), ReceiptError> {
1130    if value.is_empty() {
1131        return Err(ReceiptError::MissingField(field));
1132    }
1133    Ok(())
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    fn sample_payload() -> &'static str {
1141        // Composed_root precomputed from three_pillar::compose_root("bbbb", Some("cccc"), "aaaa", None)
1142        // (recomputed at test time to be canonical; this string is regenerated
1143        // if the domain tag ever changes).
1144        r#"{
1145            "version": "tatara-receipt/v1",
1146            "kind": "closed-loop-auth",
1147            "composed_root": "RECOMPUTE",
1148            "intent_hash":   "aaaa",
1149            "artifact_hash": "bbbb",
1150            "control_hash":  "cccc",
1151            "generated_at":  "2026-05-19T12:00:00Z"
1152        }"#
1153    }
1154
1155    fn canonical_payload_json() -> String {
1156        let root = three_pillar::compose_root("bbbb", Some("cccc"), "aaaa", None);
1157        sample_payload().replace("RECOMPUTE", &root)
1158    }
1159
1160    #[test]
1161    fn build_produces_valid_envelope() {
1162        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1163        assert_eq!(r.version, RECEIPT_VERSION);
1164        assert_eq!(r.kind, "test-suite");
1165        assert!(r.verify_shape().is_ok());
1166        assert!(r.verify_root(None));
1167    }
1168
1169    #[test]
1170    fn build_empty_control_omits_from_root() {
1171        let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
1172        let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
1173        assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
1174
1175        // And differs from a receipt with a real control hash.
1176        let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
1177        assert_ne!(with_empty.composed_root, with_control.composed_root);
1178    }
1179
1180    #[test]
1181    fn parse_json_round_trip() {
1182        let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
1183        assert_eq!(r.kind, "closed-loop-auth");
1184        assert!(r.verify_root(None));
1185    }
1186
1187    #[test]
1188    fn parse_yaml_round_trip() {
1189        let yaml = r#"
1190version: tatara-receipt/v1
1191kind: db-migration
1192composed_root: ROOT
1193intent_hash:   aaaa
1194artifact_hash: bbbb
1195control_hash:  cccc
1196generated_at:  2026-05-19T12:00:00Z
1197"#
1198        .replace(
1199            "ROOT",
1200            &three_pillar::compose_root("bbbb", Some("cccc"), "aaaa", None),
1201        );
1202        let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
1203        assert_eq!(r.kind, "db-migration");
1204        assert!(r.verify_root(None));
1205    }
1206
1207    #[test]
1208    fn wire_form_labels_pinned() {
1209        // Byte-exact wire-form labels — a rename here is a
1210        // wire-format change, not a typed-internal refactor. Log
1211        // lines / metrics tags / future CLI flags grep for these.
1212        assert_eq!(ReceiptWireForm::Json.as_str(), "json");
1213        assert_eq!(ReceiptWireForm::Yaml.as_str(), "yaml");
1214    }
1215
1216    #[test]
1217    fn receipt_wire_form_all_covers_every_declared_variant() {
1218        // Closed-set coverage pin: `ReceiptWireForm::ALL` MUST hold
1219        // every variant the enum declares. A regression that added a
1220        // `Cbor` variant to the enum's declaration + a `parse_raw`
1221        // arm + an `as_str` arm but forgot to extend `ALL` would let
1222        // `parse_either` silently keep failing over on a valid Cbor
1223        // payload without ever trying the parser — a fallback-corner
1224        // regression that no other test would catch. The pin binds
1225        // exhaustive coverage by re-projecting each variant through
1226        // `as_str` and asserting the ALL sweep hits the same set.
1227        let via_all: std::collections::HashSet<&'static str> =
1228            ReceiptWireForm::ALL.iter().map(|f| f.as_str()).collect();
1229        let via_declaration: std::collections::HashSet<&'static str> =
1230            [ReceiptWireForm::Json, ReceiptWireForm::Yaml]
1231                .iter()
1232                .map(|f| f.as_str())
1233                .collect();
1234        assert_eq!(
1235            via_all, via_declaration,
1236            "ReceiptWireForm::ALL must cover every declared variant — a new arm added to \
1237             `parse_raw` / `as_str` MUST also land in `ALL` so `parse_either` picks it up",
1238        );
1239        // Arity pin — the `[Self; 2]` type binding is compile-time,
1240        // but the runtime `.len()` guards against a `[Self; 0]` typo
1241        // that would make `parse_either` unreachable.
1242        assert_eq!(ReceiptWireForm::ALL.len(), 2);
1243    }
1244
1245    #[test]
1246    fn receipt_wire_form_all_matches_declaration_order_json_then_yaml() {
1247        // Order pin: `parse_either` iterates through ALL in
1248        // declaration order, so a reorder here changes which form's
1249        // error variant `parse_either` returns on full-failure. The
1250        // pre-lift `.or_else` chain fixed JSON THEN YAML — pin that
1251        // order at the substrate so a reorder surfaces here rather
1252        // than as silent skew at every operator-facing fallback log
1253        // line reading `ReceiptError::InvalidYaml` on a full-failure.
1254        assert_eq!(
1255            ReceiptWireForm::ALL,
1256            [ReceiptWireForm::Json, ReceiptWireForm::Yaml],
1257        );
1258    }
1259
1260    #[test]
1261    fn parse_either_dispatches_through_all_table_in_declaration_order() {
1262        // Fail-before-pass-after routing pin: `parse_either` MUST try
1263        // JSON first (the ALL table's head), then YAML (the tail).
1264        // A JSON payload succeeds on the first try — the YAML arm is
1265        // never reached, so any per-YAML normalization the future
1266        // `parse_raw` YAML arm might grow (a trim, an alias table)
1267        // does NOT affect the JSON-happy path. Conversely, a YAML-
1268        // only payload MUST fall through the JSON arm and succeed on
1269        // the YAML arm — the fallback sweep is the whole point.
1270        let json_payload = canonical_payload_json();
1271        let via_either = ReceiptEnvelope::parse_either(&json_payload).expect("json parses");
1272        let via_json_direct = ReceiptEnvelope::parse_json(&json_payload).expect("json direct");
1273        assert_eq!(
1274            via_either, via_json_direct,
1275            "parse_either on a valid-JSON payload MUST route through the JSON arm identically",
1276        );
1277
1278        let yaml = r#"
1279version: tatara-receipt/v1
1280kind: test-suite
1281composed_root: ROOT
1282intent_hash:   aaaa
1283artifact_hash: bbbb
1284control_hash:  cccc
1285generated_at:  2026-05-19T12:00:00Z
1286"#
1287        .replace(
1288            "ROOT",
1289            &three_pillar::compose_root("bbbb", Some("cccc"), "aaaa", None),
1290        );
1291        let via_either = ReceiptEnvelope::parse_either(&yaml).expect("yaml falls through JSON arm");
1292        let via_yaml_direct = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml direct");
1293        assert_eq!(
1294            via_either, via_yaml_direct,
1295            "parse_either on a valid-YAML-only payload MUST fall through JSON and match YAML",
1296        );
1297    }
1298
1299    #[test]
1300    fn parse_either_preserves_last_form_error_variant_on_full_failure() {
1301        // Semantic pin: on a payload that BOTH wire forms reject, the
1302        // pre-lift `.or_else(|_| ...)` chain returned the LAST arm's
1303        // error (YAML's `InvalidYaml`), discarding the JSON error.
1304        // Post-lift the ALL-driven sweep preserves that semantic — a
1305        // regression that returned the FIRST arm's error (`InvalidJson`)
1306        // instead would silently reshape every operator-facing
1307        // full-failure log line, since operators grep the arm variant
1308        // to know "which form was tried last." Pin the semantic at
1309        // the substrate boundary so a regression here fails loudly
1310        // rather than in log-grep drift downstream.
1311        let bad = "{ not-valid-";
1312        let err = ReceiptEnvelope::parse_either(bad).expect_err("both forms reject");
1313        assert!(
1314            matches!(err, ReceiptError::InvalidYaml(_)),
1315            "parse_either full-failure MUST return the LAST-form error \
1316             (ReceiptWireForm::ALL's tail); got {err:?}",
1317        );
1318    }
1319
1320    #[test]
1321    fn receipt_wire_form_all_is_sibling_shape_to_receipt_kind_all() {
1322        // Cross-primitive closed-set family pin: `ReceiptWireForm::ALL`
1323        // MUST have the same `[Self; N]` shape the sibling closed-set
1324        // tables the module docstring names use (`ReceiptKind::ALL`,
1325        // `ReportFormat::ALL`, `ProcessPhase::ALL`, `ConditionKind::ALL`,
1326        // `IntentKind::ALL`). Every entry is a `Copy` variant of the
1327        // enum, reachable off the type name via `X::ALL`. A regression
1328        // that promoted ONE table to a `Vec<Self>` or a `HashSet<Self>`
1329        // (splintering the family) would surface at compile time on the
1330        // callers that iterate them uniformly — the pin here just
1331        // documents the family membership at test level.
1332        let wire_forms_via_all: Vec<&'static str> =
1333            ReceiptWireForm::ALL.iter().map(|f| f.as_str()).collect();
1334        let kinds_via_all: Vec<&'static str> =
1335            ReceiptKind::ALL.iter().map(|k| k.as_str()).collect();
1336        // Both tables project their variants through an `as_str`
1337        // const projection into a stable-order slice of static strs;
1338        // that shape is the closed-set family's shared idiom.
1339        assert!(!wire_forms_via_all.is_empty());
1340        assert!(!kinds_via_all.is_empty());
1341        assert!(
1342            wire_forms_via_all.iter().all(|s| s
1343                .chars()
1344                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')),
1345            "wire-form labels are kebab/lowercase like the sibling closed sets",
1346        );
1347    }
1348
1349    #[test]
1350    fn parse_dispatches_json_arm_byte_identically_to_parse_json() {
1351        // The two owners must produce byte-identical output on the
1352        // happy path — a regression that skewed either arm surfaces
1353        // HERE rather than at every downstream call site of the
1354        // wrappers.
1355        let payload = canonical_payload_json();
1356        let via_enum = ReceiptEnvelope::parse(&payload, ReceiptWireForm::Json).expect("json parse");
1357        let via_wrapper = ReceiptEnvelope::parse_json(&payload).expect("json wrapper parse");
1358        assert_eq!(via_enum, via_wrapper);
1359    }
1360
1361    #[test]
1362    fn parse_dispatches_yaml_arm_byte_identically_to_parse_yaml() {
1363        let yaml = r#"
1364version: tatara-receipt/v1
1365kind: db-migration
1366composed_root: ROOT
1367intent_hash:   aaaa
1368artifact_hash: bbbb
1369control_hash:  cccc
1370generated_at:  2026-05-19T12:00:00Z
1371"#
1372        .replace(
1373            "ROOT",
1374            &three_pillar::compose_root("bbbb", Some("cccc"), "aaaa", None),
1375        );
1376        let via_enum = ReceiptEnvelope::parse(&yaml, ReceiptWireForm::Yaml).expect("yaml parse");
1377        let via_wrapper = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml wrapper parse");
1378        assert_eq!(via_enum, via_wrapper);
1379    }
1380
1381    #[test]
1382    fn parse_wrong_form_wraps_in_matching_error_variant() {
1383        // Wire-form arm selection travels with the ReceiptError
1384        // variant — a JSON payload parsed as YAML surfaces
1385        // `InvalidYaml`, not `InvalidJson`, so an operator log line
1386        // reads the encoding-that-failed without a hand-threaded
1387        // per-form label. Pins the (arm, wrap-variant) coherence
1388        // so a regression that decoupled either half surfaces here.
1389        let json_payload = canonical_payload_json();
1390        // JSON as JSON parses cleanly (baseline).
1391        assert!(ReceiptEnvelope::parse(&json_payload, ReceiptWireForm::Json).is_ok());
1392        // Bytes that a JSON reader rejects but a YAML reader silently
1393        // accepts (JSON is a strict YAML subset, so the reverse cross-
1394        // parse doesn't fail cleanly on any real payload; use invalid
1395        // JSON that's ALSO invalid YAML to pin per-arm wrap variants).
1396        let bad = "{ not-valid-";
1397        let json_err =
1398            ReceiptEnvelope::parse(bad, ReceiptWireForm::Json).expect_err("json rejects");
1399        let yaml_err =
1400            ReceiptEnvelope::parse(bad, ReceiptWireForm::Yaml).expect_err("yaml rejects");
1401        assert!(
1402            matches!(json_err, ReceiptError::InvalidJson(_)),
1403            "json arm must wrap in InvalidJson, got {json_err:?}"
1404        );
1405        assert!(
1406            matches!(yaml_err, ReceiptError::InvalidYaml(_)),
1407            "yaml arm must wrap in InvalidYaml, got {yaml_err:?}"
1408        );
1409    }
1410
1411    #[test]
1412    fn parse_either_falls_back_to_yaml() {
1413        let yaml = r#"
1414version: tatara-receipt/v1
1415kind: test-suite
1416composed_root: ROOT
1417intent_hash:   aaaa
1418artifact_hash: bbbb
1419control_hash:  cccc
1420generated_at:  2026-05-19T12:00:00Z
1421"#
1422        .replace(
1423            "ROOT",
1424            &three_pillar::compose_root("bbbb", Some("cccc"), "aaaa", None),
1425        );
1426        assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
1427    }
1428
1429    #[test]
1430    fn wrong_version_rejected() {
1431        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
1432        env["version"] = "tatara-receipt/v2".into();
1433        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
1434        assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
1435    }
1436
1437    #[test]
1438    fn receipt_version_wire_form_pin() {
1439        // Byte-shape pin on the ONE substrate const `RECEIPT_VERSION`.
1440        // The wire-form literal `"tatara-receipt/v1"` is the value
1441        // every serialized `ReceiptEnvelope.version` slot carries,
1442        // every reader gate rejects a mismatch against, and every
1443        // receipt-CM label value the closed-loop-probe stamps rides
1444        // through. A bump lands at this ONE const and every consumer
1445        // — the reconciler's `WrongVersion` diagnostic (routed
1446        // through the thiserror-derived Display via the enum's
1447        // `#[error("version != {RECEIPT_VERSION} (got {0:?})")]`
1448        // attribute), the closed-loop-probe's receipt-CM label VALUE,
1449        // and the envelope's `version` slot on build — inherits the
1450        // upgrade mechanically. This pin binds the current byte-form
1451        // so a bump surfaces here explicitly rather than as silent
1452        // drift at the two production sites that pre-lift restated
1453        // the literal by hand.
1454        assert_eq!(RECEIPT_VERSION, "tatara-receipt/v1");
1455    }
1456
1457    #[test]
1458    fn wrong_version_display_routes_through_receipt_version_const() {
1459        // Fail-before-pass-after substrate pin on the thiserror-
1460        // derived Display for `ReceiptError::WrongVersion`. Two
1461        // production consumers rely on this Display composing through
1462        // `RECEIPT_VERSION`:
1463        //
1464        // * `tatara-reconciler::boundary::receipt_error_message` — the
1465        //   `WrongVersion` arm now delegates to this Display directly
1466        //   (`err.to_string()`), so the reconciler's operator-facing
1467        //   diagnostic tracks the substrate const without a per-arm
1468        //   hand-authored format literal.
1469        // * `tatara-closed-loop-probe::write_receipt` — routes its
1470        //   receipt-CM label VALUE through the same `RECEIPT_VERSION`
1471        //   const directly, not this Display; both surfaces now share
1472        //   the ONE owner.
1473        //
1474        // A regression that dropped the `{RECEIPT_VERSION}` interpolation
1475        // from the enum's `#[error(...)]` attribute — reinlining the
1476        // literal `"tatara-receipt/v1"` there — would silently
1477        // desynchronize the reconciler's diagnostic from the probe's
1478        // stamped label value on any future const bump. This pin
1479        // catches such a regression at the Display byte shape.
1480        let err = ReceiptError::WrongVersion("tatara-receipt/v99".into());
1481        let msg = err.to_string();
1482
1483        // Byte-shape pin: exact composition through the substrate
1484        // const, with `{0:?}` Debug-formatting the wrapped `String`
1485        // (adding surrounding quotes).
1486        assert_eq!(
1487            msg,
1488            format!("version != {RECEIPT_VERSION} (got \"tatara-receipt/v99\")"),
1489            "WrongVersion Display must compose through the RECEIPT_VERSION const",
1490        );
1491
1492        // Routing pin: the substrate const value appears verbatim in
1493        // the Display output — a bump at `RECEIPT_VERSION` surfaces
1494        // here immediately.
1495        assert!(
1496            msg.contains(RECEIPT_VERSION),
1497            "WrongVersion Display must contain RECEIPT_VERSION verbatim, got {msg:?}",
1498        );
1499
1500        // Compat pin: the pre-lift boundary.rs hand-authored format
1501        // `format!("version != tatara-receipt/v1 (got {v:?})")` on
1502        // the same wrapped `String` produces the same bytes as the
1503        // Display — surfaces at THIS test as a byte equality, so the
1504        // reconciler's `WrongVersion` arm's routing swap (from an
1505        // inline `format!` to `err.to_string()`) preserves the
1506        // dashboard-anchored substring `"version != tatara-receipt/v1"`
1507        // that operators grep on.
1508        let v = "tatara-receipt/v99".to_string();
1509        let pre_lift = format!("version != tatara-receipt/v1 (got {v:?})");
1510        assert_eq!(
1511            msg, pre_lift,
1512            "WrongVersion Display must byte-match the pre-lift boundary.rs format literal",
1513        );
1514    }
1515
1516    #[test]
1517    fn missing_field_rejected() {
1518        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
1519        env.as_object_mut().unwrap().remove("intent_hash");
1520        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
1521        assert!(matches!(err, ReceiptError::InvalidJson(_)));
1522    }
1523
1524    #[test]
1525    fn unknown_field_rejected() {
1526        let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
1527        env["forged_extra"] = "should-fail".into();
1528        let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
1529        assert!(matches!(err, ReceiptError::InvalidJson(_)));
1530    }
1531
1532    #[test]
1533    fn empty_kind_rejected_in_verify_shape() {
1534        let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
1535        r.kind = String::new();
1536        assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
1537    }
1538
1539    #[test]
1540    fn expect_root_matches_or_mismatches() {
1541        let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1542        let root = r.composed_root.clone();
1543        assert!(r.expect_root(Some(&root)).is_ok());
1544        let err = r.expect_root(Some("nope")).unwrap_err();
1545        assert!(matches!(err, ReceiptError::RootMismatch { .. }));
1546        assert!(r.expect_root(None).is_ok());
1547    }
1548
1549    #[test]
1550    fn lower_to_attestation_chains_pillars() {
1551        let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
1552        let a = r.to_attestation(0, None);
1553        assert_eq!(a.intent_hash, "i");
1554        assert_eq!(a.artifact_hash, "a");
1555        assert_eq!(a.control_hash.as_deref(), Some("c"));
1556        // Both compose the same root.
1557        assert_eq!(a.composed_root, r.composed_root);
1558        assert!(a.verify());
1559
1560        let next = r.to_attestation(1, Some(&a.composed_root));
1561        assert_eq!(next.generation, 1);
1562        assert_eq!(
1563            next.previous_root.as_deref(),
1564            Some(a.composed_root.as_str())
1565        );
1566        // The composed_root differs because previous_root is included.
1567        assert_ne!(next.composed_root, a.composed_root);
1568    }
1569
1570    #[test]
1571    fn verify_root_detects_tamper() {
1572        let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
1573        assert!(r.verify_root(None));
1574        r.intent_hash = "tampered".into();
1575        assert!(!r.verify_root(None));
1576    }
1577
1578    #[test]
1579    fn process_ref_optional_and_round_trips() {
1580        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1581        r.process_ref = Some("demo-test/ephemeral".into());
1582        let s = serde_json::to_string(&r).unwrap();
1583        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
1584        assert_eq!(back.process_ref.as_deref(), Some("demo-test/ephemeral"));
1585    }
1586
1587    #[test]
1588    fn evidence_round_trips() {
1589        let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1590        r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
1591        let s = serde_json::to_string(&r).unwrap();
1592        let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
1593        assert_eq!(back.evidence["passed"], 12);
1594    }
1595
1596    // ── ReceiptKind closed-set truth-table ───────────────────────────
1597
1598    /// Structural well-formedness of [`ReceiptKind`] as a
1599    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1600    /// testkit lift that pins all three structural invariants (`ALL`
1601    /// is non-empty, every variant round-trips through
1602    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1603    /// outside the closed set) at ONE call site. Replaces the hand-
1604    /// derived `receipt_kind_all_enumerates_each_variant_exactly_once`
1605    /// + `receipt_kind_from_str_round_trips_canonical_names` + the
1606    /// empty-input arm of `receipt_kind_from_str_rejects_open_kinds`.
1607    /// `FromStr` delegates to
1608    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1609    /// exercises the same code path operators hit when parsing a wire
1610    /// `kind` field back to the typed kind.
1611    #[test]
1612    fn receipt_kind_is_well_formed_closed_set() {
1613        tatara_closed_set::assert_closed_set_well_formed::<ReceiptKind>();
1614    }
1615
1616    #[test]
1617    fn receipt_kind_canonical_names_pinned() {
1618        // Byte-exact wire-format pin — renaming any of these is a
1619        // wire-format change, not a typed-internal refactor.
1620        assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
1621        assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
1622        assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
1623        assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
1624        // The Export variant preserves the dot-separated wire form
1625        // `"tatara.export"` the tatara-export-worker binary published
1626        // pre-lift (four kebab-case peers on the same closed set, one
1627        // dot-separated latecomer — the shape is a wire-format
1628        // historical artifact, not a substrate style opinion). A
1629        // regression that "normalized" the spelling to `"export"` or
1630        // `"tatara-export"` would silently break every deployed
1631        // reconciler `JobAttested` verifier that reads the export
1632        // worker's receipt ConfigMap.
1633        assert_eq!(ReceiptKind::Export.as_str(), "tatara.export");
1634    }
1635
1636    #[test]
1637    fn receipt_kind_from_str_rejects_open_kinds() {
1638        // Future / typo / wrong-case all surface a typed
1639        // UnknownReceiptKind carrying the offending input verbatim
1640        // (operator-facing diagnostic); the schema is open at the
1641        // wire layer, but the closed-set view is byte-exact. The
1642        // empty-input arm is pinned by
1643        // [`receipt_kind_is_well_formed_closed_set`] via the
1644        // `tatara_lisp::ClosedSet` testkit; the cases here pin the
1645        // verbatim-echo contract on the [`UnknownReceiptKind`] newtype,
1646        // which the trait's `make_unknown` can't see.
1647        for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
1648            let err = bad.parse::<ReceiptKind>().unwrap_err();
1649            assert_eq!(err, UnknownReceiptKind(bad.to_string()));
1650        }
1651    }
1652
1653    #[test]
1654    fn receipt_kind_display_delegates_to_as_str() {
1655        for k in ReceiptKind::ALL {
1656            assert_eq!(format!("{k}"), k.as_str());
1657        }
1658    }
1659
1660    #[test]
1661    fn receipt_kind_into_string_matches_as_str() {
1662        for k in ReceiptKind::ALL {
1663            let s: String = k.into();
1664            assert_eq!(s, k.as_str());
1665        }
1666    }
1667
1668    #[test]
1669    fn build_accepts_typed_receipt_kind() {
1670        // The typed → wire bridge: `build(ReceiptKind::X, …)` produces
1671        // a receipt whose `kind` field is exactly `X.as_str()`.
1672        for k in ReceiptKind::ALL {
1673            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
1674            assert_eq!(env.kind, k.as_str());
1675            assert!(env.verify_shape().is_ok());
1676            assert!(env.verify_root(None));
1677        }
1678    }
1679
1680    #[test]
1681    fn known_kind_decodes_built_receipts() {
1682        for k in ReceiptKind::ALL {
1683            let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
1684            assert_eq!(env.known_kind(), Some(k));
1685        }
1686    }
1687
1688    #[test]
1689    fn known_kind_returns_none_for_open_kinds() {
1690        // Open-by-design: a custom operator-registered kind still
1691        // parses, still verifies, and still attests — it just doesn't
1692        // project through the closed-set typed view.
1693        let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
1694        assert_eq!(env.known_kind(), None);
1695        assert!(
1696            env.verify_shape().is_ok(),
1697            "open kind must remain a valid receipt"
1698        );
1699    }
1700
1701    // ── `control_hash_opt` / `empty_to_none` — the wire-form-to-typed
1702    //    projection of the second pillar (control step). Pre-lift the
1703    //    `is_empty() ? None : Some(&self.control_hash)` two-arm
1704    //    conditional lived at THREE inline sites — `build`,
1705    //    `verify_root`, `to_attestation` — each hand-writing the SAME
1706    //    projection with slightly-different ownership shapes
1707    //    (`Option<&str>` for the two composers, `Option<String>` for
1708    //    the attestation composer). Post-lift the projection lives at
1709    //    ONE inherent method + ONE free-fn peer for the pre-envelope
1710    //    call site. The tests below pin the substrate primitive's
1711    //    contract at its boundary so a regression at the projection
1712    //    surfaces here rather than as a silent `composed_root` drift
1713    //    at every consumer that composes the pillar.
1714
1715    #[test]
1716    fn empty_to_none_projects_empty_to_none_and_non_empty_to_some_verbatim() {
1717        // The pre-envelope free-fn peer of `control_hash_opt` — used
1718        // by `build` before the envelope exists. Pin BOTH arms of the
1719        // projection: an empty string projects to `None` (the "no
1720        // such pillar" convention that `compose_root` threads through
1721        // the absent-pillar BLAKE3 bytes pattern), and any non-empty
1722        // string projects to `Some(s)` byte-identical to the input.
1723        // A regression that (a) inverted the arms (folding `""` to
1724        // `Some("")` and every non-empty into `None`), (b) normalized
1725        // the payload (trimming whitespace, lowercasing hex), or (c)
1726        // introduced a sentinel-string special case (`"none"`, `"-"`,
1727        // etc.) would surface here rather than as a silent
1728        // `composed_root` shift at every consumer that composes the
1729        // pillar.
1730        assert_eq!(super::empty_to_none(""), None);
1731        assert_eq!(super::empty_to_none("c"), Some("c"));
1732        assert_eq!(super::empty_to_none("cccc"), Some("cccc"));
1733        // A whitespace-only string is NOT empty by the pillar's typed
1734        // contract — the substrate composes bytes verbatim through
1735        // BLAKE3, so a `" "` control hash IS a distinct pillar from
1736        // an absent one; the projection must preserve that
1737        // distinction.
1738        assert_eq!(super::empty_to_none(" "), Some(" "));
1739    }
1740
1741    #[test]
1742    fn control_hash_opt_matches_the_free_fn_peer_on_every_receipt() {
1743        // Post-envelope inherent method routes through the same
1744        // `empty_to_none` free-fn body — pin the equivalence across
1745        // both arms so a future regression that split the two
1746        // projections (e.g. the inherent method starts trimming, the
1747        // free-fn stays byte-verbatim) surfaces here rather than as a
1748        // `composed_root` mismatch between `build` (uses the free-fn
1749        // peer) and `verify_root` / `to_attestation` (use the
1750        // inherent method).
1751        let with_control = ReceiptEnvelope::build("test-suite", "i", "a", "cccc", None);
1752        assert_eq!(with_control.control_hash_opt(), Some("cccc"));
1753        assert_eq!(
1754            with_control.control_hash_opt(),
1755            super::empty_to_none(&with_control.control_hash),
1756        );
1757
1758        let no_control = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
1759        assert_eq!(no_control.control_hash_opt(), None);
1760        assert_eq!(
1761            no_control.control_hash_opt(),
1762            super::empty_to_none(&no_control.control_hash),
1763        );
1764    }
1765
1766    #[test]
1767    fn control_hash_opt_composes_the_same_root_the_three_consumers_bind() {
1768        // End-to-end pin at the receipt-lifecycle boundary — the
1769        // three consumers (`build`, `verify_root`, `to_attestation`)
1770        // must land on the SAME `composed_root` for a given pillar
1771        // tuple regardless of which projection body they route
1772        // through. Sweeps BOTH pillar arms (present control + empty
1773        // control) so a regression that mis-wired ONE consumer to
1774        // the pre-lift inline conditional or that changed the
1775        // projection at ONE site surfaces here rather than as a
1776        // silent divergence between `verify_root`'s decision and
1777        // `to_attestation`'s written `composed_root`.
1778        for control in ["", "control-hash-cccc"] {
1779            let env = ReceiptEnvelope::build("test-suite", "i", "a", control, None);
1780            // `verify_root` composes through the inherent method AND
1781            // through the same `three_pillar::compose_root(&artifact, control_opt,
1782            // &intent, previous)` skeleton `build` binds — so the
1783            // envelope must verify against its own composed root.
1784            assert!(
1785                env.verify_root(None),
1786                "verify_root failed for control={control:?}",
1787            );
1788            // `to_attestation` composes the same pillar tuple through
1789            // `ProcessAttestation::compose`'s `Option<String>` shape;
1790            // the attestation's `composed_root` must match the
1791            // envelope's `composed_root` byte-for-byte because both
1792            // compose the SAME BLAKE3 domain-tagged skeleton over
1793            // the SAME typed-Option pillar identity.
1794            let att = env.to_attestation(0, None);
1795            assert_eq!(
1796                att.composed_root, env.composed_root,
1797                "attestation root drift for control={control:?}",
1798            );
1799        }
1800    }
1801
1802    // ── `REQUIRED_PILLARS` / `require_nonempty` — the closed-set
1803    //    table + shared rejection peer that `verify_shape` composes
1804    //    the three required-pillar emptiness checks through. Pre-lift
1805    //    the three `if self.<pillar>.is_empty() { return Err(
1806    //    ReceiptError::MissingField("<pillar>")); }` two-arm
1807    //    conditionals lived inline in `verify_shape` — one per pillar
1808    //    name, each hand-writing the SAME (field-name, accessor,
1809    //    rejection) triple. Post-lift the three (field-name,
1810    //    accessor) pairs live at ONE `REQUIRED_PILLARS` const, and
1811    //    the rejection body lives at ONE `require_nonempty` peer. The
1812    //    tests below pin the substrate primitives' contract at their
1813    //    boundary so a regression at the projection surfaces here
1814    //    rather than as a silent shift in the receipt's structural
1815    //    validation semantics.
1816
1817    #[test]
1818    fn require_nonempty_rejects_empty_with_the_named_field_and_passes_non_empty_verbatim() {
1819        // The shared rejection peer of `empty_to_none` — used by
1820        // `verify_shape` through the `REQUIRED_PILLARS` sweep. Pin
1821        // BOTH arms of the projection: an empty value rejects with
1822        // `ReceiptError::MissingField(field)` carrying the literal
1823        // `field` byte-identically (so a rename at the table entry
1824        // reaches the operator's diagnostic surface — the reconciler
1825        // event, the CLI stderr, the `tatara-check` receipt-inspect
1826        // report), and any non-empty value passes with `Ok(())`
1827        // (regardless of the payload's shape — a whitespace-only `" "`
1828        // is NOT empty by the pillar's typed contract). A regression
1829        // that (a) mis-named the field on the rejection (leaking a
1830        // caller-controlled `&str` in place of the `&'static` diagnostic
1831        // literal), (b) rejected non-empty values (folding `" "` or
1832        // some other sentinel into `MissingField`), or (c) accepted
1833        // the empty payload silently would surface here rather than
1834        // as a silent semantic shift in `verify_shape`'s rejection
1835        // vocabulary.
1836        assert_eq!(
1837            super::require_nonempty("composed_root", ""),
1838            Err(ReceiptError::MissingField("composed_root")),
1839        );
1840        assert_eq!(
1841            super::require_nonempty("intent_hash", ""),
1842            Err(ReceiptError::MissingField("intent_hash")),
1843        );
1844        assert_eq!(super::require_nonempty("composed_root", "aaaa"), Ok(()));
1845        // Whitespace-only strings pass the rejection gate — the
1846        // substrate composes bytes verbatim through BLAKE3 so `" "`
1847        // IS a distinct pillar from an absent one; the rejection
1848        // must preserve that distinction.
1849        assert_eq!(super::require_nonempty("intent_hash", " "), Ok(()));
1850    }
1851
1852    #[test]
1853    fn required_pillars_table_is_pairwise_distinct_and_enumerates_the_three_names() {
1854        // The closed-set table `verify_shape` dispatches through.
1855        // Pin: (a) the arity is exactly THREE (rustc's `[…; 3]`
1856        // constant on the type binds this at compile time; the pin
1857        // here checks the runtime enumeration matches so a future
1858        // arity bump surfaces as a coordinated update rather than a
1859        // silent drift), (b) each entry's field name is a
1860        // pillar-unique string (a duplicate entry — the same pillar
1861        // listed twice — would evaluate the same rejection twice at
1862        // ONE run, hiding a distinct pillar's absence behind the
1863        // duplicate's success), (c) the three names match the
1864        // byte-exact wire literals the reconciler tests + operator
1865        // diagnostics have already published (`"composed_root"`,
1866        // `"intent_hash"`, `"artifact_hash"`) — renaming any of them
1867        // is a wire-diagnostic change, not a typed-internal refactor.
1868        let names: Vec<&'static str> = ReceiptEnvelope::REQUIRED_PILLARS
1869            .iter()
1870            .map(|(name, _)| *name)
1871            .collect();
1872        assert_eq!(names, vec!["composed_root", "intent_hash", "artifact_hash"]);
1873
1874        // Pairwise-distinct check — the table's arity is small
1875        // enough for a hand-authored O(n^2) sweep, and a duplicate
1876        // would defeat the whole point of the enumeration.
1877        for i in 0..names.len() {
1878            for j in (i + 1)..names.len() {
1879                assert_ne!(
1880                    names[i], names[j],
1881                    "REQUIRED_PILLARS[{i}] and [{j}] share field name {}",
1882                    names[i],
1883                );
1884            }
1885        }
1886
1887        // control_hash is DELIBERATELY not in the table (it carries
1888        // the "empty means absent" semantic bit — see
1889        // `control_hash_opt` + `empty_to_none`). Pin the exclusion so
1890        // a future well-meaning addition that promotes control_hash
1891        // to a required pillar surfaces here as a contract change
1892        // rather than as a silent rejection of receipts the
1893        // substrate's own compose_root treats as valid absent-pillar
1894        // walks.
1895        assert!(
1896            !names.contains(&"control_hash"),
1897            "control_hash must not be in REQUIRED_PILLARS — its emptiness \
1898             is the substrate's absent-pillar convention",
1899        );
1900    }
1901
1902    #[test]
1903    fn verify_shape_rejects_each_required_pillar_when_emptied_with_the_typed_field_name() {
1904        // End-to-end pin at the `verify_shape` boundary — each entry
1905        // in `REQUIRED_PILLARS` must surface a
1906        // `ReceiptError::MissingField(field)` carrying the entry's
1907        // OWN name when its accessor's value is empty. Sweeps the
1908        // table so a future fourth required pillar picks up the
1909        // rejection through the SAME per-entry iteration + the SAME
1910        // shared `require_nonempty` peer, and a mis-wired accessor
1911        // (an entry naming "intent_hash" whose accessor reads
1912        // `self.artifact_hash`) surfaces here as a mismatched typed
1913        // rejection rather than as a silent semantic drift at
1914        // production.
1915        for (field, accessor) in ReceiptEnvelope::REQUIRED_PILLARS {
1916            let mut env = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
1917            // Empty ONLY the pillar under test by zeroing the field
1918            // through the wire-form struct's own mutable access
1919            // (which the `#[serde(deny_unknown_fields)]` wire shape
1920            // doesn't restrict at the Rust level).
1921            match field {
1922                "composed_root" => env.composed_root.clear(),
1923                "intent_hash" => env.intent_hash.clear(),
1924                "artifact_hash" => env.artifact_hash.clear(),
1925                other => panic!("unknown REQUIRED_PILLARS entry {other}"),
1926            }
1927            assert!(
1928                accessor(&env).is_empty(),
1929                "accessor for {field} did not read the emptied field",
1930            );
1931            let err = env
1932                .verify_shape()
1933                .expect_err("verify_shape must reject empty required pillar");
1934            assert_eq!(
1935                err,
1936                ReceiptError::MissingField(field),
1937                "verify_shape returned {err:?} — expected MissingField({field:?})",
1938            );
1939        }
1940    }
1941
1942    // ── RECEIPT_CM_SUFFIX + default_receipt_config_map_name ──────────
1943    //
1944    // Fail-before-pass-after pins for the substrate-level naming
1945    // convention that the reconciler's JobAttested + ClosedLoopAuth
1946    // evaluators AND the export-worker renderer all route through
1947    // for their default-derivation callsites. A regression that
1948    // renamed the suffix (e.g. `-receipt` → `-attest`, `-cm`, or
1949    // `.receipt`) OR that swapped the composition order at the
1950    // composer (e.g. `<suffix><job>` instead of `<job><suffix>`)
1951    // would silently misroute every default-derivation receipt-CM
1952    // read against a ConfigMap the Job never wrote to — the pins
1953    // here catch the drift at the primitive itself, before it
1954    // reaches any downstream consumer.
1955
1956    #[test]
1957    fn receipt_cm_suffix_pinned_to_dash_receipt() {
1958        // Byte-exact wire-format pin — renaming this is a wire-name
1959        // change, not a typed-internal refactor. Operators grep for
1960        // the `-receipt` suffix in kubectl output; dashboards and
1961        // export tooling template on it; the closed-loop-probe chart
1962        // publishes ConfigMaps at this suffix. A silent rename here
1963        // would desync all of them at once.
1964        assert_eq!(RECEIPT_CM_SUFFIX, "-receipt");
1965    }
1966
1967    #[test]
1968    fn default_receipt_config_map_name_appends_suffix_to_job_name() {
1969        // The canonical composition every default-derivation site
1970        // routed through pre-lift as `format!("{name}-receipt")`.
1971        assert_eq!(default_receipt_config_map_name("my-job"), "my-job-receipt");
1972        assert_eq!(
1973            default_receipt_config_map_name("probe-job"),
1974            "probe-job-receipt"
1975        );
1976    }
1977
1978    #[test]
1979    fn default_receipt_config_map_name_composes_through_the_suffix_const() {
1980        // Cross-primitive coherence pin — the composer's output must
1981        // equal `<job_name>{RECEIPT_CM_SUFFIX}` verbatim across a
1982        // sweep of shipped Job-name shapes (bare, hierarchical
1983        // export-index, closed-loop probe derivation, one-char, and
1984        // empty). A regression that inlined the suffix at the
1985        // composer (breaking the const's role as the ONE source of
1986        // truth) fails HERE at the shipped-shape sweep because the
1987        // pin re-reads the const at test time.
1988        for job_name in [
1989            "my-job",
1990            "r1-export-0",
1991            "attest-export-5",
1992            "closed-loop-attest-closed-loop-probe",
1993            "x",
1994            "",
1995        ] {
1996            let mut expected = String::new();
1997            expected.push_str(job_name);
1998            expected.push_str(RECEIPT_CM_SUFFIX);
1999            assert_eq!(
2000                default_receipt_config_map_name(job_name),
2001                expected,
2002                "default_receipt_config_map_name({job_name:?}) drifted from \
2003                 <job>++RECEIPT_CM_SUFFIX composition",
2004            );
2005        }
2006    }
2007
2008    // ── resolve_receipt_config_map_name ────────────────────────────
2009    //
2010    // Fail-before-pass-after pins for the substrate-level
2011    // override-then-fallback resolution rule the reconciler's
2012    // JobAttested + ClosedLoopAuth postcondition evaluators both
2013    // route through post-lift. A regression that swapped the arm
2014    // priority (fallback beating a Some override), stripped the
2015    // empty-string preservation on the Some arm, or drifted the
2016    // None arm off the substrate composer would silently misroute
2017    // every postcondition-facing receipt-CM read — the pins here
2018    // catch the drift at the primitive itself, before it reaches
2019    // the shipped `evaluate_*` sites.
2020    //
2021    // Pre-lift the SAME 4-line `.clone().unwrap_or_else(||
2022    // default_receipt_config_map_name(&<job>))` chain was hand-
2023    // authored at TWO boundary.rs sites past the ★★ PRIME-DIRECTIVE
2024    // ≥ 2 duplication threshold; post-lift both route through this
2025    // ONE substrate primitive and any future normalization (a per-
2026    // fleet suffix override, a namespace-prefixed derivation) lands
2027    // here rather than at the pair of evaluator sites.
2028
2029    #[test]
2030    fn resolve_receipt_config_map_name_prefers_supplied_override_verbatim() {
2031        // The Some arm is byte-preserving — an operator who supplies
2032        // an explicit `receiptConfigMap: "custom-cm"` on the
2033        // postcondition's params gets `"custom-cm"` back, regardless
2034        // of the fallback Job name the postcondition would otherwise
2035        // derive against. Pins the pre-lift `.clone().unwrap_or_else(
2036        // || default_receipt_config_map_name(...))` semantics — the
2037        // Some arm never touches the fallback composer.
2038        assert_eq!(
2039            resolve_receipt_config_map_name(Some("custom-cm"), "my-job"),
2040            "custom-cm",
2041        );
2042        assert_eq!(
2043            resolve_receipt_config_map_name(Some("op-supplied-name"), "unrelated-job"),
2044            "op-supplied-name",
2045        );
2046    }
2047
2048    #[test]
2049    fn resolve_receipt_config_map_name_falls_back_to_default_composer_on_none() {
2050        // The None arm delegates to the substrate composer verbatim —
2051        // the wire-name that lands here is byte-identical to what the
2052        // sibling `default_receipt_config_map_name(<job>)` produces at
2053        // the same Job name. Pins the pre-lift `.unwrap_or_else(||
2054        // default_receipt_config_map_name(&<job>))` fallback path.
2055        for job_name in [
2056            "my-job",
2057            "closed-loop-attest-closed-loop-probe",
2058            "svc-abc-job-42",
2059        ] {
2060            assert_eq!(
2061                resolve_receipt_config_map_name(None, job_name),
2062                default_receipt_config_map_name(job_name),
2063                "resolve_receipt_config_map_name(None, {job_name:?}) must byte-match \
2064                 default_receipt_config_map_name({job_name:?}) — the None arm's fallback \
2065                 routing off the substrate composer drifted",
2066            );
2067        }
2068    }
2069
2070    #[test]
2071    fn resolve_receipt_config_map_name_preserves_empty_override_bytewise() {
2072        // The `.clone().unwrap_or_else(...)` chain pre-lift returned
2073        // the empty string verbatim when the operator supplied
2074        // `receiptConfigMap: ""` — an empty Some, not None. Pin that
2075        // the substrate primitive matches: the empty-string corner
2076        // routes through the Some arm, not the None fallback, so the
2077        // operator-visible misconfiguration surfaces downstream at
2078        // the postcondition evaluator's fetch site (as an
2079        // `Unsatisfied` diagnostic) rather than being silently
2080        // reshaped into the derived-default name.
2081        assert_eq!(resolve_receipt_config_map_name(Some(""), "my-job"), "");
2082    }
2083
2084    #[test]
2085    fn resolve_receipt_config_map_name_matches_pre_lift_boundary_chain_shape() {
2086        // Byte-shape parity pin — for every combination of (override,
2087        // job_name) that the two pre-lift boundary.rs sites could
2088        // have fed the `.clone().unwrap_or_else(||
2089        // default_receipt_config_map_name(&<job>))` chain, the post-
2090        // lift substrate primitive produces the same String. A
2091        // regression that reshaped either arm (a stray `.trim()`, an
2092        // implicit `String::new()` on an empty Some, a swap of the
2093        // second-argument's borrow form) surfaces HERE at the
2094        // reconstructed pre-lift chain, not at the shipped evaluator.
2095        let cases: &[(Option<&str>, &str)] = &[
2096            (None, "job-a"),
2097            (Some("override-cm"), "job-b"),
2098            (Some(""), "job-c"),
2099            (None, ""),
2100            (Some("custom"), ""),
2101        ];
2102        for (override_name, job_name) in cases {
2103            let via_substrate = resolve_receipt_config_map_name(*override_name, job_name);
2104            let pre_lift = override_name
2105                .map(str::to_string)
2106                .unwrap_or_else(|| default_receipt_config_map_name(job_name));
2107            assert_eq!(
2108                via_substrate, pre_lift,
2109                "resolve_receipt_config_map_name({override_name:?}, {job_name:?}) \
2110                 drifted from the pre-lift `.clone().unwrap_or_else(|| \
2111                 default_receipt_config_map_name(&<job>))` chain shape",
2112            );
2113        }
2114    }
2115
2116    // ── RECEIPT_JSON_KEY / RECEIPT_YAML_KEY / RECEIPT_CM_KEYS /
2117    //    RECEIPT_CM_MISSING_KEY_MSG / extract_receipt_payload_json ─────
2118    //
2119    // Fail-before-pass-after pins for the substrate-level (primary,
2120    // fallback) receipt-CM `data`-key pair AND the reader-side lookup
2121    // gate composer that every receipt-CM consumer routes through.
2122    // A regression that renamed either key OR that swapped the
2123    // primary/fallback ordering at the reader gate would silently
2124    // desynchronize writer/reader pairs across the workspace — the
2125    // pins here catch the drift at the primitives themselves, before
2126    // it reaches any downstream ConfigMap-fetch site.
2127    //
2128    // Pre-lift the two keys appeared as inline `&'static str` literals
2129    // at FOUR production sites (2 writer inserts + 2 reader lookups)
2130    // with no shared owner binding their spelling OR the primary-first
2131    // ordering that the reader-side gate encodes as a load-bearing
2132    // invariant.
2133
2134    #[test]
2135    fn receipt_cm_keys_pinned_to_wire_form_literals() {
2136        // Byte-exact wire-format pin — renaming either is a wire-name
2137        // change, not a typed-internal refactor. Operators grep for
2138        // these keys in kubectl output; the closed-loop-probe chart
2139        // publishes ConfigMaps carrying them; the reconciler's
2140        // JobAttested/ClosedLoopAuth evaluators gate on them. A silent
2141        // rename here would desync every writer/reader pair fleet-wide.
2142        assert_eq!(RECEIPT_JSON_KEY, "receipt.json");
2143        assert_eq!(RECEIPT_YAML_KEY, "receipt.yaml");
2144    }
2145
2146    #[test]
2147    fn receipt_cm_keys_table_pins_primary_first_ordering() {
2148        // The primary/fallback ordering is load-bearing — the reader
2149        // gate returns the FIRST hit, so JSON must precede YAML to
2150        // preserve the substrate's "JSON is machine-canonical, YAML
2151        // is operator-facing readable twin" contract. A regression
2152        // that reordered the table would silently promote YAML over
2153        // JSON — the payload STILL parses (both wire forms round-trip
2154        // through the same `ReceiptEnvelope::parse_either`), but the
2155        // reader now prefers the operator-facing form when both are
2156        // present, breaking the substrate's payload-form preference.
2157        assert_eq!(RECEIPT_CM_KEYS, [RECEIPT_JSON_KEY, RECEIPT_YAML_KEY]);
2158        assert_eq!(RECEIPT_CM_KEYS[0], RECEIPT_JSON_KEY);
2159        assert_eq!(RECEIPT_CM_KEYS[1], RECEIPT_YAML_KEY);
2160        assert_eq!(RECEIPT_CM_KEYS.len(), 2);
2161    }
2162
2163    #[test]
2164    fn receipt_cm_missing_key_msg_names_both_keys_in_primary_first_order() {
2165        // The diagnostic message the reader-side gate returns when
2166        // neither key is present must NAME both keys so the operator
2167        // reading a `ReceiptVerdict::Malformed(...)` event knows
2168        // exactly which `data.<key>` entries the reader looked up.
2169        // Pin the message contains BOTH key literals (a rename at
2170        // either key const would drift the message spelling silently
2171        // if the message were `format!`-composed at the callsite;
2172        // the substrate owns the message const alongside the two key
2173        // consts so a coordinated update lands here).
2174        assert!(
2175            RECEIPT_CM_MISSING_KEY_MSG.contains(RECEIPT_JSON_KEY),
2176            "missing-key diagnostic must name {RECEIPT_JSON_KEY}",
2177        );
2178        assert!(
2179            RECEIPT_CM_MISSING_KEY_MSG.contains(RECEIPT_YAML_KEY),
2180            "missing-key diagnostic must name {RECEIPT_YAML_KEY}",
2181        );
2182        // Primary before fallback in the diagnostic text — the
2183        // operator's mental model matches the reader's iteration order.
2184        let json_pos = RECEIPT_CM_MISSING_KEY_MSG
2185            .find(RECEIPT_JSON_KEY)
2186            .expect("json key present");
2187        let yaml_pos = RECEIPT_CM_MISSING_KEY_MSG
2188            .find(RECEIPT_YAML_KEY)
2189            .expect("yaml key present");
2190        assert!(
2191            json_pos < yaml_pos,
2192            "diagnostic must name {RECEIPT_JSON_KEY} before {RECEIPT_YAML_KEY}",
2193        );
2194    }
2195
2196    #[test]
2197    fn extract_receipt_payload_json_returns_none_when_data_absent() {
2198        // The reader-side gate handles the `data` map's own absence
2199        // gracefully — `obj.data.get("data")` returns `None` when the
2200        // ConfigMap carries no `data` map, and the gate must project
2201        // that to `None` rather than panic or return a spurious hit.
2202        assert_eq!(extract_receipt_payload_json(None), None);
2203    }
2204
2205    #[test]
2206    fn extract_receipt_payload_json_returns_none_when_neither_key_present() {
2207        // A ConfigMap with `data` but no receipt payload — the gate
2208        // returns `None` so the caller can project to the typed
2209        // `ReceiptVerdict::Malformed(RECEIPT_CM_MISSING_KEY_MSG)`.
2210        let data = serde_json::json!({ "unrelated.key": "value" });
2211        assert_eq!(extract_receipt_payload_json(Some(&data)), None);
2212    }
2213
2214    #[test]
2215    fn extract_receipt_payload_json_prefers_primary_over_fallback_when_both_present() {
2216        // Load-bearing primary-first invariant — when BOTH keys are
2217        // present (the normal writer emit shape), the reader must
2218        // return the JSON form. A regression that swapped the
2219        // iteration order would silently promote YAML over JSON with
2220        // NO observable failure at parse time (both round-trip through
2221        // `parse_either`), so the pin here catches the ordering drift
2222        // at the reader gate itself.
2223        let data = serde_json::json!({
2224            RECEIPT_JSON_KEY: "json-payload",
2225            RECEIPT_YAML_KEY: "yaml-payload",
2226        });
2227        assert_eq!(
2228            extract_receipt_payload_json(Some(&data)),
2229            Some("json-payload"),
2230            "reader must prefer {RECEIPT_JSON_KEY} over {RECEIPT_YAML_KEY} when both present",
2231        );
2232    }
2233
2234    #[test]
2235    fn extract_receipt_payload_json_falls_back_to_yaml_when_json_absent() {
2236        // Fallback arm — an older probe binary or a hand-authored
2237        // fixture that only wrote the YAML form still reads
2238        // successfully. Pins that the YAML entry is reachable through
2239        // the gate.
2240        let data = serde_json::json!({
2241            RECEIPT_YAML_KEY: "yaml-payload",
2242        });
2243        assert_eq!(
2244            extract_receipt_payload_json(Some(&data)),
2245            Some("yaml-payload"),
2246        );
2247    }
2248
2249    #[test]
2250    fn extract_receipt_payload_json_returns_first_key_when_only_primary_present() {
2251        // Primary-only arm — the writer emitted just the JSON form
2252        // (e.g. a future probe that dropped the YAML twin). Reader
2253        // still resolves the payload through the primary key.
2254        let data = serde_json::json!({
2255            RECEIPT_JSON_KEY: "json-payload",
2256        });
2257        assert_eq!(
2258            extract_receipt_payload_json(Some(&data)),
2259            Some("json-payload"),
2260        );
2261    }
2262
2263    #[test]
2264    fn extract_receipt_payload_json_rejects_non_string_scalar_values() {
2265        // The wire contract says receipt payload values are string
2266        // scalars — a JSON number, object, or array at either key is
2267        // NOT a valid payload. The gate returns `None` (the caller
2268        // then projects to a `Malformed` verdict) rather than a
2269        // spurious hit that would panic downstream in the parser.
2270        let data = serde_json::json!({
2271            RECEIPT_JSON_KEY: 42,
2272            RECEIPT_YAML_KEY: ["not", "a", "string"],
2273        });
2274        assert_eq!(extract_receipt_payload_json(Some(&data)), None);
2275    }
2276
2277    #[test]
2278    fn extract_receipt_payload_json_skips_non_string_primary_and_falls_back_to_string_fallback() {
2279        // Mixed case — the primary key exists but carries a non-string
2280        // value (a malformed writer, a partially-migrated wire form),
2281        // and the fallback key carries a valid string payload. The
2282        // gate treats the non-string primary as absent for the
2283        // string-scalar projection contract and returns the string
2284        // fallback. This preserves availability at the reader when a
2285        // writer half-populated the primary.
2286        let data = serde_json::json!({
2287            RECEIPT_JSON_KEY: { "nested": "object" },
2288            RECEIPT_YAML_KEY: "yaml-payload",
2289        });
2290        assert_eq!(
2291            extract_receipt_payload_json(Some(&data)),
2292            Some("yaml-payload"),
2293        );
2294    }
2295
2296    #[test]
2297    fn extract_receipt_payload_json_matches_hand_authored_pre_lift_chain_bytewise() {
2298        // Byte-identity pin against the pre-lift 3-link combinator
2299        // chain that `verify_receipt_cm` composed inline:
2300        //     data.and_then(|d| d.get(RECEIPT_JSON_KEY))
2301        //         .or_else(|| data.and_then(|d| d.get(RECEIPT_YAML_KEY)))
2302        //         .and_then(|v| v.as_str())
2303        // Sweeps the four (primary-present × fallback-present)
2304        // combinations so a regression at the primitive that broke
2305        // the byte identity with the pre-lift shape surfaces here
2306        // rather than as a subtle divergence at ONE quadrant.
2307        for (json_val, yaml_val) in [
2308            (Some("json"), Some("yaml")),
2309            (Some("json"), None::<&str>),
2310            (None::<&str>, Some("yaml")),
2311            (None::<&str>, None::<&str>),
2312        ] {
2313            let mut map = serde_json::Map::new();
2314            if let Some(j) = json_val {
2315                map.insert(RECEIPT_JSON_KEY.into(), serde_json::Value::String(j.into()));
2316            }
2317            if let Some(y) = yaml_val {
2318                map.insert(RECEIPT_YAML_KEY.into(), serde_json::Value::String(y.into()));
2319            }
2320            let data = serde_json::Value::Object(map);
2321            let via_primitive = extract_receipt_payload_json(Some(&data));
2322            let via_pre_lift_chain = data
2323                .get(RECEIPT_JSON_KEY)
2324                .or_else(|| data.get(RECEIPT_YAML_KEY))
2325                .and_then(serde_json::Value::as_str);
2326            assert_eq!(
2327                via_primitive, via_pre_lift_chain,
2328                "extract_receipt_payload_json diverged from pre-lift chain at \
2329                 (json={json_val:?}, yaml={yaml_val:?})",
2330            );
2331        }
2332    }
2333
2334    #[test]
2335    fn default_receipt_config_map_name_matches_prior_hand_authored_format_shape() {
2336        // Path-uniformity pin against the three pre-lift `format!`
2337        // literals — each callsite spelled the shape a slightly
2338        // different way (`format!("{}-receipt", parsed.name)` /
2339        // `format!("{job_name}-receipt")` /
2340        // `format!("{process_name}-export-{index}-receipt")`) but
2341        // all three composed the SAME `<job>-receipt` byte sequence
2342        // once evaluated. The lift preserves that byte identity so
2343        // no downstream ConfigMap grep or fleet-shipped operator
2344        // override changes meaning. A regression at the primitive
2345        // that broke the byte identity (e.g. inserted a separator,
2346        // uppercased the suffix, dropped the leading dash) would
2347        // fail HERE against the pre-lift `format!` literal for a
2348        // hand-picked Job-name that carries no ambiguity around
2349        // separators.
2350        let job_name = "svc-abc-export-3";
2351        let pre_lift = format!("{job_name}-receipt");
2352        let post_lift = default_receipt_config_map_name(job_name);
2353        assert_eq!(
2354            pre_lift, post_lift,
2355            "post-lift primitive drifted from pre-lift `format!(\"{{name}}-receipt\")` byte shape",
2356        );
2357    }
2358}