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