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