Skip to main content

heddle_object_model/object/
check_evidence.rs

1//! Immutable check results and explicit policy-bound acknowledgements. A valid
2//! signature proves authorship; admitting hosts independently verify authority.
3use serde::{Deserialize, Serialize};
4use uuid::Uuid;
5
6use super::thread_replication::metadata::AUTHORITY_FORMAT;
7use crate::{
8    error::{HeddleError, Result},
9    object::{CollaborationActor, ContentHash, StateId, collaboration::CanonicalBody},
10};
11
12pub const EVIDENCE_FORMAT: &str = "heddle-check-evidence-v2";
13pub const ACKNOWLEDGEMENT_FORMAT: &str = "heddle-check-acknowledgement-v1";
14pub const MAX_BYTES: usize = 128 * 1024;
15
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum CheckOutcome {
19    Passed,
20    Failed,
21    Error,
22    Skipped,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct CheckAuthor {
28    pub actor: CollaborationActor,
29    pub publisher: [u8; 32],
30    pub authority_digest: ContentHash,
31    pub authority_envelope: Vec<u8>,
32}
33impl CheckAuthor {
34    fn validate(&self) -> Result<()> {
35        if self.actor.principal_id.is_nil()
36            || self.publisher == [0; 32]
37            || self
38                .actor
39                .agent_id
40                .as_ref()
41                .is_some_and(|id| !valid_text(id, 256, false))
42            || self.authority_envelope.is_empty()
43            || self.authority_envelope.len() > 64 * 1024
44            || ContentHash::compute_typed(AUTHORITY_FORMAT, &self.authority_envelope)
45                != self.authority_digest
46        {
47            return Err(invalid("invalid check author authority binding"));
48        }
49        Ok(())
50    }
51}
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct CheckEvidence {
55    pub version: u16,
56    pub id: Uuid,
57    pub spool: Uuid,
58    /// Original access scope; identical source in another Thread grants no access.
59    pub thread: ContentHash,
60    pub revision: StateId,
61    pub check: String,
62    pub outcome: CheckOutcome,
63    pub detail: String,
64    /// Independently authorized retained artifacts; a reference is no grant.
65    pub artifacts: Vec<Uuid>,
66    /// Explicit prior results from this same actor/check/revision; arrival time
67    /// never decides which retry supersedes another result.
68    pub supersedes: Vec<Uuid>,
69    pub author: CheckAuthor,
70    pub completed_at_ms: i64,
71    /// Canonical MessagePack from the last successful [`Self::encode`] or [`Self::decode`].
72    /// Not serialized. Struct literals use [`Default::default`].
73    #[serde(skip)]
74    pub canonical_body: CanonicalBody,
75}
76impl CheckEvidence {
77    fn encode_fields(&self) -> Result<Vec<u8>> {
78        self.author.validate()?;
79        if self.version != 2
80            || self.id.is_nil()
81            || self.spool.is_nil()
82            || self.completed_at_ms < 0
83            || !valid_text(&self.check, 512, false)
84            || !valid_text(&self.detail, 32 * 1024, true)
85            || self.artifacts.len() > 64
86            || self.artifacts.iter().any(Uuid::is_nil)
87            || self.artifacts.windows(2).any(|ids| ids[0] >= ids[1])
88            || self.supersedes.len() > 32
89            || self
90                .supersedes
91                .iter()
92                .any(|id| id.is_nil() || *id == self.id)
93            || self.supersedes.windows(2).any(|ids| ids[0] >= ids[1])
94        {
95            return Err(invalid("invalid check evidence"));
96        }
97        bounded_encode(self)
98    }
99    pub fn encode(&self) -> Result<Vec<u8>> {
100        self.canonical_body.clear();
101        let bytes = self.encode_fields()?;
102        self.canonical_body.store(bytes.clone());
103        Ok(bytes)
104    }
105    pub fn decode(bytes: &[u8]) -> Result<Self> {
106        bound(bytes)?;
107        let value: Self = rmp_serde::from_slice(bytes)?;
108        if value.encode()? != bytes {
109            return Err(invalid("noncanonical check evidence"));
110        }
111        Ok(value)
112    }
113    pub fn id(&self) -> Result<ContentHash> {
114        if let Some(bytes) = self.canonical_body.cloned() {
115            CanonicalBody::debug_matches(&bytes, || self.encode_fields());
116            return Ok(ContentHash::compute_typed(EVIDENCE_FORMAT, &bytes));
117        }
118        Ok(ContentHash::compute_typed(EVIDENCE_FORMAT, &self.encode()?))
119    }
120}
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(deny_unknown_fields)]
123pub struct CheckAcknowledgement {
124    pub version: u16,
125    pub spool: Uuid,
126    pub evidence: Uuid,
127    /// Exact accepted evidence content, preventing a mutable ID substitution.
128    pub evidence_digest: ContentHash,
129    pub revision: StateId,
130    pub policy_version: ContentHash,
131    pub author: CheckAuthor,
132    pub client_operation_id: Uuid,
133    pub occurred_at_ms: i64,
134}
135impl CheckAcknowledgement {
136    pub fn encode(&self) -> Result<Vec<u8>> {
137        self.author.validate()?;
138        if self.version != 1
139            || self.spool.is_nil()
140            || self.evidence.is_nil()
141            || self.client_operation_id.is_nil()
142            || self.occurred_at_ms < 0
143        {
144            return Err(invalid("invalid check acknowledgement"));
145        }
146        bounded_encode(self)
147    }
148    pub fn decode(bytes: &[u8]) -> Result<Self> {
149        bound(bytes)?;
150        let value: Self = rmp_serde::from_slice(bytes)?;
151        if value.encode()? != bytes {
152            return Err(invalid("noncanonical check acknowledgement"));
153        }
154        Ok(value)
155    }
156}
157fn bounded_encode(value: &impl Serialize) -> Result<Vec<u8>> {
158    let bytes = rmp_serde::to_vec_named(value)?;
159    bound(&bytes)?;
160    Ok(bytes)
161}
162fn bound(bytes: &[u8]) -> Result<()> {
163    if bytes.is_empty() || bytes.len() > MAX_BYTES {
164        return Err(invalid("check record exceeds byte bounds"));
165    }
166    Ok(())
167}
168fn valid_text(value: &str, max: usize, empty: bool) -> bool {
169    (empty || !value.trim().is_empty()) && value.len() <= max && !value.contains('\0')
170}
171fn invalid(message: &str) -> HeddleError {
172    HeddleError::InvalidObject(message.into())
173}
174
175#[cfg(test)]
176mod tests {
177    use uuid::Uuid;
178
179    use super::*;
180    use crate::object::{
181        CollaborationActor, ContentHash, StateId, thread_replication::metadata::AUTHORITY_FORMAT,
182    };
183
184    fn sample() -> CheckEvidence {
185        let envelope = b"independently verified authority".to_vec();
186        CheckEvidence {
187            version: 2,
188            id: Uuid::from_u128(1),
189            spool: Uuid::from_u128(2),
190            thread: ContentHash::from_bytes([7; 32]),
191            revision: StateId::from_bytes([3; 32]),
192            check: "unit-tests".into(),
193            outcome: CheckOutcome::Passed,
194            detail: "42 passed".into(),
195            artifacts: Vec::new(),
196            supersedes: Vec::new(),
197            author: CheckAuthor {
198                actor: CollaborationActor {
199                    principal_id: Uuid::from_u128(4),
200                    agent_id: None,
201                },
202                publisher: [9; 32],
203                authority_digest: ContentHash::compute_typed(AUTHORITY_FORMAT, &envelope),
204                authority_envelope: envelope,
205            },
206            completed_at_ms: 1000,
207            canonical_body: Default::default(),
208        }
209    }
210
211    #[test]
212    fn id_matches_reencode_of_canonical_body() {
213        let evidence = sample();
214        let bytes = evidence.encode().expect("canonical evidence");
215        let old = ContentHash::compute_typed(EVIDENCE_FORMAT, &bytes);
216        assert_eq!(evidence.id().expect("fresh id"), old);
217        assert_eq!(evidence.id().expect("cached id"), old);
218
219        let decoded = CheckEvidence::decode(&bytes).expect("decode");
220        assert_eq!(decoded, evidence);
221        assert_eq!(decoded.id().expect("decoded id"), old);
222        assert_eq!(
223            decoded.id().expect("decoded id"),
224            ContentHash::compute_typed(EVIDENCE_FORMAT, &decoded.encode().expect("re-encode"))
225        );
226
227        let mut changed = evidence.clone();
228        changed.outcome = CheckOutcome::Failed;
229        let changed_bytes = changed.encode().expect("changed evidence");
230        let changed_id = changed.id().expect("changed id");
231        assert_ne!(changed_id, old);
232        assert_eq!(
233            changed_id,
234            ContentHash::compute_typed(EVIDENCE_FORMAT, &changed_bytes)
235        );
236    }
237}