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
240/// Sign a context revision after observing current heads by original
241/// operation id. First PutContext stays parentless; supersede/edit parents
242/// the observed causal heads.
243pub fn sign_context_parent_ids(
244    context: ContextRevision,
245    parent_ids: &[ContentHash],
246    signer: &impl Signer,
247) -> Result<SignedRecord, Error> {
248    if parent_ids.len() > 128 {
249        return Err(Error::Protocol("context has more than 128 causal parents"));
250    }
251    let thread = context.metadata.scope.thread.ok_or(Error::Protocol(
252        "Thread context requires native Thread scope",
253    ))?;
254    if parent_ids.is_empty() && context.extracted_from.is_some() {
255        return Err(Error::Protocol(
256            "context extraction requires a signed discussion resolution",
257        ));
258    }
259    let mut ids = BTreeSet::new();
260    for id in parent_ids {
261        if !ids.insert(*id) {
262            return Err(Error::Protocol("duplicate context causal parent"));
263        }
264    }
265    sign_context_ids(context, thread, ids, signer)
266}
267
268fn sign_context_ids(
269    mut context: ContextRevision,
270    thread: ContentHash,
271    ids: BTreeSet<ContentHash>,
272    signer: &impl Signer,
273) -> Result<SignedRecord, Error> {
274    context.parents = ids.iter().copied().collect();
275    let operation = ThreadOperation {
276        version: 1,
277        thread,
278        parents: ids,
279        publisher: signer
280            .public_key()
281            .try_into()
282            .map_err(|_| Error::Protocol("context requires an Ed25519 signer"))?,
283        body: ThreadOperationBody::Context(
284            context
285                .encode()
286                .map_err(|error| Error::Io(error.to_string()))?,
287        ),
288    };
289    let signed =
290        SignedOperation::sign(&operation, signer).map_err(|error| Error::Io(error.to_string()))?;
291    Ok(SignedRecord {
292        format: OPERATION_FORMAT.into(),
293        canonical_record: signed.canonical,
294        signatures: vec![RecordSignature {
295            public_key: operation.publisher.to_vec(),
296            signature: signed.signature,
297        }],
298    })
299}
300
301/// The public causal vocabulary is the outer replication operation identity.
302pub fn operation_id(record: &SignedRecord) -> Result<ContentHash, Error> {
303    verify(record)?
304        .id()
305        .map_err(|error| Error::Io(error.to_string()))
306}
307
308#[cfg(test)]
309mod tests {
310    use crypto::Ed25519Signer;
311    use heddle_object_model::object::{
312        CollaborationActor, CollaborationAnchor, CollaborationScope, DiscussionTurnV1, Principal,
313        VisibilityTier,
314    };
315    use uuid::Uuid;
316
317    use super::*;
318
319    fn command(discussion: DiscussionRecordId, body: CollaborationOperationBodyV1) -> Command {
320        Command {
321            discussion,
322            operation_id: CollaborationIdempotencyKey::new("operation-1").expect("operation ID"),
323            metadata: CollaborationMetadata {
324                scope: CollaborationScope {
325                    spool: Uuid::from_u128(1),
326                    thread: Some(ContentHash::from_bytes([2; 32])),
327                },
328                actor: CollaborationActor {
329                    principal_id: Uuid::from_u128(3),
330                    agent_id: Some("agent-4".into()),
331                },
332                mentions: vec![],
333            },
334            author: Attribution::human(Principal::new("Account", "")),
335            occurred_at_ms: 100,
336            body,
337        }
338    }
339    fn append(body: &str) -> CollaborationOperationBodyV1 {
340        CollaborationOperationBodyV1::AppendTurn {
341            turn: DiscussionTurnV1::new(body).expect("turn"),
342        }
343    }
344    #[test]
345    fn signed_parents_preserve_concurrent_heads_and_reject_changed_proofs() {
346        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
347        let discussion = DiscussionRecordId::generate();
348        let root = command(
349            discussion,
350            CollaborationOperationBodyV1::Open {
351                blocking: false,
352                title: "Review".into(),
353                anchor: CollaborationAnchor::Repository,
354                visibility: VisibilityTier::Private {
355                    scope_label: "owner".into(),
356                },
357                turn: DiscussionTurnV1::new("Review this Thread").expect("turn"),
358                thread_ref: None,
359            },
360        )
361        .sign(&[], &signer)
362        .expect("root");
363        let left = command(discussion, append("left"))
364            .sign(std::slice::from_ref(&root), &signer)
365            .expect("left");
366        let right = command(discussion, append("right"))
367            .sign(std::slice::from_ref(&root), &signer)
368            .expect("right");
369        assert_ne!(
370            operation_id(&left).expect("left ID"),
371            operation_id(&right).expect("right ID")
372        );
373        let joined = command(discussion, append("both observed"))
374            .sign(&[left.clone(), right.clone()], &signer)
375            .expect("join");
376        assert_eq!(
377            verify(&joined).expect("verified").parents,
378            BTreeSet::from([
379                operation_id(&left).expect("left ID"),
380                operation_id(&right).expect("right ID")
381            ])
382        );
383        let mut changed = root.clone();
384        changed.signatures[0].signature[0] ^= 1;
385        assert!(
386            command(discussion, append("bad proof"))
387                .sign(&[changed], &signer)
388                .is_err()
389        );
390        assert!(
391            command(DiscussionRecordId::generate(), append("wrong discussion"))
392                .sign(std::slice::from_ref(&root), &signer)
393                .is_err()
394        );
395        assert!(
396            command(discussion, append("duplicate parent"))
397                .sign(&[root.clone(), root], &signer)
398                .is_err()
399        );
400    }
401    #[test]
402    fn observed_parent_ids_are_sorted_unique_and_required_for_append() {
403        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
404        let discussion = DiscussionRecordId::generate();
405        let root = command(
406            discussion,
407            CollaborationOperationBodyV1::Open {
408                blocking: false,
409                title: "Review".into(),
410                anchor: CollaborationAnchor::Repository,
411                visibility: VisibilityTier::Private {
412                    scope_label: "owner".into(),
413                },
414                turn: DiscussionTurnV1::new("Review this Thread").expect("turn"),
415                thread_ref: None,
416            },
417        )
418        .sign(&[], &signer)
419        .expect("root");
420        let root_id = operation_id(&root).expect("root ID");
421        let appended = command(discussion, append("follow-up"))
422            .sign_parent_ids(&[root_id], &signer)
423            .expect("append");
424        assert_eq!(
425            verify(&appended).expect("verified").parents,
426            BTreeSet::from([root_id])
427        );
428        assert!(
429            command(discussion, append("no parent"))
430                .sign_parent_ids(&[], &signer)
431                .is_err()
432        );
433    }
434    #[test]
435    fn context_revisions_retain_history_and_bind_parent_record_identity() {
436        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
437        let context = ContextRevision {
438            version: 2,
439            id: Uuid::from_u128(9),
440            parents: vec![],
441            metadata: command(DiscussionRecordId::generate(), append("metadata")).metadata,
442            anchor: CollaborationAnchor::Repository,
443            content: "Original rationale".into(),
444            tags: vec!["decision".into()],
445            supersedes: None,
446            extracted_from: None,
447            occurred_at_ms: 100,
448            provenance: None,
449            canonical_body: Default::default(),
450        };
451        let first = sign_context(context.clone(), &[], &signer).expect("first context");
452        let mut revised = context.clone();
453        revised.content = "Revised rationale".into();
454        let second =
455            sign_context(revised.clone(), std::slice::from_ref(&first), &signer).expect("revision");
456        assert_eq!(
457            verify(&second).expect("second").parents,
458            BTreeSet::from([operation_id(&first).expect("first ID")])
459        );
460        let ThreadOperationBody::Context(bytes) = verify(&first).expect("first").body else {
461            panic!("context");
462        };
463        assert_eq!(
464            ContextRevision::decode(&bytes)
465                .expect("original context")
466                .content,
467            "Original rationale"
468        );
469        revised.id = Uuid::from_u128(10);
470        assert!(
471            sign_context(revised, std::slice::from_ref(&first), &signer).is_err(),
472            "parent cannot silently change record identity"
473        );
474    }
475    #[test]
476    fn extracted_context_keeps_original_resolution_proof_and_actor_binding() {
477        use heddle_object_model::object::{
478            CollaborationResolution, StateId, thread_replication::ThreadGenesis,
479        };
480        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
481        let genesis = ThreadGenesis {
482            version: 1,
483            spool: Uuid::from_u128(1).to_string(),
484            parent: None,
485            base: StateId::from_bytes([3; 32]),
486            name: "extraction".into(),
487            intent: "retain proof".into(),
488            creator: signer.public_key().try_into().expect("key"),
489            owner: heddle_object_model::object::thread_replication::GenesisOwner::LocalKey(
490                signer.public_key().try_into().expect("key"),
491            ),
492            nonce: vec![1; 16],
493        };
494        let discussion = DiscussionRecordId::generate();
495        let make = |body| {
496            let mut command = command(discussion, body);
497            command.metadata.scope.thread = Some(genesis.id().expect("Thread"));
498            command
499        };
500        let open = make(CollaborationOperationBodyV1::Open {
501            blocking: true,
502            title: "Review".into(),
503            anchor: CollaborationAnchor::Repository,
504            visibility: VisibilityTier::Public,
505            turn: DiscussionTurnV1::new("Evidence").expect("turn"),
506            thread_ref: None,
507        })
508        .sign(&[], &signer)
509        .expect("root");
510        let mut context = ContextRevision {
511            version: 2,
512            id: Uuid::from_u128(9),
513            parents: vec![],
514            metadata: make(append("metadata")).metadata,
515            anchor: CollaborationAnchor::Repository,
516            content: "Durable rationale".into(),
517            tags: vec!["decision".into()],
518            supersedes: None,
519            extracted_from: Some(discussion),
520            occurred_at_ms: 100,
521            provenance: None,
522            canonical_body: Default::default(),
523        };
524        let extracted = make(CollaborationOperationBodyV1::Resolve {
525            resolution: CollaborationResolution::IntoContext {
526                context: context.clone(),
527            },
528        })
529        .sign(std::slice::from_ref(&open), &signer)
530        .expect("atomic extraction");
531        let extracted_operation = verify(&extracted).expect("original resolution");
532        extracted_operation
533            .validate_parents(&genesis, &[verify(&open).expect("root")])
534            .expect("discussion causal proof");
535        assert_eq!(
536            extracted_operation
537                .context_revision()
538                .expect("extracted context")
539                .expect("context")
540                .parents,
541            vec![operation_id(&open).expect("root ID")]
542        );
543        context.content = "Refined rationale".into();
544        let revision = sign_context(context.clone(), std::slice::from_ref(&extracted), &signer)
545            .expect("revise directly from original extraction");
546        verify(&revision)
547            .expect("revision")
548            .validate_parents(&genesis, &[extracted_operation])
549            .expect("context retains discussion extraction as causal root");
550        let mut detached = verify(&revision).expect("context operation");
551        let mut detached_context = detached
552            .context_revision()
553            .expect("decode")
554            .expect("context");
555        detached_context.extracted_from = None;
556        detached.body = ThreadOperationBody::Context(detached_context.encode().expect("context"));
557        assert!(
558            detached
559                .validate_parents(
560                    &genesis,
561                    &[verify(&extracted).expect("original extraction")]
562                )
563                .is_err(),
564            "context revision cannot strip its extraction provenance"
565        );
566        let mut invented_root = verify(&revision).expect("context operation");
567        let mut invented_context = invented_root
568            .context_revision()
569            .expect("decode")
570            .expect("context");
571        invented_context.parents.clear();
572        invented_root.parents.clear();
573        invented_root.body =
574            ThreadOperationBody::Context(invented_context.encode().expect("context"));
575        assert!(
576            invented_root.validate_parents(&genesis, &[]).is_err(),
577            "extraction requires an original signed discussion resolution"
578        );
579        context.metadata.actor.principal_id = Uuid::from_u128(10);
580        assert!(
581            make(CollaborationOperationBodyV1::Resolve {
582                resolution: CollaborationResolution::IntoContext { context }
583            })
584            .sign(&[open], &signer)
585            .is_err(),
586            "extraction cannot assert a different author than the signed resolution"
587        );
588    }
589    #[test]
590    fn canonical_browser_interop_vectors() {
591        use heddle_object_model::object::{CollaborationMention, CollaborationResolution, StateId};
592        let signer = Ed25519Signer::from_seed(&[7; 32]).expect("signer");
593        let discussion: DiscussionRecordId = "disc-01980000-0000-7000-8000-000000000123"
594            .parse()
595            .expect("stable discussion ID");
596        let open = command(
597            discussion,
598            CollaborationOperationBodyV1::Open {
599                blocking: true,
600                title: "Review".into(),
601                anchor: CollaborationAnchor::Repository,
602                visibility: VisibilityTier::Public,
603                turn: DiscussionTurnV1::new("First turn").expect("turn"),
604                thread_ref: None,
605            },
606        )
607        .sign(&[], &signer)
608        .expect("open");
609        let mut append_command = command(discussion, append("See the reviewed state"));
610        append_command.metadata.mentions = vec![CollaborationMention::State {
611            spool: Uuid::from_u128(1),
612            state: StateId::from_bytes([5; 32]),
613        }];
614        let append = append_command
615            .sign(std::slice::from_ref(&open), &signer)
616            .expect("append");
617        let resolve = command(
618            discussion,
619            CollaborationOperationBodyV1::Resolve {
620                resolution: CollaborationResolution::Dismissed {
621                    reason: "Verified".into(),
622                },
623            },
624        )
625        .sign(std::slice::from_ref(&append), &signer)
626        .expect("resolve");
627        let reopen = command(
628            discussion,
629            CollaborationOperationBodyV1::Reopen {
630                reason: "New evidence".into(),
631            },
632        )
633        .sign(std::slice::from_ref(&resolve), &signer)
634        .expect("reopen");
635        let context = ContextRevision {
636            version: 2,
637            id: Uuid::from_u128(9),
638            parents: vec![],
639            metadata: command(
640                discussion,
641                CollaborationOperationBodyV1::Reopen {
642                    reason: "metadata".into(),
643                },
644            )
645            .metadata,
646            anchor: CollaborationAnchor::Repository,
647            content: "Design rationale".into(),
648            tags: vec!["decision".into()],
649            supersedes: None,
650            extracted_from: Some(discussion),
651            occurred_at_ms: 100,
652            provenance: None,
653            canonical_body: Default::default(),
654        };
655        let extraction = command(
656            discussion,
657            CollaborationOperationBodyV1::Resolve {
658                resolution: CollaborationResolution::IntoContext {
659                    context: context.clone(),
660                },
661            },
662        )
663        .sign(std::slice::from_ref(&reopen), &signer)
664        .expect("extraction");
665        let mut revision = context.clone();
666        revision.content = "Refined extracted rationale".into();
667        let extracted_revision = sign_context(revision, std::slice::from_ref(&extraction), &signer)
668            .expect("extracted context revision");
669        let mut standalone_context = context;
670        standalone_context.extracted_from = None;
671        let context = sign_context(standalone_context, &[], &signer).expect("context");
672        let source_record = |revision, target| {
673            command(
674                discussion,
675                CollaborationOperationBodyV1::Open {
676                    blocking: true,
677                    title: "Review source".into(),
678                    visibility: VisibilityTier::Public,
679                    anchor: CollaborationAnchor::Source {
680                        source: heddle_object_model::object::CollaborationSourceAnchor {
681                            revision,
682                            path: "src/main.rs".into(),
683                            symbol_id: "run".into(),
684                            start_line: Some(12),
685                            end_line: Some(18),
686                            target,
687                        },
688                    },
689                    turn: DiscussionTurnV1::new("Check these lines").expect("turn"),
690                    thread_ref: None,
691                },
692            )
693            .sign(&[], &signer)
694            .expect("source root")
695        };
696        let source_state = source_record(
697            heddle_object_model::object::CollaborationRevision::State {
698                state_id: StateId::from_bytes([5; 32]),
699            },
700            None,
701        );
702        let source_git = source_record(
703            heddle_object_model::object::CollaborationRevision::GitCommit {
704                oid: "a".repeat(40),
705            },
706            None,
707        );
708        let mut structured = verify(&context)
709            .expect("context proof")
710            .context_revision()
711            .expect("decode")
712            .expect("context");
713        structured.tags = vec![
714            "decision".into(),
715            heddle_object_model::object::AnnotationTag::Symbol {
716                name: "authorize".into(),
717                target: Some(heddle_object_model::object::AnnotationSourceReference {
718                    scope: structured.metadata.scope.clone(),
719                    source: heddle_object_model::object::CollaborationSourceAnchor {
720                        revision: heddle_object_model::object::CollaborationRevision::GitCommit {
721                            oid: "a".repeat(40),
722                        },
723                        path: "src/auth.rs".into(),
724                        symbol_id: "auth::authorize".into(),
725                        start_line: Some(12),
726                        end_line: Some(18),
727                        target: None,
728                    },
729                }),
730            },
731            heddle_object_model::object::AnnotationTag::Property {
732                key: "confidence".into(),
733                value: heddle_object_model::object::AnnotationValue::Decimal(
734                    heddle_object_model::object::AnnotationDecimal {
735                        coefficient: 9,
736                        scale: 1,
737                    },
738                ),
739            },
740            heddle_object_model::object::AnnotationTag::Property {
741                key: "requires_review".into(),
742                value: heddle_object_model::object::AnnotationValue::Boolean(false),
743            },
744            heddle_object_model::object::AnnotationTag::Property {
745                key: "large".into(),
746                value: heddle_object_model::object::AnnotationValue::Integer(i64::MAX),
747            },
748            heddle_object_model::object::AnnotationTag::Property {
749                key: "severity".into(),
750                value: heddle_object_model::object::AnnotationValue::Text("high".into()),
751            },
752        ];
753        let structured_context =
754            sign_context(structured, &[], &signer).expect("structured context");
755        use heddle_object_model::object::{
756            AnnotationSourceReference, AnnotationTag, CollaborationRevision, CollaborationScope,
757            CollaborationSourceAnchor,
758            source_target::{SourceTargetBinding, SourceTargetReference},
759        };
760        let mut tracked = Vec::new();
761        for (name, tag_name, binding) in [
762            (
763                "target_viewed",
764                "tag_viewed",
765                SourceTargetBinding::ViewedThread,
766            ),
767            (
768                "target_named",
769                "tag_named",
770                SourceTargetBinding::NamedThread {
771                    scope: CollaborationScope {
772                        spool: Uuid::from_u128(1),
773                        thread: Some(ContentHash::from_bytes([8; 32])),
774                    },
775                },
776            ),
777            (
778                "target_pinned",
779                "tag_pinned",
780                SourceTargetBinding::PinnedRevision {
781                    scope: CollaborationScope {
782                        spool: Uuid::from_u128(1),
783                        thread: None,
784                    },
785                    revision: CollaborationRevision::State {
786                        state_id: StateId::from_bytes([5; 32]),
787                    },
788                },
789            ),
790        ] {
791            let target = SourceTargetReference {
792                target: ContentHash::from_bytes([6; 32]),
793                binding,
794            };
795            let record = source_record(
796                CollaborationRevision::State {
797                    state_id: StateId::from_bytes([5; 32]),
798                },
799                Some(target.clone()),
800            );
801            let mut revision = verify(&context)
802                .expect("context proof")
803                .context_revision()
804                .expect("decode")
805                .expect("context");
806            revision.tags = vec![AnnotationTag::Source {
807                target: AnnotationSourceReference {
808                    scope: revision.metadata.scope.clone(),
809                    source: CollaborationSourceAnchor {
810                        revision: CollaborationRevision::State {
811                            state_id: StateId::from_bytes([5; 32]),
812                        },
813                        path: "src/main.rs".into(),
814                        symbol_id: String::new(),
815                        start_line: None,
816                        end_line: None,
817                        target: Some(target),
818                    },
819                },
820            }];
821            tracked.push((name, record));
822            tracked.push((
823                tag_name,
824                sign_context(revision, &[], &signer).expect("tracked context"),
825            ));
826        }
827        let mut vectors = String::new();
828        for (name, record) in [
829            ("open", open),
830            ("append", append),
831            ("resolve", resolve),
832            ("reopen", reopen),
833            ("context", context),
834            ("structured_context", structured_context),
835            ("source_state", source_state),
836            ("source_git", source_git),
837            ("extract_context", extraction),
838            ("extracted_revision", extracted_revision),
839        ]
840        .into_iter()
841        .chain(tracked)
842        {
843            vectors.push_str(&format!(
844                "{} {} {} {} {}\n",
845                name,
846                hex::encode(&record.signatures[0].public_key),
847                hex::encode(&record.canonical_record),
848                hex::encode(&record.signatures[0].signature),
849                operation_id(&record).expect("operation ID").to_hex()
850            ));
851        }
852        assert_eq!(
853            vectors,
854            include_str!("../tests/fixtures/collaboration_v2.txt")
855        );
856    }
857}