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/// Typed receipt envelope. Any Job in pleme-io that wants its result to
231/// chain into a Process's `status.attestation` writes one of these.
232#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
233#[serde(rename_all = "snake_case", deny_unknown_fields)]
234pub struct ReceiptEnvelope {
235 /// Must equal `RECEIPT_VERSION`. Mismatches reject the receipt.
236 pub version: String,
237 /// What this receipt proves. Known: `closed-loop-auth`, `db-migration`,
238 /// `test-suite`, `nix-build`. Operators may register new kinds —
239 /// the envelope is open.
240 pub kind: String,
241 /// Three-pillar root: `BLAKE3(domain ++ artifact ++ control ++ intent ++ previous)`.
242 pub composed_root: String,
243 /// Pillar 1: what the Job was *trying* to do (canonical intent).
244 pub intent_hash: String,
245 /// Pillar 2: what the Job *produced* (artifact / proof material).
246 pub artifact_hash: String,
247 /// Pillar 3: how the Job *verified* its work (controls / signatures /
248 /// auth steps). Empty string when there was no control step.
249 pub control_hash: String,
250 /// Timestamp the Job set when it wrote the receipt.
251 pub generated_at: DateTime<Utc>,
252 /// Optional owning-Process reference (`namespace/name`). When the
253 /// reconciler creates the Job it stamps this in via the downward
254 /// API; receipts without it still parse for ad-hoc / out-of-cluster
255 /// runs.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub process_ref: Option<String>,
258 /// Optional structured evidence. Free-form JSON. The reconciler does
259 /// not parse this — it's for human / downstream-tool inspection.
260 #[serde(default, skip_serializing_if = "is_null")]
261 pub evidence: serde_json::Value,
262}
263
264fn is_null(v: &serde_json::Value) -> bool {
265 v.is_null()
266}
267
268/// Why a receipt is rejected. Kept as a typed enum so callers can
269/// pattern-match on the failure mode and surface targeted operator
270/// messages.
271#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
272pub enum ReceiptError {
273 #[error("invalid JSON: {0}")]
274 InvalidJson(String),
275 #[error("invalid YAML: {0}")]
276 InvalidYaml(String),
277 #[error("version != {RECEIPT_VERSION} (got {0:?})")]
278 WrongVersion(String),
279 #[error("missing required field: {0}")]
280 MissingField(&'static str),
281 #[error("kind is empty")]
282 EmptyKind,
283 #[error("composed_root mismatch (got {got}, want {want})")]
284 RootMismatch { got: String, want: String },
285}
286
287impl ReceiptEnvelope {
288 /// Build a receipt envelope from typed pillars + kind. `generated_at`
289 /// defaults to `Utc::now()`.
290 pub fn build(
291 kind: impl Into<String>,
292 intent_hash: impl Into<String>,
293 artifact_hash: impl Into<String>,
294 control_hash: impl Into<String>,
295 previous_root: Option<&str>,
296 ) -> Self {
297 let intent_hash = intent_hash.into();
298 let artifact_hash = artifact_hash.into();
299 let control_hash = control_hash.into();
300 let composed_root = compose_root(
301 &artifact_hash,
302 empty_to_none(&control_hash),
303 &intent_hash,
304 previous_root,
305 );
306 Self {
307 version: RECEIPT_VERSION.into(),
308 kind: kind.into(),
309 composed_root,
310 intent_hash,
311 artifact_hash,
312 control_hash,
313 generated_at: Utc::now(),
314 process_ref: None,
315 evidence: serde_json::Value::Null,
316 }
317 }
318
319 /// Parse a receipt from a JSON string.
320 pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
321 let env: Self =
322 serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))?;
323 env.verify_shape()?;
324 Ok(env)
325 }
326
327 /// Parse a receipt from a YAML string. Useful for ConfigMaps that
328 /// store the payload in YAML form.
329 pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
330 let env: Self =
331 serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))?;
332 env.verify_shape()?;
333 Ok(env)
334 }
335
336 /// Parse via JSON first, then YAML if JSON fails. Lets a single
337 /// reader accept either wire form without the operator having to
338 /// declare it. Useful when the Job writes JSON and the reconciler
339 /// reads back through a kube DynamicObject whose `data` is YAML.
340 pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
341 match Self::parse_json(payload) {
342 Ok(env) => Ok(env),
343 Err(_) => Self::parse_yaml(payload),
344 }
345 }
346
347 /// Verify the schema-level invariants: correct version + non-empty
348 /// kind + non-empty pillar hashes (length-only, not BLAKE3-recompute).
349 pub fn verify_shape(&self) -> Result<(), ReceiptError> {
350 if self.version != RECEIPT_VERSION {
351 return Err(ReceiptError::WrongVersion(self.version.clone()));
352 }
353 if self.kind.is_empty() {
354 return Err(ReceiptError::EmptyKind);
355 }
356 if self.composed_root.is_empty() {
357 return Err(ReceiptError::MissingField("composed_root"));
358 }
359 if self.intent_hash.is_empty() {
360 return Err(ReceiptError::MissingField("intent_hash"));
361 }
362 if self.artifact_hash.is_empty() {
363 return Err(ReceiptError::MissingField("artifact_hash"));
364 }
365 // control_hash MAY be empty when there is no control step;
366 // the BLAKE3 compose treats empty as "absent" via Option.
367 Ok(())
368 }
369
370 /// Verify that `composed_root` is consistent with the pillars.
371 /// `expected_previous_root` is the previous root in the Process's
372 /// attestation chain (or `None` for first attestation).
373 pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
374 let want = compose_root(
375 &self.artifact_hash,
376 self.control_hash_opt(),
377 &self.intent_hash,
378 expected_previous_root,
379 );
380 constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
381 }
382
383 /// Strict-equality check against an operator-provided expected root.
384 /// Returns the receipt's root unchanged on success.
385 pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
386 if let Some(want) = expected {
387 if want != self.composed_root {
388 return Err(ReceiptError::RootMismatch {
389 got: self.composed_root.clone(),
390 want: want.to_string(),
391 });
392 }
393 }
394 Ok(&self.composed_root)
395 }
396
397 /// Decode `self.kind` into the typed [`ReceiptKind`] variant when
398 /// the wire string matches one of the four substrate-emitted
399 /// canonical kebab-case kinds; `None` when the kind is an
400 /// operator-registered open string (the schema is open by design —
401 /// every receipt remains a valid receipt, but only typed kinds
402 /// participate in closed-set dispatch). The (open `String`,
403 /// closed-typed view) split lets future kind-keyed consumers
404 /// (verifier registries, dashboard completion, audit-trail
405 /// classifiers) sweep the typed variants without touching the
406 /// open-by-design wire shape. Lifted as the canonical decode site
407 /// so no consumer re-implements the `match self.kind.as_str()`
408 /// arm-by-arm — the closed-set sweep happens through
409 /// [`ReceiptKind::from_str`] at ONE site.
410 #[must_use]
411 pub fn known_kind(&self) -> Option<ReceiptKind> {
412 self.kind.parse().ok()
413 }
414
415 /// Lower into a `ProcessAttestation` — the canonical handoff so a
416 /// Job's typed receipt becomes evidence on a Process. `generation`
417 /// + `previous_root` come from the owning Process's prior
418 /// attestation (or 0 + None for the first cycle).
419 pub fn to_attestation(
420 &self,
421 generation: u64,
422 previous_root: Option<&str>,
423 ) -> ProcessAttestation {
424 ProcessAttestation::compose(
425 self.artifact_hash.clone(),
426 self.control_hash_opt().map(str::to_owned),
427 self.intent_hash.clone(),
428 previous_root.map(String::from),
429 generation,
430 )
431 }
432
433 /// Typed projection of the wire form's `control_hash` field —
434 /// `Some(hash)` when a control step ran, `None` when it did not.
435 ///
436 /// The wire form stamps `control_hash: String` (schema-open,
437 /// serde-friendly), but the substrate's `compose_root` +
438 /// `ProcessAttestation::compose` compositions both take an
439 /// `Option<&str>` / `Option<String>` and thread `None` through the
440 /// exact BLAKE3 bytes pattern an absent-pillar walk emits — an
441 /// empty `control_hash` and an absent-pillar receipt hash to the
442 /// SAME `composed_root`. That "empty means absent" convention
443 /// pre-lift lived at THREE sites inside this impl block —
444 /// [`Self::build`] (constructing the envelope from typed pillars),
445 /// [`Self::verify_root`] (recomposing the root against pillars for
446 /// wire-form verification), and [`Self::to_attestation`] (lowering
447 /// the receipt into a [`ProcessAttestation`] on a Process's
448 /// attestation chain) — each hand-writing the SAME
449 /// `if self.control_hash.is_empty() { None } else {
450 /// Some(self.control_hash.as_str()) }` two-arm conditional. Post-
451 /// lift the convention lives at ONE method here; the three
452 /// consumers each compose a ONE-LINE call:
453 /// * `verify_root` → `self.control_hash_opt()` directly,
454 /// * `to_attestation` → `self.control_hash_opt().map(str::to_owned)`
455 /// for the `Option<String>` shape [`ProcessAttestation::compose`]
456 /// binds,
457 /// * `build` (which reads a local `control_hash: String` before
458 /// the envelope is constructed) → the free-fn peer
459 /// [`empty_to_none`] on the same borrowed string.
460 ///
461 /// Public because the projection is load-bearing operator-facing
462 /// contract: an authoring surface (an LSP hover, a
463 /// `tatara-check` report, a REPL `:receipt-inspect` command) that
464 /// wants to render "no control step" vs. "control_hash: <hash>"
465 /// binds to this method rather than pattern-matching on
466 /// `self.control_hash.is_empty()` at its own call site — a future
467 /// re-shape of the empty-means-absent convention (a sentinel-
468 /// string variant, an explicit `Option<String>` on the wire form
469 /// once the schema evolves, or a typed
470 /// `ControlStep::{Ran(hash), Skipped}` enum) lands at ONE method
471 /// here rather than at every consumer that inspects the pillar.
472 ///
473 /// Theory anchor: THEORY.md §V.1 — knowable platform; the
474 /// wire-vs-typed projection lives at ONE substrate method so a
475 /// consumer reads the pillar's typed-Option contract from the
476 /// receipt directly, not from three parallel inline conditionals
477 /// scattered across `build` / `verify_root` / `to_attestation`.
478 /// THEORY.md §VI.1 — generation over composition; the
479 /// `is_empty() ? None : Some(&self.control_hash)` two-arm
480 /// projection recurred at THREE inline sites past the ★★
481 /// PRIME-DIRECTIVE ≥ 2 duplication threshold and is lifted to ONE
482 /// owner here. THEORY.md §V.3 — three-pillar attestation; the
483 /// receipt's second pillar (control step) has ONE typed projection
484 /// site the composition primitives ([`compose_root`],
485 /// [`ProcessAttestation::compose`]) both bind against, so the
486 /// pillar's wire-vs-typed identity cannot drift across the three
487 /// consumers.
488 #[must_use]
489 pub fn control_hash_opt(&self) -> Option<&str> {
490 empty_to_none(&self.control_hash)
491 }
492}
493
494/// Project a wire-form pillar string onto its typed `Option<&str>`
495/// contract — `Some(s)` when `s` is non-empty, `None` when `s` is
496/// empty (the substrate's "no such pillar" convention that
497/// [`compose_root`] + [`ProcessAttestation::compose`] both thread as
498/// an absent-pillar walk through the BLAKE3 domain-tagged
499/// composition).
500///
501/// The free-fn peer of [`ReceiptEnvelope::control_hash_opt`] for
502/// call sites that hold a borrowed pillar string BEFORE a
503/// [`ReceiptEnvelope`] is constructed — namely
504/// [`ReceiptEnvelope::build`]'s inline `compose_root` call, which
505/// composes the pillar's typed-Option identity from the local
506/// `control_hash: String` intake before the envelope value exists.
507/// The two peers share ONE projection body (`(!s.is_empty()).
508/// then_some(s)`) so a future re-shape of the empty-means-absent
509/// convention (a sentinel-string variant, an explicit
510/// `Option<String>` on the wire form once the schema evolves)
511/// lands at ONE substrate primitive rather than at both the
512/// inherent method and its pre-construction free-fn peer.
513///
514/// Theory anchor: THEORY.md §VI.1 — generation over composition;
515/// the pre-construction peer of the pillar projection lives at ONE
516/// substrate primitive alongside the post-construction inherent
517/// method, so the two receipt-lifecycle stages (pre-envelope in
518/// [`ReceiptEnvelope::build`], post-envelope in every other
519/// consumer) share ONE typed projection.
520fn empty_to_none(s: &str) -> Option<&str> {
521 (!s.is_empty()).then_some(s)
522}
523
524const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
525
526/// Same composition as `ProcessAttestation::composed_hex` — kept local so
527/// `tatara_process::receipt::compose_root(...)` is a single line in
528/// downstream code without re-importing the attestation module.
529fn compose_root(
530 artifact: &str,
531 control: Option<&str>,
532 intent: &str,
533 previous: Option<&str>,
534) -> String {
535 let mut h = blake3::Hasher::new();
536 h.update(DOMAIN_TAG);
537 h.update(artifact.as_bytes());
538 h.update(b"\n");
539 h.update(control.unwrap_or("").as_bytes());
540 h.update(b"\n");
541 h.update(intent.as_bytes());
542 h.update(b"\n");
543 h.update(previous.unwrap_or("").as_bytes());
544 hex::encode(h.finalize().as_bytes())
545}
546
547fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
548 if a.len() != b.len() {
549 return false;
550 }
551 let mut acc: u8 = 0;
552 for (x, y) in a.iter().zip(b.iter()) {
553 acc |= x ^ y;
554 }
555 acc == 0
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 fn sample_payload() -> &'static str {
563 // Composed_root precomputed from compose_root("bbbb", Some("cccc"), "aaaa", None)
564 // (recomputed at test time to be canonical; this string is regenerated
565 // if the domain tag ever changes).
566 r#"{
567 "version": "tatara-receipt/v1",
568 "kind": "closed-loop-auth",
569 "composed_root": "RECOMPUTE",
570 "intent_hash": "aaaa",
571 "artifact_hash": "bbbb",
572 "control_hash": "cccc",
573 "generated_at": "2026-05-19T12:00:00Z"
574 }"#
575 }
576
577 fn canonical_payload_json() -> String {
578 let root = compose_root("bbbb", Some("cccc"), "aaaa", None);
579 sample_payload().replace("RECOMPUTE", &root)
580 }
581
582 #[test]
583 fn build_produces_valid_envelope() {
584 let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
585 assert_eq!(r.version, RECEIPT_VERSION);
586 assert_eq!(r.kind, "test-suite");
587 assert!(r.verify_shape().is_ok());
588 assert!(r.verify_root(None));
589 }
590
591 #[test]
592 fn build_empty_control_omits_from_root() {
593 let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
594 let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
595 assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
596
597 // And differs from a receipt with a real control hash.
598 let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
599 assert_ne!(with_empty.composed_root, with_control.composed_root);
600 }
601
602 #[test]
603 fn parse_json_round_trip() {
604 let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
605 assert_eq!(r.kind, "closed-loop-auth");
606 assert!(r.verify_root(None));
607 }
608
609 #[test]
610 fn parse_yaml_round_trip() {
611 let yaml = r#"
612version: tatara-receipt/v1
613kind: db-migration
614composed_root: ROOT
615intent_hash: aaaa
616artifact_hash: bbbb
617control_hash: cccc
618generated_at: 2026-05-19T12:00:00Z
619"#
620 .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
621 let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
622 assert_eq!(r.kind, "db-migration");
623 assert!(r.verify_root(None));
624 }
625
626 #[test]
627 fn parse_either_falls_back_to_yaml() {
628 let yaml = r#"
629version: tatara-receipt/v1
630kind: test-suite
631composed_root: ROOT
632intent_hash: aaaa
633artifact_hash: bbbb
634control_hash: cccc
635generated_at: 2026-05-19T12:00:00Z
636"#
637 .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
638 assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
639 }
640
641 #[test]
642 fn wrong_version_rejected() {
643 let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
644 env["version"] = "tatara-receipt/v2".into();
645 let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
646 assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
647 }
648
649 #[test]
650 fn missing_field_rejected() {
651 let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
652 env.as_object_mut().unwrap().remove("intent_hash");
653 let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
654 assert!(matches!(err, ReceiptError::InvalidJson(_)));
655 }
656
657 #[test]
658 fn unknown_field_rejected() {
659 let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
660 env["forged_extra"] = "should-fail".into();
661 let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
662 assert!(matches!(err, ReceiptError::InvalidJson(_)));
663 }
664
665 #[test]
666 fn empty_kind_rejected_in_verify_shape() {
667 let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
668 r.kind = String::new();
669 assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
670 }
671
672 #[test]
673 fn expect_root_matches_or_mismatches() {
674 let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
675 let root = r.composed_root.clone();
676 assert!(r.expect_root(Some(&root)).is_ok());
677 let err = r.expect_root(Some("nope")).unwrap_err();
678 assert!(matches!(err, ReceiptError::RootMismatch { .. }));
679 assert!(r.expect_root(None).is_ok());
680 }
681
682 #[test]
683 fn lower_to_attestation_chains_pillars() {
684 let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
685 let a = r.to_attestation(0, None);
686 assert_eq!(a.intent_hash, "i");
687 assert_eq!(a.artifact_hash, "a");
688 assert_eq!(a.control_hash.as_deref(), Some("c"));
689 // Both compose the same root.
690 assert_eq!(a.composed_root, r.composed_root);
691 assert!(a.verify());
692
693 let next = r.to_attestation(1, Some(&a.composed_root));
694 assert_eq!(next.generation, 1);
695 assert_eq!(
696 next.previous_root.as_deref(),
697 Some(a.composed_root.as_str())
698 );
699 // The composed_root differs because previous_root is included.
700 assert_ne!(next.composed_root, a.composed_root);
701 }
702
703 #[test]
704 fn verify_root_detects_tamper() {
705 let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
706 assert!(r.verify_root(None));
707 r.intent_hash = "tampered".into();
708 assert!(!r.verify_root(None));
709 }
710
711 #[test]
712 fn process_ref_optional_and_round_trips() {
713 let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
714 r.process_ref = Some("demo-test/ephemeral".into());
715 let s = serde_json::to_string(&r).unwrap();
716 let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
717 assert_eq!(back.process_ref.as_deref(), Some("demo-test/ephemeral"));
718 }
719
720 #[test]
721 fn evidence_round_trips() {
722 let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
723 r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
724 let s = serde_json::to_string(&r).unwrap();
725 let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
726 assert_eq!(back.evidence["passed"], 12);
727 }
728
729 // ── ReceiptKind closed-set truth-table ───────────────────────────
730
731 /// Structural well-formedness of [`ReceiptKind`] as a
732 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
733 /// testkit lift that pins all three structural invariants (`ALL`
734 /// is non-empty, every variant round-trips through
735 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
736 /// outside the closed set) at ONE call site. Replaces the hand-
737 /// derived `receipt_kind_all_enumerates_each_variant_exactly_once`
738 /// + `receipt_kind_from_str_round_trips_canonical_names` + the
739 /// empty-input arm of `receipt_kind_from_str_rejects_open_kinds`.
740 /// `FromStr` delegates to
741 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
742 /// exercises the same code path operators hit when parsing a wire
743 /// `kind` field back to the typed kind.
744 #[test]
745 fn receipt_kind_is_well_formed_closed_set() {
746 tatara_closed_set::assert_closed_set_well_formed::<ReceiptKind>();
747 }
748
749 #[test]
750 fn receipt_kind_canonical_names_pinned() {
751 // Byte-exact wire-format pin — renaming any of these is a
752 // wire-format change, not a typed-internal refactor.
753 assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
754 assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
755 assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
756 assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
757 }
758
759 #[test]
760 fn receipt_kind_from_str_rejects_open_kinds() {
761 // Future / typo / wrong-case all surface a typed
762 // UnknownReceiptKind carrying the offending input verbatim
763 // (operator-facing diagnostic); the schema is open at the
764 // wire layer, but the closed-set view is byte-exact. The
765 // empty-input arm is pinned by
766 // [`receipt_kind_is_well_formed_closed_set`] via the
767 // `tatara_lisp::ClosedSet` testkit; the cases here pin the
768 // verbatim-echo contract on the [`UnknownReceiptKind`] newtype,
769 // which the trait's `make_unknown` can't see.
770 for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
771 let err = bad.parse::<ReceiptKind>().unwrap_err();
772 assert_eq!(err, UnknownReceiptKind(bad.to_string()));
773 }
774 }
775
776 #[test]
777 fn receipt_kind_display_delegates_to_as_str() {
778 for k in ReceiptKind::ALL {
779 assert_eq!(format!("{k}"), k.as_str());
780 }
781 }
782
783 #[test]
784 fn receipt_kind_into_string_matches_as_str() {
785 for k in ReceiptKind::ALL {
786 let s: String = k.into();
787 assert_eq!(s, k.as_str());
788 }
789 }
790
791 #[test]
792 fn build_accepts_typed_receipt_kind() {
793 // The typed → wire bridge: `build(ReceiptKind::X, …)` produces
794 // a receipt whose `kind` field is exactly `X.as_str()`.
795 for k in ReceiptKind::ALL {
796 let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
797 assert_eq!(env.kind, k.as_str());
798 assert!(env.verify_shape().is_ok());
799 assert!(env.verify_root(None));
800 }
801 }
802
803 #[test]
804 fn known_kind_decodes_built_receipts() {
805 for k in ReceiptKind::ALL {
806 let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
807 assert_eq!(env.known_kind(), Some(k));
808 }
809 }
810
811 #[test]
812 fn known_kind_returns_none_for_open_kinds() {
813 // Open-by-design: a custom operator-registered kind still
814 // parses, still verifies, and still attests — it just doesn't
815 // project through the closed-set typed view.
816 let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
817 assert_eq!(env.known_kind(), None);
818 assert!(
819 env.verify_shape().is_ok(),
820 "open kind must remain a valid receipt"
821 );
822 }
823
824 // ── `control_hash_opt` / `empty_to_none` — the wire-form-to-typed
825 // projection of the second pillar (control step). Pre-lift the
826 // `is_empty() ? None : Some(&self.control_hash)` two-arm
827 // conditional lived at THREE inline sites — `build`,
828 // `verify_root`, `to_attestation` — each hand-writing the SAME
829 // projection with slightly-different ownership shapes
830 // (`Option<&str>` for the two composers, `Option<String>` for
831 // the attestation composer). Post-lift the projection lives at
832 // ONE inherent method + ONE free-fn peer for the pre-envelope
833 // call site. The tests below pin the substrate primitive's
834 // contract at its boundary so a regression at the projection
835 // surfaces here rather than as a silent `composed_root` drift
836 // at every consumer that composes the pillar.
837
838 #[test]
839 fn empty_to_none_projects_empty_to_none_and_non_empty_to_some_verbatim() {
840 // The pre-envelope free-fn peer of `control_hash_opt` — used
841 // by `build` before the envelope exists. Pin BOTH arms of the
842 // projection: an empty string projects to `None` (the "no
843 // such pillar" convention that `compose_root` threads through
844 // the absent-pillar BLAKE3 bytes pattern), and any non-empty
845 // string projects to `Some(s)` byte-identical to the input.
846 // A regression that (a) inverted the arms (folding `""` to
847 // `Some("")` and every non-empty into `None`), (b) normalized
848 // the payload (trimming whitespace, lowercasing hex), or (c)
849 // introduced a sentinel-string special case (`"none"`, `"-"`,
850 // etc.) would surface here rather than as a silent
851 // `composed_root` shift at every consumer that composes the
852 // pillar.
853 assert_eq!(super::empty_to_none(""), None);
854 assert_eq!(super::empty_to_none("c"), Some("c"));
855 assert_eq!(super::empty_to_none("cccc"), Some("cccc"));
856 // A whitespace-only string is NOT empty by the pillar's typed
857 // contract — the substrate composes bytes verbatim through
858 // BLAKE3, so a `" "` control hash IS a distinct pillar from
859 // an absent one; the projection must preserve that
860 // distinction.
861 assert_eq!(super::empty_to_none(" "), Some(" "));
862 }
863
864 #[test]
865 fn control_hash_opt_matches_the_free_fn_peer_on_every_receipt() {
866 // Post-envelope inherent method routes through the same
867 // `empty_to_none` free-fn body — pin the equivalence across
868 // both arms so a future regression that split the two
869 // projections (e.g. the inherent method starts trimming, the
870 // free-fn stays byte-verbatim) surfaces here rather than as a
871 // `composed_root` mismatch between `build` (uses the free-fn
872 // peer) and `verify_root` / `to_attestation` (use the
873 // inherent method).
874 let with_control = ReceiptEnvelope::build("test-suite", "i", "a", "cccc", None);
875 assert_eq!(with_control.control_hash_opt(), Some("cccc"));
876 assert_eq!(
877 with_control.control_hash_opt(),
878 super::empty_to_none(&with_control.control_hash),
879 );
880
881 let no_control = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
882 assert_eq!(no_control.control_hash_opt(), None);
883 assert_eq!(
884 no_control.control_hash_opt(),
885 super::empty_to_none(&no_control.control_hash),
886 );
887 }
888
889 #[test]
890 fn control_hash_opt_composes_the_same_root_the_three_consumers_bind() {
891 // End-to-end pin at the receipt-lifecycle boundary — the
892 // three consumers (`build`, `verify_root`, `to_attestation`)
893 // must land on the SAME `composed_root` for a given pillar
894 // tuple regardless of which projection body they route
895 // through. Sweeps BOTH pillar arms (present control + empty
896 // control) so a regression that mis-wired ONE consumer to
897 // the pre-lift inline conditional or that changed the
898 // projection at ONE site surfaces here rather than as a
899 // silent divergence between `verify_root`'s decision and
900 // `to_attestation`'s written `composed_root`.
901 for control in ["", "control-hash-cccc"] {
902 let env = ReceiptEnvelope::build("test-suite", "i", "a", control, None);
903 // `verify_root` composes through the inherent method AND
904 // through the same `compose_root(&artifact, control_opt,
905 // &intent, previous)` skeleton `build` binds — so the
906 // envelope must verify against its own composed root.
907 assert!(
908 env.verify_root(None),
909 "verify_root failed for control={control:?}",
910 );
911 // `to_attestation` composes the same pillar tuple through
912 // `ProcessAttestation::compose`'s `Option<String>` shape;
913 // the attestation's `composed_root` must match the
914 // envelope's `composed_root` byte-for-byte because both
915 // compose the SAME BLAKE3 domain-tagged skeleton over
916 // the SAME typed-Option pillar identity.
917 let att = env.to_attestation(0, None);
918 assert_eq!(
919 att.composed_root, env.composed_root,
920 "attestation root drift for control={control:?}",
921 );
922 }
923 }
924}