Skip to main content

heddle_thread_api/
collaboration.rs

1//! Portable collaboration commands. Original parent records reconstruct both
2//! causal frontiers; callers never translate UI version tokens into proofs.
3mod references;
4mod tags;
5use std::collections::BTreeSet;
6
7use crypto::{Signer, thread_operation::SignedOperation};
8use heddle_object_model::object::{
9    Attribution, CollabOpId, CollaborationIdempotencyKey, CollaborationMetadata,
10    CollaborationOperationBodyV1, CollaborationOperationEnvelope, ContentHash, ContextRevision,
11    DiscussionRecordId,
12    thread_replication::{OPERATION_FORMAT, ThreadOperation, ThreadOperationBody},
13};
14pub use references::{
15    anchor, anchor_ref, audience, mention, mention_ref, source_target_ref, visibility,
16};
17pub use tags::{
18    annotation_query, annotation_source, annotation_source_ref, annotation_tag, annotation_tag_ref,
19    annotation_tags, annotation_value, annotation_value_ref,
20};
21
22use crate::{
23    contract::{RecordSignature, SignedRecord},
24    transport::Error,
25};
26
27/// Verified original operation, before host authorization or causal admission.
28/// A valid signature never establishes a principal's spool permissions.
29pub fn verify(record: &SignedRecord) -> Result<ThreadOperation, Error> {
30    if record.format != OPERATION_FORMAT || record.signatures.len() != 1 {
31        return Err(Error::Protocol("unsupported collaboration signed record"));
32    }
33    let operation = SignedOperation {
34        canonical: record.canonical_record.clone(),
35        signature: record.signatures[0].signature.clone(),
36    }
37    .verify()
38    .map_err(|_| Error::Protocol("invalid collaboration author signature"))?;
39    if record.signatures[0].public_key != operation.publisher {
40        return Err(Error::Protocol(
41            "collaboration signature key differs from publisher",
42        ));
43    }
44    if !matches!(
45        operation.body,
46        ThreadOperationBody::Discussion(_) | ThreadOperationBody::Context(_)
47    ) {
48        return Err(Error::Protocol(
49            "collaboration requires a discussion or context operation",
50        ));
51    }
52    Ok(operation)
53}
54
55/// Typed content plus the account identity which the receiving host will bind
56/// to its independently verified delivery credential. Display names are not IDs.
57pub struct Command {
58    pub discussion: DiscussionRecordId,
59    pub operation_id: CollaborationIdempotencyKey,
60    pub metadata: CollaborationMetadata,
61    pub author: Attribution,
62    pub occurred_at_ms: i64,
63    pub body: CollaborationOperationBodyV1,
64}
65
66impl Command {
67    /// Sign after observing original frontier records. Concurrent operations can
68    /// share parents; observing both lets a subsequent command join both heads.
69    /// Root commands use an empty parent slice. Never pass mutable view versions.
70    pub fn sign(
71        self,
72        parents: &[SignedRecord],
73        signer: &impl Signer,
74    ) -> Result<SignedRecord, Error> {
75        if parents.len() > 128 {
76            return Err(Error::Protocol(
77                "collaboration has more than 128 causal parents",
78            ));
79        }
80        let thread = self.metadata.scope.thread.ok_or(Error::Protocol(
81            "Thread command requires native Thread scope",
82        ))?;
83        let mut outer = BTreeSet::new();
84        let mut inner = BTreeSet::new();
85        for record in parents {
86            let operation = verify(record)?;
87            if operation.thread != thread {
88                return Err(Error::Protocol("parent belongs to another Thread"));
89            }
90            let ThreadOperationBody::Discussion(bytes) = &operation.body else {
91                return Err(Error::Protocol("parent is not a collaboration operation"));
92            };
93            let decoded = CollaborationOperationEnvelope::decode(bytes)
94                .map_err(|_| Error::Protocol("invalid collaboration parent"))?;
95            if decoded.operation.discussion_id != self.discussion
96                || decoded
97                    .operation
98                    .metadata
99                    .as_ref()
100                    .is_none_or(|m| m.scope != self.metadata.scope)
101            {
102                return Err(Error::Protocol(
103                    "parent belongs to another discussion or spool",
104                ));
105            }
106            let id = operation
107                .id()
108                .map_err(|error| Error::Io(error.to_string()))?;
109            if !outer.insert(id) {
110                return Err(Error::Protocol("duplicate collaboration causal parent"));
111            }
112            inner.insert(decoded.operation_id);
113        }
114        self.sign_frontier(thread, outer, inner, signer)
115    }
116
117    /// Sign after observing discussion heads by original operation id.
118    ///
119    /// Hosted ObserveCollaboration returns turn/open op ids, not the original
120    /// signed records. Root commands still use an empty parent slice.
121    pub fn sign_parent_ids(
122        self,
123        parent_ids: &[ContentHash],
124        signer: &impl Signer,
125    ) -> Result<SignedRecord, Error> {
126        if parent_ids.len() > 128 {
127            return Err(Error::Protocol(
128                "collaboration has more than 128 causal parents",
129            ));
130        }
131        let thread = self.metadata.scope.thread.ok_or(Error::Protocol(
132            "Thread command requires native Thread scope",
133        ))?;
134        let mut outer = BTreeSet::new();
135        let mut inner = BTreeSet::new();
136        for id in parent_ids {
137            if !outer.insert(*id) {
138                return Err(Error::Protocol("duplicate collaboration causal parent"));
139            }
140            inner.insert(CollabOpId::from_bytes(*id.as_bytes()));
141        }
142        self.sign_frontier(thread, outer, inner, signer)
143    }
144
145    fn sign_frontier(
146        self,
147        thread: ContentHash,
148        outer: BTreeSet<ContentHash>,
149        inner: BTreeSet<CollabOpId>,
150        signer: &impl Signer,
151    ) -> Result<SignedRecord, Error> {
152        let mut body = self.body;
153        if let CollaborationOperationBodyV1::Resolve {
154            resolution:
155                heddle_object_model::object::CollaborationResolution::IntoContext { context },
156        } = &mut body
157        {
158            context.parents = outer.iter().copied().collect();
159        }
160        let envelope = CollaborationOperationEnvelope::new(
161            self.discussion,
162            inner.into_iter().collect(),
163            self.operation_id,
164            self.author,
165            self.occurred_at_ms,
166            body,
167        )
168        .and_then(|envelope| envelope.with_metadata(self.metadata))
169        .map_err(|error| Error::Io(error.to_string()))?;
170        let operation = ThreadOperation {
171            version: 1,
172            thread,
173            parents: outer,
174            publisher: signer
175                .public_key()
176                .try_into()
177                .map_err(|_| Error::Protocol("collaboration requires an Ed25519 signer"))?,
178            body: ThreadOperationBody::Discussion(
179                envelope
180                    .encode()
181                    .map_err(|error| Error::Io(error.to_string()))?,
182            ),
183        };
184        let signed = SignedOperation::sign(&operation, signer)
185            .map_err(|error| Error::Io(error.to_string()))?;
186        Ok(SignedRecord {
187            format: OPERATION_FORMAT.into(),
188            canonical_record: signed.canonical,
189            signatures: vec![RecordSignature {
190                public_key: operation.publisher.to_vec(),
191                signature: signed.signature,
192            }],
193        })
194    }
195}
196
197/// Append an immutable context revision. Original signed parent records bind
198/// the same stable record and scope; concurrent revisions remain separate heads.
199pub fn sign_context(
200    context: ContextRevision,
201    parents: &[SignedRecord],
202    signer: &impl Signer,
203) -> Result<SignedRecord, Error> {
204    if parents.len() > 128 {
205        return Err(Error::Protocol("context has more than 128 causal parents"));
206    }
207    let thread = context.metadata.scope.thread.ok_or(Error::Protocol(
208        "Thread context requires native Thread scope",
209    ))?;
210    if parents.is_empty() && context.extracted_from.is_some() {
211        return Err(Error::Protocol(
212            "context extraction requires a signed discussion resolution",
213        ));
214    }
215    let mut ids = BTreeSet::new();
216    for record in parents {
217        let parent = verify(record)?;
218        let previous = parent
219            .context_revision()
220            .map_err(|error| Error::Io(error.to_string()))?
221            .ok_or(Error::Protocol(
222                "context parent is not a context revision or extraction",
223            ))?;
224        if parent.thread != thread
225            || previous.id != context.id
226            || previous.metadata.scope != context.metadata.scope
227            || previous.extracted_from != context.extracted_from
228        {
229            return Err(Error::Protocol(
230                "context parent belongs to another record or scope",
231            ));
232        }
233        if !ids.insert(parent.id().map_err(|error| Error::Io(error.to_string()))?) {
234            return Err(Error::Protocol("duplicate context causal parent"));
235        }
236    }
237    sign_context_ids(context, thread, ids, signer)
238}
239
240fn sign_context_ids(
241    mut context: ContextRevision,
242    thread: ContentHash,
243    ids: BTreeSet<ContentHash>,
244    signer: &impl Signer,
245) -> Result<SignedRecord, Error> {
246    context.parents = ids.iter().copied().collect();
247    let operation = ThreadOperation {
248        version: 1,
249        thread,
250        parents: ids,
251        publisher: signer
252            .public_key()
253            .try_into()
254            .map_err(|_| Error::Protocol("context requires an Ed25519 signer"))?,
255        body: ThreadOperationBody::Context(
256            context
257                .encode()
258                .map_err(|error| Error::Io(error.to_string()))?,
259        ),
260    };
261    let signed =
262        SignedOperation::sign(&operation, signer).map_err(|error| Error::Io(error.to_string()))?;
263    Ok(SignedRecord {
264        format: OPERATION_FORMAT.into(),
265        canonical_record: signed.canonical,
266        signatures: vec![RecordSignature {
267            public_key: operation.publisher.to_vec(),
268            signature: signed.signature,
269        }],
270    })
271}
272
273/// The public causal vocabulary is the outer replication operation identity.
274pub fn operation_id(record: &SignedRecord) -> Result<ContentHash, Error> {
275    verify(record)?
276        .id()
277        .map_err(|error| Error::Io(error.to_string()))
278}
279
280#[cfg(test)]
281mod tests {
282    use crypto::Ed25519Signer;
283    use heddle_object_model::object::{
284        CollaborationActor, CollaborationAnchor, CollaborationScope, DiscussionTurnV1, Principal,
285        VisibilityTier,
286    };
287    use uuid::Uuid;
288
289    use super::*;
290
291    fn command(discussion: DiscussionRecordId, body: CollaborationOperationBodyV1) -> Command {
292        Command {
293            discussion,
294            operation_id: CollaborationIdempotencyKey::new("operation-1").expect("operation ID"),
295            metadata: CollaborationMetadata {
296                scope: CollaborationScope {
297                    spool: Uuid::from_u128(1),
298                    thread: Some(ContentHash::from_bytes([2; 32])),
299                },
300                actor: CollaborationActor {
301                    principal_id: Uuid::from_u128(3),
302                    agent_id: Some("agent-4".into()),
303                },
304                mentions: vec![],
305            },
306            author: Attribution::human(Principal::new("Account", "")),
307            occurred_at_ms: 100,
308            body,
309        }
310    }
311    fn append(body: &str) -> CollaborationOperationBodyV1 {
312        CollaborationOperationBodyV1::AppendTurn {
313            turn: DiscussionTurnV1::new(body).expect("turn"),
314        }
315    }
316    #[test]
317    fn signed_parents_preserve_concurrent_heads_and_reject_changed_proofs() {
318        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
319        let discussion = DiscussionRecordId::generate();
320        let root = command(
321            discussion,
322            CollaborationOperationBodyV1::Open {
323                tags: Vec::new(),
324                blocking: false,
325                title: "Review".into(),
326                anchor: CollaborationAnchor::Repository,
327                visibility: VisibilityTier::Private {
328                    scope_label: "owner".into(),
329                },
330                turn: DiscussionTurnV1::new("Review this Thread").expect("turn"),
331                thread_ref: None,
332            },
333        )
334        .sign(&[], &signer)
335        .expect("root");
336        let left = command(discussion, append("left"))
337            .sign(std::slice::from_ref(&root), &signer)
338            .expect("left");
339        let right = command(discussion, append("right"))
340            .sign(std::slice::from_ref(&root), &signer)
341            .expect("right");
342        assert_ne!(
343            operation_id(&left).expect("left ID"),
344            operation_id(&right).expect("right ID")
345        );
346        let joined = command(discussion, append("both observed"))
347            .sign(&[left.clone(), right.clone()], &signer)
348            .expect("join");
349        assert_eq!(
350            verify(&joined).expect("verified").parents,
351            BTreeSet::from([
352                operation_id(&left).expect("left ID"),
353                operation_id(&right).expect("right ID")
354            ])
355        );
356        let mut changed = root.clone();
357        changed.signatures[0].signature[0] ^= 1;
358        assert!(
359            command(discussion, append("bad proof"))
360                .sign(&[changed], &signer)
361                .is_err()
362        );
363        assert!(
364            command(DiscussionRecordId::generate(), append("wrong discussion"))
365                .sign(std::slice::from_ref(&root), &signer)
366                .is_err()
367        );
368        assert!(
369            command(discussion, append("duplicate parent"))
370                .sign(&[root.clone(), root], &signer)
371                .is_err()
372        );
373    }
374    #[test]
375    fn observed_parent_ids_are_sorted_unique_and_required_for_append() {
376        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
377        let discussion = DiscussionRecordId::generate();
378        let root = command(
379            discussion,
380            CollaborationOperationBodyV1::Open {
381                tags: Vec::new(),
382                blocking: false,
383                title: "Review".into(),
384                anchor: CollaborationAnchor::Repository,
385                visibility: VisibilityTier::Private {
386                    scope_label: "owner".into(),
387                },
388                turn: DiscussionTurnV1::new("Review this Thread").expect("turn"),
389                thread_ref: None,
390            },
391        )
392        .sign(&[], &signer)
393        .expect("root");
394        let root_id = operation_id(&root).expect("root ID");
395        let appended = command(discussion, append("follow-up"))
396            .sign_parent_ids(&[root_id], &signer)
397            .expect("append");
398        assert_eq!(
399            verify(&appended).expect("verified").parents,
400            BTreeSet::from([root_id])
401        );
402        assert!(
403            command(discussion, append("no parent"))
404                .sign_parent_ids(&[], &signer)
405                .is_err()
406        );
407    }
408    #[test]
409    fn context_revisions_retain_history_and_bind_parent_record_identity() {
410        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
411        let context = ContextRevision {
412            version: 2,
413            id: Uuid::from_u128(9),
414            parents: vec![],
415            metadata: command(DiscussionRecordId::generate(), append("metadata")).metadata,
416            anchor: CollaborationAnchor::Repository,
417            content: "Original rationale".into(),
418            tags: vec!["decision".into()],
419            supersedes: None,
420            extracted_from: None,
421            occurred_at_ms: 100,
422            provenance: None,
423            canonical_body: Default::default(),
424        };
425        let first = sign_context(context.clone(), &[], &signer).expect("first context");
426        let mut revised = context.clone();
427        revised.content = "Revised rationale".into();
428        let second =
429            sign_context(revised.clone(), std::slice::from_ref(&first), &signer).expect("revision");
430        assert_eq!(
431            verify(&second).expect("second").parents,
432            BTreeSet::from([operation_id(&first).expect("first ID")])
433        );
434        let ThreadOperationBody::Context(bytes) = verify(&first).expect("first").body else {
435            panic!("context");
436        };
437        assert_eq!(
438            ContextRevision::decode(&bytes)
439                .expect("original context")
440                .content,
441            "Original rationale"
442        );
443        revised.id = Uuid::from_u128(10);
444        assert!(
445            sign_context(revised, std::slice::from_ref(&first), &signer).is_err(),
446            "parent cannot silently change record identity"
447        );
448    }
449    #[test]
450    fn extracted_context_keeps_original_resolution_proof_and_actor_binding() {
451        use heddle_object_model::object::{
452            CollaborationResolution, StateId, thread_replication::ThreadGenesis,
453        };
454        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
455        let genesis = ThreadGenesis {
456            version: 1,
457            spool: Uuid::from_u128(1).to_string(),
458            parent: None,
459            base: StateId::from_bytes([3; 32]),
460            name: "extraction".into(),
461            intent: "retain proof".into(),
462            creator: signer.public_key().try_into().expect("key"),
463            owner: heddle_object_model::object::thread_replication::GenesisOwner::LocalKey(
464                signer.public_key().try_into().expect("key"),
465            ),
466            nonce: vec![1; 16],
467        };
468        let discussion = DiscussionRecordId::generate();
469        let make = |body| {
470            let mut command = command(discussion, body);
471            command.metadata.scope.thread = Some(genesis.id().expect("Thread"));
472            command
473        };
474        let open = make(CollaborationOperationBodyV1::Open {
475            tags: Vec::new(),
476            blocking: true,
477            title: "Review".into(),
478            anchor: CollaborationAnchor::Repository,
479            visibility: VisibilityTier::Public,
480            turn: DiscussionTurnV1::new("Evidence").expect("turn"),
481            thread_ref: None,
482        })
483        .sign(&[], &signer)
484        .expect("root");
485        let mut context = ContextRevision {
486            version: 2,
487            id: Uuid::from_u128(9),
488            parents: vec![],
489            metadata: make(append("metadata")).metadata,
490            anchor: CollaborationAnchor::Repository,
491            content: "Durable rationale".into(),
492            tags: vec!["decision".into()],
493            supersedes: None,
494            extracted_from: Some(discussion),
495            occurred_at_ms: 100,
496            provenance: None,
497            canonical_body: Default::default(),
498        };
499        let extracted = make(CollaborationOperationBodyV1::Resolve {
500            resolution: CollaborationResolution::IntoContext {
501                context: context.clone(),
502            },
503        })
504        .sign(std::slice::from_ref(&open), &signer)
505        .expect("atomic extraction");
506        let extracted_operation = verify(&extracted).expect("original resolution");
507        extracted_operation
508            .validate_parents(&genesis, &[verify(&open).expect("root")])
509            .expect("discussion causal proof");
510        assert_eq!(
511            extracted_operation
512                .context_revision()
513                .expect("extracted context")
514                .expect("context")
515                .parents,
516            vec![operation_id(&open).expect("root ID")]
517        );
518        context.content = "Refined rationale".into();
519        let revision = sign_context(context.clone(), std::slice::from_ref(&extracted), &signer)
520            .expect("revise directly from original extraction");
521        verify(&revision)
522            .expect("revision")
523            .validate_parents(&genesis, &[extracted_operation])
524            .expect("context retains discussion extraction as causal root");
525        let mut detached = verify(&revision).expect("context operation");
526        let mut detached_context = detached
527            .context_revision()
528            .expect("decode")
529            .expect("context");
530        detached_context.extracted_from = None;
531        detached.body = ThreadOperationBody::Context(detached_context.encode().expect("context"));
532        assert!(
533            detached
534                .validate_parents(
535                    &genesis,
536                    &[verify(&extracted).expect("original extraction")]
537                )
538                .is_err(),
539            "context revision cannot strip its extraction provenance"
540        );
541        let mut invented_root = verify(&revision).expect("context operation");
542        let mut invented_context = invented_root
543            .context_revision()
544            .expect("decode")
545            .expect("context");
546        invented_context.parents.clear();
547        invented_root.parents.clear();
548        invented_root.body =
549            ThreadOperationBody::Context(invented_context.encode().expect("context"));
550        assert!(
551            invented_root.validate_parents(&genesis, &[]).is_err(),
552            "extraction requires an original signed discussion resolution"
553        );
554        context.metadata.actor.principal_id = Uuid::from_u128(10);
555        assert!(
556            make(CollaborationOperationBodyV1::Resolve {
557                resolution: CollaborationResolution::IntoContext { context }
558            })
559            .sign(&[open], &signer)
560            .is_err(),
561            "extraction cannot assert a different author than the signed resolution"
562        );
563    }
564    #[test]
565    fn canonical_browser_interop_vectors() {
566        use heddle_object_model::object::{CollaborationMention, CollaborationResolution, StateId};
567        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
568        let discussion: DiscussionRecordId = "disc-01980000-0000-7000-8000-000000000123"
569            .parse()
570            .expect("stable discussion ID");
571        let open = command(
572            discussion,
573            CollaborationOperationBodyV1::Open {
574                tags: Vec::new(),
575                blocking: true,
576                title: "Review".into(),
577                anchor: CollaborationAnchor::Repository,
578                visibility: VisibilityTier::Public,
579                turn: DiscussionTurnV1::new("First turn").expect("turn"),
580                thread_ref: None,
581            },
582        )
583        .sign(&[], &signer)
584        .expect("open");
585        let mut append_command = command(discussion, append("See the reviewed state"));
586        append_command.metadata.mentions = vec![CollaborationMention::State {
587            spool: Uuid::from_u128(1),
588            state: StateId::from_bytes([5; 32]),
589        }];
590        let append = append_command
591            .sign(std::slice::from_ref(&open), &signer)
592            .expect("append");
593        let resolve = command(
594            discussion,
595            CollaborationOperationBodyV1::Resolve {
596                resolution: CollaborationResolution::Dismissed {
597                    reason: "Verified".into(),
598                },
599            },
600        )
601        .sign(std::slice::from_ref(&append), &signer)
602        .expect("resolve");
603        let reopen = command(
604            discussion,
605            CollaborationOperationBodyV1::Reopen {
606                reason: "New evidence".into(),
607            },
608        )
609        .sign(std::slice::from_ref(&resolve), &signer)
610        .expect("reopen");
611        let context = ContextRevision {
612            version: 2,
613            id: Uuid::from_u128(9),
614            parents: vec![],
615            metadata: command(
616                discussion,
617                CollaborationOperationBodyV1::Reopen {
618                    reason: "metadata".into(),
619                },
620            )
621            .metadata,
622            anchor: CollaborationAnchor::Repository,
623            content: "Design rationale".into(),
624            tags: vec!["decision".into()],
625            supersedes: None,
626            extracted_from: Some(discussion),
627            occurred_at_ms: 100,
628            provenance: None,
629            canonical_body: Default::default(),
630        };
631        let extraction = command(
632            discussion,
633            CollaborationOperationBodyV1::Resolve {
634                resolution: CollaborationResolution::IntoContext {
635                    context: context.clone(),
636                },
637            },
638        )
639        .sign(std::slice::from_ref(&reopen), &signer)
640        .expect("extraction");
641        let mut revision = context.clone();
642        revision.content = "Refined extracted rationale".into();
643        let extracted_revision = sign_context(revision, std::slice::from_ref(&extraction), &signer)
644            .expect("extracted context revision");
645        let mut standalone_context = context;
646        standalone_context.extracted_from = None;
647        let context = sign_context(standalone_context, &[], &signer).expect("context");
648        let source_record = |revision, target| {
649            command(
650                discussion,
651                CollaborationOperationBodyV1::Open {
652                    tags: Vec::new(),
653                    blocking: true,
654                    title: "Review source".into(),
655                    visibility: VisibilityTier::Public,
656                    anchor: CollaborationAnchor::Source {
657                        source: heddle_object_model::object::CollaborationSourceAnchor {
658                            revision,
659                            path: "src/main.rs".into(),
660                            symbol_id: "run".into(),
661                            start_line: Some(12),
662                            end_line: Some(18),
663                            target,
664                        },
665                    },
666                    turn: DiscussionTurnV1::new("Check these lines").expect("turn"),
667                    thread_ref: None,
668                },
669            )
670            .sign(&[], &signer)
671            .expect("source root")
672        };
673        let source_state = source_record(
674            heddle_object_model::object::CollaborationRevision::State {
675                state_id: StateId::from_bytes([5; 32]),
676            },
677            None,
678        );
679        let source_git = source_record(
680            heddle_object_model::object::CollaborationRevision::GitCommit {
681                oid: "a".repeat(40),
682            },
683            None,
684        );
685        let mut structured = verify(&context)
686            .expect("context proof")
687            .context_revision()
688            .expect("decode")
689            .expect("context");
690        structured.tags = vec![
691            "decision".into(),
692            heddle_object_model::object::AnnotationTag::Symbol {
693                name: "authorize".into(),
694                target: Some(heddle_object_model::object::AnnotationSourceReference {
695                    scope: structured.metadata.scope.clone(),
696                    source: heddle_object_model::object::CollaborationSourceAnchor {
697                        revision: heddle_object_model::object::CollaborationRevision::GitCommit {
698                            oid: "a".repeat(40),
699                        },
700                        path: "src/auth.rs".into(),
701                        symbol_id: "auth::authorize".into(),
702                        start_line: Some(12),
703                        end_line: Some(18),
704                        target: None,
705                    },
706                }),
707            },
708            heddle_object_model::object::AnnotationTag::Property {
709                key: "confidence".into(),
710                value: heddle_object_model::object::AnnotationValue::Decimal(
711                    heddle_object_model::object::AnnotationDecimal {
712                        coefficient: 9,
713                        scale: 1,
714                    },
715                ),
716            },
717            heddle_object_model::object::AnnotationTag::Property {
718                key: "requires_review".into(),
719                value: heddle_object_model::object::AnnotationValue::Boolean(false),
720            },
721            heddle_object_model::object::AnnotationTag::Property {
722                key: "large".into(),
723                value: heddle_object_model::object::AnnotationValue::Integer(i64::MAX),
724            },
725            heddle_object_model::object::AnnotationTag::Property {
726                key: "severity".into(),
727                value: heddle_object_model::object::AnnotationValue::Text("high".into()),
728            },
729        ];
730        let structured_context =
731            sign_context(structured, &[], &signer).expect("structured context");
732        use heddle_object_model::object::{
733            AnnotationSourceReference, AnnotationTag, CollaborationRevision, CollaborationScope,
734            CollaborationSourceAnchor,
735            source_target::{SourceTargetBinding, SourceTargetReference},
736        };
737        let mut tracked = Vec::new();
738        for (name, tag_name, binding) in [
739            (
740                "target_viewed",
741                "tag_viewed",
742                SourceTargetBinding::ViewedThread,
743            ),
744            (
745                "target_named",
746                "tag_named",
747                SourceTargetBinding::NamedThread {
748                    scope: CollaborationScope {
749                        spool: Uuid::from_u128(1),
750                        thread: Some(ContentHash::from_bytes([8; 32])),
751                    },
752                },
753            ),
754            (
755                "target_pinned",
756                "tag_pinned",
757                SourceTargetBinding::PinnedRevision {
758                    scope: CollaborationScope {
759                        spool: Uuid::from_u128(1),
760                        thread: None,
761                    },
762                    revision: CollaborationRevision::State {
763                        state_id: StateId::from_bytes([5; 32]),
764                    },
765                },
766            ),
767        ] {
768            let target = SourceTargetReference {
769                target: ContentHash::from_bytes([6; 32]),
770                binding,
771            };
772            let record = source_record(
773                CollaborationRevision::State {
774                    state_id: StateId::from_bytes([5; 32]),
775                },
776                Some(target.clone()),
777            );
778            let mut revision = verify(&context)
779                .expect("context proof")
780                .context_revision()
781                .expect("decode")
782                .expect("context");
783            revision.tags = vec![AnnotationTag::Source {
784                target: AnnotationSourceReference {
785                    scope: revision.metadata.scope.clone(),
786                    source: CollaborationSourceAnchor {
787                        revision: CollaborationRevision::State {
788                            state_id: StateId::from_bytes([5; 32]),
789                        },
790                        path: "src/main.rs".into(),
791                        symbol_id: String::new(),
792                        start_line: None,
793                        end_line: None,
794                        target: Some(target),
795                    },
796                },
797            }];
798            tracked.push((name, record));
799            tracked.push((
800                tag_name,
801                sign_context(revision, &[], &signer).expect("tracked context"),
802            ));
803        }
804        let mut vectors = String::new();
805        for (name, record) in [
806            ("open", open),
807            ("append", append),
808            ("resolve", resolve),
809            ("reopen", reopen),
810            ("context", context),
811            ("structured_context", structured_context),
812            ("source_state", source_state),
813            ("source_git", source_git),
814            ("extract_context", extraction),
815            ("extracted_revision", extracted_revision),
816        ]
817        .into_iter()
818        .chain(tracked)
819        {
820            vectors.push_str(&format!(
821                "{} {} {} {} {}\n",
822                name,
823                hex::encode(&record.signatures[0].public_key),
824                hex::encode(&record.canonical_record),
825                hex::encode(&record.signatures[0].signature),
826                operation_id(&record).expect("operation ID").to_hex()
827            ));
828        }
829        assert_eq!(
830            vectors,
831            include_str!("../tests/fixtures/collaboration_v2.txt")
832        );
833    }
834}