1use 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct HostedIntegration {
24 pub version: u16,
25 pub spool: Uuid,
26 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 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 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 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#[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#[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}