Skip to main content

aion_package/
manifest.rs

1//! Typed `manifest.json` model and `.aion` format-version checks.
2
3use std::{fmt, time::Duration};
4
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::PackageError;
9
10/// Canonical SHA-256 digest of one manifest's serialized JSON form.
11///
12/// Legacy package versions cover the canonical beam set only; packages that
13/// opt an explicit workflow timeout into identity additionally cover that
14/// timeout. Other `manifest.json` fields remain excluded, so this digest is the
15/// tripwire for divergent manifests that still carry one version: the engine
16/// catalog retains it and refuses a mismatched idempotent reload.
17#[derive(Clone, Debug, PartialEq, Eq, Hash)]
18pub struct ManifestDigest([u8; 32]);
19
20impl ManifestDigest {
21    /// Creates a manifest digest from raw SHA-256 digest bytes.
22    #[must_use]
23    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
24        Self(bytes)
25    }
26
27    /// Returns the raw SHA-256 digest bytes.
28    #[must_use]
29    pub const fn as_bytes(&self) -> &[u8; 32] {
30        &self.0
31    }
32}
33
34impl fmt::Display for ManifestDigest {
35    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36        for byte in &self.0 {
37            write!(formatter, "{byte:02x}")?;
38        }
39        Ok(())
40    }
41}
42
43/// Current `.aion` manifest and archive-layout schema version supported by this crate.
44pub const CURRENT_FORMAT_VERSION: u32 = 1;
45
46/// Textual content-hash version stored in `manifest.json`.
47///
48/// The content hash is computed and stamped by later package-building code; this
49/// type keeps the manifest field distinct from unrelated strings while this
50/// module remains side-effect free.
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct ManifestVersion(pub String);
53
54impl ManifestVersion {
55    /// Creates a manifest version value from the hash's stable textual form.
56    #[must_use]
57    pub fn new(version: impl Into<String>) -> Self {
58        Self(version.into())
59    }
60
61    /// Returns the stored textual content-hash version.
62    #[must_use]
63    pub fn as_str(&self) -> &str {
64        &self.0
65    }
66}
67
68/// Activity declaration recorded in the package manifest.
69#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub struct DeclaredActivity {
71    /// Stable `activity_type` key naming an activity type invoked by workflow code.
72    ///
73    /// This follows the current `aion-core` event convention, where scheduled
74    /// activity types are represented as strings.
75    #[serde(rename = "activity_type")]
76    pub activity_type: String,
77}
78
79/// An additional workflow entry exported by the same `.aion` archive.
80///
81/// Additional entries share the archive's content hash and BEAM closure with
82/// the primary manifest entry. `workflow_type` is the routing name, while
83/// `entry_module` and `entry_function` identify the callable in that closure.
84#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
85pub struct WorkflowEntry {
86    /// Stable workflow type used by start and child-spawn routing.
87    pub workflow_type: String,
88    /// Logical BEAM module exporting the entry function.
89    pub entry_module: String,
90    /// Exported engine entry function.
91    pub entry_function: String,
92    /// JSON Schema for the entry's input payload.
93    pub input_schema: serde_json::Value,
94    /// JSON Schema for the entry's result payload.
95    pub output_schema: serde_json::Value,
96    /// Workflow execution timeout.
97    pub timeout: Duration,
98    /// Whether this entry is package-internal rather than operator-authored.
99    #[serde(default)]
100    pub internal: bool,
101}
102
103/// Typed on-disk `manifest.json` descriptor for a `.aion` package.
104///
105/// The public field names are the stable JSON keys written into `manifest.json`:
106/// `entry_module`, `entry_function`, `input_schema`, `output_schema`, `timeout`,
107/// `activities`, `version`, and `format_version`.
108#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
109pub struct Manifest {
110    /// Stable `entry_module` key naming the logical workflow entry module.
111    #[serde(rename = "entry_module")]
112    pub entry_module: String,
113    /// Stable `entry_function` key naming the exported workflow entry function.
114    #[serde(rename = "entry_function")]
115    pub entry_function: String,
116    /// Stable `input_schema` key containing a JSON-Schema document for input payloads.
117    #[serde(rename = "input_schema")]
118    pub input_schema: serde_json::Value,
119    /// Stable `output_schema` key containing a JSON-Schema document for result payloads.
120    #[serde(rename = "output_schema")]
121    pub output_schema: serde_json::Value,
122    /// Stable `timeout` key containing the workflow timeout as a serde-encoded duration.
123    #[serde(rename = "timeout")]
124    pub timeout: Duration,
125    /// Stable `activities` key listing activity types declared by the workflow.
126    #[serde(rename = "activities")]
127    pub activities: Vec<DeclaredActivity>,
128    /// Stable `version` key containing the package content hash textual value.
129    #[serde(rename = "version")]
130    pub version: ManifestVersion,
131    /// Stable `format_version` key identifying the `.aion` format schema version.
132    ///
133    /// This lets future layout changes be detected rather than silently misread.
134    #[serde(rename = "format_version")]
135    pub format_version: u32,
136    /// Additional workflow entries exported by this same archive and pinned to
137    /// the same content hash. Absent in packages created before multi-entry
138    /// registration.
139    #[serde(default, skip_serializing_if = "Vec::is_empty")]
140    pub additional_workflows: Vec<WorkflowEntry>,
141}
142
143impl Manifest {
144    /// Checks whether this manifest declares a supported `.aion` format version.
145    ///
146    /// # Errors
147    ///
148    /// Returns [`PackageError::UnknownFormatVersion`] when `format_version` is
149    /// not [`CURRENT_FORMAT_VERSION`].
150    pub fn check_format_version(&self) -> Result<(), PackageError> {
151        if self.format_version == CURRENT_FORMAT_VERSION {
152            Ok(())
153        } else {
154            Err(PackageError::UnknownFormatVersion {
155                found: self.format_version,
156            })
157        }
158    }
159
160    /// Computes the canonical SHA-256 digest of this manifest.
161    ///
162    /// The digest covers the manifest's stable serialized JSON form (the same
163    /// field names and ordering written into `manifest.json`), so any
164    /// semantic difference — entry function, schemas, timeout, declared
165    /// activities — produces a different digest even when the beam set (and
166    /// therefore the content hash) is unchanged.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`PackageError::ManifestSerialise`] when the manifest cannot be
171    /// serialized to JSON.
172    pub fn canonical_digest(&self) -> Result<ManifestDigest, PackageError> {
173        let bytes = serde_json::to_vec(self)
174            .map_err(|source| PackageError::ManifestSerialise { source })?;
175        let mut digest = Sha256::new();
176        digest.update(&bytes);
177        Ok(ManifestDigest(digest.finalize().into()))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use std::time::Duration;
184
185    use serde_json::json;
186
187    use super::{
188        CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest, ManifestVersion, WorkflowEntry,
189    };
190    use crate::PackageError;
191
192    fn sample_manifest() -> Manifest {
193        Manifest {
194            entry_module: "workflow/order".to_owned(),
195            entry_function: "run".to_owned(),
196            input_schema: json!({
197                "$schema": "https://json-schema.org/draft/2020-12/schema",
198                "type": "object",
199                "required": ["order_id"],
200                "properties": {
201                    "order_id": { "type": "string" },
202                    "retry": { "type": "boolean" }
203                }
204            }),
205            output_schema: json!({
206                "$schema": "https://json-schema.org/draft/2020-12/schema",
207                "type": "object",
208                "required": ["status"],
209                "properties": {
210                    "status": { "enum": ["accepted", "rejected"] },
211                    "total": { "type": "number" }
212                }
213            }),
214            timeout: Duration::new(30, 250_000_000),
215            activities: vec![
216                DeclaredActivity {
217                    activity_type: "charge_card".to_owned(),
218                },
219                DeclaredActivity {
220                    activity_type: "send_receipt".to_owned(),
221                },
222            ],
223            version: ManifestVersion::new(
224                "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
225            ),
226            format_version: CURRENT_FORMAT_VERSION,
227            additional_workflows: Vec::new(),
228        }
229    }
230
231    #[test]
232    fn manifest_round_trips_losslessly_through_json() -> Result<(), serde_json::Error> {
233        let manifest = sample_manifest();
234
235        let json = serde_json::to_string(&manifest)?;
236        let decoded: Manifest = serde_json::from_str(&json)?;
237
238        assert_eq!(decoded, manifest);
239        Ok(())
240    }
241
242    #[test]
243    fn absent_additional_workflow_list_loads_as_legacy_empty() -> Result<(), serde_json::Error> {
244        let mut value = serde_json::to_value(sample_manifest())?;
245        let object = value
246            .as_object_mut()
247            .ok_or_else(|| serde_json::Error::io(std::io::Error::other("manifest not object")))?;
248        object.remove("additional_workflows");
249        let decoded: Manifest = serde_json::from_value(value)?;
250        assert!(decoded.additional_workflows.is_empty());
251        Ok(())
252    }
253
254    #[test]
255    fn additional_workflow_entry_round_trips_with_internal_flag() -> Result<(), serde_json::Error> {
256        let mut manifest = sample_manifest();
257        manifest.additional_workflows.push(WorkflowEntry {
258            workflow_type: "awl_distribute_items_0".to_owned(),
259            entry_module: manifest.entry_module.clone(),
260            entry_function: "awl_distribute_items_0_run".to_owned(),
261            input_schema: json!({ "type": "object" }),
262            output_schema: json!({ "type": "string" }),
263            timeout: Duration::from_secs(30),
264            internal: true,
265        });
266        let encoded = serde_json::to_string(&manifest)?;
267        let decoded: Manifest = serde_json::from_str(&encoded)?;
268        assert_eq!(decoded, manifest);
269        assert!(
270            decoded
271                .additional_workflows
272                .first()
273                .is_some_and(|entry| entry.internal)
274        );
275        Ok(())
276    }
277
278    #[test]
279    fn manifest_with_schemas_and_declared_activities_round_trips() -> Result<(), serde_json::Error>
280    {
281        let manifest = sample_manifest();
282
283        let json = serde_json::to_string(&manifest)?;
284        let decoded: Manifest = serde_json::from_str(&json)?;
285
286        assert_eq!(
287            decoded.input_schema["properties"]["order_id"]["type"],
288            "string"
289        );
290        assert_eq!(
291            decoded.output_schema["properties"]["status"]["enum"][0],
292            "accepted"
293        );
294        assert_eq!(decoded.activities.len(), 2);
295        assert_eq!(decoded, manifest);
296        Ok(())
297    }
298
299    #[test]
300    fn supported_format_version_passes() -> Result<(), PackageError> {
301        sample_manifest().check_format_version()
302    }
303
304    /// Identical manifests digest identically; any semantic change (entry
305    /// function here) changes the digest even though the beam set — and
306    /// therefore the content hash — is untouched.
307    #[test]
308    fn canonical_digest_detects_manifest_divergence() -> Result<(), PackageError> {
309        let manifest = sample_manifest();
310        let same = sample_manifest();
311        let mut diverged = sample_manifest();
312        diverged.entry_function = "start".to_owned();
313
314        assert_eq!(manifest.canonical_digest()?, same.canonical_digest()?);
315        assert_ne!(manifest.canonical_digest()?, diverged.canonical_digest()?);
316        Ok(())
317    }
318
319    #[test]
320    fn canonical_digest_renders_as_lowercase_hex() -> Result<(), PackageError> {
321        let digest = sample_manifest().canonical_digest()?;
322        let text = digest.to_string();
323
324        assert_eq!(text.len(), 64);
325        assert!(
326            text.bytes()
327                .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
328        );
329        Ok(())
330    }
331
332    #[test]
333    fn unsupported_format_version_returns_typed_error() {
334        let mut manifest = sample_manifest();
335        manifest.format_version = CURRENT_FORMAT_VERSION + 1;
336
337        let result = manifest.check_format_version();
338
339        assert!(matches!(
340            result,
341            Err(PackageError::UnknownFormatVersion { found }) if found == CURRENT_FORMAT_VERSION + 1
342        ));
343    }
344
345    #[test]
346    fn manifest_json_keys_are_stable() -> Result<(), serde_json::Error> {
347        let manifest = sample_manifest();
348
349        let json = serde_json::to_value(&manifest)?;
350
351        assert!(json.get("entry_module").is_some());
352        assert!(json.get("entry_function").is_some());
353        assert!(json.get("input_schema").is_some());
354        assert!(json.get("output_schema").is_some());
355        assert!(json.get("timeout").is_some());
356        assert!(json.get("activities").is_some());
357        assert!(json.get("version").is_some());
358        assert!(json.get("format_version").is_some());
359        assert_eq!(json["activities"][0]["activity_type"], "charge_card");
360        Ok(())
361    }
362}