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