use serde::{Deserialize, Serialize};
use crate::calendar::CalendarSection;
use crate::compliance::ComplianceSection;
use crate::conformance::ConformanceLevel;
use crate::contact::ContactSection;
use crate::governance::GovernanceSection;
use crate::interop::InteroperabilitySection;
use crate::lifecycle::LifecycleStatus;
use crate::lineage_section::LineageSection;
use crate::operational::OperationalSection;
use crate::pricing::PricingSection;
use crate::profile::ProfileSection;
use crate::proof_format::DocumentProof;
use crate::relationships::RelationshipsSection;
use crate::reputation::ReputationSection;
use crate::service::ServiceEndpoint;
use crate::verification_method::VerificationMethod;
pub const OAS_CONTEXT: &str = "https://openagent.id/ns/oas/v1";
pub const DID_CONTEXT: &str = "https://www.w3.org/ns/did/v1";
pub const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";
pub const OAS_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentMetadata {
pub created: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OasDocument {
#[serde(rename = "@context")]
pub context: Vec<String>,
pub id: String,
pub controller: String,
pub verification_method: Vec<VerificationMethod>,
pub authentication: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assertion_method: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capability_invocation: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capability_delegation: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub service: Vec<ServiceEndpoint>,
pub oas_version: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lineage: Option<LineageSection>,
#[serde(skip_serializing_if = "Option::is_none")]
pub governance: Option<GovernanceSection>,
pub conformance_level: ConformanceLevel,
#[serde(skip_serializing_if = "Option::is_none")]
pub lifecycle_status: Option<LifecycleStatus>,
pub sequence: u64,
pub metadata: DocumentMetadata,
#[serde(skip_serializing_if = "Option::is_none")]
pub proof: Option<DocumentProof>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contact: Option<ContactSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub calendar: Option<CalendarSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operational: Option<OperationalSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<PricingSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interoperability: Option<InteroperabilitySection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reputation: Option<ReputationSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compliance: Option<ComplianceSection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relationships: Option<RelationshipsSection>,
}
impl OasDocument {
pub fn default_context() -> Vec<String> {
vec![
DID_CONTEXT.to_string(),
ED25519_CONTEXT.to_string(),
OAS_CONTEXT.to_string(),
]
}
pub fn is_root(&self) -> bool {
self.kind == "hmr" || self.kind == "mhr" || self.kind == "enr"
}
pub fn is_revoked(&self) -> bool {
self.revoked.unwrap_or(false)
}
pub fn primary_public_key_multibase(&self) -> Option<&str> {
self.verification_method
.first()
.map(|vm| vm.public_key_multibase.as_str())
}
pub fn find_verification_method(&self, id_or_fragment: &str) -> Option<&VerificationMethod> {
self.verification_method.iter().find(|vm| {
vm.id == id_or_fragment
|| vm.key_id() == id_or_fragment
|| vm.id.ends_with(&format!("#{id_or_fragment}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_minimal_doc(kind: &str) -> OasDocument {
OasDocument {
context: OasDocument::default_context(),
id: format!("did:oas:test:{kind}:test-1"),
controller: format!("did:oas:test:{kind}:test-1"),
verification_method: vec![VerificationMethod::new(
&format!("did:oas:test:{kind}:test-1"),
1,
"zTestPubKey",
)],
authentication: vec![format!("did:oas:test:{kind}:test-1#key-1")],
assertion_method: vec![],
capability_invocation: vec![],
capability_delegation: vec![],
service: vec![],
oas_version: OAS_VERSION.to_string(),
kind: kind.to_string(),
name: None,
description: None,
lineage: None,
governance: None,
conformance_level: ConformanceLevel::L0,
lifecycle_status: None,
sequence: 1,
metadata: DocumentMetadata {
created: "2026-01-15T00:00:00Z".to_string(),
updated: None,
version: None,
},
proof: None,
revoked: None,
revoked_at: None,
profile: None,
contact: None,
calendar: None,
operational: None,
pricing: None,
interoperability: None,
reputation: None,
compliance: None,
relationships: None,
}
}
#[test]
fn is_root_for_hmr() {
let doc = make_minimal_doc("hmr");
assert!(doc.is_root());
}
#[test]
fn is_root_for_mhr() {
let doc = make_minimal_doc("mhr");
assert!(doc.is_root());
}
#[test]
fn is_root_for_enr() {
let doc = make_minimal_doc("enr");
assert!(doc.is_root());
}
#[test]
fn is_not_root_for_agent() {
let doc = make_minimal_doc("agent");
assert!(!doc.is_root());
}
#[test]
fn default_context_has_three_uris() {
let ctx = OasDocument::default_context();
assert_eq!(ctx.len(), 3);
assert!(ctx.contains(&DID_CONTEXT.to_string()));
assert!(ctx.contains(&OAS_CONTEXT.to_string()));
}
#[test]
fn find_verification_method_by_fragment() {
let doc = make_minimal_doc("hmr");
let vm = doc.find_verification_method("key-1");
assert!(vm.is_some());
}
#[test]
fn is_revoked_default_false() {
let doc = make_minimal_doc("hmr");
assert!(!doc.is_revoked());
}
#[test]
fn serde_round_trip() {
let doc = make_minimal_doc("agent");
let json = serde_json::to_string_pretty(&doc).unwrap_or_default();
let parsed: Result<OasDocument, _> = serde_json::from_str(&json);
assert!(parsed.is_ok());
}
#[test]
fn backward_compat_no_extended_sections() {
let json = r#"{
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/ed25519-2020/v1", "https://openagent.id/ns/oas/v1"],
"id": "did:oas:test:agent:legacy",
"controller": "did:oas:test:hmr:alice",
"verificationMethod": [{"id": "did:oas:test:agent:legacy#key-1", "type": "Ed25519VerificationKey2020", "controller": "did:oas:test:hmr:alice", "publicKeyMultibase": "zTest"}],
"authentication": ["did:oas:test:agent:legacy#key-1"],
"oasVersion": "1.0.0",
"kind": "agent",
"conformanceLevel": "L0",
"sequence": 1,
"metadata": {"created": "2026-01-15T00:00:00Z"}
}"#;
let doc: Result<OasDocument, _> = serde_json::from_str(json);
assert!(
doc.is_ok(),
"Legacy document without extended sections must deserialize"
);
let doc = doc.unwrap_or_else(|_| make_minimal_doc("agent"));
assert!(doc.profile.is_none());
assert!(doc.contact.is_none());
assert!(doc.calendar.is_none());
assert!(doc.operational.is_none());
assert!(doc.pricing.is_none());
assert!(doc.interoperability.is_none());
assert!(doc.reputation.is_none());
assert!(doc.compliance.is_none());
assert!(doc.relationships.is_none());
}
#[test]
fn extended_sections_omitted_when_none() {
let doc = make_minimal_doc("agent");
let json = serde_json::to_string(&doc).unwrap_or_default();
assert!(!json.contains("profile"));
assert!(!json.contains("contact"));
assert!(!json.contains("calendar"));
assert!(!json.contains("operational"));
assert!(!json.contains("pricing"));
assert!(!json.contains("interoperability"));
assert!(!json.contains("reputation"));
assert!(!json.contains("compliance"));
assert!(!json.contains("relationships"));
}
}