Skip to main content

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 starts with no extension fields.
196            extensions: Default::default(),
197        }
198    }
199}
200
201/// Time window the underlying data covers.
202///
203/// Per `acdp-common.schema.json#/$defs/data_period`, both `start` and `end`
204/// are required (additionalProperties: false). The schema does not compare
205/// timestamps; producers SHOULD ensure `start <= end` and registries
206/// SHOULD reject `start > end` as `schema_violation` at runtime.
207///
208/// `data_period` is a CLOSED two-field wire shape and part of
209/// ProducerContent — an unknown field would silently change the
210/// `content_hash` preimage, so `deny_unknown_fields` rejects it
211/// (RFC-ACDP-0007 §3.3.1, conformance fixture schema-009).
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(deny_unknown_fields)]
214pub struct DataPeriod {
215    /// Inclusive start of the data period.
216    #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
217    pub start: DateTime<Utc>,
218    /// Inclusive end of the data period.
219    #[serde(with = "acdp_primitives::time::canonical_rfc3339")]
220    pub end: DateTime<Utc>,
221}
222
223/// Detached Ed25519 signature over the body's `content_hash` field value.
224///
225/// The `value` bytes are a signature over the ASCII bytes of the full
226/// `content_hash` string (e.g. `"sha256:5f8d…"`) — NOT the raw 32-byte
227/// digest.  See RFC-ACDP-0001 §5.8.
228///
229/// The `signature` object is a CLOSED wire shape — exactly `algorithm`,
230/// `key_id`, `value` (`additionalProperties: false`). Future signature
231/// variants (proof chains, threshold attestations) require an explicit
232/// schema bump, not field-level extensibility, so `deny_unknown_fields`
233/// rejects an unknown field (RFC-ACDP-0007 §3.3.1, fixture schema-008).
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(deny_unknown_fields)]
236pub struct Signature {
237    /// Algorithm identifier.  Only `"ed25519"` is required in v0.1.0.
238    pub algorithm: String,
239    /// DID URL identifying the signing key (e.g. `did:web:…#key-1`).
240    pub key_id: String,
241    /// Standard base64-encoded signature bytes.
242    pub value: String,
243}
244
245// ── Registry state ────────────────────────────────────────────────────────────
246
247/// Mutable, registry-derived state returned alongside the Body on retrieval.
248///
249/// v0.1.0 contained only `status`; ACDP 0.3 adds the typed
250/// [`lifecycle_events`](Self::lifecycle_events) array (RFC-ACDP-0013,
251/// promoting the RFC-ACDP-0009 §2.1 reservation). Registry state is
252/// NEVER part of any hash or signature preimage — typing a formerly
253/// opaque member is safe — but individual lifecycle events carry their
254/// own signatures, so the event OBJECT parse is closed while unknown
255/// `event_type` VALUES are tolerated (RFC-ACDP-0013 §7.3). Other
256/// unknown fields are preserved verbatim in [`Self::extensions`] so
257/// consumers can surface them to operators (RFC-ACDP-0004 §3
258/// forward-compat) and re-serialize the state unchanged.
259///
260/// # Reserved extension field names (RFC-ACDP-0009 §2.1)
261///
262/// The following keys remain reserved for future RFCs. Until the
263/// relevant RFC ships normative text, consumers see them in
264/// [`Self::extensions`] (the `#[serde(flatten)]` map below).
265///
266/// | Name              | RFC                           | Purpose                                                       |
267/// |-------------------|-------------------------------|---------------------------------------------------------------|
268/// | `relationships`   | RFC-ACDP-0009 §2.1 (reserved) | Post-publication `builds_on` / `disputes` etc.                 |
269/// | `attestations`    | RFC-ACDP-0009 §2.1 (reserved) | Third-party `reproduced` / `audit` markers.                    |
270/// | `subscriptions`   | RFC-ACDP-0009 §2.1 (reserved) | Push-subscription receipts.                                    |
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct RegistryState {
273    pub status: Status,
274    /// Append-only lifecycle event history (RFC-ACDP-0013 §4.1): events
275    /// in registry-accepted order; never removed, reordered, or
276    /// mutated. Omitted entirely (never `[]`) when no events exist, per
277    /// the absent-vs-null wire convention (RFC-ACDP-0005 §2.2.1).
278    /// Emitted only by registries advertising `acdp-registry-lifecycle`.
279    ///
280    /// The typed parse preserves round-trip fidelity: every event field
281    /// re-serializes byte-identically (strict canonical `occurred_at`,
282    /// verbatim unknown `event_type` values), so persisted registry
283    /// state — and the signed bytes inside each event — survive a parse
284    /// → re-serialize cycle unchanged. An event violating the closed
285    /// object schema is malformed registry state and fails the parse
286    /// (RFC-ACDP-0013 §7.3).
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub lifecycle_events: Option<Vec<crate::lifecycle::LifecycleEvent>>,
289    /// Forward-compatible passthrough for fields added in future versions
290    /// (e.g. the reserved `relationships`, `attestations`,
291    /// `subscriptions` — see the type docs for the reserved set).
292    #[serde(flatten)]
293    pub extensions: serde_json::Map<String, serde_json::Value>,
294}
295
296impl RegistryState {
297    /// The `event_receipts` member (RFC-ACDP-0017 §10.2): registry-signed
298    /// acceptance receipts parallel to [`Self::lifecycle_events`],
299    /// carried in [`Self::extensions`] so the struct stays
300    /// semver-compatible. `None` when absent; each element is parsed with
301    /// the closed [`EventReceipt::from_value`](crate::EventReceipt::from_value).
302    pub fn event_receipts(&self) -> Option<Result<Vec<crate::EventReceipt>, AcdpError>> {
303        let v = self.extensions.get(EVENT_RECEIPTS)?;
304        Some(match v.as_array() {
305            Some(items) => items.iter().map(crate::EventReceipt::from_value).collect(),
306            None => Err(AcdpError::InvalidReceipt(
307                "registry_state.event_receipts is not an array".into(),
308            )),
309        })
310    }
311
312    /// Set (or, with an empty vector, remove) `event_receipts`. Like
313    /// `lifecycle_events`, the member is omitted rather than emitted as
314    /// `[]`.
315    pub fn set_event_receipts(
316        &mut self,
317        receipts: Vec<crate::EventReceipt>,
318    ) -> Result<(), AcdpError> {
319        if receipts.is_empty() {
320            self.extensions.remove(EVENT_RECEIPTS);
321        } else {
322            self.extensions
323                .insert(EVENT_RECEIPTS.into(), serde_json::to_value(receipts)?);
324        }
325        Ok(())
326    }
327
328    /// Retraction as a consumer can know it (RFC-ACDP-0013 §4.1, §7.1):
329    /// `Some(true)` retracted, `Some(false)` not retracted, `None`
330    /// unknown.
331    ///
332    /// A registry advertising `acdp-registry-lifecycle` MUST omit
333    /// `lifecycle_events` when there are none, so for such a registry an
334    /// absent member means no events: not retracted. Only a registry
335    /// that does not advertise lifecycle leaves the state unknown.
336    pub fn retraction(&self, registry_advertises_lifecycle: bool) -> Option<bool> {
337        if self.status.is_retracted() {
338            return Some(true);
339        }
340        match &self.lifecycle_events {
341            Some(_) => Some(self.is_retracted()),
342            None if registry_advertises_lifecycle => Some(false),
343            None => None,
344        }
345    }
346
347    /// The context's **retraction state** (RFC-ACDP-0013 §7.1), derived
348    /// from [`Self::lifecycle_events`]: retracted iff the last
349    /// `retracted`/`republished` event in array order is `retracted`.
350    /// Unknown event types have no effect (§7.3).
351    pub fn is_retracted(&self) -> bool {
352        self.lifecycle_events
353            .as_deref()
354            .is_some_and(crate::lifecycle::retraction_state)
355    }
356}
357
358/// The `registry_state` member carrying event receipts.
359const EVENT_RECEIPTS: &str = "event_receipts";
360
361// ── Full retrieval envelope ───────────────────────────────────────────────────
362
363/// The full context object returned by `GET /contexts/{ctx_id}`.
364///
365/// `acdp-context.schema.json` is `additionalProperties: true`: future
366/// ACDP versions may add top-level keys without a schema bump, and
367/// v0.1.0 consumers MUST tolerate unknown top-level keys. `body`,
368/// `registry_state`, and the reserved `registry_receipt` are modelled
369/// explicitly; any other top-level field is preserved verbatim in
370/// [`Self::extensions`]. These top-level fields are NOT part of
371/// `ProducerContent`, so unlike [`Body::extensions`] this carry-through
372/// is a forward-compatibility contract, not a hash-stability one.
373///
374/// **Raw body retention (RFC-ACDP-0001 §5.7 "Hash verification over raw
375/// JSON").** Deserializing a `FullContext` also keeps the `body` object
376/// exactly as received in [`Self::raw_body`], so verifiers recompute
377/// `content_hash` over the producer's bytes rather than over a typed
378/// re-serialization (which can respell a valid timestamp — `can-013`).
379#[derive(Debug, Clone, Serialize)]
380pub struct FullContext {
381    /// Producer-signed body.
382    pub body: Body,
383    /// Mutable registry-derived state (status etc).
384    pub registry_state: RegistryState,
385    /// Optional registry receipt — reserved for RFC-ACDP-0009 §2.7. Opaque
386    /// to the library; preserved verbatim if present.
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub registry_receipt: Option<serde_json::Value>,
389    /// Optional lineage-head receipt (ACDP 0.3, RFC-ACDP-0011): the
390    /// registry's signed serve-time attestation of the current head of
391    /// the lineage. REQUIRED on `GET /lineages/{id}/current` responses
392    /// from registries advertising `acdp-registry-head-receipts`; MAY
393    /// appear on full retrieval; tolerated (and preserved verbatim)
394    /// when absent — non-advertising registries never emit it.
395    #[serde(default, skip_serializing_if = "Option::is_none")]
396    pub lineage_head_receipt: Option<serde_json::Value>,
397    /// Optional transparency-log inclusion proof (ACDP 0.3,
398    /// RFC-ACDP-0012 §10): the RFC 6962 audit path plus signed
399    /// checkpoint proving this context is committed by the registry's
400    /// append-only log. MAY be carried on full retrieval by registries
401    /// advertising `acdp-registry-transparency-log`; never on the
402    /// body-only endpoint and never on the publish response. A
403    /// top-level **sibling** of `registry_receipt` — deliberately NOT a
404    /// member of it (the receipt is closed, fully signed, and
405    /// byte-immutable). Parse with
406    /// [`crate::log::LogInclusion::from_value`]; verify per
407    /// RFC-ACDP-0012 §9 — the log verdict is independent of the body
408    /// and receipt verdicts.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub log_inclusion: Option<serde_json::Value>,
411
412    /// Unknown top-level context fields, preserved per
413    /// `acdp-context.schema.json` `additionalProperties: true`. Retained
414    /// for forward compatibility with future ACDP versions that add
415    /// top-level registry fields.
416    #[serde(flatten)]
417    pub extensions: serde_json::Map<String, serde_json::Value>,
418
419    /// The `body` object exactly as received on the wire — populated by
420    /// `Deserialize`, `None` for a hand-built context. Never serialized
421    /// (the typed [`Self::body`] is). Verifiers hash this when it still
422    /// describes [`Self::body`] (see `acdp_verify::verify_context_hash`);
423    /// a caller that mutates `body` after parsing gets the typed path,
424    /// so the mutation is detected, not masked.
425    #[serde(skip)]
426    pub raw_body: Option<serde_json::Value>,
427}
428
429impl<'de> Deserialize<'de> for FullContext {
430    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
431        #[derive(Deserialize)]
432        struct Wire {
433            body: serde_json::Value,
434            registry_state: RegistryState,
435            #[serde(default)]
436            registry_receipt: Option<serde_json::Value>,
437            #[serde(default)]
438            lineage_head_receipt: Option<serde_json::Value>,
439            #[serde(default)]
440            log_inclusion: Option<serde_json::Value>,
441            #[serde(flatten)]
442            extensions: serde_json::Map<String, serde_json::Value>,
443        }
444        let w = Wire::deserialize(d)?;
445        let body = Body::deserialize(&w.body)
446            .map_err(|e| serde::de::Error::custom(format!("body: {e}")))?;
447        Ok(FullContext {
448            body,
449            registry_state: w.registry_state,
450            registry_receipt: w.registry_receipt,
451            lineage_head_receipt: w.lineage_head_receipt,
452            log_inclusion: w.log_inclusion,
453            extensions: w.extensions,
454            raw_body: Some(w.body),
455        })
456    }
457}