Skip to main content

acdp_types/
receipt.rs

1//! Registry receipts (ACDP 0.2, RFC-ACDP-0010 — promoted from the
2//! RFC-ACDP-0009 §2.7 reservation).
3//!
4//! A receipt is a **registry-signed attestation** binding the
5//! registry-assigned identifiers (`ctx_id`, `lineage_id`,
6//! `origin_registry`, `created_at`) and the resolved producer key
7//! (`key_fingerprint`) to the producer's `content_hash`. It closes the
8//! two v0.1.0 trust gaps documented in RFC-ACDP-0008 §9:
9//!
10//! - **Registry honesty (§9.1)** — without a receipt, a malicious
11//!   registry can republish signed content under a different `ctx_id`
12//!   or backdate `created_at` and producer-signature verification
13//!   still passes. A receipt makes those claims attributable and
14//!   non-repudiable (though not unforgeable at mint time — a registry
15//!   can still lie when it first issues; the transparency log reserved
16//!   by RFC-ACDP-0009 §2.11 is the next layer).
17//! - **Historical key validity (§9.3)** — `key_fingerprint` records
18//!   *which* producer key the registry resolved and verified at publish
19//!   time, so consumers can verify old contexts after the producer
20//!   rotates.
21//!
22//! ## Signing construction
23//!
24//! Deliberately identical to the producer signature (RFC-ACDP-0001
25//! §5.8): the preimage is the JCS-canonicalized receipt object **minus
26//! the `signature` field only** (no §5.7 exclusion set — the
27//! registry-assigned fields are the entire point), hashed with SHA-256,
28//! and the signature is over the **ASCII bytes of the
29//! `"sha256:<hex>"` string**, not the raw digest.
30
31use crate::body::Signature;
32use acdp_jcs::try_canonicalize_value;
33use acdp_primitives::error::AcdpError;
34use acdp_primitives::primitives::{ContentHash, CtxId, LineageId, Status};
35use chrono::{DateTime, Utc};
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38
39/// The lineage-head receipt envelope version (RFC-ACDP-0011 §4).
40/// Doubles as an in-preimage domain separator: a head receipt can never
41/// be mistaken for (or replayed as) an RFC-ACDP-0010 context receipt,
42/// whose preimage carries no such member.
43pub const LINEAGE_HEAD_RECEIPT_VERSION: &str = "acdp-lhr/1";
44
45/// Shared RFC-ACDP-0010 §5 / RFC-ACDP-0011 §5 preimage hash: the JSON
46/// object minus its `signature` member, JCS-canonicalized, SHA-256'd.
47fn preimage_hash_of_object(
48    value: &serde_json::Value,
49    what: &str,
50) -> Result<ContentHash, AcdpError> {
51    let mut map = value
52        .as_object()
53        .cloned()
54        .ok_or_else(|| AcdpError::InvalidReceipt(format!("{what} must be a JSON object")))?;
55    map.remove("signature");
56    let canonical = try_canonicalize_value(&serde_json::Value::Object(map))?;
57    let digest = Sha256::digest(&canonical);
58    Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
59}
60
61/// Shared signature check over an already-computed receipt hash. Both
62/// receipt kinds use the identical RFC-ACDP-0010 §5 construction: the
63/// signature is over the ASCII bytes of the full `"sha256:<hex>"`
64/// string.
65fn verify_receipt_signature_over_hash(
66    signature: &Signature,
67    hash: &ContentHash,
68    registry_pub_ed25519: Option<&[u8; 32]>,
69    registry_pub_p256_sec1: Option<&[u8]>,
70) -> Result<(), AcdpError> {
71    match signature.algorithm.as_str() {
72        "ed25519" => {
73            let key = registry_pub_ed25519.ok_or_else(|| {
74                AcdpError::InvalidReceipt(
75                    "receipt declares ed25519 but no ed25519 registry key was resolved".into(),
76                )
77            })?;
78            acdp_crypto::verify::verify_ed25519(key, &signature.value, hash.as_str())
79                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt signature: {e}")))
80        }
81        "ecdsa-p256" => {
82            let key = registry_pub_p256_sec1.ok_or_else(|| {
83                AcdpError::InvalidReceipt(
84                    "receipt declares ecdsa-p256 but no p256 registry key was resolved".into(),
85                )
86            })?;
87            acdp_crypto::verify::verify_ecdsa_p256(key, &signature.value, hash.as_str())
88                .map_err(|e| AcdpError::InvalidReceipt(format!("receipt signature: {e}")))
89        }
90        other => Err(AcdpError::InvalidReceipt(format!(
91            "receipt signature algorithm '{other}' is not supported"
92        ))),
93    }
94}
95
96/// True when `raw` is canonical millisecond-precision RFC 3339 UTC with
97/// exactly three fractional digits and a literal `Z`
98/// (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0001 §5.3).
99fn is_canonical_ms_utc(raw: &str) -> bool {
100    let b = raw.as_bytes();
101    b.len() == 24
102        && b[10] == b'T'
103        && b[19] == b'.'
104        && b[23] == b'Z'
105        && b[20..23].iter().all(u8::is_ascii_digit)
106        && chrono::DateTime::parse_from_rfc3339(raw).is_ok()
107}
108
109/// A registry-signed publication receipt.
110///
111/// CLOSED schema (RFC-ACDP-0010 §4, `additionalProperties: false`):
112/// a receipt has exactly the eight specified members. Future receipt
113/// fields require a schema bump, not field-level extensibility —
114/// unknown members are rejected at parse time.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(deny_unknown_fields)]
117pub struct RegistryReceipt {
118    /// The registry's own identity — MUST be `did:web:<authority>`
119    /// where `<authority>` is the authority the context is served from.
120    pub registry_did: String,
121    /// The ctx_id this receipt attests.
122    pub ctx_id: CtxId,
123    /// The lineage the registry assigned.
124    pub lineage_id: LineageId,
125    /// The registry's authority (bare hostname form).
126    pub origin_registry: String,
127    /// Publication acceptance time. Canonical millisecond-precision
128    /// RFC 3339 UTC, always serialized with exactly three fractional
129    /// digits (`…SS.mmmZ`, RFC-ACDP-0010 §8 step 6) — chrono's default
130    /// drops trailing zeros, which would change the preimage bytes.
131    #[serde(with = "ms_rfc3339")]
132    pub created_at: DateTime<Utc>,
133    /// The producer's content hash this receipt binds.
134    pub content_hash: ContentHash,
135    /// Fingerprint of the producer key the registry resolved and
136    /// verified at publish time (see [`acdp_crypto::fingerprint`]).
137    pub key_fingerprint: String,
138    /// Registry signature over the receipt preimage.
139    pub signature: Signature,
140}
141
142/// Fixed three-digit-millisecond RFC 3339 serde for receipt
143/// `created_at` (RFC-ACDP-0010 §8 step 6: `…T…SS.mmmZ`).
144mod ms_rfc3339 {
145    use chrono::{DateTime, Utc};
146    use serde::{Deserialize, Deserializer, Serializer};
147
148    pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
149        s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
150    }
151
152    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
153        let raw = String::deserialize(d)?;
154        DateTime::parse_from_rfc3339(&raw)
155            .map(|t| t.with_timezone(&Utc))
156            .map_err(serde::de::Error::custom)
157    }
158}
159
160impl RegistryReceipt {
161    /// Parse a receipt from the opaque JSON value carried in
162    /// [`crate::body::FullContext::registry_receipt`].
163    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
164        Self::deserialize(value)
165            .map_err(|e| AcdpError::InvalidReceipt(format!("registry_receipt does not parse: {e}")))
166    }
167
168    /// Compute the preimage hash from the RAW wire JSON of a receipt
169    /// (the value minus `signature`, canonicalized as received).
170    ///
171    /// Verifiers MUST hash the receipt exactly as received rather than
172    /// re-serializing a parsed struct — the same "hash verification
173    /// over raw JSON" rule as RFC-ACDP-0001 §6 bodies. Re-serialization
174    /// can normalize byte details (e.g. timestamp fraction digits) and
175    /// falsely fail an honest receipt.
176    pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
177        preimage_hash_of_object(value, "receipt")
178    }
179
180    /// Validate the §8 step 6 byte form of the receipt's raw
181    /// `created_at`: canonical millisecond-precision RFC 3339 UTC with
182    /// exactly three fractional digits and a literal `Z`.
183    pub fn validate_created_at_form(value: &serde_json::Value) -> Result<(), AcdpError> {
184        let raw = value
185            .get("created_at")
186            .and_then(|v| v.as_str())
187            .ok_or_else(|| {
188                AcdpError::InvalidReceipt("receipt created_at missing or not a string".into())
189            })?;
190        if !is_canonical_ms_utc(raw) {
191            return Err(AcdpError::InvalidReceipt(format!(
192                "receipt created_at '{raw}' is not canonical millisecond-precision \
193                 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0010 §8 step 6)"
194            )));
195        }
196        Ok(())
197    }
198
199    /// §8 step 3 body bindings: `lineage_id`, `origin_registry`, and
200    /// `created_at` MUST equal the corresponding fields of the
201    /// accompanying body. (`ctx_id` is bound separately against the
202    /// *requested* identifier in [`Self::cross_check`].)
203    pub fn cross_check_body(&self, body: &crate::body::Body) -> Result<(), AcdpError> {
204        if self.lineage_id != body.lineage_id {
205            return Err(AcdpError::InvalidReceipt(format!(
206                "receipt lineage_id '{}' ≠ body lineage_id '{}'",
207                self.lineage_id, body.lineage_id
208            )));
209        }
210        if self.origin_registry != body.origin_registry {
211            return Err(AcdpError::InvalidReceipt(format!(
212                "receipt origin_registry '{}' ≠ body origin_registry '{}'",
213                self.origin_registry, body.origin_registry
214            )));
215        }
216        if self.created_at != body.created_at {
217            return Err(AcdpError::InvalidReceipt(format!(
218                "receipt created_at '{}' ≠ body created_at '{}'",
219                self.created_at, body.created_at
220            )));
221        }
222        Ok(())
223    }
224
225    /// Compute the receipt's signature preimage hash: SHA-256 over the
226    /// JCS canonical form of the receipt **minus `signature` only**.
227    ///
228    /// Struct-based form, used at MINT time (the struct's serializer
229    /// emits the canonical three-digit-millisecond `created_at`).
230    /// Verifiers should prefer [`Self::preimage_hash_of_value`] over
231    /// the raw wire JSON.
232    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
233        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
234    }
235
236    /// Verify the receipt signature against a known registry public
237    /// key (pure — no DID resolution; the `client` feature's
238    /// `verify_receipt_value` resolves the registry DID and calls
239    /// this).
240    pub fn verify_signature_with_key(
241        &self,
242        registry_pub_ed25519: Option<&[u8; 32]>,
243        registry_pub_p256_sec1: Option<&[u8]>,
244    ) -> Result<(), AcdpError> {
245        let hash = self.preimage_hash()?;
246        self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
247    }
248
249    /// Like [`Self::verify_signature_with_key`] but over an
250    /// already-computed preimage hash — pair with
251    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
252    pub fn verify_signature_against_hash(
253        &self,
254        hash: &ContentHash,
255        registry_pub_ed25519: Option<&[u8; 32]>,
256        registry_pub_p256_sec1: Option<&[u8]>,
257    ) -> Result<(), AcdpError> {
258        verify_receipt_signature_over_hash(
259            &self.signature,
260            hash,
261            registry_pub_ed25519,
262            registry_pub_p256_sec1,
263        )
264    }
265
266    /// The pure (offline) subset of the RFC-ACDP-0010 cross-checks —
267    /// everything except registry-DID resolution and the
268    /// served-authority comparison, which need the `client` feature:
269    ///
270    /// - `ctx_id` equals the requested one.
271    /// - `content_hash` equals the *independently recomputed* body hash
272    ///   (pass the recomputed value, never the body's echoed field).
273    /// - `key_fingerprint` equals the fingerprint of the resolved
274    ///   producer key.
275    /// - `created_at` is millisecond-truncated.
276    /// - `registry_did` is `did:web:<origin_registry>` (internal
277    ///   consistency; the serving-authority comparison is the client's
278    ///   job).
279    pub fn cross_check(
280        &self,
281        expected_ctx_id: &CtxId,
282        recomputed_body_hash: &ContentHash,
283        producer_key_fingerprint: &str,
284    ) -> Result<(), AcdpError> {
285        if &self.ctx_id != expected_ctx_id {
286            return Err(AcdpError::InvalidReceipt(format!(
287                "receipt ctx_id '{}' ≠ requested '{expected_ctx_id}'",
288                self.ctx_id
289            )));
290        }
291        if &self.content_hash != recomputed_body_hash {
292            return Err(AcdpError::InvalidReceipt(format!(
293                "receipt content_hash '{}' ≠ recomputed body hash '{recomputed_body_hash}'",
294                self.content_hash
295            )));
296        }
297        if self.key_fingerprint != producer_key_fingerprint {
298            return Err(AcdpError::InvalidReceipt(format!(
299                "receipt key_fingerprint '{}' ≠ resolved producer key '{producer_key_fingerprint}'",
300                self.key_fingerprint
301            )));
302        }
303        if self.created_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
304            return Err(AcdpError::InvalidReceipt(
305                "receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
306            ));
307        }
308        let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
309        if self.registry_did != expected_did {
310            return Err(AcdpError::InvalidReceipt(format!(
311                "receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
312                self.registry_did
313            )));
314        }
315        Ok(())
316    }
317}
318
319// ── Lineage-head receipts (ACDP 0.3, RFC-ACDP-0011) ──────────────────────────
320
321/// A registry-signed **lineage-head receipt** (RFC-ACDP-0011): the
322/// registry's attestation that, as of `as_of`, the head of `lineage_id`
323/// was `head_ctx_id` at `head_version` with `head_status`.
324///
325/// CLOSED schema (`acdp-lineage-head-receipt.schema.json`,
326/// `additionalProperties: false`): every member is signed, so an
327/// unknown member changes the preimage and is rejected at parse time.
328/// The signing construction reuses RFC-ACDP-0010 §5 verbatim — JCS of
329/// the object minus `signature`, SHA-256, signature over the ASCII
330/// bytes of `"sha256:<hex>"` — with `receipt_version` acting as the
331/// in-preimage domain separator.
332///
333/// Unlike [`RegistryReceipt`], head receipts are **ephemeral**: the
334/// head moves on every supersession, so a registry mints a fresh
335/// receipt (fresh `as_of`) per `/current` response (RFC-ACDP-0011 §6).
336#[derive(Debug, Clone, Serialize, Deserialize)]
337#[serde(deny_unknown_fields)]
338pub struct LineageHeadReceipt {
339    /// MUST be exactly [`LINEAGE_HEAD_RECEIPT_VERSION`] (`"acdp-lhr/1"`).
340    pub receipt_version: String,
341    /// The attesting registry's DID — `did:web:<authority>` where
342    /// `<authority>` is the registry's serving authority
343    /// (RFC-ACDP-0011 §4, did:web-only as RFC-ACDP-0010 §4).
344    pub registry_did: String,
345    /// The attested lineage (RFC-ACDP-0001 §5.6).
346    pub lineage_id: LineageId,
347    /// The `ctx_id` of the head version. Its authority MUST equal the
348    /// method-specific identifier of `registry_did` (lineages are
349    /// single-registry, RFC-ACDP-0004 §5.3).
350    pub head_ctx_id: CtxId,
351    /// The head's `body.version` (≥ 1).
352    pub head_version: u32,
353    /// The head's registry-derived status at `as_of` (RFC-ACDP-0004
354    /// §4). Never `superseded` — a superseded version is never the
355    /// head — and never `retracted` (RFC-ACDP-0013 §8.3: a retracted
356    /// version is never served as head); in practice `active` or
357    /// `expired`. Kept as the raw wire string so byte-for-byte
358    /// comparison with the served `registry_state.status` is exact
359    /// (RFC-ACDP-0011 §7 step 5).
360    pub head_status: String,
361    /// Registry response-time clock when the head claim was evaluated.
362    /// Canonical millisecond-precision RFC 3339 UTC, always serialized
363    /// with exactly three fractional digits (RFC-ACDP-0001 §5.3).
364    #[serde(with = "ms_rfc3339")]
365    pub as_of: DateTime<Utc>,
366    /// Registry signature over the receipt preimage — same envelope and
367    /// same receipt signing key as RFC-ACDP-0010 §4/§5.
368    pub signature: Signature,
369}
370
371impl LineageHeadReceipt {
372    /// Parse a head receipt from the JSON value carried in
373    /// [`crate::body::FullContext::lineage_head_receipt`], enforcing
374    /// the closed schema plus the RFC-ACDP-0011 §4 semantic invariants
375    /// (§7 step 1): exact `receipt_version`, `head_version ≥ 1`,
376    /// `head_status` pattern and never `superseded`, `did:web`-only
377    /// `registry_did`, canonical millisecond `as_of` byte form.
378    pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
379        let receipt = Self::deserialize(value).map_err(|e| {
380            AcdpError::InvalidReceipt(format!("lineage_head_receipt does not parse: {e}"))
381        })?;
382        if receipt.receipt_version != LINEAGE_HEAD_RECEIPT_VERSION {
383            return Err(AcdpError::InvalidReceipt(format!(
384                "lineage_head_receipt receipt_version '{}' ≠ '{LINEAGE_HEAD_RECEIPT_VERSION}' \
385                 (RFC-ACDP-0011 §4)",
386                receipt.receipt_version
387            )));
388        }
389        if receipt.head_version < 1 {
390            return Err(AcdpError::InvalidReceipt(
391                "lineage_head_receipt head_version must be >= 1".into(),
392            ));
393        }
394        // Status pattern (RFC-ACDP-0004 §4.1) and the §4 rule that a
395        // superseded version is never the head — nor a retracted one
396        // (RFC-ACDP-0013 §8.3: never served as head).
397        let status = Status::parse(&receipt.head_status).map_err(|e| {
398            AcdpError::InvalidReceipt(format!("lineage_head_receipt head_status: {e}"))
399        })?;
400        if matches!(status, Status::Superseded) || receipt.head_status == "retracted" {
401            return Err(AcdpError::InvalidReceipt(format!(
402                "lineage_head_receipt head_status must never be '{}' \
403                 (RFC-ACDP-0011 §4: a superseded version is never the head; \
404                 RFC-ACDP-0013 §8.3: a retracted version is never served as head)",
405                receipt.head_status
406            )));
407        }
408        if !receipt.registry_did.starts_with("did:web:") {
409            return Err(AcdpError::InvalidReceipt(format!(
410                "lineage_head_receipt registry_did '{}' must be did:web \
411                 (RFC-ACDP-0011 §4)",
412                receipt.registry_did
413            )));
414        }
415        // §4 byte form of the RAW wire `as_of`, checked before any
416        // parsing normalization.
417        Self::validate_as_of_form(value)?;
418        Ok(receipt)
419    }
420
421    /// Compute the preimage hash from the RAW wire JSON of a head
422    /// receipt (the value minus `signature`, canonicalized as
423    /// received). Verifiers MUST hash the receipt exactly as received —
424    /// same raw-JSON rule as [`RegistryReceipt::preimage_hash_of_value`].
425    pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
426        preimage_hash_of_object(value, "lineage_head_receipt")
427    }
428
429    /// Validate the §4 byte form of the raw `as_of`: canonical
430    /// millisecond-precision RFC 3339 UTC with exactly three fractional
431    /// digits and a literal `Z`.
432    pub fn validate_as_of_form(value: &serde_json::Value) -> Result<(), AcdpError> {
433        let raw = value.get("as_of").and_then(|v| v.as_str()).ok_or_else(|| {
434            AcdpError::InvalidReceipt("lineage_head_receipt as_of missing or not a string".into())
435        })?;
436        if !is_canonical_ms_utc(raw) {
437            return Err(AcdpError::InvalidReceipt(format!(
438                "lineage_head_receipt as_of '{raw}' is not canonical millisecond-precision \
439                 RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0011 §4)"
440            )));
441        }
442        Ok(())
443    }
444
445    /// Compute the receipt's signature preimage hash from the struct.
446    /// Used at MINT time; verifiers should prefer
447    /// [`Self::preimage_hash_of_value`] over the raw wire JSON.
448    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
449        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
450    }
451
452    /// Verify the receipt signature against a known registry public key
453    /// (pure — no DID resolution; the `client` feature's
454    /// `verify_lineage_head_receipt_value` resolves the registry DID and
455    /// calls this).
456    pub fn verify_signature_with_key(
457        &self,
458        registry_pub_ed25519: Option<&[u8; 32]>,
459        registry_pub_p256_sec1: Option<&[u8]>,
460    ) -> Result<(), AcdpError> {
461        let hash = self.preimage_hash()?;
462        self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
463    }
464
465    /// Like [`Self::verify_signature_with_key`] but over an
466    /// already-computed preimage hash — pair with
467    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
468    pub fn verify_signature_against_hash(
469        &self,
470        hash: &ContentHash,
471        registry_pub_ed25519: Option<&[u8; 32]>,
472        registry_pub_p256_sec1: Option<&[u8]>,
473    ) -> Result<(), AcdpError> {
474        verify_receipt_signature_over_hash(
475            &self.signature,
476            hash,
477            registry_pub_ed25519,
478            registry_pub_p256_sec1,
479        )
480    }
481
482    /// RFC-ACDP-0011 §7 step 3 — registry binding (pure):
483    ///
484    /// - `registry_did` equals `did:web:<serving_authority>` — the
485    ///   authority the response was actually fetched from;
486    /// - `registry_did` equals `capabilities.registry_did`;
487    /// - the DID portion of `signature.key_id` equals `registry_did`;
488    /// - the authority component of `head_ctx_id` equals the
489    ///   method-specific identifier of `registry_did` (lineages are
490    ///   single-registry).
491    pub fn cross_check_registry_binding(
492        &self,
493        serving_authority: &str,
494        capabilities_registry_did: &str,
495    ) -> Result<(), AcdpError> {
496        let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
497        if self.registry_did != expected_did {
498            return Err(AcdpError::InvalidReceipt(format!(
499                "lineage_head_receipt registry_did '{}' ≠ serving authority's DID \
500                 '{expected_did}' (RFC-ACDP-0011 §7 step 3)",
501                self.registry_did
502            )));
503        }
504        if self.registry_did != capabilities_registry_did {
505            return Err(AcdpError::InvalidReceipt(format!(
506                "lineage_head_receipt registry_did '{}' ≠ capabilities.registry_did \
507                 '{capabilities_registry_did}' (RFC-ACDP-0011 §7 step 3)",
508                self.registry_did
509            )));
510        }
511        match self.signature.key_id.split_once('#') {
512            Some((did, frag)) if did == self.registry_did && !frag.is_empty() => {}
513            _ => {
514                return Err(AcdpError::InvalidReceipt(format!(
515                    "lineage_head_receipt signature.key_id '{}' is not a DID URL under \
516                     registry_did '{}' (RFC-ACDP-0011 §7 step 3)",
517                    self.signature.key_id, self.registry_did
518                )));
519            }
520        }
521        let head_authority_did = acdp_did::web::authority_to_did_web(self.head_ctx_id.authority());
522        if head_authority_did != self.registry_did {
523            return Err(AcdpError::InvalidReceipt(format!(
524                "lineage_head_receipt head_ctx_id authority '{}' ≠ registry_did '{}' \
525                 (RFC-ACDP-0011 §7 step 3: lineages are single-registry)",
526                self.head_ctx_id.authority(),
527                self.registry_did
528            )));
529        }
530        Ok(())
531    }
532
533    /// RFC-ACDP-0011 §7 step 4 — lineage binding: `lineage_id` MUST
534    /// equal the lineage the consumer requested (on `/current`) or the
535    /// accompanying `body.lineage_id` (on full retrieval), byte-for-byte.
536    pub fn cross_check_lineage(&self, requested: &LineageId) -> Result<(), AcdpError> {
537        if &self.lineage_id != requested {
538            return Err(AcdpError::InvalidReceipt(format!(
539                "lineage_head_receipt lineage_id '{}' ≠ requested lineage '{requested}' \
540                 (RFC-ACDP-0011 §7 step 4)",
541                self.lineage_id
542            )));
543        }
544        Ok(())
545    }
546
547    /// RFC-ACDP-0011 §7 steps 5 / 5b — head binding against the
548    /// accompanying response.
549    ///
550    /// `on_current_endpoint = true` for `GET /lineages/{id}/current`,
551    /// where the receipt MUST describe the very head being served
552    /// (step 5 byte-match, fixture `lhr-002`). On full retrieval the
553    /// step-5 match applies when `head_ctx_id` equals the retrieved
554    /// `ctx_id`; otherwise the receipt claims the retrieved context is
555    /// stale and step 5b's consistency rule applies (`head_version`
556    /// strictly greater, served status `superseded` — or `retracted`
557    /// on a registry also advertising `acdp-registry-lifecycle`, per
558    /// the RFC-ACDP-0013 §7.2 precedence).
559    pub fn cross_check_head(
560        &self,
561        served_ctx_id: &CtxId,
562        served_version: u32,
563        served_status: &Status,
564        on_current_endpoint: bool,
565    ) -> Result<(), AcdpError> {
566        if on_current_endpoint || &self.head_ctx_id == served_ctx_id {
567            if &self.head_ctx_id != served_ctx_id {
568                return Err(AcdpError::InvalidReceipt(format!(
569                    "lineage_head_receipt head_ctx_id '{}' ≠ served ctx_id '{served_ctx_id}' \
570                     (RFC-ACDP-0011 §7 step 5: /current must serve the attested head)",
571                    self.head_ctx_id
572                )));
573            }
574            if self.head_version != served_version {
575                return Err(AcdpError::InvalidReceipt(format!(
576                    "lineage_head_receipt head_version {} ≠ served body.version \
577                     {served_version} (RFC-ACDP-0011 §7 step 5)",
578                    self.head_version
579                )));
580            }
581            if self.head_status != served_status.as_str() {
582                return Err(AcdpError::InvalidReceipt(format!(
583                    "lineage_head_receipt head_status '{}' ≠ served registry_state.status \
584                     '{}' (RFC-ACDP-0011 §7 step 5)",
585                    self.head_status,
586                    served_status.as_str()
587                )));
588            }
589            return Ok(());
590        }
591        // Step 5b: full retrieval of a non-head version.
592        if self.head_version <= served_version {
593            return Err(AcdpError::InvalidReceipt(format!(
594                "lineage_head_receipt names a different head '{}' but head_version {} is \
595                 not greater than the served body.version {served_version} \
596                 (RFC-ACDP-0011 §7 step 5b)",
597                self.head_ctx_id, self.head_version
598            )));
599        }
600        let non_head_served =
601            matches!(served_status, Status::Superseded) || served_status.as_str() == "retracted";
602        if !non_head_served {
603            return Err(AcdpError::InvalidReceipt(format!(
604                "lineage_head_receipt names a different head '{}' but the served context's \
605                 status is '{}', not 'superseded' (or 'retracted', RFC-ACDP-0013 §7.2) — \
606                 self-contradictory response (RFC-ACDP-0011 §7 step 5b)",
607                self.head_ctx_id,
608                served_status.as_str()
609            )));
610        }
611        Ok(())
612    }
613
614    /// RFC-ACDP-0011 §7 step 6 — `as_of` sanity against the consumer's
615    /// clock: millisecond-truncated (RFC-ACDP-0001 §5.3) and not in the
616    /// future beyond `max_clock_skew` (RECOMMENDED 120 s). A
617    /// future-dated `as_of` is a forged freshness claim (fixture
618    /// `lhr-004`).
619    ///
620    /// Note this is the *verification* half only. Staleness (an old but
621    /// honest `as_of`) is consumer freshness policy (§6) — evaluate it
622    /// separately via [`Self::age_at`].
623    pub fn check_as_of_skew(
624        &self,
625        now: DateTime<Utc>,
626        max_clock_skew: chrono::Duration,
627    ) -> Result<(), AcdpError> {
628        if self.as_of.timestamp_subsec_nanos() % 1_000_000 != 0 {
629            return Err(AcdpError::InvalidReceipt(
630                "lineage_head_receipt as_of is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
631                    .into(),
632            ));
633        }
634        if self.as_of > now + max_clock_skew {
635            return Err(AcdpError::InvalidReceipt(format!(
636                "lineage_head_receipt as_of '{}' is in the future beyond the {}s clock-skew \
637                 allowance (consumer clock '{}') — forged freshness claim \
638                 (RFC-ACDP-0011 §7 step 6)",
639                self.as_of.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
640                max_clock_skew.num_seconds(),
641                now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
642            )));
643        }
644        Ok(())
645    }
646
647    /// The receipt's age at `now` — the input to the consumer's §6
648    /// freshness policy (RECOMMENDED maximum: 300 seconds). Negative
649    /// when `as_of` is ahead of `now` (bounded by the step-6 skew
650    /// check).
651    pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
652        now - self.as_of
653    }
654}
655
656/// Registry-side receipt minting identity: the signing key plus the
657/// DID URL it is published under in the registry's own DID document.
658///
659/// Lifecycle rule (RFC-ACDP-0010, normative): retired receipt keys
660/// MUST remain in the registry DID document's `verificationMethod`
661/// indefinitely — rotation removes them from `assertionMethod` only
662/// (stops new receipts, keeps every previously minted receipt
663/// verifiable). Deleting an old key bricks every receipt it signed.
664pub struct ReceiptSigner {
665    key: acdp_crypto::sign::AcdpSigningKey,
666    /// e.g. `did:web:registry.example.com#receipt-key-1`.
667    key_id: String,
668    /// e.g. `did:web:registry.example.com`.
669    registry_did: String,
670}
671
672impl ReceiptSigner {
673    /// Create a signer. `key_id`'s DID portion MUST equal
674    /// `registry_did`, which MUST be the `did:web` form of the
675    /// registry's serving authority.
676    pub fn new(
677        key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
678        registry_did: impl Into<String>,
679        key_id: impl Into<String>,
680    ) -> Result<Self, AcdpError> {
681        let registry_did = registry_did.into();
682        let key_id = key_id.into();
683        if !registry_did.starts_with("did:web:") {
684            return Err(AcdpError::SchemaViolation(format!(
685                "receipt signer registry_did must be did:web, got '{registry_did}'"
686            )));
687        }
688        match key_id.split_once('#') {
689            Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
690            _ => {
691                return Err(AcdpError::SchemaViolation(format!(
692                    "receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
693                )));
694            }
695        }
696        Ok(Self {
697            key: key.into(),
698            key_id,
699            registry_did,
700        })
701    }
702
703    /// The registry DID this signer mints under.
704    pub fn registry_did(&self) -> &str {
705        &self.registry_did
706    }
707
708    /// Mint a signed receipt for an accepted publication.
709    ///
710    /// `producer_key_fingerprint` MUST be the fingerprint of the key
711    /// the validator *actually used* for producer-signature
712    /// verification — not re-resolved later (that is the whole
713    /// historical-validity guarantee).
714    pub fn mint(
715        &self,
716        ctx_id: &CtxId,
717        lineage_id: &LineageId,
718        origin_registry: &str,
719        created_at: DateTime<Utc>,
720        content_hash: &ContentHash,
721        producer_key_fingerprint: &str,
722    ) -> Result<RegistryReceipt, AcdpError> {
723        let mut receipt = RegistryReceipt {
724            registry_did: self.registry_did.clone(),
725            ctx_id: ctx_id.clone(),
726            lineage_id: lineage_id.clone(),
727            origin_registry: origin_registry.to_string(),
728            created_at: acdp_primitives::time::trunc_ms(created_at),
729            content_hash: content_hash.clone(),
730            key_fingerprint: producer_key_fingerprint.to_string(),
731            signature: Signature {
732                algorithm: self.key.algorithm().into(),
733                key_id: self.key_id.clone(),
734                value: String::new(), // filled below
735            },
736        };
737        let hash = receipt.preimage_hash()?;
738        let (algorithm, value) = self.key.sign_content_hash(&hash);
739        receipt.signature.algorithm = algorithm.into();
740        receipt.signature.value = value;
741        Ok(receipt)
742    }
743
744    /// Mint a signed **lineage-head receipt** (RFC-ACDP-0011 §5–§6):
745    /// the registry's attestation that, as of `as_of` (its response-time
746    /// clock, truncated here to milliseconds), the head of `lineage_id`
747    /// is `head_ctx_id` at `head_version` with `head_status`.
748    ///
749    /// Signs with the same receipt signing key as [`Self::mint`] — head
750    /// receipts introduce no new key role (RFC-ACDP-0011 §5, §8); the
751    /// `receipt_version` member inside the preimage is what domain-
752    /// separates the two receipt kinds.
753    ///
754    /// Refuses to mint an internally inconsistent receipt: a
755    /// `superseded` head (never the head, RFC-ACDP-0011 §4), a
756    /// `head_version` of 0, or a `head_ctx_id` whose authority is not
757    /// this signer's registry (§4: lineages are single-registry).
758    pub fn mint_lineage_head(
759        &self,
760        lineage_id: &LineageId,
761        head_ctx_id: &CtxId,
762        head_version: u32,
763        head_status: &Status,
764        as_of: DateTime<Utc>,
765    ) -> Result<LineageHeadReceipt, AcdpError> {
766        if matches!(head_status, Status::Superseded) || head_status.as_str() == "retracted" {
767            return Err(AcdpError::SchemaViolation(format!(
768                "cannot mint a lineage-head receipt with head_status '{}' — a superseded \
769                 version is never the head (RFC-ACDP-0011 §4) and a retracted version is \
770                 never served as head (RFC-ACDP-0013 §8.3)",
771                head_status.as_str()
772            )));
773        }
774        if head_version < 1 {
775            return Err(AcdpError::SchemaViolation(
776                "cannot mint a lineage-head receipt with head_version 0 (RFC-ACDP-0011 §4)".into(),
777            ));
778        }
779        let head_authority_did = acdp_did::web::authority_to_did_web(head_ctx_id.authority());
780        if head_authority_did != self.registry_did {
781            return Err(AcdpError::SchemaViolation(format!(
782                "cannot mint a lineage-head receipt for head_ctx_id authority '{}' under \
783                 registry_did '{}' (RFC-ACDP-0011 §4: lineages are single-registry)",
784                head_ctx_id.authority(),
785                self.registry_did
786            )));
787        }
788        let mut receipt = LineageHeadReceipt {
789            receipt_version: LINEAGE_HEAD_RECEIPT_VERSION.to_string(),
790            registry_did: self.registry_did.clone(),
791            lineage_id: lineage_id.clone(),
792            head_ctx_id: head_ctx_id.clone(),
793            head_version,
794            head_status: head_status.as_str().to_string(),
795            as_of: acdp_primitives::time::trunc_ms(as_of),
796            signature: Signature {
797                algorithm: self.key.algorithm().into(),
798                key_id: self.key_id.clone(),
799                value: String::new(), // filled below
800            },
801        };
802        let hash = receipt.preimage_hash()?;
803        let (algorithm, value) = self.key.sign_content_hash(&hash);
804        receipt.signature.algorithm = algorithm.into();
805        receipt.signature.value = value;
806        Ok(receipt)
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813    use acdp_crypto::SigningKey;
814
815    fn test_signer() -> ReceiptSigner {
816        ReceiptSigner::new(
817            SigningKey::from_bytes(&[1u8; 32]),
818            "did:web:registry.example.com",
819            "did:web:registry.example.com#receipt-key-1",
820        )
821        .unwrap()
822    }
823
824    fn test_receipt() -> RegistryReceipt {
825        test_signer()
826            .mint(
827                &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
828                &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
829                "registry.example.com",
830                chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
831                    .unwrap()
832                    .with_timezone(&chrono::Utc),
833                &ContentHash(format!("sha256:{}", "b".repeat(64))),
834                "sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
835            )
836            .unwrap()
837    }
838
839    fn registry_pub() -> [u8; 32] {
840        SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
841    }
842
843    #[test]
844    fn mint_verify_round_trip() {
845        let receipt = test_receipt();
846        receipt
847            .verify_signature_with_key(Some(&registry_pub()), None)
848            .expect("freshly minted receipt must verify");
849    }
850
851    #[test]
852    fn tampered_fields_fail_verification() {
853        // rcpt-002-style: any mutated bound field breaks the signature.
854        let pubkey = registry_pub();
855        let mut r = test_receipt();
856        r.created_at += chrono::Duration::milliseconds(1);
857        assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
858
859        let mut r = test_receipt();
860        r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
861        assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
862
863        let mut r = test_receipt();
864        r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
865        assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
866    }
867
868    /// RFC-ACDP-0010 §4: the receipt schema is CLOSED. A receipt
869    /// carrying an unknown member MUST be rejected at parse time.
870    #[test]
871    fn unknown_receipt_fields_rejected() {
872        let mut wire = serde_json::to_value(test_receipt()).unwrap();
873        wire.as_object_mut()
874            .unwrap()
875            .insert("transparency_log_index".into(), serde_json::json!(42));
876        let err = RegistryReceipt::from_value(&wire).unwrap_err();
877        assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
878    }
879
880    /// Raw-JSON preimage equals the struct preimage for a minted
881    /// receipt, and the fixed three-digit-millisecond `created_at`
882    /// serialization survives a parse → re-serialize round trip even
883    /// for whole-second timestamps (chrono would otherwise drop the
884    /// `.000`).
885    #[test]
886    fn raw_and_struct_preimages_agree_incl_whole_second() {
887        let receipt = test_receipt();
888        let wire = serde_json::to_value(&receipt).unwrap();
889        assert_eq!(
890            RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
891            receipt.preimage_hash().unwrap()
892        );
893        RegistryReceipt::validate_created_at_form(&wire).unwrap();
894
895        // Whole-second created_at: serialization MUST keep `.000`.
896        let signer = test_signer();
897        let r = signer
898            .mint(
899                &receipt.ctx_id,
900                &receipt.lineage_id,
901                "registry.example.com",
902                chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
903                    .unwrap()
904                    .with_timezone(&chrono::Utc),
905                &receipt.content_hash,
906                &receipt.key_fingerprint,
907            )
908            .unwrap();
909        let wire = serde_json::to_value(&r).unwrap();
910        assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
911        RegistryReceipt::validate_created_at_form(&wire).unwrap();
912        let parsed = RegistryReceipt::from_value(&wire).unwrap();
913        parsed
914            .verify_signature_with_key(Some(&registry_pub()), None)
915            .expect("whole-second receipt must round-trip and verify");
916    }
917
918    #[test]
919    fn cross_checks_fire() {
920        let r = test_receipt();
921        let ctx = r.ctx_id.clone();
922        let hash = r.content_hash.clone();
923        let fp = r.key_fingerprint.clone();
924
925        r.cross_check(&ctx, &hash, &fp).expect("all aligned");
926
927        // rcpt-004-style: wrong ctx_id.
928        let other =
929            CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
930        assert!(matches!(
931            r.cross_check(&other, &hash, &fp).unwrap_err(),
932            AcdpError::InvalidReceipt(_)
933        ));
934        // rcpt-003-style: fingerprint mismatch.
935        assert!(r
936            .cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
937            .is_err());
938        // Body-hash mismatch.
939        assert!(r
940            .cross_check(
941                &ctx,
942                &ContentHash(format!("sha256:{}", "c".repeat(64))),
943                &fp
944            )
945            .is_err());
946    }
947
948    #[test]
949    fn signer_rejects_malformed_identity() {
950        assert!(ReceiptSigner::new(
951            SigningKey::from_bytes(&[1u8; 32]),
952            "did:key:zNotWeb",
953            "did:key:zNotWeb#k",
954        )
955        .is_err());
956        assert!(ReceiptSigner::new(
957            SigningKey::from_bytes(&[1u8; 32]),
958            "did:web:registry.example.com",
959            "did:web:other.example.com#k",
960        )
961        .is_err());
962        assert!(ReceiptSigner::new(
963            SigningKey::from_bytes(&[1u8; 32]),
964            "did:web:registry.example.com",
965            "did:web:registry.example.com",
966        )
967        .is_err());
968    }
969
970    // ── Lineage-head receipts (RFC-ACDP-0011) ────────────────────────
971
972    fn test_head_receipt() -> LineageHeadReceipt {
973        test_signer()
974            .mint_lineage_head(
975                &LineageId(format!("lin:sha256:{}", "a".repeat(64))),
976                &CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
977                2,
978                &Status::Active,
979                chrono::DateTime::parse_from_rfc3339("2026-07-04T09:00:00.123Z")
980                    .unwrap()
981                    .with_timezone(&chrono::Utc),
982            )
983            .unwrap()
984    }
985
986    #[test]
987    fn head_receipt_mint_verify_round_trip() {
988        let r = test_head_receipt();
989        assert_eq!(r.receipt_version, LINEAGE_HEAD_RECEIPT_VERSION);
990        r.verify_signature_with_key(Some(&registry_pub()), None)
991            .expect("freshly minted head receipt must verify");
992        // Wire round trip through the closed parse.
993        let wire = serde_json::to_value(&r).unwrap();
994        LineageHeadReceipt::validate_as_of_form(&wire).unwrap();
995        let parsed = LineageHeadReceipt::from_value(&wire).unwrap();
996        parsed
997            .verify_signature_with_key(Some(&registry_pub()), None)
998            .unwrap();
999        assert_eq!(
1000            LineageHeadReceipt::preimage_hash_of_value(&wire).unwrap(),
1001            r.preimage_hash().unwrap()
1002        );
1003    }
1004
1005    /// The receipt_version domain separator: a head receipt's preimage
1006    /// can never collide with an RFC-ACDP-0010 receipt's, and a
1007    /// tampered/missing receipt_version fails the closed parse.
1008    #[test]
1009    fn head_receipt_domain_separation_and_closed_schema() {
1010        let r = test_head_receipt();
1011        let mut wire = serde_json::to_value(&r).unwrap();
1012
1013        // Unknown member → rejected (closed schema).
1014        wire.as_object_mut()
1015            .unwrap()
1016            .insert("freshness_proof".into(), serde_json::json!(true));
1017        assert!(matches!(
1018            LineageHeadReceipt::from_value(&wire).unwrap_err(),
1019            AcdpError::InvalidReceipt(_)
1020        ));
1021
1022        // Wrong receipt_version → rejected.
1023        let mut wire = serde_json::to_value(&r).unwrap();
1024        wire["receipt_version"] = serde_json::json!("acdp-lhr/2");
1025        assert!(LineageHeadReceipt::from_value(&wire).is_err());
1026
1027        // A head receipt does NOT parse as an RFC-ACDP-0010 receipt
1028        // (different member set) and vice versa.
1029        let wire = serde_json::to_value(&r).unwrap();
1030        assert!(RegistryReceipt::from_value(&wire).is_err());
1031        let rcpt_wire = serde_json::to_value(test_receipt()).unwrap();
1032        assert!(LineageHeadReceipt::from_value(&rcpt_wire).is_err());
1033    }
1034
1035    #[test]
1036    fn head_receipt_semantic_invariants_rejected() {
1037        let r = test_head_receipt();
1038
1039        // superseded head — both at parse and at mint.
1040        let mut wire = serde_json::to_value(&r).unwrap();
1041        wire["head_status"] = serde_json::json!("superseded");
1042        assert!(LineageHeadReceipt::from_value(&wire).is_err());
1043        assert!(test_signer()
1044            .mint_lineage_head(
1045                &r.lineage_id,
1046                &r.head_ctx_id,
1047                1,
1048                &Status::Superseded,
1049                chrono::Utc::now(),
1050            )
1051            .is_err());
1052
1053        // retracted head (RFC-ACDP-0013 §8.3) — likewise never a head.
1054        let mut wire = serde_json::to_value(&r).unwrap();
1055        wire["head_status"] = serde_json::json!("retracted");
1056        assert!(LineageHeadReceipt::from_value(&wire).is_err());
1057        assert!(test_signer()
1058            .mint_lineage_head(
1059                &r.lineage_id,
1060                &r.head_ctx_id,
1061                1,
1062                &Status::parse("retracted").unwrap(),
1063                chrono::Utc::now(),
1064            )
1065            .is_err());
1066
1067        // head_version 0.
1068        let mut wire = serde_json::to_value(&r).unwrap();
1069        wire["head_version"] = serde_json::json!(0);
1070        assert!(LineageHeadReceipt::from_value(&wire).is_err());
1071
1072        // Non-canonical as_of byte form (no milliseconds).
1073        let mut wire = serde_json::to_value(&r).unwrap();
1074        wire["as_of"] = serde_json::json!("2026-07-04T09:00:00Z");
1075        assert!(LineageHeadReceipt::from_value(&wire).is_err());
1076
1077        // Foreign head_ctx_id authority refused at mint.
1078        assert!(test_signer()
1079            .mint_lineage_head(
1080                &r.lineage_id,
1081                &CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into()),
1082                1,
1083                &Status::Active,
1084                chrono::Utc::now(),
1085            )
1086            .is_err());
1087    }
1088
1089    #[test]
1090    fn head_receipt_cross_checks_fire() {
1091        let r = test_head_receipt();
1092
1093        // All aligned (§7 steps 3–5).
1094        r.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
1095            .unwrap();
1096        r.cross_check_lineage(&r.lineage_id).unwrap();
1097        r.cross_check_head(&r.head_ctx_id, 2, &Status::Active, true)
1098            .unwrap();
1099
1100        // lhr-003-style: wrong serving authority / capabilities DID.
1101        assert!(r
1102            .cross_check_registry_binding("hostile.example", "did:web:hostile.example")
1103            .is_err());
1104        assert!(r
1105            .cross_check_registry_binding("registry.example.com", "did:web:other.example")
1106            .is_err());
1107
1108        // Wrong lineage.
1109        assert!(r
1110            .cross_check_lineage(&LineageId(format!("lin:sha256:{}", "f".repeat(64))))
1111            .is_err());
1112
1113        // lhr-002-style: /current serving a different head.
1114        let other =
1115            CtxId("acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into());
1116        assert!(r
1117            .cross_check_head(&other, 3, &Status::Active, true)
1118            .is_err());
1119        // Version / status byte-match failures on the same ctx_id.
1120        assert!(r
1121            .cross_check_head(&r.head_ctx_id, 1, &Status::Active, true)
1122            .is_err());
1123        assert!(r
1124            .cross_check_head(&r.head_ctx_id, 2, &Status::Expired, true)
1125            .is_err());
1126
1127        // §7 step 5b (full retrieval of a non-head version): consistent
1128        // when head_version > served AND served is superseded — or
1129        // retracted (RFC-ACDP-0013 §7.2 precedence) …
1130        r.cross_check_head(&other, 1, &Status::Superseded, false)
1131            .unwrap();
1132        r.cross_check_head(&other, 1, &Status::parse("retracted").unwrap(), false)
1133            .unwrap();
1134        // … self-contradictory otherwise (active or expired).
1135        assert!(r
1136            .cross_check_head(&other, 1, &Status::Active, false)
1137            .is_err());
1138        assert!(r
1139            .cross_check_head(&other, 1, &Status::Expired, false)
1140            .is_err());
1141        assert!(r
1142            .cross_check_head(&other, 2, &Status::Superseded, false)
1143            .is_err());
1144    }
1145
1146    /// lhr-004-style: a future as_of beyond skew fails step 6; honest
1147    /// skew within the allowance passes; staleness is a separate
1148    /// verdict, not a step-6 failure.
1149    #[test]
1150    fn head_receipt_as_of_skew_and_age() {
1151        let r = test_head_receipt();
1152        let skew = chrono::Duration::seconds(120);
1153
1154        let now = r.as_of - chrono::Duration::seconds(30); // as_of 30s ahead
1155        r.check_as_of_skew(now, skew).expect("within skew");
1156
1157        let now = r.as_of - chrono::Duration::seconds(300); // as_of 5m ahead
1158        let err = r.check_as_of_skew(now, skew).unwrap_err();
1159        assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
1160
1161        // An OLD receipt passes step 6 — age is policy, not verification.
1162        let now = r.as_of + chrono::Duration::seconds(3600);
1163        r.check_as_of_skew(now, skew)
1164            .expect("stale is not a step-6 failure");
1165        assert_eq!(r.age_at(now), chrono::Duration::seconds(3600));
1166    }
1167}