Skip to main content

entity_ref/
lib.rs

1//! `entity-ref` — the one shared **contract** for cross-service entity
2//! linking in the Main X Index family
3//! (`agents/share/cross-service-linking.md` §3, §9).
4//!
5//! A record that lives in another service is named by an opaque **URN
6//! string** `"<entity_type>:<uuid>"` (e.g. `person:0c4f1e2a-…`). This
7//! crate owns:
8//!
9//! - [`EntityType`] — the globally-unique entity discriminator and its
10//!   static `entity_type → owning service` map (a multi-entity service
11//!   like `course` hosts both `course` and `courseinstance`, which is why
12//!   the **type**, not the service, is the discriminator);
13//! - [`EntityRef`] — the `{entity_type, id}` value type that parses,
14//!   `Display`s, and (de)serialises as that single URN string, so the
15//!   aggregator can index it as one `TEXT` column;
16//! - [`EdgeKind`] — the closed **v1 edge-kind registry** (§9): each kind
17//!   fixes its endpoint types, direction, temporality, inverse, and
18//!   sensitivity, and can validate an endpoint pair.
19//!
20//! It is pure data with no behaviour beyond parsing/validation — no I/O,
21//! no clock, no panics — and is deliberately **dependency-light**. The
22//! original rollout plan (`agents/share/cross-service-linking.md` §2/§11)
23//! framed this as copyable per project until a second non-aggregator
24//! consumer justified a shared dependency; in practice it is embedded as
25//! a real Cargo `path` dependency by eight crates (as of 2026-08-04):
26//! the `link-graph-service-with-loco` aggregator, the three entity
27//! services that originate edges (person, worker, case), and four
28//! consumer apps (contact-relationship-management,
29//! content-management-system, patient-flow,
30//! workforce-planning-management) that validate/dereference cross-service
31//! refs without originating edges. See the crate's `README.md` for the
32//! full picture. This crate's own contract shipped as rollout **step 1**
33//! ("land the contracts; no behaviour yet").
34
35#![forbid(unsafe_code)]
36#![deny(missing_docs)]
37#![warn(clippy::pedantic)]
38
39use core::fmt;
40use core::str::FromStr;
41
42use serde::{Deserialize, Serialize};
43use uuid::Uuid;
44
45/// The entity type of a linked record — globally unique across the
46/// family. The wire token is the lowercase snake-case form
47/// ([`EntityType::as_str`]); [`EntityType::service`] maps it to the
48/// owning service.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
50pub enum EntityType {
51    /// General person registry.
52    Person,
53    /// Workforce / professional registry.
54    Worker,
55    /// schema.org/Organization registry.
56    Organization,
57    /// Governmental case registry.
58    Case,
59    /// schema.org/Place registry.
60    Place,
61    /// schema.org/Thing registry.
62    Thing,
63    /// schema.org/Event registry.
64    Event,
65    /// schema.org/Course template registry.
66    Course,
67    /// A specific offering of a course (`course-service` sub-resource).
68    CourseInstance,
69    /// Clinical care-pathway registry.
70    CarePathway,
71}
72
73impl EntityType {
74    /// Every entity type, in a stable order (for iteration / tests).
75    pub const ALL: [EntityType; 10] = [
76        EntityType::Person,
77        EntityType::Worker,
78        EntityType::Organization,
79        EntityType::Case,
80        EntityType::Place,
81        EntityType::Thing,
82        EntityType::Event,
83        EntityType::Course,
84        EntityType::CourseInstance,
85        EntityType::CarePathway,
86    ];
87
88    /// The lowercase wire token used in the URN (e.g. `"care_pathway"`,
89    /// `"courseinstance"`).
90    #[must_use]
91    pub const fn as_str(self) -> &'static str {
92        match self {
93            EntityType::Person => "person",
94            EntityType::Worker => "worker",
95            EntityType::Organization => "organization",
96            EntityType::Case => "case",
97            EntityType::Place => "place",
98            EntityType::Thing => "thing",
99            EntityType::Event => "event",
100            EntityType::Course => "course",
101            EntityType::CourseInstance => "courseinstance",
102            EntityType::CarePathway => "care_pathway",
103        }
104    }
105
106    /// The service that owns records of this type. Multiple types can map
107    /// to one service (course + courseinstance → `course-service`), which
108    /// is why the ref encodes the type, not the service.
109    #[must_use]
110    pub const fn service(self) -> &'static str {
111        match self {
112            EntityType::Person => "person-service",
113            EntityType::Worker => "worker-service",
114            EntityType::Organization => "organization-service",
115            EntityType::Case => "case-service",
116            EntityType::Place => "place-service",
117            EntityType::Thing => "thing-service",
118            EntityType::Event => "event-service",
119            EntityType::Course | EntityType::CourseInstance => "course-service",
120            EntityType::CarePathway => "care-pathway-service",
121        }
122    }
123
124    /// Parse a wire token into an [`EntityType`], or `None` if unknown.
125    #[must_use]
126    pub fn from_token(token: &str) -> Option<Self> {
127        Self::ALL.into_iter().find(|t| t.as_str() == token)
128    }
129}
130
131impl fmt::Display for EntityType {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// Error parsing an [`EntityRef`] from its URN string form.
138#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139pub enum ParseEntityRefError {
140    /// Not exactly one `:` separating a non-empty type and id.
141    #[error("malformed entity ref (expected `entity_type:uuid`): {0:?}")]
142    Malformed(String),
143    /// The type token is not a known [`EntityType`].
144    #[error("unknown entity type: {0:?}")]
145    UnknownType(String),
146    /// The id is not a valid UUID.
147    #[error("invalid uuid in entity ref: {0:?}")]
148    BadId(String),
149}
150
151/// A reference to a record that lives in another service, identified by
152/// its entity type and public UUID (`pid`). Serialises as the single URN
153/// string `"<entity_type>:<uuid>"`.
154///
155/// ```
156/// use entity_ref::{EntityRef, EntityType};
157/// let r: EntityRef = "person:0c4f1e2a-0000-4000-8000-000000000000".parse().unwrap();
158/// assert_eq!(r.entity_type, EntityType::Person);
159/// assert_eq!(r.to_string(), "person:0c4f1e2a-0000-4000-8000-000000000000");
160/// ```
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(into = "String", try_from = "String")]
163pub struct EntityRef {
164    /// The kind of record referenced.
165    pub entity_type: EntityType,
166    /// The record's public UUID (`pid`).
167    pub id: Uuid,
168}
169
170impl EntityRef {
171    /// Build a ref from its parts.
172    #[must_use]
173    pub const fn new(entity_type: EntityType, id: Uuid) -> Self {
174        Self { entity_type, id }
175    }
176
177    /// The service that owns this reference's record.
178    #[must_use]
179    pub const fn service(&self) -> &'static str {
180        self.entity_type.service()
181    }
182}
183
184impl fmt::Display for EntityRef {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        write!(f, "{}:{}", self.entity_type.as_str(), self.id)
187    }
188}
189
190impl FromStr for EntityRef {
191    type Err = ParseEntityRefError;
192
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        // Exactly one ':' — split once and reject an empty type or a
195        // remainder that itself contains ':' (UUIDs have none).
196        let (type_token, id_token) = s
197            .split_once(':')
198            .filter(|(t, id)| !t.is_empty() && !id.is_empty() && !id.contains(':'))
199            .ok_or_else(|| ParseEntityRefError::Malformed(s.to_string()))?;
200        let entity_type = EntityType::from_token(type_token)
201            .ok_or_else(|| ParseEntityRefError::UnknownType(type_token.to_string()))?;
202        let id = Uuid::parse_str(id_token)
203            .map_err(|_| ParseEntityRefError::BadId(id_token.to_string()))?;
204        Ok(Self { entity_type, id })
205    }
206}
207
208impl From<EntityRef> for String {
209    fn from(r: EntityRef) -> Self {
210        r.to_string()
211    }
212}
213
214impl TryFrom<String> for EntityRef {
215    type Error = ParseEntityRefError;
216
217    fn try_from(s: String) -> Result<Self, Self::Error> {
218        s.parse()
219    }
220}
221
222/// Sensitivity tier of an edge kind (§9, §10) — governs the authorisation
223/// / audit / masking posture the aggregator must apply.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
225pub enum Sensitivity {
226    /// Affiliation / identity assertion (operator-asserted).
227    Medium,
228    /// Asserts a person is the subject of a government case (§10) —
229    /// carries the case service's access-control / audit / masking rules.
230    High,
231}
232
233/// The closed **v1 cross-service edge-kind registry**
234/// (`cross-service-linking.md` §9). Each kind fixes its endpoint types,
235/// direction, temporality, inverse label, and sensitivity.
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
237pub enum EdgeKind {
238    /// `person ↔ worker` — the same human across the two registries
239    /// (symmetric; the federation backbone powering `single-view`).
240    SameIdentity,
241    /// `person → organization` — a person works at an organization.
242    WorksAt,
243    /// `person → organization` — a person is a member of an organization.
244    MemberOf,
245    /// `worker → organization` — a worker is employed by an organization
246    /// (carries a `role`).
247    EmployedBy,
248    /// `case → person` — a case is about / has as its subject a person
249    /// (**high** sensitivity — §10).
250    SubjectOf,
251}
252
253impl EdgeKind {
254    /// Every edge kind, in a stable order.
255    pub const ALL: [EdgeKind; 5] = [
256        EdgeKind::SameIdentity,
257        EdgeKind::WorksAt,
258        EdgeKind::MemberOf,
259        EdgeKind::EmployedBy,
260        EdgeKind::SubjectOf,
261    ];
262
263    /// The wire token for this kind.
264    #[must_use]
265    pub const fn as_str(self) -> &'static str {
266        match self {
267            EdgeKind::SameIdentity => "same_identity",
268            EdgeKind::WorksAt => "works_at",
269            EdgeKind::MemberOf => "member_of",
270            EdgeKind::EmployedBy => "employed_by",
271            EdgeKind::SubjectOf => "subject_of",
272        }
273    }
274
275    /// Parse a wire token into an [`EdgeKind`], or `None` if unknown.
276    #[must_use]
277    pub fn from_token(token: &str) -> Option<Self> {
278        Self::ALL.into_iter().find(|k| k.as_str() == token)
279    }
280
281    /// `true` for a symmetric kind (`same_identity`): direction is
282    /// irrelevant and the aggregator canonicalises the pair.
283    #[must_use]
284    pub const fn is_symmetric(self) -> bool {
285        matches!(self, EdgeKind::SameIdentity)
286    }
287
288    /// `true` if the edge is time-bounded (`valid_from`/`valid_to`
289    /// meaningful — affiliations). `subject_of` is "sometimes"; modelled
290    /// here as time-bounded-capable.
291    #[must_use]
292    pub const fn is_temporal(self) -> bool {
293        matches!(
294            self,
295            EdgeKind::WorksAt | EdgeKind::MemberOf | EdgeKind::EmployedBy | EdgeKind::SubjectOf
296        )
297    }
298
299    /// The inverse-direction label the aggregator stores for the far
300    /// endpoint, or `None` for a symmetric kind (its own inverse).
301    #[must_use]
302    pub const fn inverse(self) -> Option<&'static str> {
303        match self {
304            EdgeKind::SameIdentity => None,
305            EdgeKind::WorksAt | EdgeKind::MemberOf => Some("has_member"),
306            EdgeKind::EmployedBy => Some("employs"),
307            EdgeKind::SubjectOf => Some("is_subject_of"),
308        }
309    }
310
311    /// This kind's sensitivity tier.
312    #[must_use]
313    pub const fn sensitivity(self) -> Sensitivity {
314        match self {
315            EdgeKind::SubjectOf => Sensitivity::High,
316            _ => Sensitivity::Medium,
317        }
318    }
319
320    /// Validate that `from`/`to` are the endpoint types this kind permits
321    /// (§9). For the symmetric `same_identity`, either ordering of
322    /// `{person, worker}` is accepted.
323    #[must_use]
324    pub fn permits(self, from: EntityType, to: EntityType) -> bool {
325        use EntityType::{Case, Organization, Person, Worker};
326        match self {
327            EdgeKind::SameIdentity => {
328                matches!((from, to), (Person, Worker) | (Worker, Person))
329            }
330            EdgeKind::WorksAt | EdgeKind::MemberOf => (from, to) == (Person, Organization),
331            EdgeKind::EmployedBy => (from, to) == (Worker, Organization),
332            EdgeKind::SubjectOf => (from, to) == (Case, Person),
333        }
334    }
335}
336
337impl fmt::Display for EdgeKind {
338    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
339        f.write_str(self.as_str())
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    fn a_uuid() -> Uuid {
348        Uuid::parse_str("0c4f1e2a-0000-4000-8000-000000000000").unwrap()
349    }
350
351    #[test]
352    fn entity_ref_round_trips_through_its_urn() {
353        let r = EntityRef::new(EntityType::Person, a_uuid());
354        let urn = r.to_string();
355        assert_eq!(urn, "person:0c4f1e2a-0000-4000-8000-000000000000");
356        assert_eq!(urn.parse::<EntityRef>().unwrap(), r);
357    }
358
359    #[test]
360    fn parses_every_entity_type_token() {
361        for t in EntityType::ALL {
362            let urn = format!("{}:{}", t.as_str(), a_uuid());
363            assert_eq!(urn.parse::<EntityRef>().unwrap().entity_type, t);
364        }
365    }
366
367    #[test]
368    fn course_and_courseinstance_share_one_service() {
369        assert_eq!(EntityType::Course.service(), "course-service");
370        assert_eq!(EntityType::CourseInstance.service(), "course-service");
371    }
372
373    #[test]
374    fn rejects_unknown_type_bad_uuid_and_malformed() {
375        assert!(matches!(
376            "widget:0c4f1e2a-0000-4000-8000-000000000000".parse::<EntityRef>(),
377            Err(ParseEntityRefError::UnknownType(_))
378        ));
379        assert!(matches!(
380            "person:not-a-uuid".parse::<EntityRef>(),
381            Err(ParseEntityRefError::BadId(_))
382        ));
383        for bad in ["", "person", "person:", ":uuid", "a:b:c"] {
384            assert!(
385                matches!(
386                    bad.parse::<EntityRef>(),
387                    Err(ParseEntityRefError::Malformed(_) | ParseEntityRefError::BadId(_))
388                ),
389                "should reject {bad:?}"
390            );
391        }
392    }
393
394    #[test]
395    fn serde_uses_the_urn_string_form() {
396        let r = EntityRef::new(EntityType::Case, a_uuid());
397        let json = serde_json::to_string(&r).unwrap();
398        assert_eq!(json, "\"case:0c4f1e2a-0000-4000-8000-000000000000\"");
399        assert_eq!(serde_json::from_str::<EntityRef>(&json).unwrap(), r);
400    }
401
402    #[test]
403    fn edge_kind_registry_endpoint_rules() {
404        use EntityType::{Case, Organization, Person, Worker};
405        // same_identity is symmetric over person/worker.
406        assert!(EdgeKind::SameIdentity.permits(Person, Worker));
407        assert!(EdgeKind::SameIdentity.permits(Worker, Person));
408        assert!(!EdgeKind::SameIdentity.permits(Person, Person));
409        // directed affiliations.
410        assert!(EdgeKind::WorksAt.permits(Person, Organization));
411        assert!(!EdgeKind::WorksAt.permits(Organization, Person));
412        assert!(EdgeKind::EmployedBy.permits(Worker, Organization));
413        assert!(EdgeKind::SubjectOf.permits(Case, Person));
414        assert!(!EdgeKind::SubjectOf.permits(Person, Case));
415    }
416
417    #[test]
418    fn edge_kind_metadata_matches_the_registry() {
419        assert!(EdgeKind::SameIdentity.is_symmetric());
420        assert!(EdgeKind::SameIdentity.inverse().is_none());
421        assert!(!EdgeKind::SameIdentity.is_temporal());
422        assert_eq!(EdgeKind::EmployedBy.inverse(), Some("employs"));
423        assert!(EdgeKind::EmployedBy.is_temporal());
424        // subject_of is the sole high-sensitivity kind.
425        assert_eq!(EdgeKind::SubjectOf.sensitivity(), Sensitivity::High);
426        for k in EdgeKind::ALL {
427            if k != EdgeKind::SubjectOf {
428                assert_eq!(k.sensitivity(), Sensitivity::Medium);
429            }
430        }
431    }
432
433    #[test]
434    fn edge_kind_tokens_round_trip() {
435        for k in EdgeKind::ALL {
436            assert_eq!(EdgeKind::from_token(k.as_str()), Some(k));
437        }
438        assert_eq!(EdgeKind::from_token("nope"), None);
439    }
440}