oas-document 1.1.1

OAS Identity Document types and validation for the Open Agent Specification
Documentation
// OAS Spec §8 — Agent Lineage Protocol
//! Lineage section types for OAS Identity Documents.

use oas_crypto::proof::AgentLineageProof;
use serde::{Deserialize, Serialize};

/// The lineage section of an OAS Identity Document.
///
/// Contains the cryptographic chain linking an entity to its human root.
/// Required for L1+ conformance on non-root entities.
///
/// See OAS Specification §8 for the complete structure.
///
/// # Examples
///
/// ```
/// use oas_document::lineage_section::LineageSection;
///
/// let lineage = LineageSection {
///     human_root_did: "did:oas:acme:hmr:alice".to_string(),
///     creator_did: "did:oas:acme:hmr:alice".to_string(),
///     generation: 0,
///     derivation_proof: None,
///     human_root_chain: vec![
///         "did:oas:acme:ao:research".to_string(),
///         "did:oas:acme:hmr:alice".to_string(),
///     ],
///     org_inclusion_proof: None,
///     anchor_refs: None,
/// };
/// assert_eq!(lineage.generation, 0);
/// ```
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LineageSection {
    /// DID of the ultimate human root (HMR, MHR, or ENR).
    pub human_root_did: String,

    /// DID of the entity that directly created this entity.
    pub creator_did: String,

    /// Number of derivation steps from human root.
    /// 0 = created directly by human root.
    pub generation: u32,

    /// AgentLineageProof2025 linking this entity to its creator.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub derivation_proof: Option<AgentLineageProof>,

    /// Ordered array of DIDs from this entity to human root.
    pub human_root_chain: Vec<String>,

    /// Optional Merkle inclusion proof binding this entity to the
    /// `OrgLineageRoot` of its creator (an MHR / ENR org). REQUIRED at
    /// resolution time when the GAL has an `OrgLineageRoot` for the
    /// creator DID. Verification uses BLAKE3; see
    /// [`OrgInclusionProof`].
    #[serde(skip_serializing_if = "Option::is_none")]
    pub org_inclusion_proof: Option<OrgInclusionProof>,

    /// References to on-chain or transparency-log anchors attesting to
    /// this lineage section's registration.
    ///
    /// Lineage authority is backend-agnostic (see [`AnchorRef`]). An
    /// entity MAY be anchored on multiple backends at once - Sigil and an
    /// EAS attestation and a transparency log - and verifiers consult the
    /// refs for the schemes they trust. A verifier MUST ignore refs whose
    /// `scheme` it does not support rather than rejecting the document:
    /// unknown schemes are additive, never breaking.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub anchor_refs: Option<Vec<AnchorRef>>,
}

/// A reference to an on-chain (or transparency-log) anchor attesting to a
/// lineage registration.
///
/// Lineage *proofs* verify offline; lineage *authority* - proof that the
/// claimed root exists, that this delegation was recorded, and that
/// nothing in the chain is revoked or superseded - is what an anchor
/// backend supplies. The scheme names the backend; the locator addresses
/// the record within it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnchorRef {
    /// The anchor scheme: `sigil`, `eas`, `ctlog`, or a future registered
    /// name. Verifiers ignore schemes they do not support.
    pub scheme: String,

    /// Scheme-specific locator: a transaction hash (`sigil`), an
    /// attestation UID (`eas`), a log identifier and leaf index (`ctlog`).
    /// Opaque to verifiers that do not implement the scheme.
    pub locator: String,

    /// Optional inclusion proof binding the locator to the anchor
    /// backend's canonical root. Presence semantics are scheme-specific;
    /// some schemes (EAS) are self-proving and carry none, others (batch
    /// Merkle anchoring) require one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub inclusion_proof: Option<AnchorInclusionProof>,
}

impl AnchorRef {
    /// The display form: `anchor:<scheme>:<locator>`.
    pub fn to_uri(&self) -> String {
        format!("anchor:{}:{}", self.scheme, self.locator)
    }

    /// Parse the display form back into a ref (without a proof).
    ///
    /// The locator may itself contain colons (e.g. `ctlog` log-id:index
    /// forms), so parsing splits at the first two colons only.
    pub fn parse_uri(uri: &str) -> Option<Self> {
        let rest = uri.strip_prefix("anchor:")?;
        let (scheme, locator) = rest.split_once(':')?;
        if scheme.is_empty() || locator.is_empty() {
            return None;
        }
        Some(Self {
            scheme: scheme.to_string(),
            locator: locator.to_string(),
            inclusion_proof: None,
        })
    }
}

/// An inclusion proof binding an [`AnchorRef`] locator to its backend's
/// canonical root.
///
/// The shape is the Merkle family generalization used by
/// [`OrgInclusionProof`], named by `proof_type` so a backend can register
/// others: `merkle-blake3` (Sigil, transparency logs), `eas-attestation`
/// (EAS, where the attestation itself is the proof and `root` is the
/// schema UID), or future registered types.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AnchorInclusionProof {
    /// The proof family: `merkle-blake3`, `eas-attestation`, or a future
    /// registered name. Verifiers ignore types they do not support.
    pub proof_type: String,

    /// The root this proof binds to (hex digest or backend-native form).
    pub root: String,

    /// The leaf the anchored registration hashes to.
    pub leaf_hash: String,

    /// Ordered sibling path from leaf to root (Merkle-family proofs).
    /// Empty for self-proving schemes.
    #[serde(default)]
    pub path: Vec<MerkleProofNode>,
}

/// Direction of a sibling node in a Merkle proof path.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MerkleDirection {
    /// Sibling is on the left; reconstructed hash combines as
    /// `H(sibling || current)`.
    Left,
    /// Sibling is on the right; reconstructed hash combines as
    /// `H(current || sibling)`.
    Right,
}

/// One step in a Merkle inclusion proof path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MerkleProofNode {
    /// `blake3:`-prefixed hex of the sibling hash at this level.
    pub sibling_hash: String,
    /// Direction of the sibling relative to the current node.
    pub direction: MerkleDirection,
}

/// Merkle inclusion proof binding a child entity to an
/// `OrgLineageRoot`.
///
/// Canonicalization rules (verified by `oas-resolve::sigil_guard` with
/// the BLAKE3-32 hash function):
///
/// 1. **Leaf hash:** `blake3:` + hex(BLAKE3(child_did_utf8_bytes)).
///    The org commits to a tree of child DIDs.
/// 2. **Internal nodes:** at each level, combine the running hash and
///    the sibling per `direction`. `Left`  → `H(sibling || current)`;
///    `Right` → `H(current || sibling)`. Both halves are taken as raw
///    32-byte BLAKE3 outputs (after stripping the `blake3:` prefix).
/// 3. **Root match:** the final reconstructed root MUST equal both
///    `proof.merkle_root` and the `merkle_root` of the
///    `OrgLineageRoot` returned by the GAL for the creator DID.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OrgInclusionProof {
    /// `blake3:`-prefixed hex of the org's Merkle root. MUST equal the
    /// on-chain `OrgLineageRoot.merkle_root`.
    pub merkle_root: String,
    /// `blake3:`-prefixed hex of the leaf — `BLAKE3(child_did)`.
    pub leaf_hash: String,
    /// Ordered path of sibling hashes from leaf to root.
    pub path: Vec<MerkleProofNode>,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn section_with_refs(refs: Option<Vec<AnchorRef>>) -> LineageSection {
        LineageSection {
            human_root_did: "did:oas:ns:hmr:alice".to_string(),
            creator_did: "did:oas:ns:hmr:alice".to_string(),
            generation: 0,
            derivation_proof: None,
            human_root_chain: vec!["did:oas:ns:hmr:alice".to_string()],
            org_inclusion_proof: None,
            anchor_refs: refs,
        }
    }

    #[test]
    fn anchor_refs_round_trip_camel_case() {
        let refs = vec![
            AnchorRef {
                scheme: "sigil".to_string(),
                locator: "tx:0xabc123".to_string(),
                inclusion_proof: Some(AnchorInclusionProof {
                    proof_type: "merkle-blake3".to_string(),
                    root: "blake3:aa".to_string(),
                    leaf_hash: "blake3:bb".to_string(),
                    path: vec![MerkleProofNode {
                        sibling_hash: "blake3:cc".to_string(),
                        direction: MerkleDirection::Left,
                    }],
                }),
            },
            AnchorRef {
                scheme: "eas".to_string(),
                locator: "1:0xdef456".to_string(),
                inclusion_proof: None,
            },
        ];
        let lineage = section_with_refs(Some(refs.clone()));
        let json = serde_json::to_string(&lineage).expect("serialize");
        // camelCase wire form.
        assert!(json.contains("\"anchorRefs\""));
        assert!(json.contains("\"inclusionProof\""));
        assert!(json.contains("\"proofType\""));
        assert!(json.contains("\"leafHash\""));
        let parsed: LineageSection = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(parsed.anchor_refs.as_deref(), Some(refs.as_slice()));
    }

    #[test]
    fn absent_anchor_refs_are_omitted_from_json() {
        let lineage = section_with_refs(None);
        let json = serde_json::to_string(&lineage).expect("serialize");
        assert!(!json.contains("anchorRefs"));
    }

    #[test]
    fn unknown_schemes_are_tolerated_and_ignored_not_rejected() {
        // A verifier's scheme filter: unknown schemes are additive, never
        // breaking - the document deserializes fully and the verifier
        // simply processes the schemes it knows.
        let json = r##"{
            "humanRootDid": "did:oas:ns:hmr:alice",
            "creatorDid": "did:oas:ns:hmr:alice",
            "generation": 0,
            "humanRootChain": ["did:oas:ns:hmr:alice"],
            "anchorRefs": [
                {"scheme": "future-scheme-2031", "locator": "whatever"},
                {"scheme": "sigil", "locator": "tx:0xabc"}
            ]
        }"##;
        let parsed: LineageSection =
            serde_json::from_str(json).expect("deserializes with unknown schemes");
        let refs = parsed.anchor_refs.as_deref().expect("refs present");
        let supported: Vec<&AnchorRef> = refs
            .iter()
            .filter(|r| matches!(r.scheme.as_str(), "sigil" | "eas" | "ctlog"))
            .collect();
        assert_eq!(supported.len(), 1);
        assert_eq!(supported[0].scheme, "sigil");
    }

    #[test]
    fn anchor_ref_uri_display_and_parse() {
        let reference = AnchorRef {
            scheme: "eas".to_string(),
            locator: "1:0xdef456".to_string(),
            inclusion_proof: None,
        };
        let uri = reference.to_uri();
        assert_eq!(uri, "anchor:eas:1:0xdef456");
        let parsed = AnchorRef::parse_uri(&uri).expect("parses");
        assert_eq!(parsed.scheme, "eas");
        // Locators may contain colons themselves (log-id:index forms).
        assert_eq!(parsed.locator, "1:0xdef456");
        assert!(AnchorRef::parse_uri("not-an-anchor-ref").is_none());
        assert!(AnchorRef::parse_uri("anchor:eas:").is_none());
    }

    #[test]
    fn serde_round_trip() {
        let lineage = LineageSection {
            human_root_did: "did:oas:ns:hmr:alice".to_string(),
            creator_did: "did:oas:ns:hmr:alice".to_string(),
            generation: 0,
            derivation_proof: None,
            human_root_chain: vec![
                "did:oas:ns:ao:org".to_string(),
                "did:oas:ns:hmr:alice".to_string(),
            ],
            org_inclusion_proof: None,
            anchor_refs: None,
        };
        let json = serde_json::to_string(&lineage).unwrap_or_default();
        let parsed: LineageSection =
            serde_json::from_str(&json).unwrap_or_else(|_| lineage.clone());
        assert_eq!(lineage, parsed);
    }

    #[test]
    fn generation_matches_chain_minus_one() {
        let lineage = LineageSection {
            human_root_did: "did:oas:ns:hmr:alice".to_string(),
            creator_did: "did:oas:ns:ao:org".to_string(),
            generation: 2,
            derivation_proof: None,
            human_root_chain: vec![
                "did:oas:ns:agent:bot".to_string(),
                "did:oas:ns:ao:org".to_string(),
                "did:oas:ns:hmr:alice".to_string(),
            ],
            org_inclusion_proof: None,
            anchor_refs: None,
        };
        assert_eq!(
            lineage.generation as usize,
            lineage.human_root_chain.len() - 1
        );
    }

    #[test]
    fn org_inclusion_proof_round_trips_through_serde() {
        let proof = OrgInclusionProof {
            merkle_root: "blake3:rootabc".into(),
            leaf_hash: "blake3:leafdef".into(),
            path: vec![
                MerkleProofNode {
                    sibling_hash: "blake3:sibling1".into(),
                    direction: MerkleDirection::Left,
                },
                MerkleProofNode {
                    sibling_hash: "blake3:sibling2".into(),
                    direction: MerkleDirection::Right,
                },
            ],
        };
        let json = serde_json::to_string(&proof).expect("serialize");
        // Direction enum is camelCase on the wire.
        assert!(json.contains("\"direction\":\"left\""));
        assert!(json.contains("\"direction\":\"right\""));
        let back: OrgInclusionProof = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(proof, back);
    }

    #[test]
    fn org_inclusion_proof_field_is_optional_in_lineage() {
        // A document without an org inclusion proof omits the field.
        let lineage = LineageSection {
            human_root_did: "did:oas:ns:mhr:org".into(),
            creator_did: "did:oas:ns:mhr:org".into(),
            generation: 1,
            derivation_proof: None,
            human_root_chain: vec!["did:oas:ns:mhr:org".into()],
            org_inclusion_proof: None,
            anchor_refs: None,
        };
        let json = serde_json::to_string(&lineage).expect("ser");
        assert!(!json.contains("orgInclusionProof"));
    }
}