acdp_types/body.rs
1use crate::anchor::AnchorEntry;
2use crate::data_ref::DataRef;
3use crate::serde_helpers::de_present;
4use acdp_primitives::error::AcdpError;
5use acdp_primitives::primitives::*;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9// ── Body ─────────────────────────────────────────────────────────────────────
10
11/// The immutable stored body of an ACDP context (RFC-ACDP-0002).
12///
13/// Contains producer-controlled fields (covered by the producer signature)
14/// plus registry-assigned identity fields (`ctx_id`, `lineage_id`,
15/// `origin_registry`, `created_at`) which rely on registry honesty in v0.1.0.
16///
17/// The hash/signature preimage is ProducerContent: the Body with
18/// `content_hash`, `signature`, and the registry-assigned identity fields
19/// removed. See RFC-ACDP-0001 §5.7.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Body {
22 // ── Registry-assigned identity fields (NOT in ProducerContent) ──────
23 pub ctx_id: CtxId,
24 pub lineage_id: LineageId,
25 pub origin_registry: String,
26 #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
27 pub created_at: DateTime<Utc>,
28
29 // ── Integrity fields (NOT in ProducerContent) ────────────────────────
30 pub content_hash: ContentHash,
31 pub signature: Signature,
32
33 // ── Producer-controlled required fields ──────────────────────────────
34 pub version: u32,
35 pub supersedes: Option<CtxId>,
36 pub agent_id: AgentDid,
37 pub contributors: Vec<AgentDid>,
38 pub title: String,
39 #[serde(rename = "type")]
40 pub context_type: ContextType,
41 pub data_refs: Vec<DataRef>,
42 pub derived_from: Vec<CtxId>,
43 pub visibility: Visibility,
44
45 // ── Producer-controlled optional fields ──────────────────────────────
46 //
47 // Optional bare-typed fields use the absent-vs-null convention
48 // (RFC-ACDP-0005 §2.2.1, schema-005/006/007): an absent key is
49 // tolerated, a present-but-`null` key is rejected at deserialize.
50 // [`crate::serde_helpers::de_present`] implements this.
51 // `supersedes` is the one v0.1.0 field whose schema is
52 // `["string","null"]` (RFC-ACDP-0002 §3.1) — it is legitimately
53 // nullable and intentionally NOT routed through `de_present`.
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub audience: Option<Vec<AgentDid>>,
56 #[serde(
57 default,
58 deserialize_with = "de_present",
59 skip_serializing_if = "Option::is_none"
60 )]
61 pub acdp_version: Option<String>,
62 #[serde(
63 default,
64 deserialize_with = "de_present",
65 skip_serializing_if = "Option::is_none"
66 )]
67 pub description: Option<String>,
68 /// Producer-supplied summary for search results (≤ 1000 chars).
69 /// Part of ProducerContent — included in the content_hash preimage.
70 #[serde(
71 default,
72 deserialize_with = "de_present",
73 skip_serializing_if = "Option::is_none"
74 )]
75 pub summary: Option<String>,
76 #[serde(
77 default,
78 deserialize_with = "de_present",
79 skip_serializing_if = "Option::is_none"
80 )]
81 pub tags: Option<Vec<String>>,
82 #[serde(
83 default,
84 deserialize_with = "de_present",
85 skip_serializing_if = "Option::is_none"
86 )]
87 pub domain: Option<String>,
88 /// Canonical emission (RFC-ACDP-0001 §5.3): a whole-second instant
89 /// serializes as `…:SS.000Z`, never chrono's default `…:SSZ`.
90 #[serde(
91 default,
92 with = "acdp_primitives::time::canonical_rfc3339_option",
93 skip_serializing_if = "Option::is_none"
94 )]
95 pub expires_at: Option<DateTime<Utc>>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub data_period: Option<DataPeriod>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub metadata: Option<serde_json::Value>,
100 #[serde(
101 default,
102 deserialize_with = "de_present",
103 skip_serializing_if = "Option::is_none"
104 )]
105 pub schema_uri: Option<String>,
106 /// Typed, content-addressed references to external (non-ACDP)
107 /// artifacts this body genesis-links to (RFC-ACDP-0016, 0.5.0).
108 /// Absent-when-empty: a producer with no anchors MUST omit this
109 /// field entirely, never send `[]` — enforced at runtime by
110 /// `acdp-validation`, not by this type (an empty `Some(vec![])` is
111 /// representable so validation can reject it with a clear error
112 /// rather than the type silently normalizing it away).
113 #[serde(
114 default,
115 deserialize_with = "de_present",
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub anchors: Option<Vec<AnchorEntry>>,
119
120 /// Forward-compatible carry-through of unknown producer-controlled
121 /// fields (e.g. v0.1's `priority`). Including these in the typed
122 /// model is required for `serde_json::to_value(body)` → JCS → SHA-256
123 /// to reproduce the original `content_hash`. Without `flatten`, a
124 /// v0.1.0 consumer reading a v0.1 body would silently drop the new
125 /// field and compute a different hash, falsely rejecting the body.
126 #[serde(flatten)]
127 pub extensions: serde_json::Map<String, serde_json::Value>,
128}
129
130impl Body {
131 /// Materialize the stored [`Body`] from a **validated**
132 /// [`PublishRequest`](crate::publish::PublishRequest) plus the four
133 /// registry-assigned identity fields (RFC-ACDP-0003 §2.1 step 8;
134 /// the RFC-ACDP-0001 §5.7 exclusion set).
135 ///
136 /// This is the single `PublishRequest → Body` materialization point.
137 /// Store backends MUST use it instead of hand-copying fields: three
138 /// independent copies existed before this constructor (the
139 /// in-memory store plus both SQL backends), and a producer field
140 /// added to `PublishRequest` but missed in one copy is a silent
141 /// data-loss bug that changes the recomputed `content_hash` of the
142 /// stored body. The field-transfer guard test at
143 /// `tests/body_materialization.rs` fails if a new `PublishRequest`
144 /// field is not mapped here.
145 ///
146 /// `created_at` is millisecond-truncated internally (RFC-ACDP-0001
147 /// §5.3) — callers may pass an untruncated `now`.
148 ///
149 /// The caller is responsible for having validated the request
150 /// (schema, hash recomputation, signature) and for deriving
151 /// `ctx_id` / `lineage_id` per RFC-ACDP-0003; this constructor only
152 /// transfers fields.
153 pub fn from_publish_request(
154 req: &crate::publish::PublishRequest,
155 ctx_id: CtxId,
156 lineage_id: LineageId,
157 origin_registry: impl Into<String>,
158 created_at: DateTime<Utc>,
159 ) -> Self {
160 Body {
161 // Registry-assigned (the §5.7 exclusion set, minus the two
162 // integrity fields echoed from the request below).
163 ctx_id,
164 lineage_id,
165 origin_registry: origin_registry.into(),
166 created_at: acdp_primitives::time::trunc_ms(created_at),
167 // Integrity fields — echoed verbatim from the validated
168 // request.
169 content_hash: req.content_hash.clone(),
170 signature: req.signature.clone(),
171 // Producer-controlled content — copied verbatim, one line
172 // per field so a missed mapping is visible in review and
173 // caught by the guard test.
174 version: req.version,
175 supersedes: req.supersedes.clone(),
176 agent_id: req.agent_id.clone(),
177 contributors: req.contributors.clone(),
178 title: req.title.clone(),
179 context_type: req.context_type.clone(),
180 data_refs: req.data_refs.clone(),
181 derived_from: req.derived_from.clone(),
182 visibility: req.visibility.clone(),
183 audience: req.audience.clone(),
184 acdp_version: req.acdp_version.clone(),
185 description: req.description.clone(),
186 summary: req.summary.clone(),
187 tags: req.tags.clone(),
188 domain: req.domain.clone(),
189 expires_at: req.expires_at,
190 data_period: req.data_period.clone(),
191 metadata: req.metadata.clone(),
192 schema_uri: req.schema_uri.clone(),
193 anchors: req.anchors.clone(),
194 // The publish schema is CLOSED (deny_unknown_fields), so a
195 // fresh body's only extension field is the RFC-ACDP-0021
196 // `key_attestation` member, which `Body` has no typed field
197 // for (read it with `Body::key_attestation`).
198 extensions: req
199 .key_attestation
200 .iter()
201 .map(|r| {
202 (
203 crate::keyless::KEY_ATTESTATION_FIELD.to_string(),
204 serde_json::json!(r),
205 )
206 })
207 .collect(),
208 }
209 }
210}
211
212/// Time window the underlying data covers.
213///
214/// Per `acdp-common.schema.json#/$defs/data_period`, both `start` and `end`
215/// are required (additionalProperties: false). The schema does not compare
216/// timestamps; producers SHOULD ensure `start <= end` and registries
217/// SHOULD reject `start > end` as `schema_violation` at runtime.
218///
219/// `data_period` is a CLOSED two-field wire shape and part of
220/// ProducerContent — an unknown field would silently change the
221/// `content_hash` preimage, so `deny_unknown_fields` rejects it
222/// (RFC-ACDP-0007 §3.3.1, conformance fixture schema-009).
223#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct DataPeriod {
226 /// Inclusive start of the data period.
227 #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
228 pub start: DateTime<Utc>,
229 /// Inclusive end of the data period.
230 #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
231 pub end: DateTime<Utc>,
232}
233
234/// Detached Ed25519 signature over the body's `content_hash` field value.
235///
236/// The `value` bytes are a signature over the ASCII bytes of the full
237/// `content_hash` string (e.g. `"sha256:5f8d…"`) — NOT the raw 32-byte
238/// digest. See RFC-ACDP-0001 §5.8.
239///
240/// The `signature` object is a CLOSED wire shape — exactly `algorithm`,
241/// `key_id`, `value` (`additionalProperties: false`). Future signature
242/// variants (proof chains, threshold attestations) require an explicit
243/// schema bump, not field-level extensibility, so `deny_unknown_fields`
244/// rejects an unknown field (RFC-ACDP-0007 §3.3.1, fixture schema-008).
245#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
246#[serde(deny_unknown_fields)]
247pub struct Signature {
248 /// Algorithm identifier. Only `"ed25519"` is required in v0.1.0.
249 pub algorithm: String,
250 /// DID URL identifying the signing key (e.g. `did:web:…#key-1`).
251 pub key_id: String,
252 /// Standard base64-encoded signature bytes.
253 pub value: String,
254}
255
256// ── Registry state ────────────────────────────────────────────────────────────
257
258/// Mutable, registry-derived state returned alongside the Body on retrieval.
259///
260/// v0.1.0 contained only `status`; ACDP 0.3 adds the typed
261/// [`lifecycle_events`](Self::lifecycle_events) array (RFC-ACDP-0013,
262/// promoting the RFC-ACDP-0009 §2.1 reservation). Registry state is
263/// NEVER part of any hash or signature preimage — typing a formerly
264/// opaque member is safe — but individual lifecycle events carry their
265/// own signatures, so the event OBJECT parse is closed while unknown
266/// `event_type` VALUES are tolerated (RFC-ACDP-0013 §7.3). Other
267/// unknown fields are preserved verbatim in [`Self::extensions`] so
268/// consumers can surface them to operators (RFC-ACDP-0004 §3
269/// forward-compat) and re-serialize the state unchanged.
270///
271/// # Reserved extension field names (RFC-ACDP-0009 §2.1)
272///
273/// The following keys remain reserved for future RFCs. Until the
274/// relevant RFC ships normative text, consumers see them in
275/// [`Self::extensions`] (the `#[serde(flatten)]` map below).
276///
277/// | Name | RFC | Purpose |
278/// |-------------------|-------------------------------|---------------------------------------------------------------|
279/// | `relationships` | RFC-ACDP-0009 §2.1 (reserved) | Post-publication `builds_on` / `disputes` etc. |
280/// | `attestations` | RFC-ACDP-0009 §2.1 (reserved) | Third-party `reproduced` / `audit` markers. |
281/// | `subscriptions` | RFC-ACDP-0009 §2.1 (reserved) | Push-subscription receipts. |
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct RegistryState {
284 pub status: Status,
285 /// Append-only lifecycle event history (RFC-ACDP-0013 §4.1): events
286 /// in registry-accepted order; never removed, reordered, or
287 /// mutated. Omitted entirely (never `[]`) when no events exist, per
288 /// the absent-vs-null wire convention (RFC-ACDP-0005 §2.2.1).
289 /// Emitted only by registries advertising `acdp-registry-lifecycle`.
290 ///
291 /// The typed parse preserves round-trip fidelity: every event field
292 /// re-serializes byte-identically (strict canonical `occurred_at`,
293 /// verbatim unknown `event_type` values), so persisted registry
294 /// state — and the signed bytes inside each event — survive a parse
295 /// → re-serialize cycle unchanged. An event violating the closed
296 /// object schema is malformed registry state and fails the parse
297 /// (RFC-ACDP-0013 §7.3).
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub lifecycle_events: Option<Vec<crate::lifecycle::LifecycleEvent>>,
300 /// Registry-signed acceptance receipts parallel to
301 /// [`Self::lifecycle_events`] (RFC-ACDP-0017 §10.2): element *i*
302 /// receipts event *i*. Omitted entirely (never `[]`) when there are
303 /// none. Each element is parsed with the closed
304 /// [`EventReceipt::from_value`](crate::EventReceipt::from_value), so
305 /// a receipt violating its schema is malformed registry state and
306 /// fails the parse, as a malformed lifecycle event does. A receipt
307 /// that parses but fails verification is the verifier's business
308 /// (RFC-ACDP-0017 §10.3: treated as absent).
309 #[serde(
310 default,
311 skip_serializing_if = "Option::is_none",
312 deserialize_with = "de_event_receipts"
313 )]
314 pub event_receipts: Option<Vec<crate::EventReceipt>>,
315 /// Forward-compatible passthrough for fields added in future versions
316 /// (e.g. the reserved `relationships`, `attestations`,
317 /// `subscriptions` — see the type docs for the reserved set).
318 #[serde(flatten)]
319 pub extensions: serde_json::Map<String, serde_json::Value>,
320}
321
322impl RegistryState {
323 /// Retraction as a consumer can know it (RFC-ACDP-0013 §4.1, §7.1):
324 /// `Some(true)` retracted, `Some(false)` not retracted, `None`
325 /// unknown.
326 ///
327 /// A registry advertising `acdp-registry-lifecycle` MUST omit
328 /// `lifecycle_events` when there are none, so for such a registry an
329 /// absent member means no events: not retracted. Only a registry
330 /// that does not advertise lifecycle leaves the state unknown.
331 pub fn retraction(&self, registry_advertises_lifecycle: bool) -> Option<bool> {
332 if self.status.is_retracted() {
333 return Some(true);
334 }
335 match &self.lifecycle_events {
336 Some(_) => Some(self.is_retracted()),
337 None if registry_advertises_lifecycle => Some(false),
338 None => None,
339 }
340 }
341
342 /// The context's **retraction state** (RFC-ACDP-0013 §7.1), derived
343 /// from [`Self::lifecycle_events`]: retracted iff the last
344 /// `retracted`/`republished` event in array order is `retracted`.
345 /// Unknown event types have no effect (§7.3).
346 pub fn is_retracted(&self) -> bool {
347 self.lifecycle_events
348 .as_deref()
349 .is_some_and(crate::lifecycle::retraction_state)
350 }
351}
352
353/// `registry_state.event_receipts`: present means a non-null array, each
354/// element parsed with the closed [`crate::EventReceipt::from_value`].
355fn de_event_receipts<'de, D: serde::Deserializer<'de>>(
356 d: D,
357) -> Result<Option<Vec<crate::EventReceipt>>, D::Error> {
358 let items = Vec::<serde_json::Value>::deserialize(d)?;
359 items
360 .iter()
361 .map(crate::EventReceipt::from_value)
362 .collect::<Result<Vec<_>, AcdpError>>()
363 .map(Some)
364 .map_err(serde::de::Error::custom)
365}
366
367// ── Full retrieval envelope ───────────────────────────────────────────────────
368
369/// The full context object returned by `GET /contexts/{ctx_id}`.
370///
371/// `acdp-context.schema.json` is `additionalProperties: true`: future
372/// ACDP versions may add top-level keys without a schema bump, and
373/// v0.1.0 consumers MUST tolerate unknown top-level keys. `body`,
374/// `registry_state`, and the reserved `registry_receipt` are modelled
375/// explicitly; any other top-level field is preserved verbatim in
376/// [`Self::extensions`]. These top-level fields are NOT part of
377/// `ProducerContent`, so unlike [`Body::extensions`] this carry-through
378/// is a forward-compatibility contract, not a hash-stability one.
379///
380/// **Raw body retention (RFC-ACDP-0001 §5.7 "Hash verification over raw
381/// JSON").** Deserializing a `FullContext` also keeps the `body` object
382/// exactly as received in [`Self::raw_body`], so verifiers recompute
383/// `content_hash` over the producer's bytes rather than over a typed
384/// re-serialization (which can respell a valid timestamp — `can-013`).
385#[derive(Debug, Clone, Serialize)]
386pub struct FullContext {
387 /// Producer-signed body.
388 pub body: Body,
389 /// Mutable registry-derived state (status etc).
390 pub registry_state: RegistryState,
391 /// Optional registry receipt — reserved for RFC-ACDP-0009 §2.7. Opaque
392 /// to the library; preserved verbatim if present.
393 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub registry_receipt: Option<serde_json::Value>,
395 /// Optional lineage-head receipt (ACDP 0.3, RFC-ACDP-0011): the
396 /// registry's signed serve-time attestation of the current head of
397 /// the lineage. REQUIRED on `GET /lineages/{id}/current` responses
398 /// from registries advertising `acdp-registry-head-receipts`; MAY
399 /// appear on full retrieval; tolerated (and preserved verbatim)
400 /// when absent — non-advertising registries never emit it.
401 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub lineage_head_receipt: Option<serde_json::Value>,
403 /// Optional transparency-log inclusion proof (ACDP 0.3,
404 /// RFC-ACDP-0012 §10): the RFC 6962 audit path plus signed
405 /// checkpoint proving this context is committed by the registry's
406 /// append-only log. MAY be carried on full retrieval by registries
407 /// advertising `acdp-registry-transparency-log`; never on the
408 /// body-only endpoint and never on the publish response. A
409 /// top-level **sibling** of `registry_receipt` — deliberately NOT a
410 /// member of it (the receipt is closed, fully signed, and
411 /// byte-immutable). Parse with
412 /// [`crate::log::LogInclusion::from_value`]; verify per
413 /// RFC-ACDP-0012 §9 — the log verdict is independent of the body
414 /// and receipt verdicts.
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub log_inclusion: Option<serde_json::Value>,
417
418 /// Unknown top-level context fields, preserved per
419 /// `acdp-context.schema.json` `additionalProperties: true`. Retained
420 /// for forward compatibility with future ACDP versions that add
421 /// top-level registry fields.
422 #[serde(flatten)]
423 pub extensions: serde_json::Map<String, serde_json::Value>,
424
425 /// The `body` object exactly as received on the wire — populated by
426 /// `Deserialize`, `None` for a hand-built context. Never serialized
427 /// (the typed [`Self::body`] is). Verifiers hash this when it still
428 /// describes [`Self::body`] (see `acdp_verify::verify_context_hash`);
429 /// a caller that mutates `body` after parsing gets the typed path,
430 /// so the mutation is detected, not masked.
431 #[serde(skip)]
432 pub raw_body: Option<serde_json::Value>,
433}
434
435impl<'de> Deserialize<'de> for FullContext {
436 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
437 #[derive(Deserialize)]
438 struct Wire {
439 body: serde_json::Value,
440 registry_state: RegistryState,
441 #[serde(default)]
442 registry_receipt: Option<serde_json::Value>,
443 #[serde(default)]
444 lineage_head_receipt: Option<serde_json::Value>,
445 #[serde(default)]
446 log_inclusion: Option<serde_json::Value>,
447 #[serde(flatten)]
448 extensions: serde_json::Map<String, serde_json::Value>,
449 }
450 let w = Wire::deserialize(d)?;
451 let body = Body::deserialize(&w.body)
452 .map_err(|e| serde::de::Error::custom(format!("body: {e}")))?;
453 Ok(FullContext {
454 body,
455 registry_state: w.registry_state,
456 registry_receipt: w.registry_receipt,
457 lineage_head_receipt: w.lineage_head_receipt,
458 log_inclusion: w.log_inclusion,
459 extensions: w.extensions,
460 raw_body: Some(w.body),
461 })
462 }
463}