Skip to main content

acdp_types/
lifecycle.rs

1//! Lifecycle events & retraction (ACDP 0.3, RFC-ACDP-0013 — promoting
2//! the RFC-ACDP-0009 §2.1 reservation).
3//!
4//! A [`LifecycleEvent`] is a signed, append-only record in
5//! `registry_state.lifecycle_events`: who performed a formal state
6//! action on a context (retraction, republication), when, and
7//! optionally why. Retraction is **mark-not-delete**: the body of a
8//! retracted context remains permanently retrievable, byte-identical to
9//! what its producer signed; what changes is the reliance signal
10//! (`status: retracted`), never the record.
11//!
12//! ## Schema shape (RFC-ACDP-0013 §4)
13//!
14//! The event OBJECT is **closed** (`additionalProperties: false`,
15//! `acdp-lifecycle-event.schema.json`): every member is signed where a
16//! signature is present, so an unknown member would change the preimage
17//! — it is rejected at parse time, exactly like the receipt schemas.
18//! The `event_type` VOCABULARY is **open**
19//! (`registries/lifecycle-event-types.md`): an unrecognized value
20//! matching the `^[a-z][a-z0-9_]*$` pattern is tolerated, preserved
21//! verbatim on re-serialization, and has *no effect on retraction
22//! state* (§7.3) — the same discipline as unknown
23//! [`Status`](acdp_primitives::primitives::Status) values.
24//!
25//! ## Signing construction (RFC-ACDP-0013 §5)
26//!
27//! Reuses RFC-ACDP-0010 §5 **verbatim** (the shared helper in
28//! [`crate::receipt`]): the preimage is the JCS-canonicalized event
29//! minus the `signature` member, hashed with SHA-256; the signature is
30//! over the **ASCII bytes of the `"sha256:<hex>"` string** — never the
31//! raw digest, never the unprefixed hex.
32
33use 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
40/// Maximum `reason` length (RFC-ACDP-0013 §4).
41pub const MAX_REASON_CHARS: usize = 1024;
42
43/// The lifecycle event kind — an **open** vocabulary
44/// ([`registries/lifecycle-event-types.md`]): v1 registers `retracted`
45/// and `republished`; unknown values matching the RFC-ACDP-0004 §4.1
46/// pattern (`^[a-z][a-z0-9_]*$`, 1–64 chars) are tolerated with **no
47/// effect on retraction state** and round-trip verbatim — mirroring how
48/// [`Status`](acdp_primitives::primitives::Status) handles unknown
49/// values. Registries MUST NOT accept unregistered values through the
50/// RFC-ACDP-0013 §6 endpoints in 0.3.0 (§7.3): openness is for future
51/// versions and consumers, not a free-form producer channel.
52///
53/// [`registries/lifecycle-event-types.md`]:
54///     https://github.com/agentcontextdistributionprotocol/agentcontextdistributionprotocol/blob/main/registries/lifecycle-event-types.md
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum LifecycleEventType {
57    /// `retracted` — the context is formally withdrawn from reliance;
58    /// `status` becomes `retracted` (dominating `superseded` and
59    /// `expired`, RFC-ACDP-0013 §7.2).
60    Retracted,
61    /// `republished` — a prior retraction is reversed; `status`
62    /// re-derives per RFC-ACDP-0004 §4 as though never retracted. Both
63    /// events remain in the append-only history.
64    Republished,
65    /// An event type this version of the library does not recognize.
66    /// Per RFC-ACDP-0013 §7.3: tolerate, preserve verbatim, no effect
67    /// on retraction state until upgrade.
68    Other(String),
69}
70
71impl LifecycleEventType {
72    /// Validate against the schema pattern `^[a-z][a-z0-9_]*$`, length
73    /// 1..=64 (the RFC-ACDP-0004 §4.1 pattern, reused by §4).
74    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    /// Wire-form string representation.
83    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    /// Parse an event-type string, validating the pattern. Values that
92    /// do not match the pattern are malformed registry state and are
93    /// rejected (RFC-ACDP-0013 §7.3).
94    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    /// True for the v1 registered vocabulary (`retracted` /
111    /// `republished`) — the only values registries may accept through
112    /// the RFC-ACDP-0013 §6 endpoints in 0.3.0 (§7.3).
113    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/// A lifecycle event (RFC-ACDP-0013 §4) — one entry of
141/// `registry_state.lifecycle_events`.
142///
143/// CLOSED schema (`acdp-lifecycle-event.schema.json`,
144/// `additionalProperties: false`): exactly the seven specified members;
145/// an unknown member changes the signed preimage and is rejected at
146/// parse time. Lifecycle events are **registry state, outside the
147/// body**: never an input to the producer's `content_hash`
148/// (RFC-ACDP-0001 §5.7) and never stored inside a body (§4.1).
149///
150/// Event **ordering** is array position in `lifecycle_events` (the
151/// registry-accepted order); `occurred_at` never participates in the
152/// retraction-state derivation (§4.1, §7.1).
153#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154#[serde(deny_unknown_fields)]
155pub struct LifecycleEvent {
156    /// UUID (RFC 9562 canonical lowercase form) minted by the actor.
157    /// MUST be unique within the context's `lifecycle_events` array.
158    pub event_id: String,
159    /// The affected context. MUST equal the `ctx_id` of the context
160    /// whose registry state carries the event — binding a signed event
161    /// to exactly one context so it cannot be replayed against another.
162    pub ctx_id: CtxId,
163    /// The event kind (open vocabulary, §7.3).
164    pub event_type: LifecycleEventType,
165    /// When the actor performed the action: canonical
166    /// millisecond-precision RFC 3339 UTC (RFC-ACDP-0001 §5.3). The
167    /// strict serde on this field rejects any non-canonical byte form
168    /// (which the schema pattern forbids) so a parsed event always
169    /// re-serializes byte-identically — a signed member must never be
170    /// silently normalized.
171    #[serde(with = "strict_ms_rfc3339")]
172    pub occurred_at: DateTime<Utc>,
173    /// The DID of the party performing the action: `body.agent_id` for
174    /// producer-initiated events; the registry's DID
175    /// (`capabilities.registry_did`) for registry-initiated events.
176    pub actor: AgentDid,
177    /// Optional human-readable explanation (≤ 1024 chars).
178    /// Informational only; MUST NOT drive automated decisions (§4).
179    #[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    /// Signature over the event hash (RFC-ACDP-0013 §5). REQUIRED on
186    /// producer-initiated events; SHOULD be present on
187    /// registry-initiated events.
188    #[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
196/// Fixed three-digit-millisecond RFC 3339 serde for event
197/// `occurred_at`, STRICT on deserialize: the schema pattern
198/// (`…SS.mmmZ`) admits exactly one byte form, and accepting a laxer
199/// form here would silently change signed bytes on re-serialization.
200mod 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
222/// True when `s` is a canonical RFC 9562 UUID string: 8-4-4-4-12
223/// lowercase hex. Deliberately version-agnostic (the schema pattern is
224/// `^[0-9a-f]{8}-…$`) — actors mint v4 or v7 UUIDs.
225fn 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    /// Construct a validated, **unsigned** event (`occurred_at` is
238    /// millisecond-truncated per RFC-ACDP-0001 §5.3). Producer-initiated
239    /// events MUST subsequently be signed via [`Self::sign_with`];
240    /// registry-initiated events SHOULD be.
241    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    /// Sign this event per RFC-ACDP-0013 §5 (the RFC-ACDP-0010 §5
263    /// construction): JCS of the event minus `signature`, SHA-256, and
264    /// a signature over the ASCII bytes of the full `"sha256:<hex>"`
265    /// string. `key_id`'s DID portion MUST equal `actor` — the signing
266    /// key need not be the key that signed the original body (§5:
267    /// producers rotate keys), but it must belong to the actor's DID.
268    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        // The preimage removes `signature` entirely, so it is identical
285        // before and after this assignment.
286        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    /// Parse an event from raw wire JSON, enforcing the closed schema
298    /// plus the §4 semantic invariants ([`Self::validate`]). The
299    /// canonical `occurred_at` byte form is enforced by the field's
300    /// strict serde. An event failing this parse is malformed registry
301    /// state — structurally non-conformant per §7.3.
302    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    /// The §4 semantic invariants not already enforced by serde:
311    /// canonical-UUID `event_id`, well-formed `ctx_id` and `actor`,
312    /// `reason` length.
313    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    /// Compute the event's preimage hash from the RAW wire JSON (the
335    /// value minus `signature`, JCS-canonicalized as received).
336    ///
337    /// Verifiers MUST hash the event exactly as received rather than
338    /// re-serializing a parsed struct — the same raw-JSON rule as
339    /// [`crate::receipt::RegistryReceipt::preimage_hash_of_value`].
340    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    /// Compute the event's preimage hash from the struct. Used at
348    /// signing time; verifiers should prefer
349    /// [`Self::preimage_hash_of_value`] over the raw wire JSON. (For
350    /// events parsed through this type's strict serde the two agree —
351    /// every field round-trips byte-identically.)
352    pub fn preimage_hash(&self) -> Result<ContentHash, AcdpError> {
353        Self::preimage_hash_of_value(&serde_json::to_value(self)?)
354    }
355
356    /// RFC-ACDP-0013 §5 actor binding: the DID portion of
357    /// `signature.key_id` MUST equal `actor`. Returns the signature for
358    /// further verification; a missing signature is an error (call
359    /// sites that tolerate unsigned registry events check
360    /// [`Self::is_signed`] first).
361    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    /// True when the event carries a signature.
380    pub fn is_signed(&self) -> bool {
381        self.signature.is_some()
382    }
383
384    /// Verify the event signature against a known actor public key
385    /// (pure — no DID resolution). Checks the §5 actor binding, then
386    /// verifies over the ASCII bytes of the recomputed event hash.
387    /// Resolver-backed verification lives in
388    /// `acdp_verify::verify_lifecycle_event`.
389    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    /// Like [`Self::verify_signature_with_key`] but over an
399    /// already-computed preimage hash — pair with
400    /// [`Self::preimage_hash_of_value`] for raw-JSON verification.
401    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
432/// Derive the **retraction state** of a context from its
433/// `lifecycle_events` (RFC-ACDP-0013 §7.1): consider only
434/// `retracted`/`republished` events; the context is retracted iff the
435/// **last such event in array order** is `retracted`. Unknown event
436/// types have no effect (§7.3); `occurred_at` never participates.
437pub 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        // Wire round trip: closed parse, canonical occurred_at bytes,
491        // raw-JSON preimage equals struct preimage.
492        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    /// The preimage is the event minus `signature` only — identical for
506    /// the signed and unsigned forms of the same event (§5 step 1).
507    #[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        // Actor-binding violation surfaces as InvalidSignature.
535        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    /// RFC-ACDP-0013 §4: the event schema is CLOSED — an unknown member
544    /// MUST be rejected at parse time (it would change the preimage).
545    #[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    /// §7.3: unknown event_type VALUES matching the pattern are
556    /// tolerated, round-trip verbatim, and have no retraction effect.
557    #[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        // Verbatim re-serialization.
568        assert_eq!(
569            serde_json::to_value(&parsed).unwrap()["event_type"],
570            json!("annotated")
571        );
572        // No effect on retraction state.
573        assert!(!retraction_state(&[parsed]));
574
575        // Pattern-violating values are malformed registry state.
576        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    /// §7.1: retraction state is the LAST retracted/republished event
587    /// in array order; unknown types are transparent to the derivation.
588    #[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        // Trailing unknown event does not flip the state.
605        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        // Non-canonical occurred_at byte form (no milliseconds).
616        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        // Uppercase / malformed event_id.
621        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        // Over-long reason.
629        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        // Present-but-null optional members violate the absent-vs-null
634        // convention (RFC-ACDP-0005 §2.2.1).
635        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()); // no fragment
659    }
660}