Skip to main content

heddle_object_model/object/
thread_authority_admission.rs

1//! Immutable hosted testimony about original authority at first durable receipt.
2//! This is separate from causal acceptance and from current review/landing policy.
3//! HYBRID retains these original/basis/actor/envelope comparisons while its
4//! crypto layer verifies each independent signature and witness proof. Boundary
5//! acceptance requires the exact API binding and current accepting authority.
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::{
10    CollaborationActor, ContentHash,
11    thread_replication::{
12        SourceAuthor, ThreadOperation, ThreadOperationBody, integration::TrustedHostedExecutor,
13        metadata::ThreadControl,
14    },
15};
16use crate::error::{HeddleError, Result};
17
18pub const FORMAT: &str = "heddle-thread-authority-admission-v3";
19pub const MAX_BYTES: usize = 2048;
20
21/// An admission never changes kind when relayed: a claim receipt cannot
22/// authorize a source operation with coincidentally equal bytes or identity.
23#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24pub enum OriginalAuthoritySubject {
25    Operation(ContentHash),
26    OwnershipClaim(ContentHash),
27    OwnershipResolution(ContentHash),
28}
29impl OriginalAuthoritySubject {
30    pub fn id(&self) -> ContentHash {
31        match self {
32            Self::Operation(id) | Self::OwnershipClaim(id) | Self::OwnershipResolution(id) => *id,
33        }
34    }
35    pub fn operation_id(&self) -> Option<ContentHash> {
36        match self {
37            Self::Operation(id) => Some(*id),
38            Self::OwnershipClaim(_) | Self::OwnershipResolution(_) => None,
39        }
40    }
41    pub fn claim_id(&self) -> Option<ContentHash> {
42        match self {
43            Self::OwnershipClaim(id) => Some(*id),
44            Self::Operation(_) | Self::OwnershipResolution(_) => None,
45        }
46    }
47}
48
49/// Signed original account identity shared by fresh admission and retained
50/// testimony. Local-key authors have no account binding to relabel.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct OriginalAuthorityBinding {
54    pub spool: Uuid,
55    pub actor: CollaborationActor,
56    pub authority_digest: ContentHash,
57}
58impl OriginalAuthorityBinding {
59    pub fn from_operation(operation: &ThreadOperation) -> Result<Option<Self>> {
60        if let ThreadOperationBody::Metadata(bytes) = &operation.body {
61            let control = ThreadControl::decode(bytes)?;
62            return Ok(Some(Self {
63                spool: control.spool,
64                actor: control.actor,
65                authority_digest: control.authority_digest,
66            }));
67        }
68        match operation.source_author()? {
69            Some(SourceAuthor::Account {
70                spool,
71                actor,
72                authority_digest,
73                authority,
74            }) => {
75                SourceAuthor::Account {
76                    spool,
77                    actor: actor.clone(),
78                    authority_digest,
79                    authority,
80                }
81                .validate()?;
82                Ok(Some(Self {
83                    spool,
84                    actor,
85                    authority_digest,
86                }))
87            }
88            Some(SourceAuthor::LocalKey) | None => Ok(None),
89        }
90    }
91}
92
93#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
94#[serde(deny_unknown_fields)]
95pub struct ThreadAuthorityAdmission {
96    pub version: u16,
97    pub basis: super::original_boundary_acceptance::AdmissionBasis,
98    pub spool: Uuid,
99    pub spool_genesis: ContentHash,
100    pub thread: ContentHash,
101    pub subject: OriginalAuthoritySubject,
102    pub actor: CollaborationActor,
103    pub publisher: [u8; 32],
104    pub authority_digest: ContentHash,
105    pub executor: [u8; 32],
106    /// Executor-observed first durable admission, never supplied by the author.
107    /// This timestamp does not revive an expired credential at fresh admission.
108    pub admitted_at_ms: i64,
109}
110impl ThreadAuthorityAdmission {
111    pub fn encode(&self) -> Result<Vec<u8>> {
112        if self.version != 3
113            || self.spool.is_nil()
114            || self.actor.principal_id.is_nil()
115            || self.publisher == [0; 32]
116            || self.executor == [0; 32]
117            || self.admitted_at_ms < 0
118            || self.actor.agent_id.as_ref().is_some_and(|id| {
119                id.is_empty() || id.len() > 256 || id.chars().any(char::is_control)
120            })
121        {
122            return Err(invalid("invalid original-author admission statement"));
123        }
124        let bytes = rmp_serde::to_vec_named(self)?;
125        if bytes.len() > MAX_BYTES {
126            return Err(invalid("authority admission statement exceeds byte bound"));
127        }
128        Ok(bytes)
129    }
130    pub fn decode(bytes: &[u8]) -> Result<Self> {
131        if bytes.is_empty() || bytes.len() > MAX_BYTES {
132            return Err(invalid("authority admission statement exceeds byte bound"));
133        }
134        let value: Self = rmp_serde::from_slice(bytes)?;
135        if value.encode()? != bytes {
136            return Err(invalid("noncanonical authority admission statement"));
137        }
138        Ok(value)
139    }
140    /// Compare testimony with an independently admitted immutable Spool/executor
141    /// pin and the original operation. Caller also verifies both signatures.
142    pub fn authorize(
143        &self,
144        operation: &ThreadOperation,
145        trust: &TrustedHostedExecutor,
146    ) -> Result<()> {
147        self.authorize_with_acceptance(operation, trust, None)
148    }
149    pub fn authorize_with_acceptance(
150        &self,
151        operation: &ThreadOperation,
152        trust: &TrustedHostedExecutor,
153        evidence: Option<&super::original_boundary_acceptance::OriginalBoundaryAcceptance>,
154    ) -> Result<()> {
155        self.basis.authorize_evidence(
156            evidence,
157            self.spool,
158            self.actor.principal_id,
159            operation
160                .source_author()?
161                .map(|_| super::original_boundary_acceptance::BoundaryOriginalKind::Source),
162        )?;
163        self.encode()?;
164        if self.spool != trust.spool
165            || self.spool_genesis != trust.spool_genesis
166            || self.executor != trust.executor
167        {
168            return Err(invalid(
169                "authority admission differs from independently pinned executor",
170            ));
171        }
172        let binding = OriginalAuthorityBinding::from_operation(operation)?
173            .ok_or_else(|| invalid("account admission requires original authored account work"))?;
174        if self.subject != OriginalAuthoritySubject::Operation(operation.id()?)
175            || self.thread != operation.thread
176            || self.publisher != operation.publisher
177            || self.actor != binding.actor
178            || self.spool != binding.spool
179            || self.authority_digest != binding.authority_digest
180        {
181            return Err(invalid(
182                "authority admission differs from original operation",
183            ));
184        }
185        Ok(())
186    }
187    pub fn authorize_claim(
188        &self,
189        claim: &super::thread_replication::ownership_claim::ThreadOwnershipClaim,
190        genesis: &super::thread_replication::ThreadGenesis,
191        trust: &TrustedHostedExecutor,
192    ) -> Result<()> {
193        self.authorize_claim_with_acceptance(claim, genesis, trust, None)
194    }
195    pub fn authorize_claim_with_acceptance(
196        &self,
197        claim: &super::thread_replication::ownership_claim::ThreadOwnershipClaim,
198        genesis: &super::thread_replication::ThreadGenesis,
199        trust: &TrustedHostedExecutor,
200        evidence: Option<&super::original_boundary_acceptance::OriginalBoundaryAcceptance>,
201    ) -> Result<()> {
202        self.basis.authorize_evidence(
203            evidence,
204            self.spool,
205            self.actor.principal_id,
206            Some(super::original_boundary_acceptance::BoundaryOriginalKind::OwnershipClaim),
207        )?;
208        self.encode()?;
209        claim.validate_genesis(genesis)?;
210        let SourceAuthor::Account {
211            spool,
212            actor,
213            authority_digest,
214            ..
215        } = &claim.acceptance
216        else {
217            return Err(invalid(
218                "claim admission requires signed account acceptance",
219            ));
220        };
221        if self.spool != trust.spool
222            || self.spool_genesis != trust.spool_genesis
223            || self.executor != trust.executor
224        {
225            return Err(invalid(
226                "authority admission differs from independently pinned executor",
227            ));
228        }
229        if self.subject != OriginalAuthoritySubject::OwnershipClaim(claim.id()?)
230            || self.thread != claim.thread
231            || self.publisher != claim.accepting_publisher
232            || self.actor != *actor
233            || self.spool != *spool
234            || self.authority_digest != *authority_digest
235        {
236            return Err(invalid(
237                "authority admission differs from original ownership claim",
238            ));
239        }
240        Ok(())
241    }
242    pub fn authorize_resolution_with_acceptance(
243        &self,
244        resolution: &super::thread_replication::ownership_resolution::ThreadOwnershipResolution,
245        genesis: &super::thread_replication::ThreadGenesis,
246        trust: &TrustedHostedExecutor,
247        evidence: Option<&super::original_boundary_acceptance::OriginalBoundaryAcceptance>,
248    ) -> Result<()> {
249        self.basis.authorize_evidence(
250            evidence,
251            self.spool,
252            self.actor.principal_id,
253            Some(super::original_boundary_acceptance::BoundaryOriginalKind::OwnershipResolution),
254        )?;
255        self.encode()?;
256        resolution.validate_genesis(genesis)?;
257        let SourceAuthor::Account {
258            spool,
259            actor,
260            authority_digest,
261            ..
262        } = &resolution.acceptance
263        else {
264            return Err(invalid("resolution admission requires account acceptance"));
265        };
266        if self.spool != trust.spool
267            || self.spool_genesis != trust.spool_genesis
268            || self.executor != trust.executor
269            || self.subject != OriginalAuthoritySubject::OwnershipResolution(resolution.id()?)
270            || self.thread != resolution.thread
271            || self.publisher != resolution.accepting_publisher
272            || self.actor != *actor
273            || self.spool != *spool
274            || self.authority_digest != *authority_digest
275        {
276            return Err(invalid(
277                "authority admission differs from original ownership resolution",
278            ));
279        }
280        Ok(())
281    }
282}
283fn invalid(message: &str) -> HeddleError {
284    HeddleError::InvalidObject(message.into())
285}