use crate::body::Signature;
use acdp_jcs::try_canonicalize_value;
use acdp_primitives::error::AcdpError;
use acdp_primitives::primitives::{ContentHash, CtxId, LineageId, Status};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const LINEAGE_HEAD_RECEIPT_VERSION: &str = "acdp-lhr/1";
pub(crate) fn preimage_hash_of_map(
mut map: serde_json::Map<String, serde_json::Value>,
) -> Result<ContentHash, AcdpError> {
map.remove("signature");
let canonical = try_canonicalize_value(&serde_json::Value::Object(map))?;
let digest = Sha256::digest(&canonical);
Ok(ContentHash(format!("sha256:{}", hex::encode(digest))))
}
pub(crate) fn preimage_hash_of_object_with(
value: &serde_json::Value,
what: &str,
mk_err: fn(String) -> AcdpError,
) -> Result<ContentHash, AcdpError> {
let map = value
.as_object()
.cloned()
.ok_or_else(|| mk_err(format!("{what} must be a JSON object")))?;
preimage_hash_of_map(map)
}
fn preimage_hash_of_object(
value: &serde_json::Value,
what: &str,
) -> Result<ContentHash, AcdpError> {
preimage_hash_of_object_with(value, what, AcdpError::InvalidReceipt)
}
pub(crate) fn verify_signature_over_hash_with(
signature: &Signature,
hash: &ContentHash,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
what: &str,
mk_err: fn(String) -> AcdpError,
) -> Result<(), AcdpError> {
match signature.algorithm.as_str() {
"ed25519" => {
let key = registry_pub_ed25519.ok_or_else(|| {
mk_err(format!(
"{what} declares ed25519 but no ed25519 registry key was resolved"
))
})?;
acdp_crypto::verify::verify_ed25519(key, &signature.value, hash.as_str())
.map_err(|e| mk_err(format!("{what} signature: {e}")))
}
"ecdsa-p256" => {
let key = registry_pub_p256_sec1.ok_or_else(|| {
mk_err(format!(
"{what} declares ecdsa-p256 but no p256 registry key was resolved"
))
})?;
acdp_crypto::verify::verify_ecdsa_p256(key, &signature.value, hash.as_str())
.map_err(|e| mk_err(format!("{what} signature: {e}")))
}
other => Err(mk_err(format!(
"{what} signature algorithm '{other}' is not supported"
))),
}
}
fn verify_receipt_signature_over_hash(
signature: &Signature,
hash: &ContentHash,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
verify_signature_over_hash_with(
signature,
hash,
registry_pub_ed25519,
registry_pub_p256_sec1,
"receipt",
AcdpError::InvalidReceipt,
)
}
pub(crate) fn is_canonical_ms_utc(raw: &str) -> bool {
let b = raw.as_bytes();
b.len() == 24
&& b[10] == b'T'
&& b[19] == b'.'
&& b[23] == b'Z'
&& b[20..23].iter().all(u8::is_ascii_digit)
&& chrono::DateTime::parse_from_rfc3339(raw).is_ok()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegistryReceipt {
pub registry_did: String,
pub ctx_id: CtxId,
pub lineage_id: LineageId,
pub origin_registry: String,
#[serde(with = "ms_rfc3339")]
pub created_at: DateTime<Utc>,
pub content_hash: ContentHash,
pub key_fingerprint: String,
pub signature: Signature,
}
pub(crate) mod ms_rfc3339 {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
let raw = String::deserialize(d)?;
DateTime::parse_from_rfc3339(&raw)
.map(|t| t.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
}
}
impl RegistryReceipt {
pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
Self::deserialize(value)
.map_err(|e| AcdpError::InvalidReceipt(format!("registry_receipt does not parse: {e}")))
}
pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
preimage_hash_of_object(value, "receipt")
}
pub fn validate_created_at_form(value: &serde_json::Value) -> Result<(), AcdpError> {
let raw = value
.get("created_at")
.and_then(|v| v.as_str())
.ok_or_else(|| {
AcdpError::InvalidReceipt("receipt created_at missing or not a string".into())
})?;
if !is_canonical_ms_utc(raw) {
return Err(AcdpError::InvalidReceipt(format!(
"receipt created_at '{raw}' is not canonical millisecond-precision \
RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0010 §8 step 6)"
)));
}
Ok(())
}
pub fn cross_check_body(&self, body: &crate::body::Body) -> Result<(), AcdpError> {
if self.lineage_id != body.lineage_id {
return Err(AcdpError::InvalidReceipt(format!(
"receipt lineage_id '{}' ≠ body lineage_id '{}'",
self.lineage_id, body.lineage_id
)));
}
if self.origin_registry != body.origin_registry {
return Err(AcdpError::InvalidReceipt(format!(
"receipt origin_registry '{}' ≠ body origin_registry '{}'",
self.origin_registry, body.origin_registry
)));
}
if self.created_at != body.created_at {
return Err(AcdpError::InvalidReceipt(format!(
"receipt created_at '{}' ≠ body created_at '{}'",
self.created_at, body.created_at
)));
}
Ok(())
}
pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
Self::preimage_hash_of_value(&serde_json::to_value(self)?)
}
pub fn verify_signature_with_key(
&self,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
let hash = self.preimage_hash()?;
self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
}
pub fn verify_signature_against_hash(
&self,
hash: &ContentHash,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
verify_receipt_signature_over_hash(
&self.signature,
hash,
registry_pub_ed25519,
registry_pub_p256_sec1,
)
}
pub fn cross_check(
&self,
expected_ctx_id: &CtxId,
recomputed_body_hash: &ContentHash,
producer_key_fingerprint: &str,
) -> Result<(), AcdpError> {
if &self.ctx_id != expected_ctx_id {
return Err(AcdpError::InvalidReceipt(format!(
"receipt ctx_id '{}' ≠ requested '{expected_ctx_id}'",
self.ctx_id
)));
}
if &self.content_hash != recomputed_body_hash {
return Err(AcdpError::InvalidReceipt(format!(
"receipt content_hash '{}' ≠ recomputed body hash '{recomputed_body_hash}'",
self.content_hash
)));
}
if self.key_fingerprint != producer_key_fingerprint {
return Err(AcdpError::InvalidReceipt(format!(
"receipt key_fingerprint '{}' ≠ resolved producer key '{producer_key_fingerprint}'",
self.key_fingerprint
)));
}
if self.created_at.timestamp_subsec_nanos() % 1_000_000 != 0 {
return Err(AcdpError::InvalidReceipt(
"receipt created_at is not millisecond-truncated (RFC-ACDP-0001 §5.3)".into(),
));
}
let expected_did = acdp_did::web::authority_to_did_web(&self.origin_registry);
if self.registry_did != expected_did {
return Err(AcdpError::InvalidReceipt(format!(
"receipt registry_did '{}' ≠ did:web form of origin_registry ('{expected_did}')",
self.registry_did
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LineageHeadReceipt {
pub receipt_version: String,
pub registry_did: String,
pub lineage_id: LineageId,
pub head_ctx_id: CtxId,
pub head_version: u32,
pub head_status: String,
#[serde(with = "ms_rfc3339")]
pub as_of: DateTime<Utc>,
pub signature: Signature,
}
impl LineageHeadReceipt {
pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
let receipt = Self::deserialize(value).map_err(|e| {
AcdpError::InvalidReceipt(format!("lineage_head_receipt does not parse: {e}"))
})?;
if receipt.receipt_version != LINEAGE_HEAD_RECEIPT_VERSION {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt receipt_version '{}' ≠ '{LINEAGE_HEAD_RECEIPT_VERSION}' \
(RFC-ACDP-0011 §4)",
receipt.receipt_version
)));
}
if receipt.head_version < 1 {
return Err(AcdpError::InvalidReceipt(
"lineage_head_receipt head_version must be >= 1".into(),
));
}
let status = Status::parse(&receipt.head_status).map_err(|e| {
AcdpError::InvalidReceipt(format!("lineage_head_receipt head_status: {e}"))
})?;
if matches!(status, Status::Superseded | Status::Retracted) {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt head_status must never be '{}' \
(RFC-ACDP-0011 §4: a superseded version is never the head; \
RFC-ACDP-0013 §8.3: a retracted version is never served as head)",
receipt.head_status
)));
}
if !receipt.registry_did.starts_with("did:web:") {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt registry_did '{}' must be did:web \
(RFC-ACDP-0011 §4)",
receipt.registry_did
)));
}
Self::validate_as_of_form(value)?;
Ok(receipt)
}
pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
preimage_hash_of_object(value, "lineage_head_receipt")
}
pub fn validate_as_of_form(value: &serde_json::Value) -> Result<(), AcdpError> {
let raw = value.get("as_of").and_then(|v| v.as_str()).ok_or_else(|| {
AcdpError::InvalidReceipt("lineage_head_receipt as_of missing or not a string".into())
})?;
if !is_canonical_ms_utc(raw) {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt as_of '{raw}' is not canonical millisecond-precision \
RFC 3339 UTC (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0011 §4)"
)));
}
Ok(())
}
pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
Self::preimage_hash_of_value(&serde_json::to_value(self)?)
}
pub fn verify_signature_with_key(
&self,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
let hash = self.preimage_hash()?;
self.verify_signature_against_hash(&hash, registry_pub_ed25519, registry_pub_p256_sec1)
}
pub fn verify_signature_against_hash(
&self,
hash: &ContentHash,
registry_pub_ed25519: Option<&[u8; 32]>,
registry_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
verify_receipt_signature_over_hash(
&self.signature,
hash,
registry_pub_ed25519,
registry_pub_p256_sec1,
)
}
pub fn cross_check_registry_binding(
&self,
serving_authority: &str,
capabilities_registry_did: &str,
) -> Result<(), AcdpError> {
let expected_did = acdp_did::web::authority_to_did_web(serving_authority);
if self.registry_did != expected_did {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt registry_did '{}' ≠ serving authority's DID \
'{expected_did}' (RFC-ACDP-0011 §7 step 3)",
self.registry_did
)));
}
if self.registry_did != capabilities_registry_did {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt registry_did '{}' ≠ capabilities.registry_did \
'{capabilities_registry_did}' (RFC-ACDP-0011 §7 step 3)",
self.registry_did
)));
}
match self.signature.key_id.split_once('#') {
Some((did, frag)) if did == self.registry_did && !frag.is_empty() => {}
_ => {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt signature.key_id '{}' is not a DID URL under \
registry_did '{}' (RFC-ACDP-0011 §7 step 3)",
self.signature.key_id, self.registry_did
)));
}
}
let head_authority_did = acdp_did::web::authority_to_did_web(self.head_ctx_id.authority());
if head_authority_did != self.registry_did {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt head_ctx_id authority '{}' ≠ registry_did '{}' \
(RFC-ACDP-0011 §7 step 3: lineages are single-registry)",
self.head_ctx_id.authority(),
self.registry_did
)));
}
Ok(())
}
pub fn cross_check_lineage(&self, requested: &LineageId) -> Result<(), AcdpError> {
if &self.lineage_id != requested {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt lineage_id '{}' ≠ requested lineage '{requested}' \
(RFC-ACDP-0011 §7 step 4)",
self.lineage_id
)));
}
Ok(())
}
pub fn cross_check_head(
&self,
served_ctx_id: &CtxId,
served_version: u32,
served_status: &Status,
on_current_endpoint: bool,
) -> Result<(), AcdpError> {
if on_current_endpoint || &self.head_ctx_id == served_ctx_id {
if &self.head_ctx_id != served_ctx_id {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt head_ctx_id '{}' ≠ served ctx_id '{served_ctx_id}' \
(RFC-ACDP-0011 §7 step 5: /current must serve the attested head)",
self.head_ctx_id
)));
}
if self.head_version != served_version {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt head_version {} ≠ served body.version \
{served_version} (RFC-ACDP-0011 §7 step 5)",
self.head_version
)));
}
if self.head_status != served_status.as_str() {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt head_status '{}' ≠ served registry_state.status \
'{}' (RFC-ACDP-0011 §7 step 5)",
self.head_status,
served_status.as_str()
)));
}
return Ok(());
}
if self.head_version <= served_version {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt names a different head '{}' but head_version {} is \
not greater than the served body.version {served_version} \
(RFC-ACDP-0011 §7 step 5b)",
self.head_ctx_id, self.head_version
)));
}
let non_head_served = matches!(served_status, Status::Superseded | Status::Retracted);
if !non_head_served {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt names a different head '{}' but the served context's \
status is '{}', not 'superseded' (or 'retracted', RFC-ACDP-0013 §7.2) — \
self-contradictory response (RFC-ACDP-0011 §7 step 5b)",
self.head_ctx_id,
served_status.as_str()
)));
}
Ok(())
}
pub fn check_as_of_skew(
&self,
now: DateTime<Utc>,
max_clock_skew: chrono::Duration,
) -> Result<(), AcdpError> {
if self.as_of.timestamp_subsec_nanos() % 1_000_000 != 0 {
return Err(AcdpError::InvalidReceipt(
"lineage_head_receipt as_of is not millisecond-truncated (RFC-ACDP-0001 §5.3)"
.into(),
));
}
if self.as_of > now + max_clock_skew {
return Err(AcdpError::InvalidReceipt(format!(
"lineage_head_receipt as_of '{}' is in the future beyond the {}s clock-skew \
allowance (consumer clock '{}') — forged freshness claim \
(RFC-ACDP-0011 §7 step 6)",
self.as_of.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
max_clock_skew.num_seconds(),
now.format("%Y-%m-%dT%H:%M:%S%.3fZ"),
)));
}
Ok(())
}
pub fn age_at(&self, now: DateTime<Utc>) -> chrono::Duration {
now - self.as_of
}
}
pub struct ReceiptSigner {
key: acdp_crypto::sign::AcdpSigningKey,
key_id: String,
registry_did: String,
}
impl ReceiptSigner {
pub fn new(
key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
registry_did: impl Into<String>,
key_id: impl Into<String>,
) -> Result<Self, AcdpError> {
let registry_did = registry_did.into();
let key_id = key_id.into();
if !registry_did.starts_with("did:web:") {
return Err(AcdpError::SchemaViolation(format!(
"receipt signer registry_did must be did:web, got '{registry_did}'"
)));
}
match key_id.split_once('#') {
Some((did, frag)) if did == registry_did && !frag.is_empty() => {}
_ => {
return Err(AcdpError::SchemaViolation(format!(
"receipt signer key_id '{key_id}' must be '<registry_did>#<fragment>'"
)));
}
}
Ok(Self {
key: key.into(),
key_id,
registry_did,
})
}
pub fn registry_did(&self) -> &str {
&self.registry_did
}
pub fn key_id(&self) -> &str {
&self.key_id
}
pub(crate) fn signing_key(&self) -> &acdp_crypto::sign::AcdpSigningKey {
&self.key
}
pub fn mint(
&self,
ctx_id: &CtxId,
lineage_id: &LineageId,
origin_registry: &str,
created_at: DateTime<Utc>,
content_hash: &ContentHash,
producer_key_fingerprint: &str,
) -> Result<RegistryReceipt, AcdpError> {
let mut receipt = RegistryReceipt {
registry_did: self.registry_did.clone(),
ctx_id: ctx_id.clone(),
lineage_id: lineage_id.clone(),
origin_registry: origin_registry.to_string(),
created_at: acdp_primitives::time::trunc_ms(created_at),
content_hash: content_hash.clone(),
key_fingerprint: producer_key_fingerprint.to_string(),
signature: Signature {
algorithm: self.key.algorithm().into(),
key_id: self.key_id.clone(),
value: String::new(), },
};
let hash = receipt.preimage_hash()?;
let (algorithm, value) = self.key.sign_content_hash(&hash);
receipt.signature.algorithm = algorithm.into();
receipt.signature.value = value;
Ok(receipt)
}
pub fn mint_lineage_head(
&self,
lineage_id: &LineageId,
head_ctx_id: &CtxId,
head_version: u32,
head_status: &Status,
as_of: DateTime<Utc>,
) -> Result<LineageHeadReceipt, AcdpError> {
if matches!(head_status, Status::Superseded | Status::Retracted) {
return Err(AcdpError::SchemaViolation(format!(
"cannot mint a lineage-head receipt with head_status '{}' — a superseded \
version is never the head (RFC-ACDP-0011 §4) and a retracted version is \
never served as head (RFC-ACDP-0013 §8.3)",
head_status.as_str()
)));
}
if head_version < 1 {
return Err(AcdpError::SchemaViolation(
"cannot mint a lineage-head receipt with head_version 0 (RFC-ACDP-0011 §4)".into(),
));
}
let head_authority_did = acdp_did::web::authority_to_did_web(head_ctx_id.authority());
if head_authority_did != self.registry_did {
return Err(AcdpError::SchemaViolation(format!(
"cannot mint a lineage-head receipt for head_ctx_id authority '{}' under \
registry_did '{}' (RFC-ACDP-0011 §4: lineages are single-registry)",
head_ctx_id.authority(),
self.registry_did
)));
}
let mut receipt = LineageHeadReceipt {
receipt_version: LINEAGE_HEAD_RECEIPT_VERSION.to_string(),
registry_did: self.registry_did.clone(),
lineage_id: lineage_id.clone(),
head_ctx_id: head_ctx_id.clone(),
head_version,
head_status: head_status.as_str().to_string(),
as_of: acdp_primitives::time::trunc_ms(as_of),
signature: Signature {
algorithm: self.key.algorithm().into(),
key_id: self.key_id.clone(),
value: String::new(), },
};
let hash = receipt.preimage_hash()?;
let (algorithm, value) = self.key.sign_content_hash(&hash);
receipt.signature.algorithm = algorithm.into();
receipt.signature.value = value;
Ok(receipt)
}
}
#[cfg(test)]
mod tests {
use super::*;
use acdp_crypto::SigningKey;
fn test_signer() -> ReceiptSigner {
ReceiptSigner::new(
SigningKey::from_bytes(&[1u8; 32]),
"did:web:registry.example.com",
"did:web:registry.example.com#receipt-key-1",
)
.unwrap()
}
fn test_receipt() -> RegistryReceipt {
test_signer()
.mint(
&CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
&LineageId(format!("lin:sha256:{}", "a".repeat(64))),
"registry.example.com",
chrono::DateTime::parse_from_rfc3339("2026-06-12T10:30:15.123Z")
.unwrap()
.with_timezone(&chrono::Utc),
&ContentHash(format!("sha256:{}", "b".repeat(64))),
"sha256:cafe0000000000000000000000000000000000000000000000000000000000ff",
)
.unwrap()
}
fn registry_pub() -> [u8; 32] {
SigningKey::from_bytes(&[1u8; 32]).verifying_key_bytes()
}
#[test]
fn mint_verify_round_trip() {
let receipt = test_receipt();
receipt
.verify_signature_with_key(Some(®istry_pub()), None)
.expect("freshly minted receipt must verify");
}
#[test]
fn tampered_fields_fail_verification() {
let pubkey = registry_pub();
let mut r = test_receipt();
r.created_at += chrono::Duration::milliseconds(1);
assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
let mut r = test_receipt();
r.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
let mut r = test_receipt();
r.key_fingerprint = format!("sha256:{}", "0".repeat(64));
assert!(r.verify_signature_with_key(Some(&pubkey), None).is_err());
}
#[test]
fn unknown_receipt_fields_rejected() {
let mut wire = serde_json::to_value(test_receipt()).unwrap();
wire.as_object_mut()
.unwrap()
.insert("transparency_log_index".into(), serde_json::json!(42));
let err = RegistryReceipt::from_value(&wire).unwrap_err();
assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
}
#[test]
fn raw_and_struct_preimages_agree_incl_whole_second() {
let receipt = test_receipt();
let wire = serde_json::to_value(&receipt).unwrap();
assert_eq!(
RegistryReceipt::preimage_hash_of_value(&wire).unwrap(),
receipt.preimage_hash().unwrap()
);
RegistryReceipt::validate_created_at_form(&wire).unwrap();
let signer = test_signer();
let r = signer
.mint(
&receipt.ctx_id,
&receipt.lineage_id,
"registry.example.com",
chrono::DateTime::parse_from_rfc3339("2026-06-12T09:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
&receipt.content_hash,
&receipt.key_fingerprint,
)
.unwrap();
let wire = serde_json::to_value(&r).unwrap();
assert_eq!(wire["created_at"], "2026-06-12T09:00:00.000Z");
RegistryReceipt::validate_created_at_form(&wire).unwrap();
let parsed = RegistryReceipt::from_value(&wire).unwrap();
parsed
.verify_signature_with_key(Some(®istry_pub()), None)
.expect("whole-second receipt must round-trip and verify");
}
#[test]
fn cross_checks_fire() {
let r = test_receipt();
let ctx = r.ctx_id.clone();
let hash = r.content_hash.clone();
let fp = r.key_fingerprint.clone();
r.cross_check(&ctx, &hash, &fp).expect("all aligned");
let other =
CtxId("acdp://registry.example.com/aaaaaaaa-1234-4321-8123-123456781234".into());
assert!(matches!(
r.cross_check(&other, &hash, &fp).unwrap_err(),
AcdpError::InvalidReceipt(_)
));
assert!(r
.cross_check(&ctx, &hash, &format!("sha256:{}", "9".repeat(64)))
.is_err());
assert!(r
.cross_check(
&ctx,
&ContentHash(format!("sha256:{}", "c".repeat(64))),
&fp
)
.is_err());
}
#[test]
fn signer_rejects_malformed_identity() {
assert!(ReceiptSigner::new(
SigningKey::from_bytes(&[1u8; 32]),
"did:key:zNotWeb",
"did:key:zNotWeb#k",
)
.is_err());
assert!(ReceiptSigner::new(
SigningKey::from_bytes(&[1u8; 32]),
"did:web:registry.example.com",
"did:web:other.example.com#k",
)
.is_err());
assert!(ReceiptSigner::new(
SigningKey::from_bytes(&[1u8; 32]),
"did:web:registry.example.com",
"did:web:registry.example.com",
)
.is_err());
}
fn test_head_receipt() -> LineageHeadReceipt {
test_signer()
.mint_lineage_head(
&LineageId(format!("lin:sha256:{}", "a".repeat(64))),
&CtxId("acdp://registry.example.com/12345678-1234-4321-8123-123456781234".into()),
2,
&Status::Active,
chrono::DateTime::parse_from_rfc3339("2026-07-04T09:00:00.123Z")
.unwrap()
.with_timezone(&chrono::Utc),
)
.unwrap()
}
#[test]
fn head_receipt_mint_verify_round_trip() {
let r = test_head_receipt();
assert_eq!(r.receipt_version, LINEAGE_HEAD_RECEIPT_VERSION);
r.verify_signature_with_key(Some(®istry_pub()), None)
.expect("freshly minted head receipt must verify");
let wire = serde_json::to_value(&r).unwrap();
LineageHeadReceipt::validate_as_of_form(&wire).unwrap();
let parsed = LineageHeadReceipt::from_value(&wire).unwrap();
parsed
.verify_signature_with_key(Some(®istry_pub()), None)
.unwrap();
assert_eq!(
LineageHeadReceipt::preimage_hash_of_value(&wire).unwrap(),
r.preimage_hash().unwrap()
);
}
#[test]
fn head_receipt_domain_separation_and_closed_schema() {
let r = test_head_receipt();
let mut wire = serde_json::to_value(&r).unwrap();
wire.as_object_mut()
.unwrap()
.insert("freshness_proof".into(), serde_json::json!(true));
assert!(matches!(
LineageHeadReceipt::from_value(&wire).unwrap_err(),
AcdpError::InvalidReceipt(_)
));
let mut wire = serde_json::to_value(&r).unwrap();
wire["receipt_version"] = serde_json::json!("acdp-lhr/2");
assert!(LineageHeadReceipt::from_value(&wire).is_err());
let wire = serde_json::to_value(&r).unwrap();
assert!(RegistryReceipt::from_value(&wire).is_err());
let rcpt_wire = serde_json::to_value(test_receipt()).unwrap();
assert!(LineageHeadReceipt::from_value(&rcpt_wire).is_err());
}
#[test]
fn head_receipt_semantic_invariants_rejected() {
let r = test_head_receipt();
let mut wire = serde_json::to_value(&r).unwrap();
wire["head_status"] = serde_json::json!("superseded");
assert!(LineageHeadReceipt::from_value(&wire).is_err());
assert!(test_signer()
.mint_lineage_head(
&r.lineage_id,
&r.head_ctx_id,
1,
&Status::Superseded,
chrono::Utc::now(),
)
.is_err());
let mut wire = serde_json::to_value(&r).unwrap();
wire["head_status"] = serde_json::json!("retracted");
assert!(LineageHeadReceipt::from_value(&wire).is_err());
assert!(test_signer()
.mint_lineage_head(
&r.lineage_id,
&r.head_ctx_id,
1,
&Status::parse("retracted").unwrap(),
chrono::Utc::now(),
)
.is_err());
let mut wire = serde_json::to_value(&r).unwrap();
wire["head_version"] = serde_json::json!(0);
assert!(LineageHeadReceipt::from_value(&wire).is_err());
let mut wire = serde_json::to_value(&r).unwrap();
wire["as_of"] = serde_json::json!("2026-07-04T09:00:00Z");
assert!(LineageHeadReceipt::from_value(&wire).is_err());
assert!(test_signer()
.mint_lineage_head(
&r.lineage_id,
&CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into()),
1,
&Status::Active,
chrono::Utc::now(),
)
.is_err());
}
#[test]
fn head_receipt_cross_checks_fire() {
let r = test_head_receipt();
r.cross_check_registry_binding("registry.example.com", "did:web:registry.example.com")
.unwrap();
r.cross_check_lineage(&r.lineage_id).unwrap();
r.cross_check_head(&r.head_ctx_id, 2, &Status::Active, true)
.unwrap();
assert!(r
.cross_check_registry_binding("hostile.example", "did:web:hostile.example")
.is_err());
assert!(r
.cross_check_registry_binding("registry.example.com", "did:web:other.example")
.is_err());
assert!(r
.cross_check_lineage(&LineageId(format!("lin:sha256:{}", "f".repeat(64))))
.is_err());
let other =
CtxId("acdp://registry.example.com/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".into());
assert!(r
.cross_check_head(&other, 3, &Status::Active, true)
.is_err());
assert!(r
.cross_check_head(&r.head_ctx_id, 1, &Status::Active, true)
.is_err());
assert!(r
.cross_check_head(&r.head_ctx_id, 2, &Status::Expired, true)
.is_err());
r.cross_check_head(&other, 1, &Status::Superseded, false)
.unwrap();
r.cross_check_head(&other, 1, &Status::parse("retracted").unwrap(), false)
.unwrap();
assert!(r
.cross_check_head(&other, 1, &Status::Active, false)
.is_err());
assert!(r
.cross_check_head(&other, 1, &Status::Expired, false)
.is_err());
assert!(r
.cross_check_head(&other, 2, &Status::Superseded, false)
.is_err());
}
#[test]
fn head_receipt_as_of_skew_and_age() {
let r = test_head_receipt();
let skew = chrono::Duration::seconds(120);
let now = r.as_of - chrono::Duration::seconds(30); r.check_as_of_skew(now, skew).expect("within skew");
let now = r.as_of - chrono::Duration::seconds(300); let err = r.check_as_of_skew(now, skew).unwrap_err();
assert!(matches!(err, AcdpError::InvalidReceipt(_)), "got {err:?}");
let now = r.as_of + chrono::Duration::seconds(3600);
r.check_as_of_skew(now, skew)
.expect("stale is not a step-6 failure");
assert_eq!(r.age_at(now), chrono::Duration::seconds(3600));
}
}