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    "anchors",
57];
58
59/// Validate a raw lifecycle request body against the closed
60/// `{"event": {…}}` envelope (RFC-ACDP-0013 §6 step 2) and parse the
61/// event through the closed §4 schema.
62///
63/// Returns the parsed event together with the RAW event JSON — signature
64/// verification must hash the event exactly as received
65/// (`LifecycleEvent::preimage_hash_of_value`), never a re-serialization.
66///
67/// Errors:
68/// - [`AcdpError::ImmutableField`] — the envelope carries a `body`
69///   member or a member named after a body field (fixture `lc-002`
70///   scenarios A/B). Checked before the generic closed-shape rejection
71///   so the category error wins.
72/// - [`AcdpError::SchemaViolation`] — any other unknown envelope
73///   member, a missing/non-object `event`, or an event violating the
74///   closed §4 schema (including an unsigned producer event's later
75///   checks — see the server's endpoint pipeline).
76pub fn parse_lifecycle_request(
77    raw: &serde_json::Value,
78) -> Result<(LifecycleEvent, serde_json::Value), AcdpError> {
79    let map = raw.as_object().ok_or_else(|| {
80        AcdpError::SchemaViolation("lifecycle request body must be a JSON object".into())
81    })?;
82
83    for key in map.keys() {
84        if key == "event" {
85            continue;
86        }
87        if BODY_FIELD_NAMES.contains(&key.as_str()) {
88            return Err(AcdpError::ImmutableField(format!(
89                "lifecycle request member '{key}' attempts to supply or alter immutable \
90                 body content — bodies are immutable and lifecycle endpoints mutate \
91                 registry state only (RFC-ACDP-0013 §6 step 2)"
92            )));
93        }
94        return Err(AcdpError::SchemaViolation(format!(
95            "lifecycle request has unknown member '{key}' — the request body is a closed \
96             object with exactly one member, 'event' (RFC-ACDP-0013 §6)"
97        )));
98    }
99
100    let raw_event = map.get("event").ok_or_else(|| {
101        AcdpError::SchemaViolation(
102            "lifecycle request is missing the required 'event' member (RFC-ACDP-0013 §6)".into(),
103        )
104    })?;
105    let event = LifecycleEvent::from_value(raw_event)?;
106    Ok((event, raw_event.clone()))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use serde_json::json;
113
114    fn valid_event() -> serde_json::Value {
115        json!({
116            "event_id": "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f",
117            "ctx_id": "acdp://registry.example.com/12345678-1234-4321-8123-123456781234",
118            "event_type": "retracted",
119            "occurred_at": "2026-07-04T09:15:42.000Z",
120            "actor": "did:web:agents.example.com:test-producer",
121            "reason": "underlying data source found to be fabricated",
122            "signature": {
123                "algorithm": "ed25519",
124                "key_id": "did:web:agents.example.com:test-producer#key-1",
125                "value": "AA=="
126            }
127        })
128    }
129
130    #[test]
131    fn valid_envelope_parses() {
132        let (event, raw) = parse_lifecycle_request(&json!({ "event": valid_event() })).unwrap();
133        assert_eq!(event.event_id, "018f6d0a-7b2e-4c4d-9e1f-3a5b7c9d1e2f");
134        assert_eq!(raw, valid_event());
135    }
136
137    /// lc-002 scenario A: a `body` member is the category error.
138    #[test]
139    fn body_member_is_immutable_field() {
140        let err = parse_lifecycle_request(&json!({
141            "event": valid_event(),
142            "body": { "title": "Corrected title" }
143        }))
144        .unwrap_err();
145        assert!(matches!(err, AcdpError::ImmutableField(_)), "got {err:?}");
146    }
147
148    /// lc-002 scenario B: a body-field-named member (here `summary`) is
149    /// the same category error — NOT a generic schema_violation.
150    #[test]
151    fn body_field_named_member_is_immutable_field() {
152        let err = parse_lifecycle_request(&json!({
153            "event": valid_event(),
154            "summary": "please update the summary too"
155        }))
156        .unwrap_err();
157        assert!(matches!(err, AcdpError::ImmutableField(_)), "got {err:?}");
158    }
159
160    /// `anchors` (RFC-ACDP-0016) is a `Body` field like any other — an
161    /// envelope member named `anchors` MUST be `immutable_field`, not a
162    /// generic `schema_violation`. Regression test: this field was added
163    /// to `Body` (PR #169) without being added to `BODY_FIELD_NAMES`.
164    #[test]
165    fn anchors_field_named_member_is_immutable_field() {
166        let err = parse_lifecycle_request(&json!({
167            "event": valid_event(),
168            "anchors": [{"scheme": "macp.commitment", "content_hash": "sha256:aa"}]
169        }))
170        .unwrap_err();
171        assert!(matches!(err, AcdpError::ImmutableField(_)), "got {err:?}");
172    }
173
174    /// An unknown member NOT naming body content is a plain
175    /// schema_violation against the closed envelope (lc-002 note).
176    #[test]
177    fn non_body_unknown_member_is_schema_violation() {
178        let err = parse_lifecycle_request(&json!({
179            "event": valid_event(),
180            "note": "hello"
181        }))
182        .unwrap_err();
183        assert!(matches!(err, AcdpError::SchemaViolation(_)), "got {err:?}");
184    }
185
186    #[test]
187    fn missing_event_member_rejected() {
188        let err = parse_lifecycle_request(&json!({})).unwrap_err();
189        assert!(matches!(err, AcdpError::SchemaViolation(_)));
190        let err = parse_lifecycle_request(&json!([1, 2])).unwrap_err();
191        assert!(matches!(err, AcdpError::SchemaViolation(_)));
192    }
193}