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 "anchors",
57];
58
59pub 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 #[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 #[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 #[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 #[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}