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