Skip to main content

aion_package/
contract.rs

1//! Durable worker-contract records and their canonical identity encoding.
2
3use std::time::Duration;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::Manifest;
9
10/// Refusal returned when a stored package predates contract-bound identity.
11#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
12pub enum ContractIdentityError {
13    /// The package is integrity-valid but its identity commits to no contract.
14    #[error(
15        "package identity `{stored_version}` predates `.v4` worker-contract commitment; re-deploy this package under `.v4`"
16    )]
17    RedeployRequired {
18        /// Stored pre-`.v4` package identity.
19        stored_version: String,
20    },
21}
22
23/// The complete contract surface committed into a package's `.v4` identity.
24#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct PackageContract {
26    /// Primary workflow input schema.
27    #[serde(serialize_with = "crate::canonical::serialize_value")]
28    pub input_schema: Value,
29    /// Primary workflow output schema.
30    #[serde(serialize_with = "crate::canonical::serialize_value")]
31    pub output_schema: Value,
32    /// Queue-scoped activity declarations.
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub workers: Vec<WorkerContract>,
35    /// Declared child workflow callables.
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub children: Vec<ChildContract>,
38    /// Declared signal payloads.
39    #[serde(default, skip_serializing_if = "Vec::is_empty")]
40    pub signals: Vec<SignalContract>,
41    /// Additional workflow entry schemas in this package.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub additional_workflows: Vec<AdditionalWorkflowContract>,
44    /// Activity names retained by legacy project manifests that cannot declare a
45    /// queue or typed activity surface. They remain identity-bound but do not
46    /// create a queue declaration.
47    #[serde(default, skip_serializing_if = "Vec::is_empty")]
48    pub unscoped_activities: Vec<String>,
49}
50
51impl PackageContract {
52    /// Produces the most precise contract available from a legacy manifest.
53    ///
54    /// This never invents a queue or schemas for bare activity names. Such names
55    /// are committed as unscoped records and therefore cannot satisfy a
56    /// structural queue-service check.
57    #[must_use]
58    pub fn from_manifest(manifest: &Manifest) -> Self {
59        Self {
60            input_schema: manifest.input_schema.clone(),
61            output_schema: manifest.output_schema.clone(),
62            workers: Vec::new(),
63            children: Vec::new(),
64            signals: Vec::new(),
65            additional_workflows: manifest
66                .additional_workflows
67                .iter()
68                .map(|entry| AdditionalWorkflowContract {
69                    workflow_type: entry.workflow_type.clone(),
70                    input_schema: entry.input_schema.clone(),
71                    output_schema: entry.output_schema.clone(),
72                })
73                .collect(),
74            unscoped_activities: manifest
75                .activities
76                .iter()
77                .map(|activity| activity.activity_type.clone())
78                .collect(),
79        }
80    }
81
82    /// Returns the deterministic binary encoding committed by the `.v4` hash.
83    ///
84    /// Declaration vectors and JSON object keys are sorted before encoding.
85    /// JSON whitespace and source map insertion order therefore cannot affect
86    /// package identity.
87    #[must_use]
88    pub fn canonical_bytes(&self) -> Vec<u8> {
89        let mut bytes = Vec::new();
90        encode_json(&mut bytes, None, &self.input_schema);
91        encode_json(&mut bytes, None, &self.output_schema);
92
93        let mut workers = self
94            .workers
95            .iter()
96            .map(WorkerContract::canonical_bytes)
97            .collect::<Vec<_>>();
98        workers.sort();
99        encode_len(&mut bytes, workers.len());
100        for worker in workers {
101            update_record(&mut bytes, &worker);
102        }
103
104        let mut children = self
105            .children
106            .iter()
107            .map(ChildContract::canonical_bytes)
108            .collect::<Vec<_>>();
109        children.sort();
110        encode_len(&mut bytes, children.len());
111        for child in children {
112            update_record(&mut bytes, &child);
113        }
114
115        let mut signals = self.signals.iter().collect::<Vec<_>>();
116        signals.sort_by(|left, right| left.name.cmp(&right.name));
117        encode_len(&mut bytes, signals.len());
118        for signal in signals {
119            encode_text(&mut bytes, &signal.name);
120            encode_json(&mut bytes, None, &signal.input_schema);
121        }
122
123        let mut additional = self.additional_workflows.iter().collect::<Vec<_>>();
124        additional.sort_by(|left, right| left.workflow_type.cmp(&right.workflow_type));
125        encode_len(&mut bytes, additional.len());
126        for workflow in additional {
127            encode_text(&mut bytes, &workflow.workflow_type);
128            encode_json(&mut bytes, None, &workflow.input_schema);
129            encode_json(&mut bytes, None, &workflow.output_schema);
130        }
131
132        let mut unscoped = self.unscoped_activities.iter().collect::<Vec<_>>();
133        unscoped.sort();
134        encode_len(&mut bytes, unscoped.len());
135        for activity in unscoped {
136            encode_text(&mut bytes, activity);
137        }
138        bytes
139    }
140}
141
142/// The actions a worker queue must serve.
143#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
144pub struct WorkerContract {
145    /// Declared task queue.
146    pub task_queue: String,
147    /// Typed activities on the queue.
148    pub actions: Vec<ActionContract>,
149}
150
151impl WorkerContract {
152    fn canonical_bytes(&self) -> Vec<u8> {
153        let mut bytes = Vec::new();
154        encode_text(&mut bytes, &self.task_queue);
155        let mut actions = self
156            .actions
157            .iter()
158            .map(ActionContract::canonical_bytes)
159            .collect::<Vec<_>>();
160        actions.sort();
161        encode_len(&mut bytes, actions.len());
162        for action in actions {
163            update_record(&mut bytes, &action);
164        }
165        bytes
166    }
167}
168
169/// Typed activity surface advertised by a concrete worker build.
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
171pub struct ActivityDescriptor {
172    /// Activity type.
173    pub name: String,
174    /// Schema accepted by the worker.
175    #[serde(serialize_with = "crate::canonical::serialize_value")]
176    pub input_schema: Value,
177    /// Schema produced by the worker.
178    #[serde(serialize_with = "crate::canonical::serialize_value")]
179    pub output_schema: Value,
180}
181
182/// One typed activity declaration.
183#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
184pub struct ActionContract {
185    /// Activity type.
186    pub name: String,
187    /// Object schema over the activity parameters.
188    #[serde(serialize_with = "crate::canonical::serialize_value")]
189    pub input_schema: Value,
190    /// Activity result schema.
191    #[serde(serialize_with = "crate::canonical::serialize_value")]
192    pub output_schema: Value,
193    /// Declared node selector.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub node: Option<String>,
196    /// Declared schedule-to-close timeout.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub timeout: Option<Duration>,
199    /// Declaration-owned retry envelope.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub retry: Option<RetryContract>,
202    /// Whether the declaration classes this activity as ADVISORY: a side
203    /// channel whose failure warns on the run and never faults the calling
204    /// step (RUNTIME-OPERATIONS.md R5). Identity-bound — flipping it changes
205    /// what the package promises a caller.
206    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
207    pub advisory: bool,
208    /// Whether the declaration classes this activity as an AGENT seam: its one
209    /// `String` parameter carries a prompt and its `String` result carries the
210    /// reply, so a worker hands the call to an agent harness rather than to a
211    /// typed handler. The checker enforces that shape, so a worker reading this
212    /// flag may rely on it.
213    ///
214    /// Identity-bound for the same reason `advisory` is — an action that
215    /// becomes an agent seam promises a caller something different — and
216    /// skipped when false, so a document with no agent action hashes exactly as
217    /// it did before the marker existed.
218    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
219    pub agent: bool,
220    /// The declarative body the action carries, when it declares one.
221    ///
222    /// `None` is a requirement on an out-of-band worker: the action's name
223    /// and schemas are the whole promise, and something must connect to
224    /// serve it. `Some` means the package itself says what the action DOES,
225    /// so the host can execute it with no worker connected.
226    ///
227    /// Identity-bound deliberately: the body is executable authority, and an
228    /// authority that did not participate in the package hash could be
229    /// rewritten in storage without changing what the deployment claims to
230    /// be. A body edit is a new package, always.
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub body: Option<ActionBodyContract>,
233}
234
235/// A declarative action body committed into package identity.
236#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(tag = "kind", rename_all = "snake_case")]
238pub enum ActionBodyContract {
239    /// One command line, executed directly with argv-element parameter
240    /// substitution — never through a shell. The text is the authored form
241    /// with `$` interpolation intact; the executor parses it and substitutes
242    /// each referenced parameter as one whole argument.
243    Run {
244        /// The authored command text.
245        command: String,
246    },
247}
248
249impl ActionContract {
250    fn canonical_bytes(&self) -> Vec<u8> {
251        let mut bytes = Vec::new();
252        encode_text(&mut bytes, &self.name);
253        encode_json(&mut bytes, None, &self.input_schema);
254        encode_json(&mut bytes, None, &self.output_schema);
255        encode_optional_text(&mut bytes, self.node.as_deref());
256        encode_optional_duration(&mut bytes, self.timeout);
257        match &self.retry {
258            None => bytes.push(0),
259            Some(RetryContract::Every { count, every }) => {
260                bytes.push(1);
261                bytes.extend_from_slice(&count.to_be_bytes());
262                encode_duration(&mut bytes, *every);
263            }
264            Some(RetryContract::Backoff { count, min, max }) => {
265                bytes.push(2);
266                bytes.extend_from_slice(&count.to_be_bytes());
267                encode_duration(&mut bytes, *min);
268                encode_duration(&mut bytes, *max);
269            }
270        }
271        // ADVISORY is encoded ONLY when true: a single marker byte appended
272        // after the retry block, and nothing at all when false. It is
273        // injective because absence and the marker cannot be confused at the
274        // end of a positional record — the block that FOLLOWS it always
275        // begins with a body discriminant (`0`/`1`), never `ADVISORY_MARKER`.
276        if self.advisory {
277            bytes.push(ADVISORY_MARKER);
278        }
279        // The BODY block always encodes — a discriminant byte, then the
280        // command text for `Run`. Adding it consumed the record's optional
281        // tail (the advisory marker was the one tail-append the previous
282        // domain could injectively absorb), which is why this encoding lives
283        // under the bumped `.v5` identity domain rather than as a second
284        // conditional suffix: two optional tails are not injective, and a
285        // contract identity that two different declarations can share is a
286        // spoofable deployment.
287        //
288        // LAW for the next field: encode it UNCONDITIONALLY after this
289        // block and bump the identity domain again. Never append another
290        // optional tail.
291        match &self.body {
292            None => bytes.push(0),
293            Some(ActionBodyContract::Run { command }) => {
294                bytes.push(1);
295                encode_text(&mut bytes, command);
296            }
297        }
298        bytes
299    }
300}
301
302/// The marker byte appended to an advisory action's canonical record.
303///
304/// Distinct from every retry-kind discriminant (`0`/`1`/`2`) it can follow,
305/// so a reader of the trailing bytes is never ambiguous.
306const ADVISORY_MARKER: u8 = 0xA0;
307
308/// A declaration-owned retry envelope.
309#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
310#[serde(tag = "kind", rename_all = "snake_case")]
311pub enum RetryContract {
312    /// Constant delay between attempts.
313    Every {
314        /// Number of further attempts after the first.
315        count: u64,
316        /// Delay between attempts.
317        every: Duration,
318    },
319    /// Bounded backoff between attempts.
320    Backoff {
321        /// Number of further attempts after the first.
322        count: u64,
323        /// Minimum delay.
324        min: Duration,
325        /// Maximum delay.
326        max: Duration,
327    },
328}
329
330/// One declared child workflow callable.
331#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
332pub struct ChildContract {
333    /// Child workflow type.
334    pub name: String,
335    /// Object schema over child parameters.
336    #[serde(serialize_with = "crate::canonical::serialize_value")]
337    pub input_schema: Value,
338    /// Child result schema.
339    #[serde(serialize_with = "crate::canonical::serialize_value")]
340    pub output_schema: Value,
341}
342
343impl ChildContract {
344    fn canonical_bytes(&self) -> Vec<u8> {
345        let mut bytes = Vec::new();
346        encode_text(&mut bytes, &self.name);
347        encode_json(&mut bytes, None, &self.input_schema);
348        encode_json(&mut bytes, None, &self.output_schema);
349        bytes
350    }
351}
352
353/// One declared signal payload.
354#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
355pub struct SignalContract {
356    /// Signal name.
357    pub name: String,
358    /// Signal payload schema.
359    #[serde(serialize_with = "crate::canonical::serialize_value")]
360    pub input_schema: Value,
361}
362
363/// Typed shape of an additional entry in the same package.
364#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
365pub struct AdditionalWorkflowContract {
366    /// Routing workflow type.
367    pub workflow_type: String,
368    /// Entry input schema.
369    #[serde(serialize_with = "crate::canonical::serialize_value")]
370    pub input_schema: Value,
371    /// Entry output schema.
372    #[serde(serialize_with = "crate::canonical::serialize_value")]
373    pub output_schema: Value,
374}
375
376fn encode_json(bytes: &mut Vec<u8>, parent_key: Option<&str>, value: &Value) {
377    match value {
378        Value::Null => bytes.push(0),
379        Value::Bool(value) => bytes.extend_from_slice(&[1, u8::from(*value)]),
380        Value::Number(value) => {
381            bytes.push(2);
382            encode_text(bytes, &value.to_string());
383        }
384        Value::String(value) => {
385            bytes.push(3);
386            encode_text(bytes, value);
387        }
388        Value::Array(values) => {
389            bytes.push(4);
390            let mut values = values.iter().collect::<Vec<_>>();
391            if matches!(parent_key, Some("required" | "enum")) {
392                values.sort_by_key(ToString::to_string);
393            }
394            encode_len(bytes, values.len());
395            for value in values {
396                encode_json(bytes, None, value);
397            }
398        }
399        Value::Object(values) => {
400            bytes.push(5);
401            let mut entries = values.iter().collect::<Vec<_>>();
402            entries.sort_by_key(|(left, _)| *left);
403            encode_len(bytes, entries.len());
404            for (key, value) in entries {
405                encode_text(bytes, key);
406                encode_json(bytes, Some(key), value);
407            }
408        }
409    }
410}
411
412fn encode_len(bytes: &mut Vec<u8>, len: usize) {
413    bytes.extend_from_slice(&(len as u64).to_be_bytes());
414}
415
416fn encode_text(bytes: &mut Vec<u8>, value: &str) {
417    encode_len(bytes, value.len());
418    bytes.extend_from_slice(value.as_bytes());
419}
420
421fn update_record(bytes: &mut Vec<u8>, record: &[u8]) {
422    encode_len(bytes, record.len());
423    bytes.extend_from_slice(record);
424}
425
426fn encode_optional_text(bytes: &mut Vec<u8>, value: Option<&str>) {
427    match value {
428        Some(value) => {
429            bytes.push(1);
430            encode_text(bytes, value);
431        }
432        None => bytes.push(0),
433    }
434}
435
436fn encode_optional_duration(bytes: &mut Vec<u8>, value: Option<Duration>) {
437    match value {
438        Some(value) => {
439            bytes.push(1);
440            encode_duration(bytes, value);
441        }
442        None => bytes.push(0),
443    }
444}
445
446fn encode_duration(bytes: &mut Vec<u8>, value: Duration) {
447    bytes.extend_from_slice(&value.as_secs().to_be_bytes());
448    bytes.extend_from_slice(&value.subsec_nanos().to_be_bytes());
449}
450
451#[cfg(test)]
452#[path = "contract_tests.rs"]
453mod tests;