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