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 (Akeyless gator↔gateway, 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 akeyless-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: "akeyless-test/ephemeral-akeyless" # 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 (Akeyless gator↔gateway,
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 if control_hash.is_empty() {
303 None
304 } else {
305 Some(control_hash.as_str())
306 },
307 &intent_hash,
308 previous_root,
309 );
310 Self {
311 version: RECEIPT_VERSION.into(),
312 kind: kind.into(),
313 composed_root,
314 intent_hash,
315 artifact_hash,
316 control_hash,
317 generated_at: Utc::now(),
318 process_ref: None,
319 evidence: serde_json::Value::Null,
320 }
321 }
322
323 /// Parse a receipt from a JSON string.
324 pub fn parse_json(payload: &str) -> Result<Self, ReceiptError> {
325 let env: Self =
326 serde_json::from_str(payload).map_err(|e| ReceiptError::InvalidJson(e.to_string()))?;
327 env.verify_shape()?;
328 Ok(env)
329 }
330
331 /// Parse a receipt from a YAML string. Useful for ConfigMaps that
332 /// store the payload in YAML form.
333 pub fn parse_yaml(payload: &str) -> Result<Self, ReceiptError> {
334 let env: Self =
335 serde_yaml::from_str(payload).map_err(|e| ReceiptError::InvalidYaml(e.to_string()))?;
336 env.verify_shape()?;
337 Ok(env)
338 }
339
340 /// Parse via JSON first, then YAML if JSON fails. Lets a single
341 /// reader accept either wire form without the operator having to
342 /// declare it. Useful when the Job writes JSON and the reconciler
343 /// reads back through a kube DynamicObject whose `data` is YAML.
344 pub fn parse_either(payload: &str) -> Result<Self, ReceiptError> {
345 match Self::parse_json(payload) {
346 Ok(env) => Ok(env),
347 Err(_) => Self::parse_yaml(payload),
348 }
349 }
350
351 /// Verify the schema-level invariants: correct version + non-empty
352 /// kind + non-empty pillar hashes (length-only, not BLAKE3-recompute).
353 pub fn verify_shape(&self) -> Result<(), ReceiptError> {
354 if self.version != RECEIPT_VERSION {
355 return Err(ReceiptError::WrongVersion(self.version.clone()));
356 }
357 if self.kind.is_empty() {
358 return Err(ReceiptError::EmptyKind);
359 }
360 if self.composed_root.is_empty() {
361 return Err(ReceiptError::MissingField("composed_root"));
362 }
363 if self.intent_hash.is_empty() {
364 return Err(ReceiptError::MissingField("intent_hash"));
365 }
366 if self.artifact_hash.is_empty() {
367 return Err(ReceiptError::MissingField("artifact_hash"));
368 }
369 // control_hash MAY be empty when there is no control step;
370 // the BLAKE3 compose treats empty as "absent" via Option.
371 Ok(())
372 }
373
374 /// Verify that `composed_root` is consistent with the pillars.
375 /// `expected_previous_root` is the previous root in the Process's
376 /// attestation chain (or `None` for first attestation).
377 pub fn verify_root(&self, expected_previous_root: Option<&str>) -> bool {
378 let want = compose_root(
379 &self.artifact_hash,
380 if self.control_hash.is_empty() {
381 None
382 } else {
383 Some(self.control_hash.as_str())
384 },
385 &self.intent_hash,
386 expected_previous_root,
387 );
388 constant_time_eq(want.as_bytes(), self.composed_root.as_bytes())
389 }
390
391 /// Strict-equality check against an operator-provided expected root.
392 /// Returns the receipt's root unchanged on success.
393 pub fn expect_root(&self, expected: Option<&str>) -> Result<&str, ReceiptError> {
394 if let Some(want) = expected {
395 if want != self.composed_root {
396 return Err(ReceiptError::RootMismatch {
397 got: self.composed_root.clone(),
398 want: want.to_string(),
399 });
400 }
401 }
402 Ok(&self.composed_root)
403 }
404
405 /// Decode `self.kind` into the typed [`ReceiptKind`] variant when
406 /// the wire string matches one of the four substrate-emitted
407 /// canonical kebab-case kinds; `None` when the kind is an
408 /// operator-registered open string (the schema is open by design —
409 /// every receipt remains a valid receipt, but only typed kinds
410 /// participate in closed-set dispatch). The (open `String`,
411 /// closed-typed view) split lets future kind-keyed consumers
412 /// (verifier registries, dashboard completion, audit-trail
413 /// classifiers) sweep the typed variants without touching the
414 /// open-by-design wire shape. Lifted as the canonical decode site
415 /// so no consumer re-implements the `match self.kind.as_str()`
416 /// arm-by-arm — the closed-set sweep happens through
417 /// [`ReceiptKind::from_str`] at ONE site.
418 #[must_use]
419 pub fn known_kind(&self) -> Option<ReceiptKind> {
420 self.kind.parse().ok()
421 }
422
423 /// Lower into a `ProcessAttestation` — the canonical handoff so a
424 /// Job's typed receipt becomes evidence on a Process. `generation`
425 /// + `previous_root` come from the owning Process's prior
426 /// attestation (or 0 + None for the first cycle).
427 pub fn to_attestation(
428 &self,
429 generation: u64,
430 previous_root: Option<&str>,
431 ) -> ProcessAttestation {
432 ProcessAttestation::compose(
433 self.artifact_hash.clone(),
434 if self.control_hash.is_empty() {
435 None
436 } else {
437 Some(self.control_hash.clone())
438 },
439 self.intent_hash.clone(),
440 previous_root.map(String::from),
441 generation,
442 )
443 }
444}
445
446const DOMAIN_TAG: &[u8] = b"tatara-process/v1alpha1\n";
447
448/// Same composition as `ProcessAttestation::composed_hex` — kept local so
449/// `tatara_process::receipt::compose_root(...)` is a single line in
450/// downstream code without re-importing the attestation module.
451fn compose_root(
452 artifact: &str,
453 control: Option<&str>,
454 intent: &str,
455 previous: Option<&str>,
456) -> String {
457 let mut h = blake3::Hasher::new();
458 h.update(DOMAIN_TAG);
459 h.update(artifact.as_bytes());
460 h.update(b"\n");
461 h.update(control.unwrap_or("").as_bytes());
462 h.update(b"\n");
463 h.update(intent.as_bytes());
464 h.update(b"\n");
465 h.update(previous.unwrap_or("").as_bytes());
466 hex::encode(h.finalize().as_bytes())
467}
468
469fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
470 if a.len() != b.len() {
471 return false;
472 }
473 let mut acc: u8 = 0;
474 for (x, y) in a.iter().zip(b.iter()) {
475 acc |= x ^ y;
476 }
477 acc == 0
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 fn sample_payload() -> &'static str {
485 // Composed_root precomputed from compose_root("bbbb", Some("cccc"), "aaaa", None)
486 // (recomputed at test time to be canonical; this string is regenerated
487 // if the domain tag ever changes).
488 r#"{
489 "version": "tatara-receipt/v1",
490 "kind": "closed-loop-auth",
491 "composed_root": "RECOMPUTE",
492 "intent_hash": "aaaa",
493 "artifact_hash": "bbbb",
494 "control_hash": "cccc",
495 "generated_at": "2026-05-19T12:00:00Z"
496 }"#
497 }
498
499 fn canonical_payload_json() -> String {
500 let root = compose_root("bbbb", Some("cccc"), "aaaa", None);
501 sample_payload().replace("RECOMPUTE", &root)
502 }
503
504 #[test]
505 fn build_produces_valid_envelope() {
506 let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
507 assert_eq!(r.version, RECEIPT_VERSION);
508 assert_eq!(r.kind, "test-suite");
509 assert!(r.verify_shape().is_ok());
510 assert!(r.verify_root(None));
511 }
512
513 #[test]
514 fn build_empty_control_omits_from_root() {
515 let with_empty = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
516 let with_explicit_none = ReceiptEnvelope::build("nix-build", "i", "a", "", None);
517 assert_eq!(with_empty.composed_root, with_explicit_none.composed_root);
518
519 // And differs from a receipt with a real control hash.
520 let with_control = ReceiptEnvelope::build("nix-build", "i", "a", "c", None);
521 assert_ne!(with_empty.composed_root, with_control.composed_root);
522 }
523
524 #[test]
525 fn parse_json_round_trip() {
526 let r = ReceiptEnvelope::parse_json(&canonical_payload_json()).expect("parse");
527 assert_eq!(r.kind, "closed-loop-auth");
528 assert!(r.verify_root(None));
529 }
530
531 #[test]
532 fn parse_yaml_round_trip() {
533 let yaml = r#"
534version: tatara-receipt/v1
535kind: db-migration
536composed_root: ROOT
537intent_hash: aaaa
538artifact_hash: bbbb
539control_hash: cccc
540generated_at: 2026-05-19T12:00:00Z
541"#
542 .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
543 let r = ReceiptEnvelope::parse_yaml(&yaml).expect("yaml parse");
544 assert_eq!(r.kind, "db-migration");
545 assert!(r.verify_root(None));
546 }
547
548 #[test]
549 fn parse_either_falls_back_to_yaml() {
550 let yaml = r#"
551version: tatara-receipt/v1
552kind: test-suite
553composed_root: ROOT
554intent_hash: aaaa
555artifact_hash: bbbb
556control_hash: cccc
557generated_at: 2026-05-19T12:00:00Z
558"#
559 .replace("ROOT", &compose_root("bbbb", Some("cccc"), "aaaa", None));
560 assert!(ReceiptEnvelope::parse_either(&yaml).is_ok());
561 }
562
563 #[test]
564 fn wrong_version_rejected() {
565 let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
566 env["version"] = "tatara-receipt/v2".into();
567 let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
568 assert!(matches!(err, ReceiptError::WrongVersion(ref s) if s == "tatara-receipt/v2"));
569 }
570
571 #[test]
572 fn missing_field_rejected() {
573 let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
574 env.as_object_mut().unwrap().remove("intent_hash");
575 let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
576 assert!(matches!(err, ReceiptError::InvalidJson(_)));
577 }
578
579 #[test]
580 fn unknown_field_rejected() {
581 let mut env: serde_json::Value = serde_json::from_str(&canonical_payload_json()).unwrap();
582 env["forged_extra"] = "should-fail".into();
583 let err = ReceiptEnvelope::parse_json(&env.to_string()).unwrap_err();
584 assert!(matches!(err, ReceiptError::InvalidJson(_)));
585 }
586
587 #[test]
588 fn empty_kind_rejected_in_verify_shape() {
589 let mut r = ReceiptEnvelope::build("k", "i", "a", "c", None);
590 r.kind = String::new();
591 assert!(matches!(r.verify_shape(), Err(ReceiptError::EmptyKind)));
592 }
593
594 #[test]
595 fn expect_root_matches_or_mismatches() {
596 let r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
597 let root = r.composed_root.clone();
598 assert!(r.expect_root(Some(&root)).is_ok());
599 let err = r.expect_root(Some("nope")).unwrap_err();
600 assert!(matches!(err, ReceiptError::RootMismatch { .. }));
601 assert!(r.expect_root(None).is_ok());
602 }
603
604 #[test]
605 fn lower_to_attestation_chains_pillars() {
606 let r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
607 let a = r.to_attestation(0, None);
608 assert_eq!(a.intent_hash, "i");
609 assert_eq!(a.artifact_hash, "a");
610 assert_eq!(a.control_hash.as_deref(), Some("c"));
611 // Both compose the same root.
612 assert_eq!(a.composed_root, r.composed_root);
613 assert!(a.verify());
614
615 let next = r.to_attestation(1, Some(&a.composed_root));
616 assert_eq!(next.generation, 1);
617 assert_eq!(
618 next.previous_root.as_deref(),
619 Some(a.composed_root.as_str())
620 );
621 // The composed_root differs because previous_root is included.
622 assert_ne!(next.composed_root, a.composed_root);
623 }
624
625 #[test]
626 fn verify_root_detects_tamper() {
627 let mut r = ReceiptEnvelope::build("closed-loop-auth", "i", "a", "c", None);
628 assert!(r.verify_root(None));
629 r.intent_hash = "tampered".into();
630 assert!(!r.verify_root(None));
631 }
632
633 #[test]
634 fn process_ref_optional_and_round_trips() {
635 let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
636 r.process_ref = Some("akeyless-test/ephemeral".into());
637 let s = serde_json::to_string(&r).unwrap();
638 let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
639 assert_eq!(back.process_ref.as_deref(), Some("akeyless-test/ephemeral"));
640 }
641
642 #[test]
643 fn evidence_round_trips() {
644 let mut r = ReceiptEnvelope::build("test-suite", "i", "a", "c", None);
645 r.evidence = serde_json::json!({ "passed": 12, "failed": 0, "duration_ms": 4200 });
646 let s = serde_json::to_string(&r).unwrap();
647 let back = ReceiptEnvelope::parse_json(&s).expect("round-trip");
648 assert_eq!(back.evidence["passed"], 12);
649 }
650
651 // ── ReceiptKind closed-set truth-table ───────────────────────────
652
653 /// Structural well-formedness of [`ReceiptKind`] as a
654 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
655 /// testkit lift that pins all three structural invariants (`ALL`
656 /// is non-empty, every variant round-trips through
657 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
658 /// outside the closed set) at ONE call site. Replaces the hand-
659 /// derived `receipt_kind_all_enumerates_each_variant_exactly_once`
660 /// + `receipt_kind_from_str_round_trips_canonical_names` + the
661 /// empty-input arm of `receipt_kind_from_str_rejects_open_kinds`.
662 /// `FromStr` delegates to
663 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
664 /// exercises the same code path operators hit when parsing a wire
665 /// `kind` field back to the typed kind.
666 #[test]
667 fn receipt_kind_is_well_formed_closed_set() {
668 tatara_closed_set::assert_closed_set_well_formed::<ReceiptKind>();
669 }
670
671 #[test]
672 fn receipt_kind_canonical_names_pinned() {
673 // Byte-exact wire-format pin — renaming any of these is a
674 // wire-format change, not a typed-internal refactor.
675 assert_eq!(ReceiptKind::ClosedLoopAuth.as_str(), "closed-loop-auth");
676 assert_eq!(ReceiptKind::DbMigration.as_str(), "db-migration");
677 assert_eq!(ReceiptKind::TestSuite.as_str(), "test-suite");
678 assert_eq!(ReceiptKind::NixBuild.as_str(), "nix-build");
679 }
680
681 #[test]
682 fn receipt_kind_from_str_rejects_open_kinds() {
683 // Future / typo / wrong-case all surface a typed
684 // UnknownReceiptKind carrying the offending input verbatim
685 // (operator-facing diagnostic); the schema is open at the
686 // wire layer, but the closed-set view is byte-exact. The
687 // empty-input arm is pinned by
688 // [`receipt_kind_is_well_formed_closed_set`] via the
689 // `tatara_lisp::ClosedSet` testkit; the cases here pin the
690 // verbatim-echo contract on the [`UnknownReceiptKind`] newtype,
691 // which the trait's `make_unknown` can't see.
692 for bad in ["closed_loop_auth", "ClosedLoopAuth", "operator-custom-kind"] {
693 let err = bad.parse::<ReceiptKind>().unwrap_err();
694 assert_eq!(err, UnknownReceiptKind(bad.to_string()));
695 }
696 }
697
698 #[test]
699 fn receipt_kind_display_delegates_to_as_str() {
700 for k in ReceiptKind::ALL {
701 assert_eq!(format!("{k}"), k.as_str());
702 }
703 }
704
705 #[test]
706 fn receipt_kind_into_string_matches_as_str() {
707 for k in ReceiptKind::ALL {
708 let s: String = k.into();
709 assert_eq!(s, k.as_str());
710 }
711 }
712
713 #[test]
714 fn build_accepts_typed_receipt_kind() {
715 // The typed → wire bridge: `build(ReceiptKind::X, …)` produces
716 // a receipt whose `kind` field is exactly `X.as_str()`.
717 for k in ReceiptKind::ALL {
718 let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
719 assert_eq!(env.kind, k.as_str());
720 assert!(env.verify_shape().is_ok());
721 assert!(env.verify_root(None));
722 }
723 }
724
725 #[test]
726 fn known_kind_decodes_built_receipts() {
727 for k in ReceiptKind::ALL {
728 let env = ReceiptEnvelope::build(k, "i", "a", "c", None);
729 assert_eq!(env.known_kind(), Some(k));
730 }
731 }
732
733 #[test]
734 fn known_kind_returns_none_for_open_kinds() {
735 // Open-by-design: a custom operator-registered kind still
736 // parses, still verifies, and still attests — it just doesn't
737 // project through the closed-set typed view.
738 let env = ReceiptEnvelope::build("operator-custom-kind", "i", "a", "c", None);
739 assert_eq!(env.known_kind(), None);
740 assert!(
741 env.verify_shape().is_ok(),
742 "open kind must remain a valid receipt"
743 );
744 }
745}