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    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}