1use 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
27pub const OAS_CONTEXT: &str = "https://openagent.id/ns/oas/v1";
29
30pub const DID_CONTEXT: &str = "https://www.w3.org/ns/did/v1";
32
33pub const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
35
36pub const OAS_VERSION: &str = "1.0.0";
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53pub struct DocumentMetadata {
54 pub created: String,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub updated: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub version: Option<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct OasDocument {
94 #[serde(rename = "@context")]
96 pub context: Vec<String>,
97
98 pub id: String,
100
101 pub controller: String,
103
104 pub verification_method: Vec<VerificationMethod>,
106
107 pub authentication: Vec<String>,
109
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub assertion_method: Vec<String>,
113
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub capability_invocation: Vec<String>,
117
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub capability_delegation: Vec<String>,
121
122 #[serde(default, skip_serializing_if = "Vec::is_empty")]
124 pub service: Vec<ServiceEndpoint>,
125
126 pub oas_version: String,
128
129 pub kind: String,
131
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub name: Option<String>,
135
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub description: Option<String>,
139
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub lineage: Option<LineageSection>,
143
144 #[serde(skip_serializing_if = "Option::is_none")]
147 pub governance: Option<GovernanceSection>,
148
149 pub conformance_level: ConformanceLevel,
151
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub lifecycle_status: Option<LifecycleStatus>,
155
156 pub sequence: u64,
158
159 pub metadata: DocumentMetadata,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub proof: Option<DocumentProof>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub revoked: Option<bool>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub revoked_at: Option<String>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub profile: Option<ProfileSection>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
181 pub contact: Option<ContactSection>,
182
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub calendar: Option<CalendarSection>,
186
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub operational: Option<OperationalSection>,
190
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub pricing: Option<PricingSection>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub interoperability: Option<InteroperabilitySection>,
198
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub reputation: Option<ReputationSection>,
202
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub compliance: Option<ComplianceSection>,
206
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub relationships: Option<RelationshipsSection>,
210}
211
212impl OasDocument {
213 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 pub fn is_root(&self) -> bool {
224 self.kind == "hmr" || self.kind == "mhr" || self.kind == "enr"
225 }
226
227 pub fn is_revoked(&self) -> bool {
229 self.revoked.unwrap_or(false)
230 }
231
232 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 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 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 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 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}