oas-document 1.1.0

OAS Identity Document types and validation for the Open Agent Specification
Documentation
// OAS Spec §5 — OAS Identity Document
//! The core [`OasDocument`] type representing an OAS Identity Document.
//!
//! An OAS Identity Document is a W3C DID Document extended with OAS-specific
//! properties: lineage, capabilities, conformance level, and metadata.

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;

/// The OAS JSON-LD context URI.
pub const OAS_CONTEXT: &str = "https://openagent.id/ns/oas/v1";

/// The W3C DID context URI.
pub const DID_CONTEXT: &str = "https://www.w3.org/ns/did/v1";

/// The Ed25519 2020 suite context URI.
pub const ED25519_CONTEXT: &str = "https://w3id.org/security/suites/ed25519-2020/v1";

/// The current OAS specification version.
pub const OAS_VERSION: &str = "1.0.0";

/// Metadata section of an OAS Identity Document.
///
/// # Examples
///
/// ```
/// use oas_document::document::DocumentMetadata;
///
/// let meta = DocumentMetadata {
///     created: "2026-01-15T00:00:00Z".to_string(),
///     updated: None,
///     version: None,
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentMetadata {
    /// ISO 8601 creation timestamp (required).
    pub created: String,

    /// ISO 8601 last update timestamp (optional).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub updated: Option<String>,

    /// Semantic version of this entity's definition (optional).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
}

/// An OAS Identity Document as defined in OAS Specification §5.
///
/// This is the canonical representation of an autonomous entity's identity,
/// extending the W3C DID Document with OAS-specific properties.
///
/// # Examples
///
/// ```
/// use oas_document::OasDocument;
///
/// 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:hmr:alice",
///   "controller": "did:oas:test:hmr:alice",
///   "verificationMethod": [{"id": "did:oas:test:hmr:alice#key-1", "type": "Ed25519VerificationKey2020", "controller": "did:oas:test:hmr:alice", "publicKeyMultibase": "zTest"}],
///   "authentication": ["did:oas:test:hmr:alice#key-1"],
///   "oasVersion": "1.0.0",
///   "kind": "hmr",
///   "conformanceLevel": "L1",
///   "sequence": 1,
///   "metadata": {"created": "2026-01-15T00:00:00Z"}
/// }"#;
/// let doc: OasDocument = serde_json::from_str(json).unwrap();
/// assert_eq!(doc.id, "did:oas:test:hmr:alice");
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OasDocument {
    /// JSON-LD contexts. MUST include the OAS context.
    #[serde(rename = "@context")]
    pub context: Vec<String>,

    /// The `did:oas` identifier.
    pub id: String,

    /// DID of the controlling entity.
    pub controller: String,

    /// At least one Ed25519VerificationKey2020.
    pub verification_method: Vec<VerificationMethod>,

    /// References to keys authorized for authentication.
    pub authentication: Vec<String>,

    /// References to keys authorized for assertions.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub assertion_method: Vec<String>,

    /// References to keys authorized for capability invocation.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub capability_invocation: Vec<String>,

    /// References to keys authorized for capability delegation.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub capability_delegation: Vec<String>,

    /// Service endpoints.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub service: Vec<ServiceEndpoint>,

    /// OAS specification version (currently `"1.0.0"`).
    pub oas_version: String,

    /// Entity kind from the taxonomy.
    pub kind: String,

    /// Human-readable name (max 256 chars).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,

    /// Human-readable description (max 4096 chars).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Lineage information (required for L1+ non-root entities).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub lineage: Option<LineageSection>,

    /// Governance section (ENR entities only).
    /// Tracks which MHR governs this enterprise identity, epoch, and transition history.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub governance: Option<GovernanceSection>,

    /// Conformance level: L0, L1, or L2.
    pub conformance_level: ConformanceLevel,

    /// Lifecycle status.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub lifecycle_status: Option<LifecycleStatus>,

    /// Monotonically increasing version counter (starting at 1).
    pub sequence: u64,

    /// Document metadata.
    pub metadata: DocumentMetadata,

    /// Document proof (Ed25519Signature2020).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub proof: Option<DocumentProof>,

    /// Whether this identity has been revoked.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub revoked: Option<bool>,

    /// Timestamp of revocation.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub revoked_at: Option<String>,

    // ── Extended Identity Sections (OAS §5.8–§5.16) ──────────────────
    /// Profile / presentation data (§5.8): avatar, tagline, categories.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub profile: Option<ProfileSection>,

    /// Contact directory (§5.9): phones, emails, socials, websites.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub contact: Option<ContactSection>,

    /// Calendar & scheduling (§5.10): booking links, office hours.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub calendar: Option<CalendarSection>,

    /// Operational specification (§5.11): SLA, rate limits, formats.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub operational: Option<OperationalSection>,

    /// Pricing & economics (§5.12): cost model, billing, free tier.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pricing: Option<PricingSection>,

    /// Interoperability (§5.13): protocols, API schemas, auth methods.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub interoperability: Option<InteroperabilitySection>,

    /// Reputation summary (§5.14): interactions, endorsements, uptime.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reputation: Option<ReputationSection>,

    /// Compliance & jurisdiction (§5.15): certifications, policy URLs.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compliance: Option<ComplianceSection>,

    /// Relationships & affiliations (§5.16): org structure, partnerships.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relationships: Option<RelationshipsSection>,
}

impl OasDocument {
    /// Returns the default JSON-LD context array for OAS documents.
    pub fn default_context() -> Vec<String> {
        vec![
            DID_CONTEXT.to_string(),
            ED25519_CONTEXT.to_string(),
            OAS_CONTEXT.to_string(),
        ]
    }

    /// Returns true if this document represents a root entity (HMR, MHR, or ENR).
    pub fn is_root(&self) -> bool {
        self.kind == "hmr" || self.kind == "mhr" || self.kind == "enr"
    }

    /// Returns true if this document has been revoked.
    pub fn is_revoked(&self) -> bool {
        self.revoked.unwrap_or(false)
    }

    /// Returns the primary public key multibase string, if available.
    ///
    /// Looks for the first verification method's public key.
    pub fn primary_public_key_multibase(&self) -> Option<&str> {
        self.verification_method
            .first()
            .map(|vm| vm.public_key_multibase.as_str())
    }

    /// Finds a verification method by its full ID or fragment.
    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() {
        // Documents created before §5.8–§5.16 (no profile, contact, etc.)
        // must deserialize with all new fields as None.
        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();
        // None sections should not appear in serialized output
        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"));
    }
}