Skip to main content

acdp_server/registry/
lifecycle.rs

1//! Lifecycle endpoint request validation (ACDP 0.3, RFC-ACDP-0013 §6
2//! step 2) — the envelope layer for `POST /contexts/{ctx_id}/retract`
3//! and `POST /contexts/{ctx_id}/republish`.
4//!
5//! The request body is a CLOSED JSON object with exactly one member,
6//! `event`. A request that attempts to supply or alter **body content**
7//! through a lifecycle endpoint — a `body` member, or any envelope
8//! member named after a body field — MUST be rejected with the
9//! **`immutable_field`** wire code (HTTP 400), activated in 0.3.0 from
10//! the reservation held since v0.1.0 (RFC-ACDP-0007 §5, RFC-ACDP-0009
11//! §2.1): bodies are immutable, and lifecycle endpoints mutate registry
12//! state only. The distinct code exists so producers learn the
13//! *category* error, not a generic validation failure (fixture
14//! `lc-002`). Any other unknown envelope member is a plain
15//! `schema_violation` against the closed request shape.
16
17use acdp_primitives::error::AcdpError;
18use acdp_types::lifecycle::LifecycleEvent;
19
20/// The wire names of every [`Body`](acdp_types::body::Body) field
21/// (RFC-ACDP-0002 §3.1). An envelope member with one of these names is
22/// an attempt to modify immutable body content (`immutable_field`); the
23/// set matches the struct in `acdp-types/src/body.rs` — extend both
24/// together (the registry-assigned and integrity fields are included:
25/// none of them is writable through a lifecycle endpoint either).
26const BODY_FIELD_NAMES: &[&str] = &[
27    "body",
28    // Registry-assigned identity fields.
29    "ctx_id",
30    "lineage_id",
31    "origin_registry",
32    "created_at",
33    // Integrity fields.
34    "content_hash",
35    "signature",
36    // Producer-controlled fields.
37    "version",
38    "supersedes",
39    "agent_id",
40    "contributors",
41    "title",
42    "type",
43    "data_refs",
44    "derived_from",
45    "visibility",
46    "audience",
47    "acdp_version",
48    "description",
49    "summary",
50    "tags",
51    "domain",
52    "expires_at",
53    "data_period",
54    "metadata",
55    "schema_uri",
56];
57
58/// Validate a raw lifecycle request body against the closed
59/// `{"event": {…}}` envelope (RFC-ACDP-0013 §6 step 2) and parse the
60/// event through the closed §4 schema.
61///
62/// Returns the parsed event together with the RAW event JSON — signature
63/// verification must hash the event exactly as received
64/// (`LifecycleEvent::preimage_hash_of_value`), never a re-serialization.
65///
66/// Errors:
67/// - [`AcdpError::ImmutableField`] — the envelope carries a `body`
68///   member or a member named after a body field (fixture `lc-002`
69///   scenarios A/B). Checked before the generic closed-shape rejection
70///   so the category error wins.
71/// - [`AcdpError::SchemaViolation`] — any other unknown envelope
72///   member, a missing/non-object `event`, or an event violating the
73///   closed §4 schema (including an unsigned producer event's later
74///   checks — see the server's endpoint pipeline).
75pub fn parse_lifecycle_request(
76    raw: &serde_json::Value,
77) -> Result<(LifecycleEvent, serde_json::Value), AcdpError> {
78    let map = raw.as_object().ok_or_else(|| {
79        AcdpError::SchemaViolation("lifecycle request body must be a JSON object".into())
80    })?;
81
82    for key in map.keys() {
83        if key == "event" {
84            continue;
85        }
86        if BODY_FIELD_NAMES.contains(&key.as_str()) {
87            return Err(AcdpError::ImmutableField(format!(
88                "lifecycle request member '{key}' attempts to supply or alter immutable \
89                 body content — bodies are immutable and lifecycle endpoints mutate \
90                 registry state only (RFC-ACDP-0013 §6 step 2)"
91            )));
92        }
93        return Err(AcdpError::SchemaViolation(format!(
94            "lifecycle request has unknown member '{key}' — the request body is a closed \
95             object with exactly one member, 'event' (RFC-ACDP-0013 §6)"
96        )));
97    }
98
99    let raw_event = map.get("event").ok_or_else(|| {
100        AcdpError::SchemaViolation(
101            "lifecycle request is missing the required 'event' member (RFC-ACDP-0013 §6)".into(),
102        )
103    })?;
104    let event = LifecycleEvent::from_value(raw_event)?;
105    Ok((event, raw_event.clone()))
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use serde_json::json;
112
113    fn valid_event() -> serde_json::Value {
114        json!({
115            "event_id": "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f",
116            "ctx_id": "acdp://registry.example.com/12345678-1234-4321-8123-123456781234",
117            "event_type": "retracted",
118            "occurred_at": "2026-07-04T09:15:42.000Z",
119            "actor": "did:web:agents.example.com:test-producer",
120            "reason": "underlying data source found to be fabricated",
121            "signature": {
122                "algorithm": "ed25519",
123                "key_id": "did:web:agents.example.com:test-producer#key-1",
124                "value": "AA=="
125            }
126        })
127    }
128
129    #[test]
130    fn valid_envelope_parses() {
131        let (event, raw) = parse_lifecycle_request(&json!({ "event": valid_event() })).unwrap();
132        assert_eq!(event.event_id, "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f");
133        assert_eq!(raw, valid_event());
134    }
135
136    /// lc-002 scenario A: a `body` member is the category error.
137    #[test]
138    fn body_member_is_immutable_field() {
139        let err = parse_lifecycle_request(&json!({
140            "event": valid_event(),
141            "body": { "title": "Corrected title" }
142        }))
143        .unwrap_err();
144        assert!(matches!(err, AcdpError::ImmutableField(_)), "got {err:?}");
145    }
146
147    /// lc-002 scenario B: a body-field-named member (here `summary`) is
148    /// the same category error — NOT a generic schema_violation.
149    #[test]
150    fn body_field_named_member_is_immutable_field() {
151        let err = parse_lifecycle_request(&json!({
152            "event": valid_event(),
153            "summary": "please update the summary too"
154        }))
155        .unwrap_err();
156        assert!(matches!(err, AcdpError::ImmutableField(_)), "got {err:?}");
157    }
158
159    /// An unknown member NOT naming body content is a plain
160    /// schema_violation against the closed envelope (lc-002 note).
161    #[test]
162    fn non_body_unknown_member_is_schema_violation() {
163        let err = parse_lifecycle_request(&json!({
164            "event": valid_event(),
165            "note": "hello"
166        }))
167        .unwrap_err();
168        assert!(matches!(err, AcdpError::SchemaViolation(_)), "got {err:?}");
169    }
170
171    #[test]
172    fn missing_event_member_rejected() {
173        let err = parse_lifecycle_request(&json!({})).unwrap_err();
174        assert!(matches!(err, AcdpError::SchemaViolation(_)));
175        let err = parse_lifecycle_request(&json!([1, 2])).unwrap_err();
176        assert!(matches!(err, AcdpError::SchemaViolation(_)));
177    }
178}