1use crate::body::Signature;
34use crate::receipt::preimage_hash_of_map;
35use acdp_primitives::error::AcdpError;
36use acdp_primitives::primitives::{AgentDid, ContentHash, CtxId};
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40pub const MAX_REASON_CHARS: usize = 1024;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum LifecycleEventType {
57 Retracted,
61 Republished,
65 Other(String),
69}
70
71impl LifecycleEventType {
72 fn pattern_ok(s: &str) -> bool {
75 !s.is_empty()
76 && s.len() <= 64
77 && s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
78 && s.chars()
79 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
80 }
81
82 pub fn as_str(&self) -> &str {
84 match self {
85 LifecycleEventType::Retracted => "retracted",
86 LifecycleEventType::Republished => "republished",
87 LifecycleEventType::Other(s) => s,
88 }
89 }
90
91 pub fn parse(s: &str) -> Result<Self, AcdpError> {
95 match s {
96 "retracted" => Ok(LifecycleEventType::Retracted),
97 "republished" => Ok(LifecycleEventType::Republished),
98 other => {
99 if !Self::pattern_ok(other) {
100 return Err(AcdpError::SchemaViolation(format!(
101 "event_type '{other}' does not match the open-vocabulary pattern \
102 ^[a-z][a-z0-9_]*$ (length 1..=64, RFC-ACDP-0013 §4)"
103 )));
104 }
105 Ok(LifecycleEventType::Other(other.to_string()))
106 }
107 }
108 }
109
110 pub fn is_registered(&self) -> bool {
114 matches!(
115 self,
116 LifecycleEventType::Retracted | LifecycleEventType::Republished
117 )
118 }
119}
120
121impl Serialize for LifecycleEventType {
122 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
123 s.serialize_str(self.as_str())
124 }
125}
126
127impl<'de> Deserialize<'de> for LifecycleEventType {
128 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
129 let s = String::deserialize(d)?;
130 LifecycleEventType::parse(&s).map_err(serde::de::Error::custom)
131 }
132}
133
134impl std::fmt::Display for LifecycleEventType {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 f.write_str(self.as_str())
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct LifecycleEvent {
156 pub event_id: String,
159 pub ctx_id: CtxId,
163 pub event_type: LifecycleEventType,
165 #[serde(with = "strict_ms_rfc3339")]
172 pub occurred_at: DateTime<Utc>,
173 pub actor: AgentDid,
177 #[serde(
180 default,
181 deserialize_with = "crate::serde_helpers::de_present",
182 skip_serializing_if = "Option::is_none"
183 )]
184 pub reason: Option<String>,
185 #[serde(
189 default,
190 deserialize_with = "crate::serde_helpers::de_present",
191 skip_serializing_if = "Option::is_none"
192 )]
193 pub signature: Option<Signature>,
194}
195
196mod strict_ms_rfc3339 {
201 use chrono::{DateTime, Utc};
202 use serde::{Deserialize, Deserializer, Serializer};
203
204 pub fn serialize<S: Serializer>(dt: &DateTime<Utc>, s: S) -> Result<S::Ok, S::Error> {
205 s.serialize_str(&dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string())
206 }
207
208 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<DateTime<Utc>, D::Error> {
209 let raw = String::deserialize(d)?;
210 if !crate::receipt::is_canonical_ms_utc(&raw) {
211 return Err(serde::de::Error::custom(format!(
212 "occurred_at '{raw}' is not canonical millisecond-precision RFC 3339 UTC \
213 (`YYYY-MM-DDTHH:MM:SS.mmmZ`, RFC-ACDP-0001 §5.3 / RFC-ACDP-0013 §4)"
214 )));
215 }
216 DateTime::parse_from_rfc3339(&raw)
217 .map(|t| t.with_timezone(&Utc))
218 .map_err(serde::de::Error::custom)
219 }
220}
221
222fn is_canonical_uuid(s: &str) -> bool {
226 let b = s.as_bytes();
227 if b.len() != 36 {
228 return false;
229 }
230 b.iter().enumerate().all(|(i, &c)| match i {
231 8 | 13 | 18 | 23 => c == b'-',
232 _ => c.is_ascii_digit() || (b'a'..=b'f').contains(&c),
233 })
234}
235
236impl LifecycleEvent {
237 pub fn new(
242 event_id: impl Into<String>,
243 ctx_id: CtxId,
244 event_type: LifecycleEventType,
245 occurred_at: DateTime<Utc>,
246 actor: AgentDid,
247 reason: Option<String>,
248 ) -> Result<Self, AcdpError> {
249 let event = Self {
250 event_id: event_id.into(),
251 ctx_id,
252 event_type,
253 occurred_at: acdp_primitives::time::trunc_ms(occurred_at),
254 actor,
255 reason,
256 signature: None,
257 };
258 event.validate()?;
259 Ok(event)
260 }
261
262 pub fn sign_with(
269 mut self,
270 key: impl Into<acdp_crypto::sign::AcdpSigningKey>,
271 key_id: impl Into<String>,
272 ) -> Result<Self, AcdpError> {
273 let key_id = key_id.into();
274 match key_id.split_once('#') {
275 Some((did, frag)) if did == self.actor.as_str() && !frag.is_empty() => {}
276 _ => {
277 return Err(AcdpError::SchemaViolation(format!(
278 "lifecycle event signature key_id '{key_id}' must be '<actor>#<fragment>' \
279 with DID portion equal to actor '{}' (RFC-ACDP-0013 §5)",
280 self.actor
281 )));
282 }
283 }
284 let hash = self.preimage_hash()?;
287 let key = key.into();
288 let (algorithm, value) = key.sign_content_hash(&hash);
289 self.signature = Some(Signature {
290 algorithm: algorithm.into(),
291 key_id,
292 value,
293 });
294 Ok(self)
295 }
296
297 pub fn from_value(value: &serde_json::Value) -> Result<Self, AcdpError> {
303 let event = Self::deserialize(value).map_err(|e| {
304 AcdpError::SchemaViolation(format!("lifecycle event does not parse: {e}"))
305 })?;
306 event.validate()?;
307 Ok(event)
308 }
309
310 pub fn validate(&self) -> Result<(), AcdpError> {
314 if !is_canonical_uuid(&self.event_id) {
315 return Err(AcdpError::SchemaViolation(format!(
316 "lifecycle event_id '{}' is not a canonical lowercase RFC 9562 UUID \
317 (RFC-ACDP-0013 §4)",
318 self.event_id
319 )));
320 }
321 CtxId::parse(self.ctx_id.as_str().to_string())?;
322 AgentDid::parse(self.actor.as_str().to_string())?;
323 if let Some(reason) = &self.reason {
324 if reason.chars().count() > MAX_REASON_CHARS {
325 return Err(AcdpError::SchemaViolation(format!(
326 "lifecycle event reason exceeds {MAX_REASON_CHARS} characters \
327 (RFC-ACDP-0013 §4)"
328 )));
329 }
330 }
331 Ok(())
332 }
333
334 pub fn preimage_hash_of_value(value: &serde_json::Value) -> Result<ContentHash, AcdpError> {
341 let map = value.as_object().cloned().ok_or_else(|| {
342 AcdpError::SchemaViolation("lifecycle event must be a JSON object".into())
343 })?;
344 preimage_hash_of_map(map)
345 }
346
347 pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
353 Self::preimage_hash_of_value(&serde_json::to_value(self)?)
354 }
355
356 pub fn actor_bound_signature(&self) -> Result<&Signature, AcdpError> {
362 let signature = self.signature.as_ref().ok_or_else(|| {
363 AcdpError::SchemaViolation(
364 "lifecycle event has no signature (producer-initiated events MUST be \
365 signed, RFC-ACDP-0013 §5)"
366 .into(),
367 )
368 })?;
369 match signature.key_id.split_once('#') {
370 Some((did, frag)) if did == self.actor.as_str() && !frag.is_empty() => Ok(signature),
371 _ => Err(AcdpError::InvalidSignature(format!(
372 "lifecycle event signature.key_id '{}' is not a DID URL under actor '{}' \
373 (RFC-ACDP-0013 §5 actor binding)",
374 signature.key_id, self.actor
375 ))),
376 }
377 }
378
379 pub fn is_signed(&self) -> bool {
381 self.signature.is_some()
382 }
383
384 pub fn verify_signature_with_key(
390 &self,
391 actor_pub_ed25519: Option<&[u8; 32]>,
392 actor_pub_p256_sec1: Option<&[u8]>,
393 ) -> Result<(), AcdpError> {
394 let hash = self.preimage_hash()?;
395 self.verify_signature_against_hash(&hash, actor_pub_ed25519, actor_pub_p256_sec1)
396 }
397
398 pub fn verify_signature_against_hash(
402 &self,
403 hash: &ContentHash,
404 actor_pub_ed25519: Option<&[u8; 32]>,
405 actor_pub_p256_sec1: Option<&[u8]>,
406 ) -> Result<(), AcdpError> {
407 let signature = self.actor_bound_signature()?;
408 match signature.algorithm.as_str() {
409 "ed25519" => {
410 let key = actor_pub_ed25519.ok_or_else(|| {
411 AcdpError::InvalidSignature(
412 "event declares ed25519 but no ed25519 actor key was resolved".into(),
413 )
414 })?;
415 acdp_crypto::verify::verify_ed25519(key, &signature.value, hash.as_str())
416 }
417 "ecdsa-p256" => {
418 let key = actor_pub_p256_sec1.ok_or_else(|| {
419 AcdpError::InvalidSignature(
420 "event declares ecdsa-p256 but no p256 actor key was resolved".into(),
421 )
422 })?;
423 acdp_crypto::verify::verify_ecdsa_p256(key, &signature.value, hash.as_str())
424 }
425 other => Err(AcdpError::UnsupportedAlgorithm(format!(
426 "lifecycle event signature algorithm '{other}' is not supported"
427 ))),
428 }
429 }
430}
431
432pub fn retraction_state(events: &[LifecycleEvent]) -> bool {
438 events
439 .iter()
440 .rev()
441 .find_map(|e| match e.event_type {
442 LifecycleEventType::Retracted => Some(true),
443 LifecycleEventType::Republished => Some(false),
444 LifecycleEventType::Other(_) => None,
445 })
446 .unwrap_or(false)
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452 use acdp_crypto::SigningKey;
453 use serde_json::json;
454
455 const CTX: &str = "acdp://registry.example.com/12345678-1234-4321-8123-123456781234";
456 const ACTOR: &str = "did:web:agents.example.com:test-producer";
457 const EVENT_ID: &str = "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f";
458
459 fn actor_key() -> SigningKey {
460 SigningKey::from_bytes(&[5u8; 32])
461 }
462
463 fn signed_retraction() -> LifecycleEvent {
464 LifecycleEvent::new(
465 EVENT_ID,
466 CtxId(CTX.into()),
467 LifecycleEventType::Retracted,
468 chrono::DateTime::parse_from_rfc3339("2026-07-04T09:15:42.000Z")
469 .unwrap()
470 .with_timezone(&Utc),
471 AgentDid::new(ACTOR),
472 Some("underlying data source found to be fabricated".into()),
473 )
474 .unwrap()
475 .sign_with(actor_key(), format!("{ACTOR}#key-1"))
476 .unwrap()
477 }
478
479 fn actor_pub() -> [u8; 32] {
480 actor_key().verifying_key_bytes()
481 }
482
483 #[test]
484 fn sign_verify_round_trip_incl_wire() {
485 let event = signed_retraction();
486 event
487 .verify_signature_with_key(Some(&actor_pub()), None)
488 .expect("freshly signed event must verify");
489
490 let wire = serde_json::to_value(&event).unwrap();
493 assert_eq!(wire["occurred_at"], "2026-07-04T09:15:42.000Z");
494 let parsed = LifecycleEvent::from_value(&wire).unwrap();
495 assert_eq!(parsed, event);
496 assert_eq!(
497 LifecycleEvent::preimage_hash_of_value(&wire).unwrap(),
498 event.preimage_hash().unwrap()
499 );
500 parsed
501 .verify_signature_with_key(Some(&actor_pub()), None)
502 .unwrap();
503 }
504
505 #[test]
508 fn preimage_excludes_signature_only() {
509 let signed = signed_retraction();
510 let mut unsigned = signed.clone();
511 unsigned.signature = None;
512 assert_eq!(
513 signed.preimage_hash().unwrap(),
514 unsigned.preimage_hash().unwrap()
515 );
516 }
517
518 #[test]
519 fn tampered_fields_fail_verification() {
520 let pubkey = actor_pub();
521
522 let mut e = signed_retraction();
523 e.reason = Some("innocuous edit".into());
524 assert!(e.verify_signature_with_key(Some(&pubkey), None).is_err());
525
526 let mut e = signed_retraction();
527 e.ctx_id = CtxId("acdp://evil.example.com/12345678-1234-4321-8123-123456781234".into());
528 assert!(e.verify_signature_with_key(Some(&pubkey), None).is_err());
529
530 let mut e = signed_retraction();
531 e.occurred_at += chrono::Duration::milliseconds(1);
532 assert!(e.verify_signature_with_key(Some(&pubkey), None).is_err());
533
534 let mut e = signed_retraction();
536 e.actor = AgentDid::new("did:web:agents.example.com:other");
537 let err = e
538 .verify_signature_with_key(Some(&pubkey), None)
539 .unwrap_err();
540 assert!(matches!(err, AcdpError::InvalidSignature(_)), "got {err:?}");
541 }
542
543 #[test]
546 fn unknown_event_member_rejected() {
547 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
548 wire.as_object_mut()
549 .unwrap()
550 .insert("severity".into(), json!("high"));
551 let err = LifecycleEvent::from_value(&wire).unwrap_err();
552 assert!(matches!(err, AcdpError::SchemaViolation(_)), "got {err:?}");
553 }
554
555 #[test]
558 fn unknown_event_type_tolerated_and_inert() {
559 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
560 wire["event_type"] = json!("annotated");
561 let parsed = LifecycleEvent::from_value(&wire).unwrap();
562 assert_eq!(
563 parsed.event_type,
564 LifecycleEventType::Other("annotated".into())
565 );
566 assert!(!parsed.event_type.is_registered());
567 assert_eq!(
569 serde_json::to_value(&parsed).unwrap()["event_type"],
570 json!("annotated")
571 );
572 assert!(!retraction_state(&[parsed]));
574
575 for bad in ["Retracted", "has space", "", "9starts_with_digit"] {
577 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
578 wire["event_type"] = json!(bad);
579 assert!(
580 LifecycleEvent::from_value(&wire).is_err(),
581 "event_type {bad:?} must be rejected"
582 );
583 }
584 }
585
586 #[test]
589 fn retraction_state_last_event_wins() {
590 let retract = signed_retraction();
591 let mut republish = retract.clone();
592 republish.event_type = LifecycleEventType::Republished;
593 let mut unknown = retract.clone();
594 unknown.event_type = LifecycleEventType::Other("annotated".into());
595
596 assert!(!retraction_state(&[]));
597 assert!(retraction_state(std::slice::from_ref(&retract)));
598 assert!(!retraction_state(&[retract.clone(), republish.clone()]));
599 assert!(retraction_state(&[
600 retract.clone(),
601 republish.clone(),
602 retract.clone()
603 ]));
604 assert!(retraction_state(&[retract.clone(), unknown.clone()]));
606 assert!(!retraction_state(&[
607 retract.clone(),
608 republish.clone(),
609 unknown
610 ]));
611 }
612
613 #[test]
614 fn semantic_invariants_rejected() {
615 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
617 wire["occurred_at"] = json!("2026-07-04T09:15:42Z");
618 assert!(LifecycleEvent::from_value(&wire).is_err());
619
620 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
622 wire["event_id"] = json!("018F6D0A-7B2E-4C4D-9E1F-3A5B7C9D1E2F");
623 assert!(LifecycleEvent::from_value(&wire).is_err());
624 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
625 wire["event_id"] = json!("not-a-uuid");
626 assert!(LifecycleEvent::from_value(&wire).is_err());
627
628 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
630 wire["reason"] = json!("x".repeat(1025));
631 assert!(LifecycleEvent::from_value(&wire).is_err());
632
633 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
636 wire["reason"] = serde_json::Value::Null;
637 assert!(LifecycleEvent::from_value(&wire).is_err());
638 let mut wire = serde_json::to_value(signed_retraction()).unwrap();
639 wire["signature"] = serde_json::Value::Null;
640 assert!(LifecycleEvent::from_value(&wire).is_err());
641 }
642
643 #[test]
644 fn sign_with_rejects_foreign_key_id() {
645 let unsigned = LifecycleEvent::new(
646 EVENT_ID,
647 CtxId(CTX.into()),
648 LifecycleEventType::Retracted,
649 Utc::now(),
650 AgentDid::new(ACTOR),
651 None,
652 )
653 .unwrap();
654 assert!(unsigned
655 .clone()
656 .sign_with(actor_key(), "did:web:other.example.com#key-1")
657 .is_err());
658 assert!(unsigned.sign_with(actor_key(), ACTOR).is_err()); }
660}