Skip to main content

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, 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/// In v0.1.0 this contains only `status`. Future versions add lifecycle
225/// events, relationships, and attestations here without modifying the
226/// Body. Unknown fields are preserved in [`Self::extensions`] so consumers
227/// can surface them to operators (RFC-ACDP-0004 §3 forward-compat).
228///
229/// # Reserved extension field names (RFC-ACDP-0009 §2.1)
230///
231/// The following keys are reserved for future RFCs. Until the relevant
232/// RFC ships normative text, v0.1.0 consumers will see them in
233/// [`Self::extensions`] (the `#[serde(flatten)]` map below). v0.1.0
234/// producers MUST NOT emit them.
235///
236/// | Name              | RFC                           | Purpose                                                       |
237/// |-------------------|-------------------------------|---------------------------------------------------------------|
238/// | `lifecycle_events`| RFC-ACDP-0009 §2.1 (reserved) | Retraction / republication / status-change audit trail.        |
239/// | `relationships`   | RFC-ACDP-0009 §2.1 (reserved) | Post-publication `builds_on` / `disputes` etc.                 |
240/// | `attestations`    | RFC-ACDP-0009 §2.1 (reserved) | Third-party `reproduced` / `audit` markers.                    |
241/// | `subscriptions`   | RFC-ACDP-0009 §2.1 (reserved) | Push-subscription receipts.                                    |
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct RegistryState {
244    pub status: Status,
245    /// Forward-compatible passthrough for fields added in future versions
246    /// (e.g. v0.1's `lifecycle_events`, `relationships`, `attestations`,
247    /// `subscriptions` — see the type docs for the reserved set).
248    #[serde(flatten)]
249    pub extensions: serde_json::Map<String, serde_json::Value>,
250}
251
252// ── Full retrieval envelope ───────────────────────────────────────────────────
253
254/// The full context object returned by `GET /contexts/{ctx_id}`.
255///
256/// `acdp-context.schema.json` is `additionalProperties: true`: future
257/// ACDP versions may add top-level keys without a schema bump, and
258/// v0.1.0 consumers MUST tolerate unknown top-level keys. `body`,
259/// `registry_state`, and the reserved `registry_receipt` are modelled
260/// explicitly; any other top-level field is preserved verbatim in
261/// [`Self::extensions`]. These top-level fields are NOT part of
262/// `ProducerContent`, so unlike [`Body::extensions`] this carry-through
263/// is a forward-compatibility contract, not a hash-stability one.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct FullContext {
266    /// Producer-signed body.
267    pub body: Body,
268    /// Mutable registry-derived state (status etc).
269    pub registry_state: RegistryState,
270    /// Optional registry receipt — reserved for RFC-ACDP-0009 §2.7. Opaque
271    /// to the library; preserved verbatim if present.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub registry_receipt: Option<serde_json::Value>,
274    /// Optional lineage-head receipt (ACDP 0.3, RFC-ACDP-0011): the
275    /// registry's signed serve-time attestation of the current head of
276    /// the lineage. REQUIRED on `GET /lineages/{id}/current` responses
277    /// from registries advertising `acdp-registry-head-receipts`; MAY
278    /// appear on full retrieval; tolerated (and preserved verbatim)
279    /// when absent — non-advertising registries never emit it.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub lineage_head_receipt: Option<serde_json::Value>,
282
283    /// Unknown top-level context fields, preserved per
284    /// `acdp-context.schema.json` `additionalProperties: true`. Retained
285    /// for forward compatibility with future ACDP versions that add
286    /// top-level registry fields.
287    #[serde(flatten)]
288    pub extensions: serde_json::Map<String, serde_json::Value>,
289}