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