Skip to main content

tatara_process/
attestation.rs

1//! Three-pillar BLAKE3 attestation — wire-compatible with
2//! `tatara_engine::domain::attestation::ConvergenceAttestation`.
3//!
4//! Every BLAKE3-side operation (compose, verify) rides through the
5//! substrate primitive [`crate::three_pillar`] — pre-lift the same
6//! domain-tagged chain + constant-time comparator lived at TWO
7//! sites (here + `crate::receipt`), each with its own private
8//! `DOMAIN_TAG`, `composed_hex`/`compose_root` fn, and
9//! `constant_time_eq` body. Post-lift the theorem-critical
10//! composition + the comparator live at ONE substrate owner so a
11//! future CRD-version bump or a comparator normalization lands at
12//! one edit rather than two silently divergent ones. See the
13//! module-doc of [`crate::three_pillar`] for the full lift
14//! narrative.
15
16use chrono::{DateTime, Utc};
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::three_pillar;
21
22/// Attestation written to `Process.status.attestation` after each convergence cycle.
23///
24/// Composition:
25/// ```text
26/// composed_root = BLAKE3(
27///     "tatara-process/v1alpha1\n"
28///     ++ artifact_hash ++ "\n"
29///     ++ control_hash.unwrap_or("") ++ "\n"
30///     ++ intent_hash ++ "\n"
31///     ++ previous_root.unwrap_or("")
32/// )
33/// ```
34#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
35#[serde(rename_all = "camelCase")]
36pub struct ProcessAttestation {
37    /// `BLAKE3(rendered resources ++ their applied-status digests)`.
38    pub artifact_hash: String,
39    /// `BLAKE3(compliance-verification proof)` — absent iff no compliance bindings.
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub control_hash: Option<String>,
42    /// `BLAKE3(canonical-spec ++ nix-store-path? ++ lisp-AST?)`.
43    pub intent_hash: String,
44    /// `BLAKE3` of the three pillars + previous root.
45    pub composed_root: String,
46    /// Monotonic generation counter — starts at 0, increments each cycle.
47    pub generation: u64,
48    /// The prior `composed_root` in the chain. `None` for generation 0.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub previous_root: Option<String>,
51    /// When the attestation was computed.
52    pub attested_at: DateTime<Utc>,
53}
54
55impl ProcessAttestation {
56    /// Compose an attestation from the three pillars + chain context.
57    pub fn compose(
58        artifact_hash: String,
59        control_hash: Option<String>,
60        intent_hash: String,
61        previous_root: Option<String>,
62        generation: u64,
63    ) -> Self {
64        let composed_root = three_pillar::compose_root(
65            &artifact_hash,
66            control_hash.as_deref(),
67            &intent_hash,
68            previous_root.as_deref(),
69        );
70        Self {
71            artifact_hash,
72            control_hash,
73            intent_hash,
74            composed_root,
75            generation,
76            previous_root,
77            attested_at: Utc::now(),
78        }
79    }
80
81    /// Convenience for the initial attestation (generation 0, no previous root).
82    pub fn initial(
83        artifact_hash: String,
84        control_hash: Option<String>,
85        intent_hash: String,
86    ) -> Self {
87        Self::compose(artifact_hash, control_hash, intent_hash, None, 0)
88    }
89
90    /// Convenience for chaining: `self.next(new_pillars)` yields the next attestation.
91    pub fn next(
92        &self,
93        artifact_hash: String,
94        control_hash: Option<String>,
95        intent_hash: String,
96    ) -> Self {
97        Self::compose(
98            artifact_hash,
99            control_hash,
100            intent_hash,
101            Some(self.composed_root.clone()),
102            self.generation + 1,
103        )
104    }
105
106    /// Verify that `composed_root` is consistent with the pillars + `previous_root`.
107    pub fn verify(&self) -> bool {
108        let recomputed = three_pillar::compose_root(
109            &self.artifact_hash,
110            self.control_hash.as_deref(),
111            &self.intent_hash,
112            self.previous_root.as_deref(),
113        );
114        three_pillar::constant_time_eq(recomputed.as_bytes(), self.composed_root.as_bytes())
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn initial_has_generation_zero() {
124        let a = ProcessAttestation::initial("a".into(), None, "i".into());
125        assert_eq!(a.generation, 0);
126        assert!(a.previous_root.is_none());
127        assert!(a.verify());
128    }
129
130    #[test]
131    fn chain_extends_previous_root() {
132        let a0 = ProcessAttestation::initial("a0".into(), Some("c0".into()), "i0".into());
133        let a1 = a0.next("a1".into(), Some("c1".into()), "i1".into());
134        assert_eq!(a1.generation, 1);
135        assert_eq!(a1.previous_root.as_deref(), Some(a0.composed_root.as_str()));
136        assert_ne!(a0.composed_root, a1.composed_root);
137        assert!(a1.verify());
138    }
139
140    #[test]
141    fn verify_detects_tamper() {
142        let mut a = ProcessAttestation::initial("a".into(), None, "i".into());
143        assert!(a.verify());
144        a.artifact_hash = "tampered".into();
145        assert!(!a.verify());
146    }
147
148    #[test]
149    fn control_hash_affects_root() {
150        let a = ProcessAttestation::initial("x".into(), None, "y".into());
151        let b = ProcessAttestation::initial("x".into(), Some("c".into()), "y".into());
152        assert_ne!(a.composed_root, b.composed_root);
153    }
154}