Skip to main content

oas_document/
document.rs

1// OAS Spec §5 — OAS Identity Document
2//! The core [`OasDocument`] type representing an OAS Identity Document.
3//!
4//! An OAS Identity Document is a W3C DID Document extended with OAS-specific
5//! properties: lineage, capabilities, conformance level, and metadata.
6
7use serde::{Deserialize, Serialize};
8
9use crate::calendar::CalendarSection;
10use crate::compliance::ComplianceSection;
11use crate::conformance::ConformanceLevel;
12use crate::contact::ContactSection;
13use crate::error::DocumentError;
14use crate::governance::GovernanceSection;
15use crate::interop::InteroperabilitySection;
16use crate::lifecycle::LifecycleStatus;
17use crate::lineage_section::LineageSection;
18use crate::operational::OperationalSection;
19use crate::pricing::PricingSection;
20use crate::profile::ProfileSection;
21use crate::proof_format::DocumentProof;
22use crate::relationships::RelationshipsSection;
23use crate::reputation::ReputationSection;
24use crate::service::ServiceEndpoint;
25use crate::verification_method::VerificationMethod;
26
27/// The OAS JSON-LD context URI.
28pub const OAS_CONTEXT: &str = "https://openagent.id/ns/oas/v1";
29
30/// The W3C DID context URI.
31pub const DID_CONTEXT: &str = "https://www.w3.org/ns/did/v1";
32
33/// The Ed25519 2020 suite context URI.
34pub const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
35
36/// The current OAS specification version.
37pub const OAS_VERSION: &str = "1.0.0";
38
39/// Metadata section of an OAS Identity Document.
40///
41/// # Examples
42///
43/// ```
44/// use oas_document::document::DocumentMetadata;
45///
46/// let meta = DocumentMetadata {
47///     created: "2026-01-15T00:00:00Z".to_string(),
48///     updated: None,
49///     version: None,
50/// };
51/// ```
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct DocumentMetadata {
54    /// ISO 8601 creation timestamp (required).
55    pub created: String,
56
57    /// ISO 8601 last update timestamp (optional).
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub updated: Option<String>,
60
61    /// Semantic version of this entity's definition (optional).
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub version: Option<String>,
64}
65
66/// An OAS Identity Document as defined in OAS Specification §5.
67///
68/// This is the canonical representation of an autonomous entity's identity,
69/// extending the W3C DID Document with OAS-specific properties.
70///
71/// # Examples
72///
73/// ```
74/// use oas_document::OasDocument;
75///
76/// let json = r#"{
77///   "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1", "https://openagent.id/ns/oas/v1"],
78///   "id": "did:oas:test:hmr:alice",
79///   "controller": "did:oas:test:hmr:alice",
80///   "verificationMethod": [{"id": "did:oas:test:hmr:alice#key-1", "type": "Ed25519VerificationKey2020", "controller": "did:oas:test:hmr:alice", "publicKeyMultibase": "zTest"}],
81///   "authentication": ["did:oas:test:hmr:alice#key-1"],
82///   "oasVersion": "1.0.0",
83///   "kind": "hmr",
84///   "conformanceLevel": "L1",
85///   "sequence": 1,
86///   "metadata": {"created": "2026-01-15T00:00:00Z"}
87/// }"#;
88/// let doc: OasDocument = serde_json::from_str(json).unwrap();
89/// assert_eq!(doc.id, "did:oas:test:hmr:alice");
90/// ```
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct OasDocument {
94    /// JSON-LD contexts. MUST include the OAS context.
95    #[serde(rename = "@context")]
96    pub context: Vec<String>,
97
98    /// The `did:oas` identifier.
99    pub id: String,
100
101    /// DID of the controlling entity.
102    pub controller: String,
103
104    /// At least one Ed25519VerificationKey2020.
105    pub verification_method: Vec<VerificationMethod>,
106
107    /// References to keys authorized for authentication.
108    pub authentication: Vec<String>,
109
110    /// References to keys authorized for assertions.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub assertion_method: Vec<String>,
113
114    /// References to keys authorized for capability invocation.
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub capability_invocation: Vec<String>,
117
118    /// References to keys authorized for capability delegation.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub capability_delegation: Vec<String>,
121
122    /// Service endpoints.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub service: Vec<ServiceEndpoint>,
125
126    /// OAS specification version (currently `"1.0.0"`).
127    pub oas_version: String,
128
129    /// Entity kind from the taxonomy.
130    pub kind: String,
131
132    /// Human-readable name (max 256 chars).
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub name: Option<String>,
135
136    /// Human-readable description (max 4096 chars).
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub description: Option<String>,
139
140    /// Lineage information (required for L1+ non-root entities).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub lineage: Option<LineageSection>,
143
144    /// Governance section (ENR entities only).
145    /// Tracks which MHR governs this enterprise identity, epoch, and transition history.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub governance: Option<GovernanceSection>,
148
149    /// Conformance level: L0, L1, or L2.
150    pub conformance_level: ConformanceLevel,
151
152    /// Lifecycle status.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub lifecycle_status: Option<LifecycleStatus>,
155
156    /// Monotonically increasing version counter (starting at 1).
157    pub sequence: u64,
158
159    /// Document metadata.
160    pub metadata: DocumentMetadata,
161
162    /// Document proof (Ed25519Signature2020).
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub proof: Option<DocumentProof>,
165
166    /// Whether this identity has been revoked.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub revoked: Option<bool>,
169
170    /// Timestamp of revocation.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub revoked_at: Option<String>,
173
174    // ── Extended Identity Sections (OAS §5.8–§5.16) ──────────────────
175    /// Profile / presentation data (§5.8): avatar, tagline, categories.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub profile: Option<ProfileSection>,
178
179    /// Contact directory (§5.9): phones, emails, socials, websites.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub contact: Option<ContactSection>,
182
183    /// Calendar & scheduling (§5.10): booking links, office hours.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub calendar: Option<CalendarSection>,
186
187    /// Operational specification (§5.11): SLA, rate limits, formats.
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub operational: Option<OperationalSection>,
190
191    /// Pricing & economics (§5.12): cost model, billing, free tier.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub pricing: Option<PricingSection>,
194
195    /// Interoperability (§5.13): protocols, API schemas, auth methods.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub interoperability: Option<InteroperabilitySection>,
198
199    /// Reputation summary (§5.14): interactions, endorsements, uptime.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub reputation: Option<ReputationSection>,
202
203    /// Compliance & jurisdiction (§5.15): certifications, policy URLs.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub compliance: Option<ComplianceSection>,
206
207    /// Relationships & affiliations (§5.16): org structure, partnerships.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub relationships: Option<RelationshipsSection>,
210}
211
212impl OasDocument {
213    /// Returns the default JSON-LD context array for OAS documents.
214    pub fn default_context() -> Vec<String> {
215        vec![
216            DID_CONTEXT.to_string(),
217            ED25519_CONTEXT.to_string(),
218            OAS_CONTEXT.to_string(),
219        ]
220    }
221
222    /// Returns true if this document represents a root entity (HMR, MHR, or ENR).
223    pub fn is_root(&self) -> bool {
224        self.kind == "hmr" || self.kind == "mhr" || self.kind == "enr"
225    }
226
227    /// Returns true if this document has been revoked.
228    pub fn is_revoked(&self) -> bool {
229        self.revoked.unwrap_or(false)
230    }
231
232    /// Computes the canonical BLAKE3 commitment for this complete document.
233    ///
234    /// The digest covers the RFC 8785 canonical JSON representation, including
235    /// the document proof and all status, relationship, key, and lineage fields.
236    /// Lineage proofs bind this value to the authenticated parent state used at
237    /// issuance time.
238    ///
239    /// # Returns
240    ///
241    /// A lowercase `blake3:`-prefixed digest.
242    ///
243    /// # Errors
244    ///
245    /// Returns [`DocumentError::Crypto`] if canonicalization fails.
246    pub fn canonical_digest(&self) -> Result<String, DocumentError> {
247        let value = serde_json::to_value(self)?;
248        let canonical = oas_crypto::jcs::canonicalize(&value)?;
249        Ok(format!(
250            "blake3:{}",
251            oas_crypto::hashing::blake3_hash_hex(&canonical)
252        ))
253    }
254
255    /// Returns the primary public key multibase string, if available.
256    ///
257    /// Looks for the first verification method's public key.
258    pub fn primary_public_key_multibase(&self) -> Option<&str> {
259        self.verification_method
260            .first()
261            .map(|vm| vm.public_key_multibase.as_str())
262    }
263
264    /// Finds a verification method by its full ID or fragment.
265    pub fn find_verification_method(&self, id_or_fragment: &str) -> Option<&VerificationMethod> {
266        self.verification_method.iter().find(|vm| {
267            vm.id == id_or_fragment
268                || vm.key_id() == id_or_fragment
269                || vm.id.ends_with(&format!("#{id_or_fragment}"))
270        })
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn make_minimal_doc(kind: &str) -> OasDocument {
279        OasDocument {
280            context: OasDocument::default_context(),
281            id: format!("did:oas:test:{kind}:test-1"),
282            controller: format!("did:oas:test:{kind}:test-1"),
283            verification_method: vec![VerificationMethod::new(
284                &format!("did:oas:test:{kind}:test-1"),
285                1,
286                "zTestPubKey",
287            )],
288            authentication: vec![format!("did:oas:test:{kind}:test-1#key-1")],
289            assertion_method: vec![],
290            capability_invocation: vec![],
291            capability_delegation: vec![],
292            service: vec![],
293            oas_version: OAS_VERSION.to_string(),
294            kind: kind.to_string(),
295            name: None,
296            description: None,
297            lineage: None,
298            governance: None,
299            conformance_level: ConformanceLevel::L0,
300            lifecycle_status: None,
301            sequence: 1,
302            metadata: DocumentMetadata {
303                created: "2026-01-15T00:00:00Z".to_string(),
304                updated: None,
305                version: None,
306            },
307            proof: None,
308            revoked: None,
309            revoked_at: None,
310            profile: None,
311            contact: None,
312            calendar: None,
313            operational: None,
314            pricing: None,
315            interoperability: None,
316            reputation: None,
317            compliance: None,
318            relationships: None,
319        }
320    }
321
322    #[test]
323    fn is_root_for_hmr() {
324        let doc = make_minimal_doc("hmr");
325        assert!(doc.is_root());
326    }
327
328    #[test]
329    fn is_root_for_mhr() {
330        let doc = make_minimal_doc("mhr");
331        assert!(doc.is_root());
332    }
333
334    #[test]
335    fn is_root_for_enr() {
336        let doc = make_minimal_doc("enr");
337        assert!(doc.is_root());
338    }
339
340    #[test]
341    fn is_not_root_for_agent() {
342        let doc = make_minimal_doc("agent");
343        assert!(!doc.is_root());
344    }
345
346    #[test]
347    fn default_context_has_three_uris() {
348        let ctx = OasDocument::default_context();
349        assert_eq!(ctx.len(), 3);
350        assert!(ctx.contains(&DID_CONTEXT.to_string()));
351        assert!(ctx.contains(&OAS_CONTEXT.to_string()));
352    }
353
354    #[test]
355    fn find_verification_method_by_fragment() {
356        let doc = make_minimal_doc("hmr");
357        let vm = doc.find_verification_method("key-1");
358        assert!(vm.is_some());
359    }
360
361    #[test]
362    fn is_revoked_default_false() {
363        let doc = make_minimal_doc("hmr");
364        assert!(!doc.is_revoked());
365    }
366
367    #[test]
368    fn canonical_digest_is_deterministic_and_state_binding() {
369        let doc = make_minimal_doc("hmr");
370        let first = doc.canonical_digest().unwrap_or_default();
371        let second = doc.canonical_digest().unwrap_or_default();
372        assert_eq!(first, second);
373        assert!(first.starts_with("blake3:") && first.len() == 71);
374
375        let mut changed = doc;
376        changed.sequence += 1;
377        assert_ne!(first, changed.canonical_digest().unwrap_or_default());
378    }
379
380    #[test]
381    fn serde_round_trip() {
382        let doc = make_minimal_doc("agent");
383        let json = serde_json::to_string_pretty(&doc).unwrap_or_default();
384        let parsed: Result<OasDocument, _> = serde_json::from_str(&json);
385        assert!(parsed.is_ok());
386    }
387
388    #[test]
389    fn backward_compat_no_extended_sections() {
390        // Documents created before §5.8–§5.16 (no profile, contact, etc.)
391        // must deserialize with all new fields as None.
392        let json = r#"{
393          "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1", "https://openagent.id/ns/oas/v1"],
394          "id": "did:oas:test:agent:legacy",
395          "controller": "did:oas:test:hmr:alice",
396          "verificationMethod": [{"id": "did:oas:test:agent:legacy#key-1", "type": "Ed25519VerificationKey2020", "controller": "did:oas:test:hmr:alice", "publicKeyMultibase": "zTest"}],
397          "authentication": ["did:oas:test:agent:legacy#key-1"],
398          "oasVersion": "1.0.0",
399          "kind": "agent",
400          "conformanceLevel": "L0",
401          "sequence": 1,
402          "metadata": {"created": "2026-01-15T00:00:00Z"}
403        }"#;
404        let doc: Result<OasDocument, _> = serde_json::from_str(json);
405        assert!(
406            doc.is_ok(),
407            "Legacy document without extended sections must deserialize"
408        );
409        let doc = doc.unwrap_or_else(|_| make_minimal_doc("agent"));
410        assert!(doc.profile.is_none());
411        assert!(doc.contact.is_none());
412        assert!(doc.calendar.is_none());
413        assert!(doc.operational.is_none());
414        assert!(doc.pricing.is_none());
415        assert!(doc.interoperability.is_none());
416        assert!(doc.reputation.is_none());
417        assert!(doc.compliance.is_none());
418        assert!(doc.relationships.is_none());
419    }
420
421    #[test]
422    fn extended_sections_omitted_when_none() {
423        let doc = make_minimal_doc("agent");
424        let json = serde_json::to_string(&doc).unwrap_or_default();
425        // None sections should not appear in serialized output
426        assert!(!json.contains("profile"));
427        assert!(!json.contains("contact"));
428        assert!(!json.contains("calendar"));
429        assert!(!json.contains("operational"));
430        assert!(!json.contains("pricing"));
431        assert!(!json.contains("interoperability"));
432        assert!(!json.contains("reputation"));
433        assert!(!json.contains("compliance"));
434        assert!(!json.contains("relationships"));
435    }
436}