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