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