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}