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