Skip to main content

acdp_primitives/
primitives.rs

1use crate::error::AcdpError;
2use serde::{Deserialize, Serialize};
3
4// ── Opaque identifier newtypes ───────────────────────────────────────────────
5
6/// `acdp://<authority>/<uuid-v4>` — registry-assigned context identifier.
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8pub struct CtxId(pub String);
9
10impl CtxId {
11    /// Underlying string slice.
12    pub fn as_str(&self) -> &str {
13        &self.0
14    }
15
16    /// Extract the authority (DNS hostname) component.
17    pub fn authority(&self) -> &str {
18        self.0
19            .strip_prefix("acdp://")
20            .and_then(|s| s.split('/').next())
21            .unwrap_or("")
22    }
23
24    /// Validate against `acdp-common.schema.json#/$defs/ctx_id`.
25    ///
26    /// Form: `acdp://<lowercase-DNS-authority>/<v4-uuid>`. The UUID's
27    /// version digit (13th hex char) MUST be `4` and the variant digit
28    /// (17th hex char) MUST be one of `8`, `9`, `a`, `b`.
29    pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
30        let s: String = s.into();
31        let rest = s.strip_prefix("acdp://").ok_or_else(|| {
32            AcdpError::SchemaViolation(format!("ctx_id must start with 'acdp://', got: {s}"))
33        })?;
34        let (authority, uuid_str) = rest
35            .split_once('/')
36            .ok_or_else(|| AcdpError::SchemaViolation(format!("ctx_id missing '/<uuid>': {s}")))?;
37        if !is_valid_dns_authority(authority) {
38            return Err(AcdpError::SchemaViolation(format!(
39                "ctx_id authority '{authority}' is not a lowercase DNS hostname"
40            )));
41        }
42        if !is_valid_uuid_v4(uuid_str) {
43            return Err(AcdpError::SchemaViolation(format!(
44                "ctx_id uuid '{uuid_str}' is not a lowercase v4 UUID"
45            )));
46        }
47        Ok(Self(s))
48    }
49
50    /// Extract the UUID component, if `self.0` is well-formed.
51    pub fn uuid(&self) -> Option<uuid::Uuid> {
52        let rest = self.0.strip_prefix("acdp://")?;
53        let (_authority, uuid_str) = rest.split_once('/')?;
54        uuid::Uuid::parse_str(uuid_str).ok()
55    }
56}
57
58impl std::fmt::Display for CtxId {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.write_str(&self.0)
61    }
62}
63
64/// `lin:sha256:<64-lowercase-hex>` — registry-assigned lineage identifier.
65#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct LineageId(pub String);
67
68impl LineageId {
69    /// Underlying string slice.
70    pub fn as_str(&self) -> &str {
71        &self.0
72    }
73
74    /// Validate against `acdp-common.schema.json#/$defs/lineage_id`.
75    /// Form: `lin:sha256:<64-lowercase-hex>`.
76    pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
77        let s: String = s.into();
78        let hex = s.strip_prefix("lin:sha256:").ok_or_else(|| {
79            AcdpError::SchemaViolation(format!(
80                "lineage_id must start with 'lin:sha256:', got: {s}"
81            ))
82        })?;
83        if hex.len() != 64 || !is_lowercase_hex(hex) {
84            return Err(AcdpError::SchemaViolation(format!(
85                "lineage_id digest must be 64 lowercase hex chars, got: {hex}"
86            )));
87        }
88        Ok(Self(s))
89    }
90}
91
92impl std::fmt::Display for LineageId {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(&self.0)
95    }
96}
97
98/// `sha256:<64-lowercase-hex>` — content-addressable hash with algorithm prefix.
99#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub struct ContentHash(pub String);
101
102impl ContentHash {
103    /// Underlying string slice.
104    pub fn as_str(&self) -> &str {
105        &self.0
106    }
107
108    /// Validate against `acdp-common.schema.json#/$defs/content_hash`.
109    /// Form: `sha256:<64-lowercase-hex>`.
110    pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
111        let s: String = s.into();
112        let hex = s.strip_prefix("sha256:").ok_or_else(|| {
113            AcdpError::SchemaViolation(format!("content_hash must start with 'sha256:', got: {s}"))
114        })?;
115        if hex.len() != 64 || !is_lowercase_hex(hex) {
116            return Err(AcdpError::SchemaViolation(format!(
117                "content_hash digest must be 64 lowercase hex chars, got: {hex}"
118            )));
119        }
120        Ok(Self(s))
121    }
122}
123
124impl std::fmt::Display for ContentHash {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.write_str(&self.0)
127    }
128}
129
130/// A Decentralized Identifier — v0.1.0 mandates `did:web`.
131#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
132pub struct AgentDid(pub String);
133
134impl AgentDid {
135    /// Construct without validation (back-compat).
136    pub fn new(s: impl Into<String>) -> Self {
137        Self(s.into())
138    }
139
140    /// Underlying string slice.
141    pub fn as_str(&self) -> &str {
142        &self.0
143    }
144
145    /// Validate against `acdp-common.schema.json#/$defs/did`.
146    ///
147    /// Pattern: `^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$`. Length 7..=2048.
148    /// Note: full method-specific validation (e.g. did:web hostname syntax)
149    /// is delegated to the resolver per RFC-ACDP-0001 §5.11.
150    pub fn parse(s: impl Into<String>) -> Result<Self, AcdpError> {
151        let s: String = s.into();
152        if s.len() < 7 || s.len() > 2048 {
153            return Err(AcdpError::SchemaViolation(format!(
154                "DID length {} not in 7..=2048",
155                s.len()
156            )));
157        }
158        let rest = s
159            .strip_prefix("did:")
160            .ok_or_else(|| AcdpError::SchemaViolation(format!("DID missing 'did:' prefix: {s}")))?;
161        let (method, id) = rest.split_once(':').ok_or_else(|| {
162            AcdpError::SchemaViolation(format!("DID must have method:id form: {s}"))
163        })?;
164        if method.is_empty()
165            || !method
166                .chars()
167                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
168        {
169            return Err(AcdpError::SchemaViolation(format!(
170                "DID method '{method}' must match [a-z0-9]+"
171            )));
172        }
173        if id.is_empty()
174            || !id
175                .chars()
176                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '%' | '-'))
177        {
178            return Err(AcdpError::SchemaViolation(format!(
179                "DID method-specific id '{id}' contains invalid characters"
180            )));
181        }
182        Ok(Self(s))
183    }
184
185    /// Validate and require `did:web:` (RFC-ACDP-0001 §5.4 mandate for v0.1.0).
186    pub fn parse_web(s: impl Into<String>) -> Result<Self, AcdpError> {
187        let parsed = Self::parse(s)?;
188        if !parsed.0.starts_with("did:web:") {
189            return Err(AcdpError::SchemaViolation(format!(
190                "v0.1.0 producers MUST use did:web; got: {}",
191                parsed.0
192            )));
193        }
194        Ok(parsed)
195    }
196}
197
198impl std::fmt::Display for AgentDid {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.write_str(&self.0)
201    }
202}
203
204// ── Enumerations ─────────────────────────────────────────────────────────────
205
206/// Visibility level of a context.
207#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
208#[serde(rename_all = "snake_case")]
209pub enum Visibility {
210    Public,
211    Restricted,
212    Private,
213}
214
215/// Registered context types plus open-ended custom namespace.
216///
217/// Wire form is a single string. Standard values (`data_snapshot`,
218/// `analysis`, `prediction`, `alert`) deserialize to the named variants;
219/// any other value MUST be a namespaced custom type matching
220/// `^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$` (e.g. `finance:portfolio_snapshot`)
221/// per `acdp-common.schema.json#/$defs/context_type`. Inputs that match
222/// neither are rejected at deserialization time so the type cannot encode
223/// schema-invalid context types.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum ContextType {
226    /// `data_snapshot` — point-in-time data.
227    DataSnapshot,
228    /// `analysis`.
229    Analysis,
230    /// `prediction`.
231    Prediction,
232    /// `alert`.
233    Alert,
234    /// Namespaced custom type, e.g. `finance:portfolio_snapshot`.
235    /// MUST match `^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$`.
236    Custom(String),
237}
238
239impl Serialize for ContextType {
240    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
241        let s = match self {
242            ContextType::DataSnapshot => "data_snapshot",
243            ContextType::Analysis => "analysis",
244            ContextType::Prediction => "prediction",
245            ContextType::Alert => "alert",
246            ContextType::Custom(s) => s.as_str(),
247        };
248        serializer.serialize_str(s)
249    }
250}
251
252impl<'de> Deserialize<'de> for ContextType {
253    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
254        let s = String::deserialize(deserializer)?;
255        Ok(match s.as_str() {
256            "data_snapshot" => ContextType::DataSnapshot,
257            "analysis" => ContextType::Analysis,
258            "prediction" => ContextType::Prediction,
259            "alert" => ContextType::Alert,
260            other => {
261                // Custom types MUST be namespaced
262                if !is_namespaced_context_type(other) {
263                    return Err(serde::de::Error::custom(format!(
264                        "context_type '{other}' is not a known ACDP type and does not match the \
265                         namespaced custom pattern ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$"
266                    )));
267                }
268                ContextType::Custom(s)
269            }
270        })
271    }
272}
273
274fn is_namespaced_context_type(s: &str) -> bool {
275    let Some((ns, name)) = s.split_once(':') else {
276        return false;
277    };
278    if ns.is_empty()
279        || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
280        || !ns
281            .chars()
282            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
283    {
284        return false;
285    }
286    if name.is_empty()
287        || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
288        || !name
289            .chars()
290            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
291    {
292        return false;
293    }
294    true
295}
296
297/// Registry-derived lifecycle status.
298///
299/// The schema (`acdp-common.schema.json#/$defs/status`) defines an open
300/// `^[a-z][a-z0-9_]*$` pattern, length 1..=64. v0.1.0 emits `active`,
301/// `superseded`, `expired`; future versions add `retracted`
302/// (RFC-ACDP-0009 §2.1) and possibly others. Consumers MUST tolerate
303/// unknown values matching the pattern; values that DO NOT match the
304/// pattern (uppercase, whitespace, empty) are rejected on
305/// deserialization as malformed registry state.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub enum Status {
308    /// First-class, current version of its lineage.
309    Active,
310    /// Replaced by a later version in the same lineage.
311    Superseded,
312    /// Past `expires_at`.
313    Expired,
314    /// A status string this version of the library does not recognize.
315    /// Per the spec, treat as `active` for read-side decisions until upgrade.
316    Other(String),
317}
318
319impl Status {
320    /// Validate against the schema pattern `^[a-z][a-z0-9_]*$`, length 1..=64.
321    fn pattern_ok(s: &str) -> bool {
322        !s.is_empty()
323            && s.len() <= 64
324            && s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
325            && s.chars()
326                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
327    }
328
329    /// Wire-form string representation, matching the schema enum.
330    pub fn as_str(&self) -> &str {
331        match self {
332            Status::Active => "active",
333            Status::Superseded => "superseded",
334            Status::Expired => "expired",
335            Status::Other(s) => s,
336        }
337    }
338
339    /// Parse a status string from any source, validating the pattern.
340    pub fn parse(s: &str) -> Result<Self, AcdpError> {
341        match s {
342            "active" => Ok(Status::Active),
343            "superseded" => Ok(Status::Superseded),
344            "expired" => Ok(Status::Expired),
345            other => {
346                if !Self::pattern_ok(other) {
347                    return Err(AcdpError::SchemaViolation(format!(
348                        "status '{other}' does not match the open-enum pattern \
349                         ^[a-z][a-z0-9_]*$ (length 1..=64)"
350                    )));
351                }
352                Ok(Status::Other(other.to_string()))
353            }
354        }
355    }
356}
357
358impl Serialize for Status {
359    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
360        s.serialize_str(self.as_str())
361    }
362}
363
364impl<'de> Deserialize<'de> for Status {
365    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
366        let s = String::deserialize(d)?;
367        Status::parse(&s).map_err(serde::de::Error::custom)
368    }
369}
370
371impl Status {
372    /// Returns `true` if status is `Active`.
373    pub fn is_active(&self) -> bool {
374        matches!(self, Status::Active)
375    }
376
377    /// Returns `true` if status is `Superseded`.
378    pub fn is_superseded(&self) -> bool {
379        matches!(self, Status::Superseded)
380    }
381
382    /// Returns `true` if status is `Expired`.
383    pub fn is_expired(&self) -> bool {
384        matches!(self, Status::Expired)
385    }
386
387    /// Returns the unrecognized status string, if any.
388    pub fn as_other(&self) -> Option<&str> {
389        match self {
390            Status::Other(s) => Some(s),
391            _ => None,
392        }
393    }
394
395    /// Forward-compatible degradation: maps unknown statuses to
396    /// [`Status::Active`] for functional decisions, per RFC-ACDP-0004 §4.1
397    /// ("v0.1.0 consumers MUST tolerate unknown status values and SHOULD
398    /// treat them as 'active' until they upgrade"). Callers MUST log the
399    /// original `Other(_)` value so the unknown is observable.
400    pub fn known_or_active(&self) -> Status {
401        match self {
402            Status::Other(_) => Status::Active,
403            s => s.clone(),
404        }
405    }
406}
407
408// ── Validation helpers (private) ─────────────────────────────────────────────
409
410fn is_lowercase_hex(s: &str) -> bool {
411    s.chars()
412        .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
413}
414
415/// Validate a bare DNS authority: lowercase ASCII labels separated by
416/// dots, each `1..=63` chars of `[a-z0-9-]` with no leading/trailing
417/// hyphen, total `<= 253`. Rejects uppercase, underscores, and ports.
418///
419/// Public so `acdp-validation` can reuse it for
420/// `origin_registry` (BUG-02) — the schema's `hostname` type and
421/// `CtxId`'s authority share exactly this grammar.
422pub fn is_valid_dns_authority(s: &str) -> bool {
423    if s.is_empty() || s.len() > 253 {
424        return false;
425    }
426    s.split('.').all(|label| {
427        !label.is_empty()
428            && label.len() <= 63
429            && label
430                .chars()
431                .next()
432                .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
433            && label
434                .chars()
435                .last()
436                .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
437            && label
438                .chars()
439                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
440    })
441}
442
443/// Validate a UUID-v4 string per the ACDP ctx_id schema:
444/// 8-4-4-4-12 lowercase hex with version digit `4` and variant digit in 8/9/a/b.
445fn is_valid_uuid_v4(s: &str) -> bool {
446    let bytes = s.as_bytes();
447    if bytes.len() != 36 {
448        return false;
449    }
450    for (i, &b) in bytes.iter().enumerate() {
451        match i {
452            8 | 13 | 18 | 23 => {
453                if b != b'-' {
454                    return false;
455                }
456            }
457            _ => {
458                if !(b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
459                    return false;
460                }
461            }
462        }
463    }
464    bytes[14] == b'4' && matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470    use serde_json::json;
471
472    #[test]
473    fn known_status_values_deserialize() {
474        let s: Status = serde_json::from_value(json!("active")).unwrap();
475        assert!(s.is_active());
476        let s: Status = serde_json::from_value(json!("superseded")).unwrap();
477        assert!(s.is_superseded());
478        let s: Status = serde_json::from_value(json!("expired")).unwrap();
479        assert!(s.is_expired());
480    }
481
482    #[test]
483    fn unknown_status_value_falls_back_to_other() {
484        // RFC-ACDP-0009 §2.1 reserves `retracted` for v0.1+; v0.1.0 consumers
485        // MUST tolerate it without panicking.
486        let s: Status = serde_json::from_value(json!("retracted")).unwrap();
487        assert_eq!(s.as_other(), Some("retracted"));
488        assert!(!s.is_active());
489        assert!(!s.is_superseded());
490        assert!(!s.is_expired());
491
492        let s: Status = serde_json::from_value(json!("archived")).unwrap();
493        assert_eq!(s.as_other(), Some("archived"));
494    }
495
496    #[test]
497    fn ctx_id_authority() {
498        let id = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
499        assert_eq!(id.authority(), "registry.example.com");
500    }
501
502    #[test]
503    fn ctx_id_parse_valid() {
504        let id = CtxId::parse(
505            "acdp://registry.example.com/12345678-1234-4321-8123-123456781234".to_string(),
506        )
507        .unwrap();
508        assert_eq!(id.authority(), "registry.example.com");
509        assert!(id.uuid().is_some());
510    }
511
512    #[test]
513    fn ctx_id_parse_rejects_uppercase_authority() {
514        assert!(
515            CtxId::parse("acdp://Registry.Example.com/12345678-1234-4321-8123-123456781234")
516                .is_err()
517        );
518    }
519
520    #[test]
521    fn ctx_id_parse_rejects_non_v4_uuid() {
522        // Version digit (13th hex char) is `1`, not `4`
523        assert!(
524            CtxId::parse("acdp://registry.example.com/12345678-1234-1321-8123-123456781234")
525                .is_err()
526        );
527    }
528
529    #[test]
530    fn ctx_id_parse_rejects_bad_variant() {
531        // Variant digit (17th hex char) is `0`, not 8/9/a/b
532        assert!(
533            CtxId::parse("acdp://registry.example.com/12345678-1234-4321-0123-123456781234")
534                .is_err()
535        );
536    }
537
538    #[test]
539    fn lineage_id_parse() {
540        let l = LineageId::parse(
541            "lin:sha256:b14ccd2a8b34530309255db68c151a10689b6a82feb30aff9222d54fdd871720"
542                .to_string(),
543        )
544        .unwrap();
545        assert!(l.as_str().starts_with("lin:sha256:"));
546        assert!(LineageId::parse("lin:sha256:abc").is_err());
547        assert!(LineageId::parse(
548            "lin:sha256:B14CCD2A8B34530309255DB68C151A10689B6A82FEB30AFF9222D54FDD871720"
549        )
550        .is_err());
551    }
552
553    #[test]
554    fn content_hash_parse() {
555        ContentHash::parse(
556            "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".to_string(),
557        )
558        .unwrap();
559        assert!(ContentHash::parse("md5:abc").is_err());
560        assert!(ContentHash::parse("sha256:zzzz").is_err());
561    }
562
563    #[test]
564    fn agent_did_parse_valid() {
565        AgentDid::parse("did:web:agents.example.com:test").unwrap();
566        AgentDid::parse("did:key:z6Mki...").unwrap();
567    }
568
569    #[test]
570    fn agent_did_parse_rejects_invalid_method() {
571        assert!(AgentDid::parse("did:WEB:agents.example.com").is_err());
572        assert!(AgentDid::parse("did::test").is_err());
573        assert!(AgentDid::parse("notadid").is_err());
574    }
575
576    #[test]
577    fn agent_did_parse_web_enforces_method() {
578        AgentDid::parse_web("did:web:agents.example.com:test").unwrap();
579        assert!(AgentDid::parse_web("did:key:z6Mki...").is_err());
580    }
581
582    #[test]
583    fn agent_did_new_skips_validation() {
584        // `new` is the unchecked back-compat constructor — it must NOT reject.
585        let did = AgentDid::new("not-a-did");
586        assert_eq!(did.as_str(), "not-a-did");
587    }
588
589    #[test]
590    fn agent_did_parse_rejects_length_bounds() {
591        assert!(AgentDid::parse("did:w:").is_err(), "too short / empty id");
592        let long = format!("did:web:{}", "a".repeat(2100));
593        assert!(AgentDid::parse(long).is_err(), "over 2048 chars");
594    }
595
596    // ── ContextType ────────────────────────────────────────────────────────
597
598    #[test]
599    fn context_type_known_values_round_trip() {
600        for (s, expect) in [
601            ("data_snapshot", ContextType::DataSnapshot),
602            ("analysis", ContextType::Analysis),
603            ("prediction", ContextType::Prediction),
604            ("alert", ContextType::Alert),
605        ] {
606            let parsed: ContextType = serde_json::from_value(json!(s)).unwrap();
607            assert_eq!(parsed, expect);
608            assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
609        }
610    }
611
612    #[test]
613    fn context_type_accepts_namespaced_custom() {
614        let parsed: ContextType =
615            serde_json::from_value(json!("finance:portfolio_snapshot")).unwrap();
616        assert_eq!(
617            parsed,
618            ContextType::Custom("finance:portfolio_snapshot".into())
619        );
620        // Custom round-trips back to its exact wire string.
621        assert_eq!(
622            serde_json::to_value(&parsed).unwrap(),
623            json!("finance:portfolio_snapshot")
624        );
625    }
626
627    #[test]
628    fn context_type_rejects_unnamespaced_unknown() {
629        // No colon and not a known keyword ⇒ schema-invalid.
630        for bad in [
631            "totally_unknown",
632            "Finance:x",
633            "finance:",
634            ":name",
635            "1ns:name",
636        ] {
637            let parsed: Result<ContextType, _> = serde_json::from_value(json!(bad));
638            assert!(parsed.is_err(), "context_type {bad:?} must be rejected");
639        }
640    }
641
642    #[test]
643    fn namespaced_context_type_helper_edges() {
644        assert!(is_namespaced_context_type("a:b"));
645        assert!(is_namespaced_context_type("ns1:name_2-x"));
646        assert!(!is_namespaced_context_type("nocolon"));
647        assert!(!is_namespaced_context_type("ns:Name")); // uppercase in name
648        assert!(!is_namespaced_context_type("ns:-bad")); // name starts with hyphen
649        assert!(!is_namespaced_context_type("ns:1bad")); // name starts with digit
650    }
651
652    // ── Status edge cases ──────────────────────────────────────────────────
653
654    #[test]
655    fn status_parse_rejects_pattern_violations() {
656        for bad in ["Active", "has space", "", "UPPER", "trailing!"] {
657            assert!(
658                Status::parse(bad).is_err(),
659                "status {bad:?} violates ^[a-z][a-z0-9_]*$ and must be rejected"
660            );
661        }
662    }
663
664    #[test]
665    fn status_deserialize_rejects_malformed() {
666        // Deserialization funnels through `Status::parse`.
667        let parsed: Result<Status, _> = serde_json::from_value(json!("Active"));
668        assert!(parsed.is_err());
669    }
670
671    #[test]
672    fn status_known_or_active_degrades_unknown() {
673        // Unknown ⇒ Active for read-side decisions (RFC-ACDP-0004 §4.1).
674        let other = Status::Other("retracted".into());
675        assert_eq!(other.known_or_active(), Status::Active);
676        // Known statuses are preserved unchanged.
677        assert_eq!(Status::Superseded.known_or_active(), Status::Superseded);
678        assert_eq!(Status::Expired.known_or_active(), Status::Expired);
679    }
680
681    #[test]
682    fn status_as_str_matches_wire_form() {
683        assert_eq!(Status::Active.as_str(), "active");
684        assert_eq!(Status::Superseded.as_str(), "superseded");
685        assert_eq!(Status::Expired.as_str(), "expired");
686        assert_eq!(Status::Other("custom".into()).as_str(), "custom");
687    }
688
689    // ── Visibility / Display ───────────────────────────────────────────────
690
691    #[test]
692    fn visibility_round_trips_snake_case() {
693        for (s, expect) in [
694            ("public", Visibility::Public),
695            ("restricted", Visibility::Restricted),
696            ("private", Visibility::Private),
697        ] {
698            let parsed: Visibility = serde_json::from_value(json!(s)).unwrap();
699            assert_eq!(parsed, expect);
700            assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
701        }
702        assert!(serde_json::from_value::<Visibility>(json!("Public")).is_err());
703    }
704
705    #[test]
706    fn identifier_display_matches_inner_string() {
707        let ctx = "acdp://r.example.com/12345678-1234-4321-8123-123456781234";
708        assert_eq!(CtxId(ctx.into()).to_string(), ctx);
709        let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
710        assert_eq!(LineageId(lin.into()).to_string(), lin);
711        let hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
712        assert_eq!(ContentHash(hash.into()).to_string(), hash);
713        assert_eq!(AgentDid::new("did:web:x").to_string(), "did:web:x");
714    }
715
716    #[test]
717    fn ctx_id_uuid_returns_none_for_malformed() {
718        // `uuid()` is best-effort: malformed input yields None, not a panic.
719        assert!(CtxId("not-a-ctx-id".into()).uuid().is_none());
720        assert!(CtxId("acdp://host/not-a-uuid".into()).uuid().is_none());
721    }
722}