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`, and — since acdp/0.3.0 —
219/// `key-revocation`, RFC-ACDP-0014 §4) deserialize to the named variants;
220/// any other value MUST be a namespaced custom type matching
221/// `^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$` (e.g. `finance:portfolio_snapshot`)
222/// per `acdp-common.schema.json#/$defs/context_type`. Inputs that match
223/// neither are rejected at deserialization time so the type cannot encode
224/// schema-invalid context types.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum ContextType {
227    /// `data_snapshot` — point-in-time data.
228    DataSnapshot,
229    /// `analysis`.
230    Analysis,
231    /// `prediction`.
232    Prediction,
233    /// `alert`.
234    Alert,
235    /// `key-revocation` — a producer's time-scoped declaration that one
236    /// of its signing keys is compromised (RFC-ACDP-0014 §4; standard in
237    /// acdp/0.3.0). The interim form published on pre-0.3.0 registries
238    /// is the namespaced custom type `acdp:key-revocation`
239    /// ([`ContextType::Custom`]); use [`ContextType::is_key_revocation`]
240    /// to recognize both, as 0.3.0 consumers MUST (RFC-ACDP-0014 §10).
241    KeyRevocation,
242    /// Namespaced custom type, e.g. `finance:portfolio_snapshot`.
243    /// MUST match `^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$`.
244    Custom(String),
245}
246
247impl ContextType {
248    /// The interim namespaced form of `key-revocation` accepted from
249    /// pre-0.3.0 registries (RFC-ACDP-0014 §10).
250    pub const KEY_REVOCATION_INTERIM: &'static str = "acdp:key-revocation";
251
252    /// True when this type denotes an RFC-ACDP-0014 key-revocation
253    /// context: the standard `key-revocation` form or the interim
254    /// `acdp:key-revocation` custom form, which 0.3.0 consumers MUST
255    /// treat as equivalent when the body satisfies §4–§5
256    /// (RFC-ACDP-0014 §10).
257    pub fn is_key_revocation(&self) -> bool {
258        match self {
259            ContextType::KeyRevocation => true,
260            ContextType::Custom(s) => s == Self::KEY_REVOCATION_INTERIM,
261            _ => false,
262        }
263    }
264}
265
266impl Serialize for ContextType {
267    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
268        let s = match self {
269            ContextType::DataSnapshot => "data_snapshot",
270            ContextType::Analysis => "analysis",
271            ContextType::Prediction => "prediction",
272            ContextType::Alert => "alert",
273            ContextType::KeyRevocation => "key-revocation",
274            ContextType::Custom(s) => s.as_str(),
275        };
276        serializer.serialize_str(s)
277    }
278}
279
280impl<'de> Deserialize<'de> for ContextType {
281    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
282        let s = String::deserialize(deserializer)?;
283        Ok(match s.as_str() {
284            "data_snapshot" => ContextType::DataSnapshot,
285            "analysis" => ContextType::Analysis,
286            "prediction" => ContextType::Prediction,
287            "alert" => ContextType::Alert,
288            "key-revocation" => ContextType::KeyRevocation,
289            other => {
290                // Custom types MUST be namespaced
291                if !is_namespaced_context_type(other) {
292                    return Err(serde::de::Error::custom(format!(
293                        "context_type '{other}' is not a known ACDP type and does not match the \
294                         namespaced custom pattern ^[a-z][a-z0-9_]*:[a-z][a-z0-9_-]*$"
295                    )));
296                }
297                ContextType::Custom(s)
298            }
299        })
300    }
301}
302
303fn is_namespaced_context_type(s: &str) -> bool {
304    let Some((ns, name)) = s.split_once(':') else {
305        return false;
306    };
307    if ns.is_empty()
308        || !ns.chars().next().is_some_and(|c| c.is_ascii_lowercase())
309        || !ns
310            .chars()
311            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
312    {
313        return false;
314    }
315    if name.is_empty()
316        || !name.chars().next().is_some_and(|c| c.is_ascii_lowercase())
317        || !name
318            .chars()
319            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-'))
320    {
321        return false;
322    }
323    true
324}
325
326/// Registry-derived lifecycle status.
327///
328/// The schema (`acdp-common.schema.json#/$defs/status`) defines an open
329/// `^[a-z][a-z0-9_]*$` pattern, length 1..=64. v0.1.0 emits `active`,
330/// `superseded`, `expired`; 0.3.0 activates `retracted` (RFC-ACDP-0013
331/// §7, promoting the RFC-ACDP-0009 §2.1 reservation); future versions
332/// may add others. Consumers MUST tolerate unknown values matching the
333/// pattern; values that DO NOT match the pattern (uppercase, whitespace,
334/// empty) are rejected on deserialization as malformed registry state.
335///
336/// Derivation precedence (RFC-ACDP-0004 §4 as amended by RFC-ACDP-0013
337/// §7.2): `retracted` > `superseded` > `expired` > `active`. The
338/// dominated facts remain independently visible (supersession via the
339/// lineage array, expiry via the body's signed `expires_at`, the
340/// withdrawal via `lifecycle_events`) — precedence collapses only this
341/// single derived summary field.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum Status {
344    /// First-class, current version of its lineage.
345    Active,
346    /// Replaced by a later version in the same lineage.
347    Superseded,
348    /// Past `expires_at`.
349    Expired,
350    /// Formally withdrawn from reliance via a `retracted` lifecycle
351    /// event (RFC-ACDP-0013 §7). Dominates `superseded` and `expired`.
352    /// The body remains retrievable — retraction is mark-not-delete.
353    Retracted,
354    /// A status string this version of the library does not recognize.
355    /// Per the spec, treat as `active` for read-side decisions until upgrade.
356    Other(String),
357}
358
359impl Status {
360    /// Validate against the schema pattern `^[a-z][a-z0-9_]*$`, length 1..=64.
361    fn pattern_ok(s: &str) -> bool {
362        !s.is_empty()
363            && s.len() <= 64
364            && s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
365            && s.chars()
366                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
367    }
368
369    /// Wire-form string representation, matching the schema enum.
370    pub fn as_str(&self) -> &str {
371        match self {
372            Status::Active => "active",
373            Status::Superseded => "superseded",
374            Status::Expired => "expired",
375            Status::Retracted => "retracted",
376            Status::Other(s) => s,
377        }
378    }
379
380    /// Parse a status string from any source, validating the pattern.
381    pub fn parse(s: &str) -> Result<Self, AcdpError> {
382        match s {
383            "active" => Ok(Status::Active),
384            "superseded" => Ok(Status::Superseded),
385            "expired" => Ok(Status::Expired),
386            "retracted" => Ok(Status::Retracted),
387            other => {
388                if !Self::pattern_ok(other) {
389                    return Err(AcdpError::SchemaViolation(format!(
390                        "status '{other}' does not match the open-enum pattern \
391                         ^[a-z][a-z0-9_]*$ (length 1..=64)"
392                    )));
393                }
394                Ok(Status::Other(other.to_string()))
395            }
396        }
397    }
398}
399
400impl Serialize for Status {
401    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
402        s.serialize_str(self.as_str())
403    }
404}
405
406impl<'de> Deserialize<'de> for Status {
407    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
408        let s = String::deserialize(d)?;
409        Status::parse(&s).map_err(serde::de::Error::custom)
410    }
411}
412
413impl Status {
414    /// Returns `true` if status is `Active`.
415    pub fn is_active(&self) -> bool {
416        matches!(self, Status::Active)
417    }
418
419    /// Returns `true` if status is `Superseded`.
420    pub fn is_superseded(&self) -> bool {
421        matches!(self, Status::Superseded)
422    }
423
424    /// Returns `true` if status is `Expired`.
425    pub fn is_expired(&self) -> bool {
426        matches!(self, Status::Expired)
427    }
428
429    /// Returns `true` if status is `Retracted` (RFC-ACDP-0013 §7).
430    pub fn is_retracted(&self) -> bool {
431        matches!(self, Status::Retracted)
432    }
433
434    /// Returns the unrecognized status string, if any.
435    pub fn as_other(&self) -> Option<&str> {
436        match self {
437            Status::Other(s) => Some(s),
438            _ => None,
439        }
440    }
441
442    /// Forward-compatible degradation: maps unknown statuses to
443    /// [`Status::Active`] for functional decisions, per RFC-ACDP-0004 §4.1
444    /// ("v0.1.0 consumers MUST tolerate unknown status values and SHOULD
445    /// treat them as 'active' until they upgrade"). Callers MUST log the
446    /// original `Other(_)` value so the unknown is observable.
447    pub fn known_or_active(&self) -> Status {
448        match self {
449            Status::Other(_) => Status::Active,
450            s => s.clone(),
451        }
452    }
453}
454
455// ── Validation helpers (private) ─────────────────────────────────────────────
456
457fn is_lowercase_hex(s: &str) -> bool {
458    s.chars()
459        .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))
460}
461
462/// Validate a bare DNS authority: lowercase ASCII labels separated by
463/// dots, each `1..=63` chars of `[a-z0-9-]` with no leading/trailing
464/// hyphen, total `<= 253`. Rejects uppercase, underscores, and ports.
465///
466/// Public so `acdp-validation` can reuse it for
467/// `origin_registry` (BUG-02) — the schema's `hostname` type and
468/// `CtxId`'s authority share exactly this grammar.
469pub fn is_valid_dns_authority(s: &str) -> bool {
470    if s.is_empty() || s.len() > 253 {
471        return false;
472    }
473    s.split('.').all(|label| {
474        !label.is_empty()
475            && label.len() <= 63
476            && label
477                .chars()
478                .next()
479                .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
480            && label
481                .chars()
482                .last()
483                .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
484            && label
485                .chars()
486                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
487    })
488}
489
490/// Validate a UUID-v4 string per the ACDP ctx_id schema:
491/// 8-4-4-4-12 lowercase hex with version digit `4` and variant digit in 8/9/a/b.
492fn is_valid_uuid_v4(s: &str) -> bool {
493    let bytes = s.as_bytes();
494    if bytes.len() != 36 {
495        return false;
496    }
497    for (i, &b) in bytes.iter().enumerate() {
498        match i {
499            8 | 13 | 18 | 23 => {
500                if b != b'-' {
501                    return false;
502                }
503            }
504            _ => {
505                if !(b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
506                    return false;
507                }
508            }
509        }
510    }
511    bytes[14] == b'4' && matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use serde_json::json;
518
519    #[test]
520    fn known_status_values_deserialize() {
521        let s: Status = serde_json::from_value(json!("active")).unwrap();
522        assert!(s.is_active());
523        let s: Status = serde_json::from_value(json!("superseded")).unwrap();
524        assert!(s.is_superseded());
525        let s: Status = serde_json::from_value(json!("expired")).unwrap();
526        assert!(s.is_expired());
527        // 0.3.0: `retracted` is first-class (RFC-ACDP-0013 §7, activated
528        // from the RFC-ACDP-0009 §2.1 reservation).
529        let s: Status = serde_json::from_value(json!("retracted")).unwrap();
530        assert!(s.is_retracted());
531        assert_eq!(s, Status::Retracted);
532        assert!(!s.is_active());
533        assert!(!s.is_superseded());
534        assert!(!s.is_expired());
535        assert_eq!(s.as_other(), None);
536    }
537
538    #[test]
539    fn unknown_status_value_falls_back_to_other() {
540        // Open-pattern forward compat: an unrecognized value matching
541        // ^[a-z][a-z0-9_]*$ MUST be tolerated (RFC-ACDP-0004 §4.1).
542        let s: Status = serde_json::from_value(json!("archived")).unwrap();
543        assert_eq!(s.as_other(), Some("archived"));
544        assert!(!s.is_active());
545        assert!(!s.is_retracted());
546    }
547
548    #[test]
549    fn ctx_id_authority() {
550        let id = CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into());
551        assert_eq!(id.authority(), "registry.example.com");
552    }
553
554    #[test]
555    fn ctx_id_parse_valid() {
556        let id = CtxId::parse(
557            "acdp://registry.example.com/12345678-1234-4321-8123-123456781234".to_string(),
558        )
559        .unwrap();
560        assert_eq!(id.authority(), "registry.example.com");
561        assert!(id.uuid().is_some());
562    }
563
564    #[test]
565    fn ctx_id_parse_rejects_uppercase_authority() {
566        assert!(
567            CtxId::parse("acdp://Registry.Example.com/12345678-1234-4321-8123-123456781234")
568                .is_err()
569        );
570    }
571
572    #[test]
573    fn ctx_id_parse_rejects_non_v4_uuid() {
574        // Version digit (13th hex char) is `1`, not `4`
575        assert!(
576            CtxId::parse("acdp://registry.example.com/12345678-1234-1321-8123-123456781234")
577                .is_err()
578        );
579    }
580
581    #[test]
582    fn ctx_id_parse_rejects_bad_variant() {
583        // Variant digit (17th hex char) is `0`, not 8/9/a/b
584        assert!(
585            CtxId::parse("acdp://registry.example.com/12345678-1234-4321-0123-123456781234")
586                .is_err()
587        );
588    }
589
590    #[test]
591    fn lineage_id_parse() {
592        let l = LineageId::parse(
593            "lin:sha256:b14ccd2a8b34530309255db68c151a10689b6a82feb30aff9222d54fdd871720"
594                .to_string(),
595        )
596        .unwrap();
597        assert!(l.as_str().starts_with("lin:sha256:"));
598        assert!(LineageId::parse("lin:sha256:abc").is_err());
599        assert!(LineageId::parse(
600            "lin:sha256:B14CCD2A8B34530309255DB68C151A10689B6A82FEB30AFF9222D54FDD871720"
601        )
602        .is_err());
603    }
604
605    #[test]
606    fn content_hash_parse() {
607        ContentHash::parse(
608            "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".to_string(),
609        )
610        .unwrap();
611        assert!(ContentHash::parse("md5:abc").is_err());
612        assert!(ContentHash::parse("sha256:zzzz").is_err());
613    }
614
615    #[test]
616    fn agent_did_parse_valid() {
617        AgentDid::parse("did:web:agents.example.com:test").unwrap();
618        AgentDid::parse("did:key:z6Mki...").unwrap();
619    }
620
621    #[test]
622    fn agent_did_parse_rejects_invalid_method() {
623        assert!(AgentDid::parse("did:WEB:agents.example.com").is_err());
624        assert!(AgentDid::parse("did::test").is_err());
625        assert!(AgentDid::parse("notadid").is_err());
626    }
627
628    #[test]
629    fn agent_did_parse_web_enforces_method() {
630        AgentDid::parse_web("did:web:agents.example.com:test").unwrap();
631        assert!(AgentDid::parse_web("did:key:z6Mki...").is_err());
632    }
633
634    #[test]
635    fn agent_did_new_skips_validation() {
636        // `new` is the unchecked back-compat constructor — it must NOT reject.
637        let did = AgentDid::new("not-a-did");
638        assert_eq!(did.as_str(), "not-a-did");
639    }
640
641    #[test]
642    fn agent_did_parse_rejects_length_bounds() {
643        assert!(AgentDid::parse("did:w:").is_err(), "too short / empty id");
644        let long = format!("did:web:{}", "a".repeat(2100));
645        assert!(AgentDid::parse(long).is_err(), "over 2048 chars");
646    }
647
648    // ── ContextType ────────────────────────────────────────────────────────
649
650    #[test]
651    fn context_type_known_values_round_trip() {
652        for (s, expect) in [
653            ("data_snapshot", ContextType::DataSnapshot),
654            ("analysis", ContextType::Analysis),
655            ("prediction", ContextType::Prediction),
656            ("alert", ContextType::Alert),
657            ("key-revocation", ContextType::KeyRevocation),
658        ] {
659            let parsed: ContextType = serde_json::from_value(json!(s)).unwrap();
660            assert_eq!(parsed, expect);
661            assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
662        }
663    }
664
665    /// RFC-ACDP-0014 §10 — both the standard `key-revocation` form and
666    /// the interim `acdp:key-revocation` custom form MUST be
667    /// recognizable as key-revocation contexts; nothing else is.
668    #[test]
669    fn context_type_key_revocation_recognition() {
670        let standard: ContextType = serde_json::from_value(json!("key-revocation")).unwrap();
671        assert_eq!(standard, ContextType::KeyRevocation);
672        assert!(standard.is_key_revocation());
673
674        // The interim form is a plain namespaced custom type on the
675        // wire but MUST be treated as equivalent by 0.3.0 consumers.
676        let interim: ContextType = serde_json::from_value(json!("acdp:key-revocation")).unwrap();
677        assert_eq!(
678            interim,
679            ContextType::Custom(ContextType::KEY_REVOCATION_INTERIM.into())
680        );
681        assert!(interim.is_key_revocation());
682
683        assert!(!ContextType::DataSnapshot.is_key_revocation());
684        assert!(!ContextType::Custom("acdp:key-rotation".into()).is_key_revocation());
685    }
686
687    #[test]
688    fn context_type_accepts_namespaced_custom() {
689        let parsed: ContextType =
690            serde_json::from_value(json!("finance:portfolio_snapshot")).unwrap();
691        assert_eq!(
692            parsed,
693            ContextType::Custom("finance:portfolio_snapshot".into())
694        );
695        // Custom round-trips back to its exact wire string.
696        assert_eq!(
697            serde_json::to_value(&parsed).unwrap(),
698            json!("finance:portfolio_snapshot")
699        );
700    }
701
702    #[test]
703    fn context_type_rejects_unnamespaced_unknown() {
704        // No colon and not a known keyword ⇒ schema-invalid.
705        for bad in [
706            "totally_unknown",
707            "Finance:x",
708            "finance:",
709            ":name",
710            "1ns:name",
711        ] {
712            let parsed: Result<ContextType, _> = serde_json::from_value(json!(bad));
713            assert!(parsed.is_err(), "context_type {bad:?} must be rejected");
714        }
715    }
716
717    #[test]
718    fn namespaced_context_type_helper_edges() {
719        assert!(is_namespaced_context_type("a:b"));
720        assert!(is_namespaced_context_type("ns1:name_2-x"));
721        assert!(!is_namespaced_context_type("nocolon"));
722        assert!(!is_namespaced_context_type("ns:Name")); // uppercase in name
723        assert!(!is_namespaced_context_type("ns:-bad")); // name starts with hyphen
724        assert!(!is_namespaced_context_type("ns:1bad")); // name starts with digit
725    }
726
727    // ── Status edge cases ──────────────────────────────────────────────────
728
729    #[test]
730    fn status_parse_rejects_pattern_violations() {
731        for bad in ["Active", "has space", "", "UPPER", "trailing!"] {
732            assert!(
733                Status::parse(bad).is_err(),
734                "status {bad:?} violates ^[a-z][a-z0-9_]*$ and must be rejected"
735            );
736        }
737    }
738
739    #[test]
740    fn status_deserialize_rejects_malformed() {
741        // Deserialization funnels through `Status::parse`.
742        let parsed: Result<Status, _> = serde_json::from_value(json!("Active"));
743        assert!(parsed.is_err());
744    }
745
746    #[test]
747    fn status_known_or_active_degrades_unknown() {
748        // Unknown ⇒ Active for read-side decisions (RFC-ACDP-0004 §4.1).
749        let other = Status::Other("archived".into());
750        assert_eq!(other.known_or_active(), Status::Active);
751        // Known statuses are preserved unchanged — including `retracted`,
752        // which as of 0.3.0 is a KNOWN non-reliance signal and MUST NOT
753        // degrade to active (RFC-ACDP-0001 §9 consumer rule 3, amended).
754        assert_eq!(Status::Superseded.known_or_active(), Status::Superseded);
755        assert_eq!(Status::Expired.known_or_active(), Status::Expired);
756        assert_eq!(Status::Retracted.known_or_active(), Status::Retracted);
757    }
758
759    #[test]
760    fn status_as_str_matches_wire_form() {
761        assert_eq!(Status::Active.as_str(), "active");
762        assert_eq!(Status::Superseded.as_str(), "superseded");
763        assert_eq!(Status::Expired.as_str(), "expired");
764        assert_eq!(Status::Retracted.as_str(), "retracted");
765        assert_eq!(Status::Other("custom".into()).as_str(), "custom");
766    }
767
768    // ── Visibility / Display ───────────────────────────────────────────────
769
770    #[test]
771    fn visibility_round_trips_snake_case() {
772        for (s, expect) in [
773            ("public", Visibility::Public),
774            ("restricted", Visibility::Restricted),
775            ("private", Visibility::Private),
776        ] {
777            let parsed: Visibility = serde_json::from_value(json!(s)).unwrap();
778            assert_eq!(parsed, expect);
779            assert_eq!(serde_json::to_value(&parsed).unwrap(), json!(s));
780        }
781        assert!(serde_json::from_value::<Visibility>(json!("Public")).is_err());
782    }
783
784    #[test]
785    fn identifier_display_matches_inner_string() {
786        let ctx = "acdp://r.example.com/12345678-1234-4321-8123-123456781234";
787        assert_eq!(CtxId(ctx.into()).to_string(), ctx);
788        let lin = "lin:sha256:1111111111111111111111111111111111111111111111111111111111111111";
789        assert_eq!(LineageId(lin.into()).to_string(), lin);
790        let hash = "sha256:1111111111111111111111111111111111111111111111111111111111111111";
791        assert_eq!(ContentHash(hash.into()).to_string(), hash);
792        assert_eq!(AgentDid::new("did:web:x").to_string(), "did:web:x");
793    }
794
795    #[test]
796    fn ctx_id_uuid_returns_none_for_malformed() {
797        // `uuid()` is best-effort: malformed input yields None, not a panic.
798        assert!(CtxId("not-a-ctx-id".into()).uuid().is_none());
799        assert!(CtxId("acdp://host/not-a-uuid".into()).uuid().is_none());
800    }
801}