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},
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}
72impl CheckEvidence {
73    pub fn encode(&self) -> Result<Vec<u8>> {
74        self.author.validate()?;
75        if self.version != 2
76            || self.id.is_nil()
77            || self.spool.is_nil()
78            || self.completed_at_ms < 0
79            || !valid_text(&self.check, 512, false)
80            || !valid_text(&self.detail, 32 * 1024, true)
81            || self.artifacts.len() > 64
82            || self.artifacts.iter().any(Uuid::is_nil)
83            || self.artifacts.windows(2).any(|ids| ids[0] >= ids[1])
84            || self.supersedes.len() > 32
85            || self
86                .supersedes
87                .iter()
88                .any(|id| id.is_nil() || *id == self.id)
89            || self.supersedes.windows(2).any(|ids| ids[0] >= ids[1])
90        {
91            return Err(invalid("invalid check evidence"));
92        }
93        bounded_encode(self)
94    }
95    pub fn decode(bytes: &[u8]) -> Result<Self> {
96        bound(bytes)?;
97        let value: Self = rmp_serde::from_slice(bytes)?;
98        if value.encode()? != bytes {
99            return Err(invalid("noncanonical check evidence"));
100        }
101        Ok(value)
102    }
103    pub fn id(&self) -> Result<ContentHash> {
104        Ok(ContentHash::compute_typed(EVIDENCE_FORMAT, &self.encode()?))
105    }
106}
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct CheckAcknowledgement {
110    pub version: u16,
111    pub spool: Uuid,
112    pub evidence: Uuid,
113    /// Exact accepted evidence content, preventing a mutable ID substitution.
114    pub evidence_digest: ContentHash,
115    pub revision: StateId,
116    pub policy_version: ContentHash,
117    pub author: CheckAuthor,
118    pub client_operation_id: Uuid,
119    pub occurred_at_ms: i64,
120}
121impl CheckAcknowledgement {
122    pub fn encode(&self) -> Result<Vec<u8>> {
123        self.author.validate()?;
124        if self.version != 1
125            || self.spool.is_nil()
126            || self.evidence.is_nil()
127            || self.client_operation_id.is_nil()
128            || self.occurred_at_ms < 0
129        {
130            return Err(invalid("invalid check acknowledgement"));
131        }
132        bounded_encode(self)
133    }
134    pub fn decode(bytes: &[u8]) -> Result<Self> {
135        bound(bytes)?;
136        let value: Self = rmp_serde::from_slice(bytes)?;
137        if value.encode()? != bytes {
138            return Err(invalid("noncanonical check acknowledgement"));
139        }
140        Ok(value)
141    }
142}
143fn bounded_encode(value: &impl Serialize) -> Result<Vec<u8>> {
144    let bytes = rmp_serde::to_vec_named(value)?;
145    bound(&bytes)?;
146    Ok(bytes)
147}
148fn bound(bytes: &[u8]) -> Result<()> {
149    if bytes.is_empty() || bytes.len() > MAX_BYTES {
150        return Err(invalid("check record exceeds byte bounds"));
151    }
152    Ok(())
153}
154fn valid_text(value: &str, max: usize, empty: bool) -> bool {
155    (empty || !value.trim().is_empty()) && value.len() <= max && !value.contains('\0')
156}
157fn invalid(message: &str) -> HeddleError {
158    HeddleError::InvalidObject(message.into())
159}