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