Skip to main content

oas_document/
lineage_section.rs

1// OAS Spec §8 — Agent Lineage Protocol
2//! Lineage section types for OAS Identity Documents.
3
4use oas_crypto::proof::AgentLineageProof;
5use serde::{Deserialize, Serialize};
6
7/// The lineage section of an OAS Identity Document.
8///
9/// Contains the cryptographic chain linking an entity to its human root.
10/// Required for L1+ conformance on non-root entities.
11///
12/// See OAS Specification §8 for the complete structure.
13///
14/// # Examples
15///
16/// ```
17/// use oas_document::lineage_section::LineageSection;
18///
19/// let lineage = LineageSection {
20///     human_root_did: "did:oas:acme:hmr:alice".to_string(),
21///     creator_did: "did:oas:acme:hmr:alice".to_string(),
22///     generation: 0,
23///     derivation_proof: None,
24///     human_root_chain: vec![
25///         "did:oas:acme:ao:research".to_string(),
26///         "did:oas:acme:hmr:alice".to_string(),
27///     ],
28///     org_inclusion_proof: None,
29///     anchor_refs: None,
30/// };
31/// assert_eq!(lineage.generation, 0);
32/// ```
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct LineageSection {
36    /// DID of the ultimate human root (HMR, MHR, or ENR).
37    pub human_root_did: String,
38
39    /// DID of the entity that directly created this entity.
40    pub creator_did: String,
41
42    /// Number of derivation steps from human root.
43    /// 0 = created directly by human root.
44    pub generation: u32,
45
46    /// AgentLineageProof2025 linking this entity to its creator.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub derivation_proof: Option<AgentLineageProof>,
49
50    /// Ordered array of DIDs from this entity to human root.
51    pub human_root_chain: Vec<String>,
52
53    /// Optional Merkle inclusion proof binding this entity to the
54    /// `OrgLineageRoot` of its creator (an MHR / ENR org). REQUIRED at
55    /// resolution time when the GAL has an `OrgLineageRoot` for the
56    /// creator DID. Verification uses BLAKE3; see
57    /// [`OrgInclusionProof`].
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub org_inclusion_proof: Option<OrgInclusionProof>,
60
61    /// References to on-chain or transparency-log anchors attesting to
62    /// this lineage section's registration.
63    ///
64    /// Lineage authority is backend-agnostic (see [`AnchorRef`]). An
65    /// entity MAY be anchored on multiple backends at once - Sigil and an
66    /// EAS attestation and a transparency log - and verifiers consult the
67    /// refs for the schemes they trust. A verifier MUST ignore refs whose
68    /// `scheme` it does not support rather than rejecting the document:
69    /// unknown schemes are additive, never breaking.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub anchor_refs: Option<Vec<AnchorRef>>,
72}
73
74/// A reference to an on-chain (or transparency-log) anchor attesting to a
75/// lineage registration.
76///
77/// Lineage *proofs* verify offline; lineage *authority* - proof that the
78/// claimed root exists, that this delegation was recorded, and that
79/// nothing in the chain is revoked or superseded - is what an anchor
80/// backend supplies. The scheme names the backend; the locator addresses
81/// the record within it.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct AnchorRef {
85    /// The anchor scheme: `sigil`, `eas`, `ctlog`, or a future registered
86    /// name. Verifiers ignore schemes they do not support.
87    pub scheme: String,
88
89    /// Scheme-specific locator: a transaction hash (`sigil`), an
90    /// attestation UID (`eas`), a log identifier and leaf index (`ctlog`).
91    /// Opaque to verifiers that do not implement the scheme.
92    pub locator: String,
93
94    /// Optional inclusion proof binding the locator to the anchor
95    /// backend's canonical root. Presence semantics are scheme-specific;
96    /// some schemes (EAS) are self-proving and carry none, others (batch
97    /// Merkle anchoring) require one.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub inclusion_proof: Option<AnchorInclusionProof>,
100}
101
102impl AnchorRef {
103    /// The display form: `anchor:<scheme>:<locator>`.
104    pub fn to_uri(&self) -> String {
105        format!("anchor:{}:{}", self.scheme, self.locator)
106    }
107
108    /// Parse the display form back into a ref (without a proof).
109    ///
110    /// The locator may itself contain colons (e.g. `ctlog` log-id:index
111    /// forms), so parsing splits at the first two colons only.
112    pub fn parse_uri(uri: &str) -> Option<Self> {
113        let rest = uri.strip_prefix("anchor:")?;
114        let (scheme, locator) = rest.split_once(':')?;
115        if scheme.is_empty() || locator.is_empty() {
116            return None;
117        }
118        Some(Self {
119            scheme: scheme.to_string(),
120            locator: locator.to_string(),
121            inclusion_proof: None,
122        })
123    }
124}
125
126/// An inclusion proof binding an [`AnchorRef`] locator to its backend's
127/// canonical root.
128///
129/// The shape is the Merkle family generalization used by
130/// [`OrgInclusionProof`], named by `proof_type` so a backend can register
131/// others: `merkle-blake3` (Sigil, transparency logs), `eas-attestation`
132/// (EAS, where the attestation itself is the proof and `root` is the
133/// schema UID), or future registered types.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct AnchorInclusionProof {
137    /// The proof family: `merkle-blake3`, `eas-attestation`, or a future
138    /// registered name. Verifiers ignore types they do not support.
139    pub proof_type: String,
140
141    /// The root this proof binds to (hex digest or backend-native form).
142    pub root: String,
143
144    /// The leaf the anchored registration hashes to.
145    pub leaf_hash: String,
146
147    /// Ordered sibling path from leaf to root (Merkle-family proofs).
148    /// Empty for self-proving schemes.
149    #[serde(default)]
150    pub path: Vec<MerkleProofNode>,
151}
152
153/// Direction of a sibling node in a Merkle proof path.
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub enum MerkleDirection {
157    /// Sibling is on the left; reconstructed hash combines as
158    /// `H(sibling || current)`.
159    Left,
160    /// Sibling is on the right; reconstructed hash combines as
161    /// `H(current || sibling)`.
162    Right,
163}
164
165/// One step in a Merkle inclusion proof path.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct MerkleProofNode {
169    /// `blake3:`-prefixed hex of the sibling hash at this level.
170    pub sibling_hash: String,
171    /// Direction of the sibling relative to the current node.
172    pub direction: MerkleDirection,
173}
174
175/// Merkle inclusion proof binding a child entity to an
176/// `OrgLineageRoot`.
177///
178/// Canonicalization rules (verified by `oas-resolve::sigil_guard` with
179/// the BLAKE3-32 hash function):
180///
181/// 1. **Leaf hash:** `blake3:` + hex(BLAKE3(child_did_utf8_bytes)).
182///    The org commits to a tree of child DIDs.
183/// 2. **Internal nodes:** at each level, combine the running hash and
184///    the sibling per `direction`. `Left`  → `H(sibling || current)`;
185///    `Right` → `H(current || sibling)`. Both halves are taken as raw
186///    32-byte BLAKE3 outputs (after stripping the `blake3:` prefix).
187/// 3. **Root match:** the final reconstructed root MUST equal both
188///    `proof.merkle_root` and the `merkle_root` of the
189///    `OrgLineageRoot` returned by the GAL for the creator DID.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub struct OrgInclusionProof {
193    /// `blake3:`-prefixed hex of the org's Merkle root. MUST equal the
194    /// on-chain `OrgLineageRoot.merkle_root`.
195    pub merkle_root: String,
196    /// `blake3:`-prefixed hex of the leaf — `BLAKE3(child_did)`.
197    pub leaf_hash: String,
198    /// Ordered path of sibling hashes from leaf to root.
199    pub path: Vec<MerkleProofNode>,
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn section_with_refs(refs: Option<Vec<AnchorRef>>) -> LineageSection {
207        LineageSection {
208            human_root_did: "did:oas:ns:hmr:alice".to_string(),
209            creator_did: "did:oas:ns:hmr:alice".to_string(),
210            generation: 0,
211            derivation_proof: None,
212            human_root_chain: vec!["did:oas:ns:hmr:alice".to_string()],
213            org_inclusion_proof: None,
214            anchor_refs: refs,
215        }
216    }
217
218    #[test]
219    fn anchor_refs_round_trip_camel_case() {
220        let refs = vec![
221            AnchorRef {
222                scheme: "sigil".to_string(),
223                locator: "tx:0xabc123".to_string(),
224                inclusion_proof: Some(AnchorInclusionProof {
225                    proof_type: "merkle-blake3".to_string(),
226                    root: "blake3:aa".to_string(),
227                    leaf_hash: "blake3:bb".to_string(),
228                    path: vec![MerkleProofNode {
229                        sibling_hash: "blake3:cc".to_string(),
230                        direction: MerkleDirection::Left,
231                    }],
232                }),
233            },
234            AnchorRef {
235                scheme: "eas".to_string(),
236                locator: "1:0xdef456".to_string(),
237                inclusion_proof: None,
238            },
239        ];
240        let lineage = section_with_refs(Some(refs.clone()));
241        let json = serde_json::to_string(&lineage).expect("serialize");
242        // camelCase wire form.
243        assert!(json.contains("\"anchorRefs\""));
244        assert!(json.contains("\"inclusionProof\""));
245        assert!(json.contains("\"proofType\""));
246        assert!(json.contains("\"leafHash\""));
247        let parsed: LineageSection = serde_json::from_str(&json).expect("deserialize");
248        assert_eq!(parsed.anchor_refs.as_deref(), Some(refs.as_slice()));
249    }
250
251    #[test]
252    fn absent_anchor_refs_are_omitted_from_json() {
253        let lineage = section_with_refs(None);
254        let json = serde_json::to_string(&lineage).expect("serialize");
255        assert!(!json.contains("anchorRefs"));
256    }
257
258    #[test]
259    fn unknown_schemes_are_tolerated_and_ignored_not_rejected() {
260        // A verifier's scheme filter: unknown schemes are additive, never
261        // breaking - the document deserializes fully and the verifier
262        // simply processes the schemes it knows.
263        let json = r##"{
264            "humanRootDid": "did:oas:ns:hmr:alice",
265            "creatorDid": "did:oas:ns:hmr:alice",
266            "generation": 0,
267            "humanRootChain": ["did:oas:ns:hmr:alice"],
268            "anchorRefs": [
269                {"scheme": "future-scheme-2031", "locator": "whatever"},
270                {"scheme": "sigil", "locator": "tx:0xabc"}
271            ]
272        }"##;
273        let parsed: LineageSection =
274            serde_json::from_str(json).expect("deserializes with unknown schemes");
275        let refs = parsed.anchor_refs.as_deref().expect("refs present");
276        let supported: Vec<&AnchorRef> = refs
277            .iter()
278            .filter(|r| matches!(r.scheme.as_str(), "sigil" | "eas" | "ctlog"))
279            .collect();
280        assert_eq!(supported.len(), 1);
281        assert_eq!(supported[0].scheme, "sigil");
282    }
283
284    #[test]
285    fn anchor_ref_uri_display_and_parse() {
286        let reference = AnchorRef {
287            scheme: "eas".to_string(),
288            locator: "1:0xdef456".to_string(),
289            inclusion_proof: None,
290        };
291        let uri = reference.to_uri();
292        assert_eq!(uri, "anchor:eas:1:0xdef456");
293        let parsed = AnchorRef::parse_uri(&uri).expect("parses");
294        assert_eq!(parsed.scheme, "eas");
295        // Locators may contain colons themselves (log-id:index forms).
296        assert_eq!(parsed.locator, "1:0xdef456");
297        assert!(AnchorRef::parse_uri("not-an-anchor-ref").is_none());
298        assert!(AnchorRef::parse_uri("anchor:eas:").is_none());
299    }
300
301    #[test]
302    fn serde_round_trip() {
303        let lineage = LineageSection {
304            human_root_did: "did:oas:ns:hmr:alice".to_string(),
305            creator_did: "did:oas:ns:hmr:alice".to_string(),
306            generation: 0,
307            derivation_proof: None,
308            human_root_chain: vec![
309                "did:oas:ns:ao:org".to_string(),
310                "did:oas:ns:hmr:alice".to_string(),
311            ],
312            org_inclusion_proof: None,
313            anchor_refs: None,
314        };
315        let json = serde_json::to_string(&lineage).unwrap_or_default();
316        let parsed: LineageSection =
317            serde_json::from_str(&json).unwrap_or_else(|_| lineage.clone());
318        assert_eq!(lineage, parsed);
319    }
320
321    #[test]
322    fn generation_matches_chain_minus_one() {
323        let lineage = LineageSection {
324            human_root_did: "did:oas:ns:hmr:alice".to_string(),
325            creator_did: "did:oas:ns:ao:org".to_string(),
326            generation: 2,
327            derivation_proof: None,
328            human_root_chain: vec![
329                "did:oas:ns:agent:bot".to_string(),
330                "did:oas:ns:ao:org".to_string(),
331                "did:oas:ns:hmr:alice".to_string(),
332            ],
333            org_inclusion_proof: None,
334            anchor_refs: None,
335        };
336        assert_eq!(
337            lineage.generation as usize,
338            lineage.human_root_chain.len() - 1
339        );
340    }
341
342    #[test]
343    fn org_inclusion_proof_round_trips_through_serde() {
344        let proof = OrgInclusionProof {
345            merkle_root: "blake3:rootabc".into(),
346            leaf_hash: "blake3:leafdef".into(),
347            path: vec![
348                MerkleProofNode {
349                    sibling_hash: "blake3:sibling1".into(),
350                    direction: MerkleDirection::Left,
351                },
352                MerkleProofNode {
353                    sibling_hash: "blake3:sibling2".into(),
354                    direction: MerkleDirection::Right,
355                },
356            ],
357        };
358        let json = serde_json::to_string(&proof).expect("serialize");
359        // Direction enum is camelCase on the wire.
360        assert!(json.contains("\"direction\":\"left\""));
361        assert!(json.contains("\"direction\":\"right\""));
362        let back: OrgInclusionProof = serde_json::from_str(&json).expect("deserialize");
363        assert_eq!(proof, back);
364    }
365
366    #[test]
367    fn org_inclusion_proof_field_is_optional_in_lineage() {
368        // A document without an org inclusion proof omits the field.
369        let lineage = LineageSection {
370            human_root_did: "did:oas:ns:mhr:org".into(),
371            creator_did: "did:oas:ns:mhr:org".into(),
372            generation: 1,
373            derivation_proof: None,
374            human_root_chain: vec!["did:oas:ns:mhr:org".into()],
375            org_inclusion_proof: None,
376            anchor_refs: None,
377        };
378        let json = serde_json::to_string(&lineage).expect("ser");
379        assert!(!json.contains("orgInclusionProof"));
380    }
381}