Skip to main content

heddle_thread_api/collaboration/
references.rs

1//! Lossless typed mention conversion shared by command builders and hosts.
2use heddle_object_model::object::{
3    CollaborationMention as Mention, CollaborationRecordKind as Kind, ContentHash, StateId,
4};
5use uuid::Uuid;
6
7use crate::{contract::*, transport::Error};
8
9fn spool(value: Option<&SpoolRef>) -> Result<Uuid, Error> {
10    let id = value
11        .ok_or(Error::Protocol("mention requires spool"))?
12        .id
13        .parse::<Uuid>()
14        .map_err(|_| Error::Protocol("mention spool must be UUID"))?;
15    if id.is_nil() {
16        return Err(Error::Protocol("mention spool cannot be nil"));
17    }
18    Ok(id)
19}
20fn key(value: &[u8]) -> Result<[u8; 32], Error> {
21    value
22        .try_into()
23        .map_err(|_| Error::Protocol("mention identity must contain 32 bytes"))
24}
25fn endpoint(value: Option<&EndpointRef>) -> Result<[u8; 32], Error> {
26    let endpoint = value.ok_or(Error::Protocol("device mention requires endpoint"))?;
27    if endpoint.kind != EndpointKind::Device as i32 {
28        return Err(Error::Protocol("device mention must target a device"));
29    }
30    key(&endpoint.public_key)
31}
32/// Decode a received or retained reference. Endpoint support gates sending,
33/// not interpretation of an author's signed metadata.
34pub fn mention(value: &EntityRef) -> Result<Mention, Error> {
35    use entity_ref::Entity;
36    let result = match value
37        .entity
38        .as_ref()
39        .ok_or(Error::Protocol("empty mention"))?
40    {
41        Entity::Spool(r) => Mention::Spool {
42            spool: spool(Some(r))?,
43        },
44        Entity::Thread(r) => Mention::Thread {
45            spool: spool(r.spool.as_ref())?,
46            thread: ContentHash::from_bytes(key(&r
47                .id
48                .as_ref()
49                .ok_or(Error::Protocol("Thread mention requires ID"))?
50                .value)?),
51        },
52        Entity::Checkout(r) => Mention::Checkout {
53            spool: spool(r.spool.as_ref())?,
54            device: endpoint(r.device.as_ref())?,
55            id: r.id.clone(),
56        },
57        Entity::Revision(r) => match r
58            .revision
59            .as_ref()
60            .ok_or(Error::Protocol("revision mention requires exact revision"))?
61        {
62            revision_ref::Revision::State(id) => Mention::State {
63                spool: spool(r.spool.as_ref())?,
64                state: StateId::from_bytes(key(&id.value)?),
65            },
66            revision_ref::Revision::GitCommitOid(oid) => Mention::GitCommit {
67                spool: spool(r.spool.as_ref())?,
68                oid: oid.clone(),
69            },
70        },
71        Entity::Device(r) => Mention::Device {
72            key: endpoint(Some(r))?,
73        },
74        Entity::Discussion(r) => record(r, Kind::Discussion)?,
75        Entity::Context(r) => record(r, Kind::Context)?,
76        Entity::Operation(r) => record(r, Kind::Operation)?,
77        Entity::Run(r) => record(r, Kind::Run)?,
78        Entity::Policy(r) => record(r, Kind::Policy)?,
79        Entity::Analysis(r) => record(r, Kind::Analysis)?,
80        Entity::Invitation(r) => record(r, Kind::Invitation)?,
81        Entity::Grant(r) => record(r, Kind::Grant)?,
82        Entity::DiscussionTurn(r) => record(r, Kind::DiscussionTurn)?,
83        Entity::Review(r) => record(r, Kind::Review)?,
84        Entity::Notification(r) => record(r, Kind::Notification)?,
85        Entity::AttentionItem(r) => record(r, Kind::AttentionItem)?,
86        Entity::Member(r) => record(r, Kind::Member)?,
87        Entity::ApprovalGroup(r) => record(r, Kind::ApprovalGroup)?,
88        Entity::Session(r) => record(r, Kind::Session)?,
89        Entity::SignupInvitation(r) => record(r, Kind::SignupInvitation)?,
90        Entity::TimelineEvent(r) => record(r, Kind::TimelineEvent)?,
91        Entity::Artifact(r) => record(r, Kind::Artifact)?,
92        Entity::Mount(r) => record(r, Kind::Mount)?,
93        Entity::SupportAccess(r) => record(r, Kind::SupportAccess)?,
94        Entity::DeviceRecord(r) => record(r, Kind::DeviceRecord)?,
95        Entity::Delegation(r) => record(r, Kind::Delegation)?,
96        Entity::Recovery(r) => record(r, Kind::Recovery)?,
97        Entity::OwnerTransition(r) => record(r, Kind::OwnerTransition)?,
98        Entity::Billing(r) => record(r, Kind::Billing)?,
99        Entity::Evidence(r) => record(r, Kind::Evidence)?,
100        Entity::CheckAcknowledgement(r) => record(r, Kind::CheckAcknowledgement)?,
101        Entity::ProviderConnection(r) => record(r, Kind::ProviderConnection)?,
102        Entity::RemoteLink(r) => record(r, Kind::RemoteLink)?,
103        Entity::Bookmark(_) => {
104            return Err(Error::Protocol(
105                "bookmarks are account-private; mention their Spool or Thread instead",
106            ));
107        }
108        Entity::Passkey(_) => {
109            return Err(Error::Protocol(
110                "passkeys are account-private credentials and cannot be mentioned",
111            ));
112        }
113        Entity::Principal(r) => Mention::Principal {
114            principal_id: r
115                .id
116                .parse()
117                .map_err(|_| Error::Protocol("principal mention requires UUID"))?,
118        },
119        Entity::Agent(_) => {
120            return Err(Error::Protocol(
121                "agent references require an explicit mention contract",
122            ));
123        }
124        Entity::Source(r) => {
125            let target = super::annotation_source(&AnnotationSourceReference {
126                source: Some(SourceAnchor {
127                    revision: r.revision.clone(),
128                    path: r.path.clone(),
129                    symbol_id: r.symbol_id.clone(),
130                    start_line: r.start_line,
131                    end_line: r.end_line,
132                    thread: r.thread.clone(),
133                    ..Default::default()
134                }),
135            })?;
136            Mention::Source {
137                target: Box::new(target),
138            }
139        }
140    };
141    result
142        .validate()
143        .map_err(|_| Error::Protocol("invalid collaboration mention"))?;
144    Ok(result)
145}
146fn record(r: &RecordRef, record_kind: Kind) -> Result<Mention, Error> {
147    Ok(Mention::Record {
148        spool: r.spool.as_ref().map(|r| spool(Some(r))).transpose()?,
149        record_kind,
150        id: r.id.clone(),
151    })
152}
153fn wire_spool(id: Uuid) -> Option<SpoolRef> {
154    Some(SpoolRef { id: id.to_string() })
155}
156fn wire_device(key: &[u8; 32]) -> EndpointRef {
157    EndpointRef {
158        kind: EndpointKind::Device as i32,
159        public_key: key.to_vec(),
160    }
161}
162pub fn mention_ref(value: &Mention) -> EntityRef {
163    use entity_ref::Entity;
164    let entity = match value {
165        Mention::Spool { spool } => Entity::Spool(SpoolRef {
166            id: spool.to_string(),
167        }),
168        Mention::Thread { spool, thread } => Entity::Thread(ThreadRef {
169            spool: wire_spool(*spool),
170            id: Some(ThreadId {
171                value: thread.as_bytes().to_vec(),
172            }),
173        }),
174        Mention::Checkout { spool, device, id } => Entity::Checkout(CheckoutRef {
175            spool: wire_spool(*spool),
176            device: Some(wire_device(device)),
177            id: id.clone(),
178        }),
179        Mention::State { spool, state } => Entity::Revision(RevisionRef {
180            spool: wire_spool(*spool),
181            revision: Some(revision_ref::Revision::State(
182                api::heddle::api::common::StateId {
183                    value: state.as_bytes().to_vec(),
184                },
185            )),
186        }),
187        Mention::GitCommit { spool, oid } => Entity::Revision(RevisionRef {
188            spool: wire_spool(*spool),
189            revision: Some(revision_ref::Revision::GitCommitOid(oid.clone())),
190        }),
191        Mention::Device { key } => Entity::Device(wire_device(key)),
192        Mention::Principal { principal_id } => Entity::Principal(PrincipalRef {
193            id: principal_id.to_string(),
194        }),
195        Mention::Source { target } => {
196            let source = super::annotation_source_ref(target).source;
197            // Annotation source projection always supplies the original coordinates.
198            let source = source.unwrap_or_default();
199            Entity::Source(SourceMention {
200                revision: source.revision,
201                path: source.path,
202                symbol_id: source.symbol_id,
203                start_line: source.start_line,
204                end_line: source.end_line,
205                thread: source.thread,
206            })
207        }
208        Mention::Record {
209            spool,
210            record_kind,
211            id,
212        } => {
213            let r = RecordRef {
214                spool: spool.and_then(wire_spool),
215                id: id.clone(),
216            };
217            match record_kind {
218                Kind::Discussion => Entity::Discussion(r),
219                Kind::Context => Entity::Context(r),
220                Kind::Operation => Entity::Operation(r),
221                Kind::Run => Entity::Run(r),
222                Kind::Policy => Entity::Policy(r),
223                Kind::Analysis => Entity::Analysis(r),
224                Kind::Invitation => Entity::Invitation(r),
225                Kind::Grant => Entity::Grant(r),
226                Kind::DiscussionTurn => Entity::DiscussionTurn(r),
227                Kind::Review => Entity::Review(r),
228                Kind::Notification => Entity::Notification(r),
229                Kind::AttentionItem => Entity::AttentionItem(r),
230                Kind::Member => Entity::Member(r),
231                Kind::ApprovalGroup => Entity::ApprovalGroup(r),
232                Kind::Session => Entity::Session(r),
233                Kind::SignupInvitation => Entity::SignupInvitation(r),
234                Kind::TimelineEvent => Entity::TimelineEvent(r),
235                Kind::Artifact => Entity::Artifact(r),
236                Kind::Mount => Entity::Mount(r),
237                Kind::SupportAccess => Entity::SupportAccess(r),
238                Kind::DeviceRecord => Entity::DeviceRecord(r),
239                Kind::Delegation => Entity::Delegation(r),
240                Kind::Recovery => Entity::Recovery(r),
241                Kind::OwnerTransition => Entity::OwnerTransition(r),
242                Kind::Billing => Entity::Billing(r),
243                Kind::Evidence => Entity::Evidence(r),
244                Kind::CheckAcknowledgement => Entity::CheckAcknowledgement(r),
245                Kind::ProviderConnection => Entity::ProviderConnection(r),
246                Kind::RemoteLink => Entity::RemoteLink(r),
247            }
248        }
249    };
250    EntityRef {
251        entity: Some(entity),
252    }
253}
254
255pub(super) fn source_target(
256    value: &SourceTargetReference,
257) -> Result<heddle_object_model::object::source_target::SourceTargetReference, Error> {
258    use heddle_object_model::object::{
259        CollaborationRevision, CollaborationScope,
260        source_target::{SourceTargetBinding, SourceTargetReference as Target},
261    };
262    fn named(value: &ThreadRef) -> Result<CollaborationScope, Error> {
263        Ok(CollaborationScope {
264            spool: spool(value.spool.as_ref())?,
265            thread: Some(ContentHash::from_bytes(key(&value
266                .id
267                .as_ref()
268                .ok_or(Error::Protocol("target requires Thread ID"))?
269                .value)?)),
270        })
271    }
272    let binding = match value
273        .binding
274        .as_ref()
275        .ok_or(Error::Protocol("explicit source target binding required"))?
276    {
277        source_target_reference::Binding::ViewedThread(true) => SourceTargetBinding::ViewedThread,
278        source_target_reference::Binding::ViewedThread(false) => {
279            return Err(Error::Protocol("viewed Thread binding must be true"));
280        }
281        source_target_reference::Binding::NamedThread(value) => SourceTargetBinding::NamedThread {
282            scope: named(value)?,
283        },
284        source_target_reference::Binding::PinnedRevision(value) => {
285            let revision = value
286                .revision
287                .as_ref()
288                .ok_or(Error::Protocol("pinned target requires revision"))?;
289            let spool = spool(revision.spool.as_ref())?;
290            let scope = match &value.thread {
291                Some(thread) => {
292                    let scope = named(thread)?;
293                    if scope.spool != spool {
294                        return Err(Error::Protocol(
295                            "pinned target Thread belongs to another Spool",
296                        ));
297                    }
298                    scope
299                }
300                None => CollaborationScope {
301                    spool,
302                    thread: None,
303                },
304            };
305            let revision = match revision
306                .revision
307                .as_ref()
308                .ok_or(Error::Protocol("pinned target requires exact revision"))?
309            {
310                revision_ref::Revision::State(id) => CollaborationRevision::State {
311                    state_id: StateId::from_bytes(key(&id.value)?),
312                },
313                revision_ref::Revision::GitCommitOid(oid) => {
314                    CollaborationRevision::GitCommit { oid: oid.clone() }
315                }
316            };
317            SourceTargetBinding::PinnedRevision { scope, revision }
318        }
319    };
320    let target = Target {
321        target: ContentHash::from_bytes(key(&value.target_id)?),
322        binding,
323    };
324    target
325        .validate()
326        .map_err(|_| Error::Protocol("invalid source target binding"))?;
327    Ok(target)
328}
329pub fn source_target_ref(
330    value: &heddle_object_model::object::source_target::SourceTargetReference,
331) -> SourceTargetReference {
332    use heddle_object_model::object::{
333        CollaborationRevision, CollaborationScope, source_target::SourceTargetBinding,
334    };
335    fn thread(scope: &CollaborationScope) -> Option<ThreadRef> {
336        scope.thread.map(|id| ThreadRef {
337            spool: wire_spool(scope.spool),
338            id: Some(ThreadId {
339                value: id.as_bytes().to_vec(),
340            }),
341        })
342    }
343    let binding = match &value.binding {
344        SourceTargetBinding::ViewedThread => source_target_reference::Binding::ViewedThread(true),
345        SourceTargetBinding::NamedThread { scope } => {
346            source_target_reference::Binding::NamedThread(ThreadRef {
347                spool: wire_spool(scope.spool),
348                id: scope.thread.map(|id| ThreadId {
349                    value: id.as_bytes().to_vec(),
350                }),
351            })
352        }
353        SourceTargetBinding::PinnedRevision { scope, revision } => {
354            source_target_reference::Binding::PinnedRevision(PinnedSourceTargetRevision {
355                thread: thread(scope),
356                revision: Some(RevisionRef {
357                    spool: wire_spool(scope.spool),
358                    revision: Some(match revision {
359                        CollaborationRevision::State { state_id } => {
360                            revision_ref::Revision::State(api::heddle::api::common::StateId {
361                                value: state_id.as_bytes().to_vec(),
362                            })
363                        }
364                        CollaborationRevision::GitCommit { oid } => {
365                            revision_ref::Revision::GitCommitOid(oid.clone())
366                        }
367                    }),
368                }),
369            })
370        }
371    };
372    SourceTargetReference {
373        target_id: value.target.as_bytes().to_vec(),
374        binding: Some(binding),
375    }
376}
377
378/// Bind an API anchor to the canonical scope before signing or admitting it.
379pub fn anchor(
380    value: &CollaborationAnchor,
381    scope: &heddle_object_model::object::CollaborationScope,
382) -> Result<heddle_object_model::object::CollaborationAnchor, Error> {
383    use heddle_object_model::object::{
384        CollaborationAnchor as Anchor, CollaborationRevision, CollaborationSourceAnchor,
385    };
386    fn thread(
387        value: &ThreadRef,
388        scope: &heddle_object_model::object::CollaborationScope,
389    ) -> Result<(), Error> {
390        if spool(value.spool.as_ref())? != scope.spool
391            || Some(ContentHash::from_bytes(key(&value
392                .id
393                .as_ref()
394                .ok_or(Error::Protocol("anchor Thread requires ID"))?
395                .value)?))
396                != scope.thread
397        {
398            return Err(Error::Protocol("anchor belongs to another Thread or spool"));
399        }
400        Ok(())
401    }
402    Ok(
403        match value
404            .target
405            .as_ref()
406            .ok_or(Error::Protocol("collaboration anchor required"))?
407        {
408            collaboration_anchor::Target::Thread(value) => {
409                thread(value, scope)?;
410                Anchor::Repository
411            }
412            collaboration_anchor::Target::Spool(value) => {
413                if scope.thread.is_some() || spool(Some(value))? != scope.spool {
414                    return Err(Error::Protocol(
415                        "spool anchor requires independent spool scope",
416                    ));
417                }
418                Anchor::Repository
419            }
420            collaboration_anchor::Target::Source(value) => {
421                reject_path_kind(value)?;
422                thread(
423                    value
424                        .thread
425                        .as_ref()
426                        .ok_or(Error::Protocol("source anchor requires owning Thread"))?,
427                    scope,
428                )?;
429                let revision = value
430                    .revision
431                    .as_ref()
432                    .ok_or(Error::Protocol("source anchor requires exact revision"))?;
433                if spool(revision.spool.as_ref())? != scope.spool {
434                    return Err(Error::Protocol("source revision belongs to another spool"));
435                }
436                let revision = match revision
437                    .revision
438                    .as_ref()
439                    .ok_or(Error::Protocol("exact revision required"))?
440                {
441                    revision_ref::Revision::State(id) => CollaborationRevision::State {
442                        state_id: StateId::from_bytes(key(&id.value)?),
443                    },
444                    revision_ref::Revision::GitCommitOid(oid) => {
445                        CollaborationRevision::GitCommit { oid: oid.clone() }
446                    }
447                };
448                Anchor::Source {
449                    source: CollaborationSourceAnchor {
450                        revision,
451                        path: value.path.clone(),
452                        symbol_id: value.symbol_id.clone(),
453                        start_line: value.start_line,
454                        end_line: value.end_line,
455                        target: value.target.as_ref().map(source_target).transpose()?,
456                    },
457                }
458            }
459        },
460    )
461}
462
463/// The native source map cannot record `path_kind` yet (heddle#1959). A set
464/// kind or provenance is refused rather than dropped, so it is never silently
465/// left out of a signature.
466pub(crate) fn reject_path_kind(value: &SourceAnchor) -> Result<(), Error> {
467    if value.path_kind != 0 || value.path_kind_source != 0 {
468        return Err(Error::Protocol(
469            "source anchor path_kind is not supported yet (heddle#1959)",
470        ));
471    }
472    Ok(())
473}
474
475/// Native audiences map exactly to canonical tiers. An omitted audience must
476/// never silently turn a private record into public data.
477pub fn visibility(
478    audience: i32,
479    label: &str,
480) -> Result<heddle_object_model::object::VisibilityTier, Error> {
481    use heddle_object_model::object::VisibilityTier as Tier;
482    match Audience::try_from(audience) {
483        Ok(Audience::Public) if label.is_empty() => Ok(Tier::Public),
484        Ok(Audience::Members) if label.is_empty() => Ok(Tier::Internal),
485        Ok(Audience::Private)
486            if !label.trim().is_empty()
487                && label.len() <= 512
488                && !label.chars().any(char::is_control) =>
489        {
490            Ok(Tier::Private {
491                scope_label: label.into(),
492            })
493        }
494        _ => Err(Error::Protocol(
495            "explicit audience and matching private label required",
496        )),
497    }
498}
499/// None means this canonical tier has no lossless native audience projection;
500/// callers retain its original signed record and original visibility guard.
501pub fn audience(tier: &heddle_object_model::object::VisibilityTier) -> Option<(Audience, String)> {
502    use heddle_object_model::object::VisibilityTier as Tier;
503    match tier {
504        Tier::Public => Some((Audience::Public, String::new())),
505        Tier::Internal => Some((Audience::Members, String::new())),
506        Tier::Private { scope_label } => Some((Audience::Private, scope_label.clone())),
507        Tier::TeamScoped { .. } | Tier::Restricted { .. } => None,
508    }
509}
510
511/// Project source location and ownership without discarding path, symbol or
512/// line information. Change anchors require an exact source revision first.
513pub fn anchor_ref(
514    value: &heddle_object_model::object::CollaborationAnchor,
515    scope: &heddle_object_model::object::CollaborationScope,
516) -> Result<CollaborationAnchor, Error> {
517    use heddle_object_model::object::{
518        CollaborationAnchor as Anchor, CollaborationRevision as Revision,
519    };
520    let thread = scope.thread.map(|id| ThreadRef {
521        spool: wire_spool(scope.spool),
522        id: Some(ThreadId {
523            value: id.as_bytes().to_vec(),
524        }),
525    });
526    let source = match value {
527        Anchor::Repository => {
528            return Ok(CollaborationAnchor {
529                target: Some(match thread {
530                    Some(thread) => collaboration_anchor::Target::Thread(thread),
531                    None => collaboration_anchor::Target::Spool(SpoolRef {
532                        id: scope.spool.to_string(),
533                    }),
534                }),
535            });
536        }
537        Anchor::Source { source } => SourceAnchor {
538            revision: Some(RevisionRef {
539                spool: wire_spool(scope.spool),
540                revision: Some(match &source.revision {
541                    Revision::State { state_id } => {
542                        revision_ref::Revision::State(api::heddle::api::common::StateId {
543                            value: state_id.as_bytes().to_vec(),
544                        })
545                    }
546                    Revision::GitCommit { oid } => {
547                        revision_ref::Revision::GitCommitOid(oid.clone())
548                    }
549                }),
550            }),
551            path: source.path.clone(),
552            symbol_id: source.symbol_id.clone(),
553            start_line: source.start_line,
554            end_line: source.end_line,
555            target: source.target.as_ref().map(source_target_ref),
556            thread,
557            // Native source maps record no path kind; unknown, never derived here.
558            path_kind: api::heddle::api::v1alpha2::SourcePathKind::Unspecified as i32,
559            path_kind_source: api::heddle::api::v1alpha2::SourcePathKindSource::Unspecified as i32,
560        },
561        Anchor::State { state_id }
562        | Anchor::Path { state_id, .. }
563        | Anchor::Symbol { state_id, .. } => SourceAnchor {
564            revision: Some(RevisionRef {
565                spool: wire_spool(scope.spool),
566                revision: Some(revision_ref::Revision::State(
567                    api::heddle::api::common::StateId {
568                        value: state_id.as_bytes().to_vec(),
569                    },
570                )),
571            }),
572            path: match value {
573                Anchor::Path { path, .. } | Anchor::Symbol { path, .. } => path.clone(),
574                _ => String::new(),
575            },
576            symbol_id: match value {
577                Anchor::Symbol { symbol, .. } => symbol.clone(),
578                _ => String::new(),
579            },
580            start_line: None,
581            end_line: None,
582            target: None,
583            thread,
584            path_kind: api::heddle::api::v1alpha2::SourcePathKind::Unspecified as i32,
585            path_kind_source: api::heddle::api::v1alpha2::SourcePathKindSource::Unspecified as i32,
586        },
587        Anchor::Change { .. } => {
588            return Err(Error::Protocol(
589                "change anchor requires exact source revision",
590            ));
591        }
592    };
593    Ok(CollaborationAnchor {
594        target: Some(collaboration_anchor::Target::Source(source)),
595    })
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601    #[test]
602    fn explicit_target_bindings_round_trip_and_reject_incomplete_identity() {
603        use heddle_object_model::object::{
604            CollaborationRevision, CollaborationScope,
605            source_target::{SourceTargetBinding, SourceTargetReference as Target},
606        };
607        let scope = CollaborationScope {
608            spool: Uuid::from_u128(1),
609            thread: Some(ContentHash::from_bytes([2; 32])),
610        };
611        for binding in [
612            SourceTargetBinding::ViewedThread,
613            SourceTargetBinding::NamedThread {
614                scope: scope.clone(),
615            },
616            SourceTargetBinding::PinnedRevision {
617                scope: scope.clone(),
618                revision: CollaborationRevision::GitCommit {
619                    oid: "a".repeat(40),
620                },
621            },
622            SourceTargetBinding::PinnedRevision {
623                scope: CollaborationScope {
624                    thread: None,
625                    ..scope.clone()
626                },
627                revision: CollaborationRevision::State {
628                    state_id: StateId::from_bytes([5; 32]),
629                },
630            },
631        ] {
632            let target = Target {
633                target: ContentHash::from_bytes([6; 32]),
634                binding,
635            };
636            assert_eq!(
637                source_target(&source_target_ref(&target)).expect("lossless binding"),
638                target
639            );
640            let source = heddle_object_model::object::CollaborationSourceAnchor {
641                revision: CollaborationRevision::State {
642                    state_id: StateId::from_bytes([5; 32]),
643                },
644                path: "src/main.rs".into(),
645                symbol_id: "run".into(),
646                start_line: Some(12),
647                end_line: Some(18),
648                target: Some(target),
649            };
650            let original = heddle_object_model::object::CollaborationAnchor::Source {
651                source: source.clone(),
652            };
653            assert_eq!(
654                anchor(&anchor_ref(&original, &scope).expect("wire anchor"), &scope)
655                    .expect("canonical anchor"),
656                original
657            );
658            let reference = heddle_object_model::object::AnnotationSourceReference {
659                scope: scope.clone(),
660                source,
661            };
662            assert_eq!(
663                super::super::annotation_source(&super::super::annotation_source_ref(&reference))
664                    .expect("canonical tag reference"),
665                reference
666            );
667        }
668        for binding in [
669            None,
670            Some(source_target_reference::Binding::ViewedThread(false)),
671            Some(source_target_reference::Binding::NamedThread(ThreadRef {
672                spool: wire_spool(Uuid::from_u128(1)),
673                id: None,
674            })),
675            Some(source_target_reference::Binding::PinnedRevision(
676                PinnedSourceTargetRevision {
677                    revision: None,
678                    thread: None,
679                },
680            )),
681        ] {
682            assert!(
683                source_target(&SourceTargetReference {
684                    target_id: vec![6; 32],
685                    binding
686                })
687                .is_err()
688            );
689        }
690        assert!(
691            source_target(&SourceTargetReference {
692                target_id: vec![6; 31],
693                binding: Some(source_target_reference::Binding::ViewedThread(true))
694            })
695            .is_err()
696        );
697    }
698    #[test]
699    fn every_native_mention_retains_kind_scope_and_identity() {
700        let spool = Uuid::from_u128(1);
701        let mut mentions = vec![
702            Mention::Spool { spool },
703            Mention::Thread {
704                spool,
705                thread: ContentHash::from_bytes([2; 32]),
706            },
707            Mention::State {
708                spool,
709                state: StateId::from_bytes([3; 32]),
710            },
711            Mention::GitCommit {
712                spool,
713                oid: "a".repeat(40),
714            },
715            Mention::Checkout {
716                spool,
717                device: [4; 32],
718                id: "checkout".into(),
719            },
720            Mention::Device { key: [5; 32] },
721        ];
722        for record_kind in [
723            Kind::Discussion,
724            Kind::Context,
725            Kind::Operation,
726            Kind::Run,
727            Kind::Policy,
728            Kind::Analysis,
729            Kind::Invitation,
730            Kind::Grant,
731            Kind::DiscussionTurn,
732            Kind::Review,
733            Kind::Notification,
734            Kind::AttentionItem,
735            Kind::Member,
736            Kind::ApprovalGroup,
737            Kind::Session,
738            Kind::SignupInvitation,
739            Kind::TimelineEvent,
740            Kind::Artifact,
741            Kind::Mount,
742            Kind::SupportAccess,
743            Kind::DeviceRecord,
744            Kind::Delegation,
745            Kind::Recovery,
746            Kind::OwnerTransition,
747            Kind::Billing,
748            Kind::Evidence,
749            Kind::CheckAcknowledgement,
750            Kind::ProviderConnection,
751            Kind::RemoteLink,
752        ] {
753            for spool in [None, Some(spool)] {
754                mentions.push(Mention::Record {
755                    spool,
756                    record_kind,
757                    id: "record".into(),
758                });
759            }
760        }
761        assert_eq!(mentions.len(), 64);
762        for original in mentions {
763            assert_eq!(
764                mention(&mention_ref(&original)).expect("typed mention"),
765                original
766            );
767        }
768        assert!(
769            mention(&EntityRef {
770                entity: Some(entity_ref::Entity::Device(EndpointRef {
771                    kind: EndpointKind::Weft as i32,
772                    public_key: vec![5; 32]
773                }))
774            })
775            .is_err()
776        );
777    }
778    #[test]
779    fn source_anchor_binds_owning_thread_and_preserves_lines() {
780        use heddle_object_model::object::{CollaborationAnchor as Anchor, CollaborationScope};
781        let scope = CollaborationScope {
782            spool: Uuid::from_u128(1),
783            thread: Some(ContentHash::from_bytes([2; 32])),
784        };
785        let thread = ThreadRef {
786            spool: wire_spool(scope.spool),
787            id: Some(ThreadId { value: vec![2; 32] }),
788        };
789        let mut source = SourceAnchor {
790            revision: Some(RevisionRef {
791                spool: wire_spool(scope.spool),
792                revision: Some(revision_ref::Revision::GitCommitOid("a".repeat(40))),
793            }),
794            path: "src/main.rs".into(),
795            symbol_id: "run".into(),
796            start_line: Some(12),
797            end_line: Some(18),
798            target: None,
799            thread: Some(thread),
800            path_kind: 0,
801            path_kind_source: 0,
802        };
803        let value = CollaborationAnchor {
804            target: Some(collaboration_anchor::Target::Source(source.clone())),
805        };
806        let Anchor::Source { source: actual } =
807            anchor(&value, &scope).expect("bound source anchor")
808        else {
809            panic!("source");
810        };
811        assert_eq!(
812            (
813                actual.path.as_str(),
814                actual.symbol_id.as_str(),
815                actual.start_line,
816                actual.end_line
817            ),
818            ("src/main.rs", "run", Some(12), Some(18))
819        );
820        assert_eq!(
821            anchor_ref(&Anchor::Source { source: actual }, &scope).expect("source projection"),
822            value,
823            "source fields survive both directions"
824        );
825        source
826            .thread
827            .as_mut()
828            .expect("Thread")
829            .id
830            .as_mut()
831            .expect("ID")
832            .value[0] ^= 1;
833        assert!(
834            anchor(
835                &CollaborationAnchor {
836                    target: Some(collaboration_anchor::Target::Source(source))
837                },
838                &scope
839            )
840            .is_err(),
841            "source must not escape the Thread"
842        );
843    }
844    #[test]
845    fn set_source_path_kind_is_rejected_not_dropped() {
846        use heddle_object_model::object::CollaborationScope;
847        let scope = CollaborationScope {
848            spool: Uuid::from_u128(1),
849            thread: Some(ContentHash::from_bytes([2; 32])),
850        };
851        let source = SourceAnchor {
852            revision: Some(RevisionRef {
853                spool: wire_spool(scope.spool),
854                revision: Some(revision_ref::Revision::GitCommitOid("a".repeat(40))),
855            }),
856            path: "src/main.rs".into(),
857            thread: Some(ThreadRef {
858                spool: wire_spool(scope.spool),
859                id: Some(ThreadId { value: vec![2; 32] }),
860            }),
861            ..Default::default()
862        };
863        let convert = |source: &SourceAnchor| {
864            (
865                anchor(
866                    &CollaborationAnchor {
867                        target: Some(collaboration_anchor::Target::Source(source.clone())),
868                    },
869                    &scope,
870                ),
871                super::super::tags::annotation_source(&AnnotationSourceReference {
872                    source: Some(source.clone()),
873                }),
874            )
875        };
876        let (native, tag) = convert(&source);
877        native.expect("an unset kind converts");
878        tag.expect("an unset kind converts in a source tag");
879        for (kind, provenance) in [
880            (SourcePathKind::File, SourcePathKindSource::Recorded),
881            (SourcePathKind::Directory, SourcePathKindSource::Derived),
882            (SourcePathKind::File, SourcePathKindSource::Unspecified),
883            (SourcePathKind::Unspecified, SourcePathKindSource::Recorded),
884        ] {
885            let set = SourceAnchor {
886                path_kind: kind as i32,
887                path_kind_source: provenance as i32,
888                ..source.clone()
889            };
890            let (native, tag) = convert(&set);
891            for result in [native.map(|_| ()), tag.map(|_| ())] {
892                assert!(
893                    matches!(result, Err(Error::Protocol(message)) if message.contains("heddle#1959")),
894                    "{kind:?}/{provenance:?} must be refused, not dropped"
895                );
896            }
897        }
898    }
899    #[test]
900    fn audience_mapping_is_explicit_and_lossless() {
901        use heddle_object_model::object::VisibilityTier as Tier;
902        for (kind, label) in [
903            (Audience::Public, ""),
904            (Audience::Members, ""),
905            (Audience::Private, "review-team"),
906        ] {
907            let tier = visibility(kind as i32, label).expect("valid audience");
908            assert_eq!(audience(&tier), Some((kind, label.into())));
909        }
910        for (kind, label) in [
911            (Audience::Unspecified, ""),
912            (Audience::Private, ""),
913            (Audience::Public, "review-team"),
914            (Audience::Members, "review-team"),
915        ] {
916            assert!(
917                visibility(kind as i32, label).is_err(),
918                "must not change audience or fabricate label"
919            );
920        }
921        assert!(
922            audience(&Tier::TeamScoped {
923                team_id: "team".into()
924            })
925            .is_none()
926        );
927        assert!(
928            audience(&Tier::Restricted {
929                scope_label: "restricted".into()
930            })
931            .is_none()
932        );
933    }
934}