use crate::body::Signature;
use crate::receipt::preimage_hash_of_map;
use acdp_primitives::error::AcdpError;
use acdp_primitives::primitives::{AgentDid, ContentHash, CtxId};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub const MAX_REASON_CHARS: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleEventType {
Retracted,
Republished,
Other(String),
}
impl LifecycleEventType {
fn pattern_ok(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 64
&& s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& s.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
pub fn as_str(&self) -> &str {
match self {
LifecycleEventType::Retracted => "retracted",
LifecycleEventType::Republished => "republished",
LifecycleEventType::Other(s) => s,
}
}
pub fn parse(s: &str) -> Result<Self, AcdpError> {
match s {
"retracted" => Ok(LifecycleEventType::Retracted),
"republished" => Ok(LifecycleEventType::Republished),
other => {
if !Self::pattern_ok(other) {
return Err(AcdpError::SchemaViolation(format!(
"event_type '{other}' does not match the open-vocabulary pattern \
^[a-z][a-z0-9_]*$ (length 1..=64, RFC-ACDP-0013 §4)"
)));
}
Ok(LifecycleEventType::Other(other.to_string()))
}
}
}
pub fn is_registered(&self) -> bool {
matches!(
self,
LifecycleEventType::Retracted | LifecycleEventType::Republished
)
}
}
impl Serialize for LifecycleEventType {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for LifecycleEventType {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
LifecycleEventType::parse(&s).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for LifecycleEventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LifecycleEvent {
pub event_id: String,
pub ctx_id: CtxId,
pub event_type: LifecycleEventType,
#[serde(with = "strict_ms_rfc3339")]
pub occurred_at: DateTime<Utc>,
pub actor: AgentDid,
#[serde(
default,
deserialize_with = "crate::serde_helpers::de_present",
skip_serializing_if = "Option::is_none"
)]
pub reason: Option<String>,
#[serde(
default,
deserialize_with = "crate::serde_helpers::de_present",
skip_serializing_if = "Option::is_none"
)]
pub signature: Option<Signature>,
}
mod strict_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)?;
if !crate::receipt::is_canonical_ms_utc(&raw) {
return Err(serde::de::Error::custom(format!(
"occurred_at '{raw}' is not canonical millisecond-precision RFC 3339 UTC \
(`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0001 §5.3 / RFC-ACDP-0013 §4)"
)));
}
DateTime::parse_from_rfc3339(&raw)
.map(|t| t.with_timezone(&Utc))
.map_err(serde::de::Error::custom)
}
}
fn is_canonical_uuid(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 36 {
return false;
}
b.iter().enumerate().all(|(i, &c)| match i {
8 | 13 | 18 | 23 => c == b'-',
_ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
})
}
impl LifecycleEvent {
pub fn new(
event_id: impl Into<String>,
ctx_id: CtxId,
event_type: LifecycleEventType,
occurred_at: DateTime<Utc>,
actor: AgentDid,
reason: Option<String>,
) -> Result<Self, AcdpError> {
let event = Self {
event_id: event_id.into(),
ctx_id,
event_type,
occurred_at: acdp_primitives::time::trunc_ms(occurred_at),
actor,
reason,
signature: None,
};
event.validate()?;
Ok(event)
}
pub fn sign_with(
mut self,
key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
key_id: impl Into<String>,
) -> Result<Self, AcdpError> {
let key_id = key_id.into();
match key_id.split_once('#') {
Some((did, frag)) if did == self.actor.as_str() && !frag.is_empty() => {}
_ => {
return Err(AcdpError::SchemaViolation(format!(
"lifecycle event signature key_id '{key_id}' must be '<actor>#<fragment>' \
with DID portion equal to actor '{}' (RFC-ACDP-0013 §5)",
self.actor
)));
}
}
let hash = self.preimage_hash()?;
let key = key.into();
let (algorithm, value) = key.sign_content_hash(&hash);
self.signature = Some(Signature {
algorithm: algorithm.into(),
key_id,
value,
});
Ok(self)
}
pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
let event = Self::deserialize(value).map_err(|e| {
AcdpError::SchemaViolation(format!("lifecycle event does not parse: {e}"))
})?;
event.validate()?;
Ok(event)
}
pub fn validate(&self) -> Result<(), AcdpError> {
if !is_canonical_uuid(&self.event_id) {
return Err(AcdpError::SchemaViolation(format!(
"lifecycle event_id '{}' is not a canonical lowercase RFC 9562 UUID \
(RFC-ACDP-0013 §4)",
self.event_id
)));
}
CtxId::parse(self.ctx_id.as_str().to_string())?;
AgentDid::parse(self.actor.as_str().to_string())?;
if let Some(reason) = &self.reason {
if reason.chars().count() > MAX_REASON_CHARS {
return Err(AcdpError::SchemaViolation(format!(
"lifecycle event reason exceeds {MAX_REASON_CHARS} characters \
(RFC-ACDP-0013 §4)"
)));
}
}
Ok(())
}
pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
let map = value.as_object().cloned().ok_or_else(|| {
AcdpError::SchemaViolation("lifecycle event must be a JSON object".into())
})?;
preimage_hash_of_map(map)
}
pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
Self::preimage_hash_of_value(&serde_json::to_value(self)?)
}
pub fn actor_bound_signature(&self) -> Result<&Signature, AcdpError> {
let signature = self.signature.as_ref().ok_or_else(|| {
AcdpError::SchemaViolation(
"lifecycle event has no signature (producer-initiated events MUST be \
signed, RFC-ACDP-0013 §5)"
.into(),
)
})?;
match signature.key_id.split_once('#') {
Some((did, frag)) if did == self.actor.as_str() && !frag.is_empty() => Ok(signature),
_ => Err(AcdpError::InvalidSignature(format!(
"lifecycle event signature.key_id '{}' is not a DID URL under actor '{}' \
(RFC-ACDP-0013 §5 actor binding)",
signature.key_id, self.actor
))),
}
}
pub fn is_signed(&self) -> bool {
self.signature.is_some()
}
pub fn verify_signature_with_key(
&self,
actor_pub_ed25519: Option<&[u8; 32]>,
actor_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
let hash = self.preimage_hash()?;
self.verify_signature_against_hash(&hash, actor_pub_ed25519, actor_pub_p256_sec1)
}
pub fn verify_signature_against_hash(
&self,
hash: &ContentHash,
actor_pub_ed25519: Option<&[u8; 32]>,
actor_pub_p256_sec1: Option<&[u8]>,
) -> Result<(), AcdpError> {
let signature = self.actor_bound_signature()?;
match signature.algorithm.as_str() {
"ed25519" => {
let key = actor_pub_ed25519.ok_or_else(|| {
AcdpError::InvalidSignature(
"event declares ed25519 but no ed25519 actor key was resolved".into(),
)
})?;
acdp_crypto::verify::verify_ed25519(key, &signature.value, hash.as_str())
}
"ecdsa-p256" => {
let key = actor_pub_p256_sec1.ok_or_else(|| {
AcdpError::InvalidSignature(
"event declares ecdsa-p256 but no p256 actor key was resolved".into(),
)
})?;
acdp_crypto::verify::verify_ecdsa_p256(key, &signature.value, hash.as_str())
}
other => Err(AcdpError::UnsupportedAlgorithm(format!(
"lifecycle event signature algorithm '{other}' is not supported"
))),
}
}
}
pub fn retraction_state(events: &[LifecycleEvent]) -> bool {
events
.iter()
.rev()
.find_map(|e| match e.event_type {
LifecycleEventType::Retracted => Some(true),
LifecycleEventType::Republished => Some(false),
LifecycleEventType::Other(_) => None,
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use acdp_crypto::SigningKey;
use serde_json::json;
const CTX: &str = "acdp://registry.example.com/12345678-1234-4321-8123-123456781234";
const ACTOR: &str = "did:web:agents.example.com:test-producer";
const EVENT_ID: &str = "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f";
fn actor_key() -> SigningKey {
SigningKey::from_bytes(&[5u8; 32])
}
fn signed_retraction() -> LifecycleEvent {
LifecycleEvent::new(
EVENT_ID,
CtxId(CTX.into()),
LifecycleEventType::Retracted,
chrono::DateTime::parse_from_rfc3339("2026-07-04T09:15:42.000Z")
.unwrap()
.with_timezone(&Utc),
AgentDid::new(ACTOR),
Some("underlying data source found to be fabricated".into()),
)
.unwrap()
.sign_with(actor_key(), format!("{ACTOR}#key-1"))
.unwrap()
}
fn actor_pub() -> [u8; 32] {
actor_key().verifying_key_bytes()
}
#[test]
fn sign_verify_round_trip_incl_wire() {
let event = signed_retraction();
event
.verify_signature_with_key(Some(&actor_pub()), None)
.expect("freshly signed event must verify");
let wire = serde_json::to_value(&event).unwrap();
assert_eq!(wire["occurred_at"], "2026-07-04T09:15:42.000Z");
let parsed = LifecycleEvent::from_value(&wire).unwrap();
assert_eq!(parsed, event);
assert_eq!(
LifecycleEvent::preimage_hash_of_value(&wire).unwrap(),
event.preimage_hash().unwrap()
);
parsed
.verify_signature_with_key(Some(&actor_pub()), None)
.unwrap();
}
#[test]
fn preimage_excludes_signature_only() {
let signed = signed_retraction();
let mut unsigned = signed.clone();
unsigned.signature = None;
assert_eq!(
signed.preimage_hash().unwrap(),
unsigned.preimage_hash().unwrap()
);
}
#[test]
fn tampered_fields_fail_verification() {
let pubkey = actor_pub();
let mut e = signed_retraction();
e.reason = Some("innocuous edit".into());
assert!(e.verify_signature_with_key(Some(&pubkey), None).is_err());
let mut e = signed_retraction();
e.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
assert!(e.verify_signature_with_key(Some(&pubkey), None).is_err());
let mut e = signed_retraction();
e.occurred_at += chrono::Duration::milliseconds(1);
assert!(e.verify_signature_with_key(Some(&pubkey), None).is_err());
let mut e = signed_retraction();
e.actor = AgentDid::new("did:web:agents.example.com:other");
let err = e
.verify_signature_with_key(Some(&pubkey), None)
.unwrap_err();
assert!(matches!(err, AcdpError::InvalidSignature(_)), "got {err:?}");
}
#[test]
fn unknown_event_member_rejected() {
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire.as_object_mut()
.unwrap()
.insert("severity".into(), json!("high"));
let err = LifecycleEvent::from_value(&wire).unwrap_err();
assert!(matches!(err, AcdpError::SchemaViolation(_)), "got {err:?}");
}
#[test]
fn unknown_event_type_tolerated_and_inert() {
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["event_type"] = json!("annotated");
let parsed = LifecycleEvent::from_value(&wire).unwrap();
assert_eq!(
parsed.event_type,
LifecycleEventType::Other("annotated".into())
);
assert!(!parsed.event_type.is_registered());
assert_eq!(
serde_json::to_value(&parsed).unwrap()["event_type"],
json!("annotated")
);
assert!(!retraction_state(&[parsed]));
for bad in ["Retracted", "has space", "", "9starts_with_digit"] {
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["event_type"] = json!(bad);
assert!(
LifecycleEvent::from_value(&wire).is_err(),
"event_type {bad:?} must be rejected"
);
}
}
#[test]
fn retraction_state_last_event_wins() {
let retract = signed_retraction();
let mut republish = retract.clone();
republish.event_type = LifecycleEventType::Republished;
let mut unknown = retract.clone();
unknown.event_type = LifecycleEventType::Other("annotated".into());
assert!(!retraction_state(&[]));
assert!(retraction_state(std::slice::from_ref(&retract)));
assert!(!retraction_state(&[retract.clone(), republish.clone()]));
assert!(retraction_state(&[
retract.clone(),
republish.clone(),
retract.clone()
]));
assert!(retraction_state(&[retract.clone(), unknown.clone()]));
assert!(!retraction_state(&[
retract.clone(),
republish.clone(),
unknown
]));
}
#[test]
fn semantic_invariants_rejected() {
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["occurred_at"] = json!("2026-07-04T09:15:42Z");
assert!(LifecycleEvent::from_value(&wire).is_err());
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["event_id"] = json!("018F6D0A-7B2E-4C4D-9E1F-3A5B7C9D1E2F");
assert!(LifecycleEvent::from_value(&wire).is_err());
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["event_id"] = json!("not-a-uuid");
assert!(LifecycleEvent::from_value(&wire).is_err());
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["reason"] = json!("x".repeat(1025));
assert!(LifecycleEvent::from_value(&wire).is_err());
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["reason"] = serde_json::Value::Null;
assert!(LifecycleEvent::from_value(&wire).is_err());
let mut wire = serde_json::to_value(signed_retraction()).unwrap();
wire["signature"] = serde_json::Value::Null;
assert!(LifecycleEvent::from_value(&wire).is_err());
}
#[test]
fn sign_with_rejects_foreign_key_id() {
let unsigned = LifecycleEvent::new(
EVENT_ID,
CtxId(CTX.into()),
LifecycleEventType::Retracted,
Utc::now(),
AgentDid::new(ACTOR),
None,
)
.unwrap();
assert!(unsigned
.clone()
.sign_with(actor_key(), "did:web:other.example.com#key-1")
.is_err());
assert!(unsigned.sign_with(actor_key(), ACTOR).is_err()); }
}