Skip to main content

acdp_types/
publish.rs

1use crate::body::{DataPeriod, Signature};
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/// Wire-ready publish request body (`POST /contexts`).
9///
10/// Contains all producer-controlled fields plus `content_hash` and
11/// `signature`.  Does NOT contain registry-assigned fields (`ctx_id`,
12/// `lineage_id`, `origin_registry`, `created_at`).
13///
14/// Normally built via `acdp::producer::RequestBuilder::build`.
15///
16/// Mirrors `acdp-publish-request.schema.json` (`additionalProperties: false`).
17/// Registry-assigned fields (`ctx_id`, `origin_registry`, `created_at`)
18/// in an incoming request are a producer bug, not forward-compat slack —
19/// silently dropping them would mean the registry recomputes a different
20/// hash than the producer signed. `deny_unknown_fields` surfaces them at
21/// deserialization, before they can confuse the hash recomputation in
22/// `acdp::registry::PublishValidator::validate_post_schema`.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct PublishRequest {
26    // Producer-controlled required fields
27    pub version: u32,
28    pub supersedes: Option<CtxId>,
29    pub agent_id: AgentDid,
30    pub contributors: Vec<AgentDid>,
31    pub title: String,
32    #[serde(rename = "type")]
33    pub context_type: ContextType,
34    pub data_refs: Vec<DataRef>,
35    pub derived_from: Vec<CtxId>,
36    pub visibility: Visibility,
37
38    // Integrity fields (computed, not optional)
39    pub content_hash: ContentHash,
40    pub signature: Signature,
41
42    // Producer-controlled optional fields
43    //
44    // Bare-typed optional fields use the absent-vs-null convention
45    // (RFC-ACDP-0005 §2.2.1, schema-005/006/007 fixtures): absent →
46    // `None`, present with `null` → rejected at deserialize. See
47    // [`crate::serde_helpers::de_present`]. `supersedes` is the
48    // one v0.1.0 field declared `["string","null"]` (RFC-ACDP-0002
49    // §3.1) and stays permissively nullable.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub audience: Option<Vec<AgentDid>>,
52    #[serde(
53        default,
54        deserialize_with = "de_present",
55        skip_serializing_if = "Option::is_none"
56    )]
57    pub acdp_version: Option<String>,
58    #[serde(
59        default,
60        deserialize_with = "de_present",
61        skip_serializing_if = "Option::is_none"
62    )]
63    pub description: Option<String>,
64    /// Producer-supplied summary for search results (≤ 1000 chars).
65    #[serde(
66        default,
67        deserialize_with = "de_present",
68        skip_serializing_if = "Option::is_none"
69    )]
70    pub summary: Option<String>,
71    /// Optional self-verification of the lineage_id on supersession publish.
72    /// Per `acdp-publish-request.schema.json` `allOf` conditional: v1
73    /// publications MUST NOT include this field; v2+ MAY include it for the
74    /// registry to verify against the deterministically-derived value.
75    /// Excluded from ProducerContent (hash preimage) per RFC-ACDP-0001 §5.7.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub lineage_id: Option<LineageId>,
78    #[serde(
79        default,
80        deserialize_with = "de_present",
81        skip_serializing_if = "Option::is_none"
82    )]
83    pub tags: Option<Vec<String>>,
84    #[serde(
85        default,
86        deserialize_with = "de_present",
87        skip_serializing_if = "Option::is_none"
88    )]
89    pub domain: Option<String>,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub expires_at: Option<DateTime<Utc>>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub data_period: Option<DataPeriod>,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub metadata: Option<serde_json::Value>,
96    #[serde(
97        default,
98        deserialize_with = "de_present",
99        skip_serializing_if = "Option::is_none"
100    )]
101    pub schema_uri: Option<String>,
102}
103
104/// Successful publish response (HTTP 201).
105///
106/// Per `acdp-publish-response.schema.json` (additionalProperties: false),
107/// the response contains exactly the five registry-assigned fields. It
108/// MUST NOT echo `content_hash`, the producer's signature, or any body
109/// field — the producer already submitted those and the response is for
110/// retrieving the assigned identifiers.
111///
112/// `Serialize` is supported (alongside `Deserialize`) so CLI/HTTP-binding
113/// layers can echo the response shape back to operators.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct PublishResponse {
117    /// Registry-assigned context identifier.
118    pub ctx_id: CtxId,
119    /// Lineage identifier (derived from the v1 ctx_id).
120    pub lineage_id: LineageId,
121    /// Version of the published context (1 for first-version, prior+1 otherwise).
122    pub version: u32,
123    /// Registry's acceptance timestamp (millisecond precision).
124    pub created_at: DateTime<Utc>,
125    /// Lifecycle status. MUST be `Active` on a successful first-publish.
126    pub status: Status,
127    /// Registry-signed publication receipt (ACDP 0.2, RFC-ACDP-0010).
128    /// Present when the registry advertises the
129    /// `acdp-registry-receipts` profile; absent from 0.1.0 registries.
130    /// Carried as opaque JSON for wire stability — parse with
131    /// [`crate::receipt::RegistryReceipt::from_value`].
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub registry_receipt: Option<serde_json::Value>,
134}
135
136// `WireError` / `WireErrorBody` moved to `acdp-primitives` (the error type
137// references them); re-exported here so `crate::publish::WireError`
138// keeps resolving.
139pub use acdp_primitives::wire_error::{WireError, WireErrorBody};
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn minimal_request_with_extra(extra: &str) -> String {
146        format!(
147            r#"{{
148            "version": 1,
149            "agent_id": "did:web:agents.example.com:test",
150            "contributors": [],
151            "title": "t",
152            "type": "data_snapshot",
153            "data_refs": [],
154            "derived_from": [],
155            "visibility": "public",
156            "content_hash": "sha256:0",
157            "signature": {{
158              "algorithm": "ed25519",
159              "key_id": "did:web:agents.example.com:test#key-1",
160              "value": "{sig}"
161            }}{extra}
162          }}"#,
163            sig = "A".repeat(88),
164            extra = extra
165        )
166    }
167
168    /// BUG-02 — `PublishRequest` is `additionalProperties: false` per
169    /// `acdp-publish-request.schema.json`. Registry-assigned fields
170    /// (`ctx_id`, `origin_registry`, `created_at`) in a publish request
171    /// are a producer bug; silently dropping them would mean the
172    /// registry recomputes a different hash than the producer signed.
173    #[test]
174    fn extra_top_level_field_is_rejected() {
175        let body = minimal_request_with_extra(r#", "ctx_id": "acdp://r/x""#);
176        let res: Result<PublishRequest, _> = serde_json::from_str(&body);
177        assert!(res.is_err(), "ctx_id in publish request must be rejected");
178    }
179
180    #[test]
181    fn extra_origin_registry_field_is_rejected() {
182        let body = minimal_request_with_extra(r#", "origin_registry": "did:web:r.x""#);
183        let res: Result<PublishRequest, _> = serde_json::from_str(&body);
184        assert!(res.is_err());
185    }
186
187    #[test]
188    fn extra_created_at_field_is_rejected() {
189        let body = minimal_request_with_extra(r#", "created_at": "2026-01-01T00:00:00.000Z""#);
190        let res: Result<PublishRequest, _> = serde_json::from_str(&body);
191        assert!(res.is_err());
192    }
193
194    #[test]
195    fn arbitrary_unknown_field_is_rejected() {
196        let body = minimal_request_with_extra(r#", "noodle": 42"#);
197        let res: Result<PublishRequest, _> = serde_json::from_str(&body);
198        assert!(res.is_err());
199    }
200
201    #[test]
202    fn baseline_no_extra_fields_deserializes_ok() {
203        let body = minimal_request_with_extra("");
204        serde_json::from_str::<PublishRequest>(&body)
205            .expect("baseline minimal request must still deserialize");
206    }
207}