Skip to main content

heddle_object_model/object/thread_replication/
integration.rs

1//! Hosted landing is an executor attestation, never a human signature or a
2//! transferable grant. Trust comes from the receiver's selected remote and
3//! verified immutable Spool genesis, independently of incoming operation bytes.
4use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::{ThreadGenesis, ThreadOperation, bounded, invalid};
10use crate::{
11    error::Result,
12    object::{ContentHash, State, StateId},
13};
14
15pub const SPOOL_GENESIS_TRUST_FORMAT: &str = "heddle-spool-owner-genesis-executor-trust-v1";
16pub const EXECUTION_FORMAT: &str = "heddle-hosted-integration-v1";
17pub const MAX_EXECUTION_EVIDENCE: usize = 128;
18
19/// This receipt is signed by the enclosing Thread operation's publisher. Source
20/// originals stay in their own Thread; this fact evolves only the target graph.
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct HostedIntegration {
24    pub version: u16,
25    pub spool: Uuid,
26    /// Digest of verified immutable Spool owner genesis, not its mutable owner.
27    pub spool_genesis: ContentHash,
28    pub executor: [u8; 32],
29    pub source_thread: ContentHash,
30    pub source_operation: ContentHash,
31    pub source_revision: StateId,
32    pub target_thread: ContentHash,
33    pub expected_target_frontier: BTreeSet<ContentHash>,
34    /// Exact canonical resulting State. Content installation is a separate,
35    /// authorized closure transfer and never writes a checkout on receive.
36    pub result: super::Capture,
37    pub initiating_request_proof: ContentHash,
38    pub review_policy_version: ContentHash,
39    pub review_evidence: BTreeSet<ContentHash>,
40    pub executed_at_ms: i64,
41}
42impl HostedIntegration {
43    pub fn encode(&self) -> Result<Vec<u8>> {
44        if self.version != 1
45            || self.spool.is_nil()
46            || self.source_thread == self.target_thread
47            || self.expected_target_frontier.len() > 128
48            || self.review_evidence.len() > MAX_EXECUTION_EVIDENCE
49            || self.executed_at_ms < 0
50        {
51            return Err(invalid("invalid or unbounded hosted integration receipt"));
52        }
53        let state = self.result.validated_state()?;
54        if state.encode_current_msgpack()? != self.result.state {
55            return Err(invalid("non-canonical integration result"));
56        }
57        let bytes = rmp_serde::to_vec_named(self)?;
58        bounded(&bytes)?;
59        Ok(bytes)
60    }
61    pub fn decode(bytes: &[u8]) -> Result<Self> {
62        bounded(bytes)?;
63        let receipt: Self = rmp_serde::from_slice(bytes)?;
64        if receipt.encode()? != bytes {
65            return Err(invalid("non-canonical hosted integration receipt"));
66        }
67        Ok(receipt)
68    }
69    pub fn id(&self) -> Result<ContentHash> {
70        Ok(ContentHash::compute_typed(
71            EXECUTION_FORMAT,
72            &self.encode()?,
73        ))
74    }
75    pub fn resulting_state(&self) -> Result<State> {
76        self.result.validated_state()
77    }
78    /// Supply the independently authenticated original source operation.
79    pub fn validate_source(&self, source: &ThreadOperation) -> Result<()> {
80        if source.thread != self.source_thread
81            || source.id()? != self.source_operation
82            || source
83                .source_state()?
84                .is_none_or(|state| state.id() != self.source_revision)
85        {
86            return Err(invalid(
87                "integration differs from original source operation",
88            ));
89        }
90        if self.result.source_targets.is_none()
91            && source
92                .source_result()?
93                .is_some_and(|result| result.source_targets.is_some())
94        {
95            return Err(invalid("integration drops source reference closure"));
96        }
97        Ok(())
98    }
99    pub(super) fn validate_operation(&self, operation: &ThreadOperation) -> Result<()> {
100        if self.target_thread != operation.thread
101            || self.executor != operation.publisher
102            || self.expected_target_frontier != operation.parents
103        {
104            return Err(invalid(
105                "integration receipt differs from signed executor, target or frontier",
106            ));
107        }
108        Ok(())
109    }
110    pub(super) fn validate_parents(
111        &self,
112        genesis: &ThreadGenesis,
113        parents: &[ThreadOperation],
114    ) -> Result<()> {
115        if self.spool.to_string() != genesis.spool {
116            return Err(invalid("integration receipt belongs to another Spool"));
117        }
118        let state = self.resulting_state()?;
119        // A fast-forward retains the original State identity; the trusted
120        // executor attests its ancestry. A merge must explicitly retain both
121        // the selected source revision and every target source parent.
122        if state.id() != self.source_revision {
123            let mut expected = BTreeSet::from([self.source_revision]);
124            if parents.is_empty() {
125                expected.insert(genesis.base);
126            }
127            for parent in parents {
128                expected.insert(
129                    parent
130                        .source_state()?
131                        .ok_or_else(|| invalid("integration parent is not source"))?
132                        .id(),
133                );
134            }
135            if state.parents.iter().copied().collect::<BTreeSet<_>>() != expected
136                || state.parents.len() != expected.len()
137            {
138                return Err(invalid(
139                    "integration result drops source or target ancestry",
140                ));
141            }
142        }
143        Ok(())
144    }
145}
146
147/// Construct only from receiver-owned remote configuration and verified Spool
148/// genesis. Copying these fields from an incoming receipt establishes no trust.
149/// A hosted server also requires its persisted original execution receipt.
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct TrustedHostedExecutor {
152    pub spool: Uuid,
153    pub spool_genesis: ContentHash,
154    pub executor: [u8; 32],
155}
156impl TrustedHostedExecutor {
157    pub fn authorize(&self, operation: &ThreadOperation) -> Result<()> {
158        let receipt = operation
159            .hosted_execution_binding()?
160            .ok_or_else(|| invalid("executor trust requires a hosted execution"))?;
161        if receipt.spool != self.spool
162            || receipt.spool_genesis != self.spool_genesis
163            || receipt.executor != self.executor
164        {
165            return Err(invalid(
166                "hosted execution has no independently trusted executor for this Spool genesis",
167            ));
168        }
169        Ok(())
170    }
171}
172
173/// Extracted after canonical receipt and enclosing operation binding checks.
174/// It identifies an execution; receiving these bytes never establishes trust.
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub struct HostedExecutionBinding {
177    pub kind: HostedExecutionKind,
178    pub spool: Uuid,
179    pub spool_genesis: ContentHash,
180    pub executor: [u8; 32],
181    pub initiating_request_proof: ContentHash,
182    pub review_policy_version: Option<ContentHash>,
183    pub executed_at_ms: i64,
184}
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186pub enum HostedExecutionKind {
187    Integration,
188    Import,
189}
190impl ThreadOperation {
191    pub fn hosted_execution_binding(&self) -> Result<Option<HostedExecutionBinding>> {
192        if let Some(value) = self.integration()? {
193            value.validate_operation(self)?;
194            return Ok(Some(HostedExecutionBinding {
195                kind: HostedExecutionKind::Integration,
196                spool: value.spool,
197                spool_genesis: value.spool_genesis,
198                executor: value.executor,
199                initiating_request_proof: value.initiating_request_proof,
200                review_policy_version: Some(value.review_policy_version),
201                executed_at_ms: value.executed_at_ms,
202            }));
203        }
204        if let Some(value) = self.hosted_import()? {
205            value.validate_operation(self)?;
206            return Ok(Some(HostedExecutionBinding {
207                kind: HostedExecutionKind::Import,
208                spool: value.spool,
209                spool_genesis: value.spool_genesis,
210                executor: value.executor,
211                initiating_request_proof: value.initiating_request_proof,
212                review_policy_version: None,
213                executed_at_ms: value.executed_at_ms,
214            }));
215        }
216        Ok(None)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::object::{Attribution, Principal, Tree, thread_replication::ThreadOperationBody};
224
225    fn fixture() -> (
226        ThreadGenesis,
227        ThreadOperation,
228        HostedIntegration,
229        ThreadOperation,
230    ) {
231        let genesis = ThreadGenesis {
232            version: 1,
233            spool: Uuid::from_u128(7).to_string(),
234            parent: None,
235            base: StateId::from_bytes([1; 32]),
236            name: "target".into(),
237            intent: "hosted landing".into(),
238            owner: crate::object::thread_replication::GenesisOwner::Account(Uuid::from_u128(12)),
239            creator: [2; 32],
240            nonce: vec![3; 16],
241        };
242        let target = State::new_snapshot(
243            Tree::new().hash(),
244            vec![genesis.base],
245            Attribution::human(Principal::new("target", "target@example.test")),
246        );
247        let target_operation = ThreadOperation {
248            version: 1,
249            thread: genesis.id().expect("Thread"),
250            parents: BTreeSet::new(),
251            publisher: [2; 32],
252            body: ThreadOperationBody::Capture(
253                crate::object::thread_replication::AuthoredCapture::local(
254                    target.encode_current_msgpack().expect("target").into(),
255                ),
256            ),
257        };
258        let source = State::new_snapshot(
259            Tree::new().hash(),
260            vec![target.id()],
261            Attribution::human(Principal::new("source", "source@example.test")),
262        );
263        let receipt = HostedIntegration {
264            version: 1,
265            spool: Uuid::from_u128(7),
266            spool_genesis: ContentHash::from_bytes([4; 32]),
267            executor: [5; 32],
268            source_thread: ContentHash::from_bytes([6; 32]),
269            source_operation: ContentHash::from_bytes([7; 32]),
270            source_revision: source.id(),
271            target_thread: genesis.id().expect("Thread"),
272            expected_target_frontier: BTreeSet::from([target_operation.id().expect("parent")]),
273            result: source.encode_current_msgpack().expect("source").into(),
274            initiating_request_proof: ContentHash::from_bytes([8; 32]),
275            review_policy_version: ContentHash::from_bytes([9; 32]),
276            review_evidence: BTreeSet::from([ContentHash::from_bytes([10; 32])]),
277            executed_at_ms: 100,
278        };
279        let operation = ThreadOperation {
280            version: 1,
281            thread: receipt.target_thread,
282            parents: receipt.expected_target_frontier.clone(),
283            publisher: receipt.executor,
284            body: ThreadOperationBody::Integration(receipt.encode().expect("receipt")),
285        };
286        (genesis, target_operation, receipt, operation)
287    }
288    #[test]
289    fn integration_is_bound_to_executor_target_frontier_and_scope() {
290        let (genesis, parent, receipt, operation) = fixture();
291        operation
292            .validate_parents(&genesis, std::slice::from_ref(&parent))
293            .expect("valid integration");
294        assert_eq!(
295            ThreadOperation::decode(&operation.encode().expect("encode")).expect("canonical"),
296            operation
297        );
298        let mut changed = operation.clone();
299        changed.publisher = [99; 32];
300        assert!(changed.encode().is_err(), "receipt cannot change executor");
301        changed = operation.clone();
302        changed.parents.clear();
303        assert!(
304            changed.encode().is_err(),
305            "receipt cannot change target frontier"
306        );
307        changed = operation.clone();
308        changed.thread = ContentHash::from_bytes([99; 32]);
309        assert!(
310            changed.encode().is_err(),
311            "receipt cannot change target Thread"
312        );
313        let mut wrong_scope = receipt;
314        wrong_scope.spool = Uuid::from_u128(99);
315        changed = operation;
316        changed.body =
317            ThreadOperationBody::Integration(wrong_scope.encode().expect("structural receipt"));
318        assert!(
319            changed.validate_parents(&genesis, &[parent]).is_err(),
320            "receipt cannot change immutable Spool scope"
321        );
322    }
323    #[test]
324    fn integration_source_evolution_preserves_merge_parents_and_accepts_later_captures() {
325        let (genesis, parent, mut receipt, mut operation) = fixture();
326        let target = parent.source_state().expect("source").expect("target");
327        let merge = State::new_snapshot(
328            Tree::new().hash(),
329            vec![target.id(), receipt.source_revision],
330            Attribution::human(Principal::new("executor", "weft@example.test")),
331        );
332        receipt.result = merge.encode_current_msgpack().expect("merge").into();
333        operation.body = ThreadOperationBody::Integration(receipt.encode().expect("receipt"));
334        operation
335            .validate_parents(&genesis, std::slice::from_ref(&parent))
336            .expect("explicit merge ancestry");
337        let after = State::new_snapshot(
338            Tree::new().hash(),
339            vec![merge.id()],
340            Attribution::human(Principal::new("agent", "agent@example.test")),
341        );
342        let capture = ThreadOperation {
343            version: 1,
344            thread: operation.thread,
345            parents: BTreeSet::from([operation.id().expect("integration")]),
346            publisher: [3; 32],
347            body: ThreadOperationBody::Capture(
348                crate::object::thread_replication::AuthoredCapture::local(
349                    after.encode_current_msgpack().expect("capture").into(),
350                ),
351            ),
352        };
353        capture
354            .validate_parents(&genesis, std::slice::from_ref(&operation))
355            .expect("capture after hosted integration");
356        let dropped = State::new_snapshot(
357            Tree::new().hash(),
358            vec![target.id()],
359            Attribution::human(Principal::new("executor", "weft@example.test")),
360        );
361        receipt.result = dropped
362            .encode_current_msgpack()
363            .expect("dropped ancestry")
364            .into();
365        operation.body = ThreadOperationBody::Integration(receipt.encode().expect("receipt"));
366        assert!(
367            operation.validate_parents(&genesis, &[parent]).is_err(),
368            "merge cannot drop source ancestry"
369        );
370    }
371    #[test]
372    fn integration_signature_identity_does_not_create_executor_trust() {
373        let (_, _, _, operation) = fixture();
374        let trust = TrustedHostedExecutor {
375            spool: Uuid::from_u128(7),
376            spool_genesis: ContentHash::from_bytes([4; 32]),
377            executor: [5; 32],
378        };
379        trust
380            .authorize(&operation)
381            .expect("independent selected remote pin");
382        let mut different = trust.clone();
383        different.executor = [9; 32];
384        assert!(
385            different.authorize(&operation).is_err(),
386            "different endpoint is not user authority"
387        );
388        different = trust.clone();
389        different.spool_genesis = ContentHash::from_bytes([9; 32]);
390        assert!(
391            different.authorize(&operation).is_err(),
392            "same UUID does not replace immutable genesis"
393        );
394        different = trust;
395        different.spool = Uuid::from_u128(9);
396        assert!(
397            different.authorize(&operation).is_err(),
398            "executor trust is scoped to one Spool"
399        );
400    }
401}