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