acdp_server/registry/
lifecycle.rs1use acdp_primitives::error::AcdpError;
18use acdp_types::lifecycle::LifecycleEvent;
19
20const BODY_FIELD_NAMES: &[&str] = &[
27 "body",
28 "ctx_id",
30 "lineage_id",
31 "origin_registry",
32 "created_at",
33 "content_hash",
35 "signature",
36 "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
58pub 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 #[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 #[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 #[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}