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