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