Skip to main content

heddle_object_model/object/thread_replication/
hosted_import.rs

1//! Hosted import is an executor attestation of provider provenance. The original
2//! creator-signed genesis and imported Git authorship are never replaced by a
3//! claim that the initiating human signed a future source capture.
4use std::collections::BTreeSet;
5
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use super::{Capture, ThreadGenesis, ThreadOperation, bounded, invalid};
10use crate::{
11    error::Result,
12    object::{ContentHash, State},
13};
14
15pub const HOSTED_IMPORT_FORMAT: &str = "heddle-hosted-import-v1";
16
17/// Stable synthetic pre-history for every new Spool. This is system
18/// initialization, never an assertion of human capture authorship.
19pub fn synthetic_initial_base() -> Result<State> {
20    use crate::object::{Attribution, ChangeId, Principal, Tree};
21    let mut state = State::new_refresh_of(
22        Tree::new().hash(),
23        Vec::new(),
24        Attribution::human(Principal::new("Heddle", "init@heddle")),
25        ChangeId::from_bytes(*b"heddle-seed-v2!!"),
26    );
27    state.created_at = chrono::DateTime::UNIX_EPOCH;
28    // Restore the derived cached ID after fixing the canonical creation time.
29    State::decode_current_msgpack(&state.encode_current_msgpack()?)
30}
31
32/// Only the exact deterministic empty seed can bootstrap without an original
33/// source operation. A random empty State, even with Heddle attribution, is
34/// authored content and requires ordinary source provenance.
35pub fn initial_base_state(genesis: &ThreadGenesis, bytes: &[u8]) -> Result<State> {
36    if bytes.is_empty() || bytes.len() > 4096 {
37        return Err(invalid("initial Thread base exceeds bootstrap bound"));
38    }
39    let state = State::decode_current_msgpack(bytes)?;
40    let expected = synthetic_initial_base()?;
41    if state.id() != genesis.base || expected.encode_current_msgpack()? != bytes {
42        return Err(invalid(
43            "initial Thread base differs from signed empty seed",
44        ));
45    }
46    Ok(state)
47}
48
49/// Git's algorithm is part of identity; a SHA-256 object never truncates to SHA-1.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum ImportedCommit {
53    Sha1([u8; 20]),
54    Sha256([u8; 32]),
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ImportProvider {
60    /// Connected GitHub App installation; `repository_id` is the numeric GitHub repo id.
61    GitHub { repository_id: String },
62    /// Credential-free public Git; `clone_url` is the exact clone URL and sole locator.
63    Git { clone_url: String },
64}
65
66impl ImportProvider {
67    fn is_valid(&self) -> bool {
68        let locator = match self {
69            Self::GitHub { repository_id } => repository_id,
70            Self::Git { clone_url } => clone_url,
71        };
72        !locator.is_empty() && locator.len() <= 4096 && !locator.chars().any(char::is_control)
73    }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct HostedImport {
79    pub version: u16,
80    pub spool: Uuid,
81    pub spool_genesis: ContentHash,
82    pub executor: [u8; 32],
83    pub target_thread: ContentHash,
84    pub expected_target_frontier: BTreeSet<ContentHash>,
85    /// Thread source ancestry is independent of the retained Git history. The
86    /// executor preserves Git attribution and records its source commit below.
87    pub result: Capture,
88    pub provider: ImportProvider,
89    pub source_ref: String,
90    pub source_commit: ImportedCommit,
91    pub initiating_request_proof: ContentHash,
92    pub executed_at_ms: i64,
93}
94impl HostedImport {
95    pub fn encode(&self) -> Result<Vec<u8>> {
96        if self.version != 1
97            || self.spool.is_nil()
98            || self.expected_target_frontier.len() > 128
99            || self.executed_at_ms < 0
100            || !self.provider.is_valid()
101            || self.source_ref.len() > 4096
102            || !self.source_ref.starts_with("refs/heads/")
103            || self.source_ref.chars().any(char::is_control)
104        {
105            return Err(invalid("invalid or unbounded hosted import receipt"));
106        }
107        let state = self.resulting_state()?;
108        if state.encode_current_msgpack()? != self.result.state {
109            return Err(invalid("non-canonical hosted import capture"));
110        }
111        let bytes = rmp_serde::to_vec_named(self)?;
112        bounded(&bytes)?;
113        Ok(bytes)
114    }
115    pub fn decode(bytes: &[u8]) -> Result<Self> {
116        bounded(bytes)?;
117        let value: Self = rmp_serde::from_slice(bytes)?;
118        if value.encode()? != bytes {
119            return Err(invalid("non-canonical hosted import receipt"));
120        }
121        Ok(value)
122    }
123    pub fn id(&self) -> Result<ContentHash> {
124        Ok(ContentHash::compute_typed(
125            HOSTED_IMPORT_FORMAT,
126            &self.encode()?,
127        ))
128    }
129    pub fn resulting_state(&self) -> Result<State> {
130        self.result.validated_state()
131    }
132    pub(super) fn validate_operation(&self, operation: &ThreadOperation) -> Result<()> {
133        if self.target_thread != operation.thread
134            || self.executor != operation.publisher
135            || self.expected_target_frontier != operation.parents
136        {
137            return Err(invalid(
138                "hosted import differs from signed executor, Thread or frontier",
139            ));
140        }
141        Ok(())
142    }
143    /// Initial Git parent fidelity is enforced by weft W1′ with the shared
144    /// GitImportGraph converter, and by local import running that converter.
145    /// This receipt alone cannot authenticate a Git-parent mapping.
146    pub(super) fn validate_parents(
147        &self,
148        genesis: &ThreadGenesis,
149        parents: &[ThreadOperation],
150    ) -> Result<()> {
151        if self.spool.to_string() != genesis.spool {
152            return Err(invalid("hosted import belongs to another Spool"));
153        }
154        if self.source_ref != format!("refs/heads/{}", genesis.name) {
155            return Err(invalid(
156                "hosted import source ref differs from signed Thread name",
157            ));
158        }
159        let state = self.resulting_state()?;
160        let mut expected = BTreeSet::new();
161        if parents.is_empty() {
162            if genesis.base != synthetic_initial_base()?.id() || genesis.parent.is_some() {
163                return Err(invalid(
164                    "initial hosted import requires the canonical empty seed",
165                ));
166            }
167            if state.parents.contains(&genesis.base) {
168                return Err(invalid(
169                    "Git tip cannot have the synthetic seed as a parent",
170                ));
171            }
172            return Ok(());
173        }
174        for parent in parents {
175            expected.insert(
176                parent
177                    .source_state()?
178                    .ok_or_else(|| invalid("hosted import parent is not source"))?
179                    .id(),
180            );
181        }
182        if state.parents.iter().copied().collect::<BTreeSet<_>>() != expected
183            || state.parents.len() != expected.len()
184        {
185            return Err(invalid(
186                "hosted import capture drops or invents Thread ancestry",
187            ));
188        }
189        Ok(())
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::object::{
197        Attribution, Principal, StateId, Tree, thread_replication::ThreadOperationBody,
198    };
199    fn fixture() -> (ThreadGenesis, HostedImport, ThreadOperation) {
200        let genesis = ThreadGenesis {
201            version: 1,
202            spool: Uuid::from_u128(7).to_string(),
203            parent: None,
204            base: synthetic_initial_base().expect("seed").id(),
205            name: "import".into(),
206            intent: "import Git history".into(),
207            owner: crate::object::thread_replication::GenesisOwner::Account(Uuid::from_u128(12)),
208            creator: [2; 32],
209            nonce: vec![3; 16],
210        };
211        let state = State::new_snapshot(
212            Tree::new().hash(),
213            vec![StateId::from_bytes([31; 32])],
214            Attribution::human(Principal::new("Git author", "git@example.test")),
215        );
216        let receipt = HostedImport {
217            version: 1,
218            spool: Uuid::from_u128(7),
219            spool_genesis: ContentHash::from_bytes([4; 32]),
220            executor: [5; 32],
221            target_thread: genesis.id().expect("Thread"),
222            expected_target_frontier: BTreeSet::new(),
223            result: state.encode_current_msgpack().expect("source").into(),
224            provider: ImportProvider::GitHub {
225                repository_id: "123".into(),
226            },
227            source_ref: "refs/heads/import".into(),
228            source_commit: ImportedCommit::Sha1([6; 20]),
229            initiating_request_proof: ContentHash::from_bytes([7; 32]),
230            executed_at_ms: 100,
231        };
232        let operation = ThreadOperation {
233            version: 1,
234            thread: receipt.target_thread,
235            parents: BTreeSet::new(),
236            publisher: receipt.executor,
237            body: ThreadOperationBody::HostedImport(receipt.encode().expect("receipt")),
238        };
239        (genesis, receipt, operation)
240    }
241    #[test]
242    fn initial_import_accepts_real_git_parents_and_root() {
243        let (genesis, mut receipt, mut operation) = fixture();
244        for parents in [
245            vec![StateId::from_bytes([31; 32]), StateId::from_bytes([32; 32])],
246            vec![],
247        ] {
248            let mut state = receipt.resulting_state().expect("capture");
249            state.parents = parents;
250            receipt.result.state = state.encode_current_msgpack().expect("Git tip");
251            operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
252            operation
253                .validate_parents(&genesis, &[])
254                .expect("real Git ancestry, including root");
255        }
256    }
257    #[test]
258    fn initial_import_rejects_another_branch() {
259        let (genesis, mut receipt, mut operation) = fixture();
260        receipt.source_ref = "refs/heads/another".into();
261        operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
262        assert!(operation.validate_parents(&genesis, &[]).is_err());
263    }
264
265    #[test]
266    fn initial_import_rejects_non_head_refs() {
267        let (_, mut receipt, _) = fixture();
268        for source_ref in ["refs/tags/import", "import"] {
269            receipt.source_ref = source_ref.into();
270            assert!(receipt.encode().is_err(), "{source_ref}");
271        }
272    }
273
274    #[test]
275    fn initial_import_rejects_seed_among_git_parents() {
276        let (genesis, mut receipt, mut operation) = fixture();
277        let mut state = receipt.resulting_state().expect("state");
278        state.parents.push(genesis.base);
279        receipt.result.state = state.encode_current_msgpack().expect("seed parent");
280        operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
281        assert!(operation.validate_parents(&genesis, &[]).is_err());
282    }
283
284    #[test]
285    fn later_hosted_import_rejects_another_branch() {
286        let (genesis, mut receipt, imported) = fixture();
287        receipt.expected_target_frontier = BTreeSet::from([imported.id().expect("ID")]);
288        let mut state = receipt.resulting_state().expect("state");
289        state.parents = vec![state.id()];
290        receipt.result.state = state.encode_current_msgpack().expect("later capture");
291        let mut operation = ThreadOperation {
292            parents: receipt.expected_target_frontier.clone(),
293            body: ThreadOperationBody::HostedImport(receipt.encode().expect("receipt")),
294            ..imported.clone()
295        };
296        operation
297            .validate_parents(&genesis, std::slice::from_ref(&imported))
298            .expect("matching branch and causal parent");
299        receipt.source_ref = "refs/heads/another".into();
300        operation.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
301        assert!(operation.validate_parents(&genesis, &[imported]).is_err());
302    }
303
304    #[test]
305    fn later_hosted_import_keeps_the_causal_parent_rule() {
306        let (genesis, mut receipt, imported) = fixture();
307        receipt.expected_target_frontier = BTreeSet::from([imported.id().expect("ID")]);
308        let parent = receipt.resulting_state().expect("parent").id();
309        let mut state = receipt.resulting_state().expect("state");
310        state.parents = vec![parent];
311        receipt.result.state = state.encode_current_msgpack().expect("later capture");
312        receipt
313            .validate_parents(&genesis, std::slice::from_ref(&imported))
314            .expect("causal parents");
315        state.parents.clear();
316        receipt.result.state = state
317            .encode_current_msgpack()
318            .expect("missing causal parent");
319        assert!(receipt.validate_parents(&genesis, &[imported]).is_err());
320    }
321    #[test]
322    fn public_git_provider_round_trips_and_validates() {
323        let (_, mut receipt, _) = fixture();
324        receipt.provider = ImportProvider::Git {
325            clone_url: "https://example.test/owner/repository.git".into(),
326        };
327
328        let bytes = receipt.encode().expect("valid public Git provider");
329        assert_eq!(
330            HostedImport::decode(&bytes).expect("canonical public Git provider"),
331            receipt
332        );
333    }
334    #[test]
335    fn provider_locators_are_bounded_and_free_of_control_characters() {
336        let (_, mut receipt, _) = fixture();
337        for provider in [
338            ImportProvider::GitHub {
339                repository_id: String::new(),
340            },
341            ImportProvider::GitHub {
342                repository_id: "x".repeat(4097),
343            },
344            ImportProvider::Git {
345                clone_url: "https://example.test/repository.git\n".into(),
346            },
347        ] {
348            receipt.provider = provider;
349            assert!(receipt.encode().is_err());
350        }
351    }
352    #[test]
353    fn import_preserves_git_attribution_and_binds_executor_scope_and_ancestry() {
354        let (genesis, receipt, operation) = fixture();
355        operation.validate_parents(&genesis, &[]).expect("import");
356        let decoded =
357            ThreadOperation::decode(&operation.encode().expect("canonical")).expect("decode");
358        assert_eq!(decoded, operation);
359        assert_eq!(
360            decoded
361                .source_state()
362                .expect("state")
363                .expect("capture")
364                .attribution
365                .principal
366                .name,
367            b"Git author".to_vec()
368        );
369        assert_ne!(
370            operation.publisher, genesis.creator,
371            "executor does not impersonate creator"
372        );
373        let mut changed = operation.clone();
374        changed.publisher = genesis.creator;
375        assert!(changed.encode().is_err());
376        changed = operation.clone();
377        changed.parents.insert(ContentHash::from_bytes([99; 32]));
378        assert!(changed.encode().is_err());
379        let mut foreign = receipt.clone();
380        foreign.spool = Uuid::from_u128(8);
381        changed = operation.clone();
382        changed.body = ThreadOperationBody::HostedImport(foreign.encode().expect("structural"));
383        assert!(changed.validate_parents(&genesis, &[]).is_err());
384        let mut wrong = receipt;
385        let mut state = wrong.resulting_state().expect("capture");
386        state.parents = vec![genesis.base];
387        wrong.result.state = state.encode_current_msgpack().expect("wrong ancestry");
388        changed = operation;
389        changed.body = ThreadOperationBody::HostedImport(wrong.encode().expect("structural"));
390        assert!(changed.validate_parents(&genesis, &[]).is_err());
391    }
392    #[test]
393    fn later_capture_retains_imported_source_and_reference_frontier() {
394        let (genesis, mut receipt, mut imported) = fixture();
395        receipt.result.source_targets = Some(ContentHash::from_bytes([17; 32]));
396        imported.body = ThreadOperationBody::HostedImport(receipt.encode().expect("receipt"));
397        let state = State::new_snapshot(
398            Tree::new().hash(),
399            vec![receipt.resulting_state().expect("source").id()],
400            Attribution::human(Principal::new("Human", "human@example.test")),
401        );
402        let mut capture = ThreadOperation {
403            version: 1,
404            thread: imported.thread,
405            parents: BTreeSet::from([imported.id().expect("parent")]),
406            publisher: genesis.creator,
407            body: ThreadOperationBody::Capture(
408                crate::object::thread_replication::AuthoredCapture::local(Capture {
409                    state: state.encode_current_msgpack().expect("capture"),
410                    source_targets: receipt.result.source_targets,
411                    visibility: receipt.result.visibility.clone(),
412                }),
413            ),
414        };
415        capture
416            .validate_parents(&genesis, std::slice::from_ref(&imported))
417            .expect("later capture");
418        let ThreadOperationBody::Capture(result) = &mut capture.body else {
419            panic!("capture")
420        };
421        result.result.source_targets = None;
422        assert!(
423            capture.validate_parents(&genesis, &[imported]).is_err(),
424            "imported references cannot disappear"
425        );
426    }
427    #[test]
428    fn genesis_local_ownership_is_creator_bound_and_account_ownership_is_explicit() {
429        use crate::object::thread_replication::GenesisOwner;
430        let (mut genesis, _, _) = fixture();
431        let account_id = genesis.id().expect("account-owned identity");
432        genesis.owner = GenesisOwner::LocalKey(genesis.creator);
433        let local_id = genesis.id().expect("local key needs no account");
434        assert_ne!(account_id, local_id, "owner is part of immutable identity");
435        genesis.owner = GenesisOwner::LocalKey([99; 32]);
436        assert!(
437            genesis.encode().is_err(),
438            "local owner must sign its genesis"
439        );
440        genesis.owner = GenesisOwner::LocalKey([0; 32]);
441        genesis.creator = [0; 32];
442        assert!(genesis.encode().is_err(), "zero local key is not an owner");
443        genesis.owner = GenesisOwner::Account(Uuid::nil());
444        assert!(genesis.encode().is_err(), "nil account is not an owner");
445    }
446    #[test]
447    fn import_initial_base_is_exact_bounded_empty_seed() {
448        let (mut genesis, _, _) = fixture();
449        let seed = synthetic_initial_base().expect("known system seed");
450        let bytes = seed.encode_current_msgpack().expect("seed");
451        genesis.base = seed.id();
452        assert_eq!(
453            initial_base_state(&genesis, &bytes)
454                .expect("one-call bootstrap")
455                .id(),
456            seed.id()
457        );
458        let random_seed = State::new_snapshot(
459            Tree::new().hash(),
460            vec![],
461            Attribution::human(Principal::new("Heddle", "init@heddle")),
462        );
463        genesis.base = random_seed.id();
464        assert!(
465            initial_base_state(
466                &genesis,
467                &random_seed.encode_current_msgpack().expect("random seed")
468            )
469            .is_err(),
470            "the old random empty seed shape is not a bootstrap exception"
471        );
472        genesis.base = seed.id();
473        let mut changed = seed.clone();
474        changed.tree = ContentHash::from_bytes([88; 32]);
475        genesis.base = changed.id();
476        assert!(
477            initial_base_state(
478                &genesis,
479                &changed.encode_current_msgpack().expect("changed")
480            )
481            .is_err(),
482            "nonempty source needs authorized closure transfer"
483        );
484        changed = seed.clone();
485        changed.provenance = Some(ContentHash::from_bytes([89; 32]));
486        genesis.base = changed.id();
487        assert!(
488            initial_base_state(
489                &genesis,
490                &changed.encode_current_msgpack().expect("changed")
491            )
492            .is_err(),
493            "seed cannot introduce another reference"
494        );
495        assert!(
496            initial_base_state(&genesis, &bytes).is_err(),
497            "base must match signed identity"
498        );
499    }
500    #[test]
501    fn synthetic_initial_base_is_stable_across_rust_and_browser() {
502        let state = synthetic_initial_base().expect("synthetic seed");
503        let bytes = state.encode_current_msgpack().expect("canonical seed");
504        let expected = include_str!("../../../tests/fixtures/synthetic-initial-base-v2.txt");
505        assert_eq!(
506            format!(
507                "canonical={}\nid={}\n",
508                hex::encode(&bytes),
509                hex::encode(state.id().as_bytes())
510            ),
511            expected
512        );
513        assert_eq!(
514            bytes,
515            synthetic_initial_base()
516                .expect("repeat")
517                .encode_current_msgpack()
518                .expect("repeat bytes")
519        );
520        let (mut genesis, _, _) = fixture();
521        genesis.base = state.id();
522        initial_base_state(&genesis, &bytes).expect("accepted seed shape");
523    }
524}