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