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    /// Whether serving this action is a WORKER's job.
251    ///
252    /// `true` when the action carries no declared body: the declaration is a
253    /// requirement on an out-of-band worker, and nothing runs until one
254    /// connects and advertises the action. `false` when the action carries a
255    /// declared body: the package itself says what the action does, the host
256    /// executes it, and no worker is needed or admitted for it.
257    ///
258    /// This is the one place the body-exemption rule lives. Every surface
259    /// that splits a queue's actions into worker-owed and server-run —
260    /// admission diffs, availability, scaffolding, codegen — must call this
261    /// rather than restate `body.is_none()`.
262    #[must_use]
263    pub fn worker_owed(&self) -> bool {
264        self.body.is_none()
265    }
266
267    fn canonical_bytes(&self) -> Vec<u8> {
268        let mut bytes = Vec::new();
269        encode_text(&mut bytes, &self.name);
270        encode_json(&mut bytes, None, &self.input_schema);
271        encode_json(&mut bytes, None, &self.output_schema);
272        encode_optional_text(&mut bytes, self.node.as_deref());
273        encode_optional_duration(&mut bytes, self.timeout);
274        match &self.retry {
275            None => bytes.push(0),
276            Some(RetryContract::Every { count, every }) => {
277                bytes.push(1);
278                bytes.extend_from_slice(&count.to_be_bytes());
279                encode_duration(&mut bytes, *every);
280            }
281            Some(RetryContract::Backoff { count, min, max }) => {
282                bytes.push(2);
283                bytes.extend_from_slice(&count.to_be_bytes());
284                encode_duration(&mut bytes, *min);
285                encode_duration(&mut bytes, *max);
286            }
287        }
288        // ADVISORY is encoded ONLY when true: a single marker byte appended
289        // after the retry block, and nothing at all when false. It is
290        // injective because absence and the marker cannot be confused at the
291        // end of a positional record — the block that FOLLOWS it always
292        // begins with a body discriminant (`0`/`1`), never `ADVISORY_MARKER`.
293        if self.advisory {
294            bytes.push(ADVISORY_MARKER);
295        }
296        // The BODY block always encodes — a discriminant byte, then the
297        // command text for `Run`. Adding it consumed the record's optional
298        // tail (the advisory marker was the one tail-append the previous
299        // domain could injectively absorb), which is why this encoding lives
300        // under the bumped `.v5` identity domain rather than as a second
301        // conditional suffix: two optional tails are not injective, and a
302        // contract identity that two different declarations can share is a
303        // spoofable deployment.
304        //
305        // LAW for the next field: encode it UNCONDITIONALLY after this
306        // block and bump the identity domain again. Never append another
307        // optional tail.
308        match &self.body {
309            None => bytes.push(0),
310            Some(ActionBodyContract::Run { command }) => {
311                bytes.push(1);
312                encode_text(&mut bytes, command);
313            }
314        }
315        bytes
316    }
317}
318
319/// The marker byte appended to an advisory action's canonical record.
320///
321/// Distinct from every retry-kind discriminant (`0`/`1`/`2`) it can follow,
322/// so a reader of the trailing bytes is never ambiguous.
323const ADVISORY_MARKER: u8 = 0xA0;
324
325/// A declaration-owned retry envelope.
326#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(tag = "kind", rename_all = "snake_case")]
328pub enum RetryContract {
329    /// Constant delay between attempts.
330    Every {
331        /// Number of further attempts after the first.
332        count: u64,
333        /// Delay between attempts.
334        every: Duration,
335    },
336    /// Bounded backoff between attempts.
337    Backoff {
338        /// Number of further attempts after the first.
339        count: u64,
340        /// Minimum delay.
341        min: Duration,
342        /// Maximum delay.
343        max: Duration,
344    },
345}
346
347/// One declared child workflow callable.
348#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
349pub struct ChildContract {
350    /// Child workflow type.
351    pub name: String,
352    /// Object schema over child parameters.
353    #[serde(serialize_with = "crate::canonical::serialize_value")]
354    pub input_schema: Value,
355    /// Child result schema.
356    #[serde(serialize_with = "crate::canonical::serialize_value")]
357    pub output_schema: Value,
358}
359
360impl ChildContract {
361    fn canonical_bytes(&self) -> Vec<u8> {
362        let mut bytes = Vec::new();
363        encode_text(&mut bytes, &self.name);
364        encode_json(&mut bytes, None, &self.input_schema);
365        encode_json(&mut bytes, None, &self.output_schema);
366        bytes
367    }
368}
369
370/// One declared signal payload.
371#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
372pub struct SignalContract {
373    /// Signal name.
374    pub name: String,
375    /// Signal payload schema.
376    #[serde(serialize_with = "crate::canonical::serialize_value")]
377    pub input_schema: Value,
378}
379
380/// Typed shape of an additional entry in the same package.
381#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
382pub struct AdditionalWorkflowContract {
383    /// Routing workflow type.
384    pub workflow_type: String,
385    /// Entry input schema.
386    #[serde(serialize_with = "crate::canonical::serialize_value")]
387    pub input_schema: Value,
388    /// Entry output schema.
389    #[serde(serialize_with = "crate::canonical::serialize_value")]
390    pub output_schema: Value,
391}
392
393fn encode_json(bytes: &mut Vec<u8>, parent_key: Option<&str>, value: &Value) {
394    match value {
395        Value::Null => bytes.push(0),
396        Value::Bool(value) => bytes.extend_from_slice(&[1, u8::from(*value)]),
397        Value::Number(value) => {
398            bytes.push(2);
399            encode_text(bytes, &value.to_string());
400        }
401        Value::String(value) => {
402            bytes.push(3);
403            encode_text(bytes, value);
404        }
405        Value::Array(values) => {
406            bytes.push(4);
407            let mut values = values.iter().collect::<Vec<_>>();
408            if matches!(parent_key, Some("required" | "enum")) {
409                values.sort_by_key(ToString::to_string);
410            }
411            encode_len(bytes, values.len());
412            for value in values {
413                encode_json(bytes, None, value);
414            }
415        }
416        Value::Object(values) => {
417            bytes.push(5);
418            let mut entries = values.iter().collect::<Vec<_>>();
419            entries.sort_by_key(|(left, _)| *left);
420            encode_len(bytes, entries.len());
421            for (key, value) in entries {
422                encode_text(bytes, key);
423                encode_json(bytes, Some(key), value);
424            }
425        }
426    }
427}
428
429fn encode_len(bytes: &mut Vec<u8>, len: usize) {
430    bytes.extend_from_slice(&(len as u64).to_be_bytes());
431}
432
433fn encode_text(bytes: &mut Vec<u8>, value: &str) {
434    encode_len(bytes, value.len());
435    bytes.extend_from_slice(value.as_bytes());
436}
437
438fn update_record(bytes: &mut Vec<u8>, record: &[u8]) {
439    encode_len(bytes, record.len());
440    bytes.extend_from_slice(record);
441}
442
443fn encode_optional_text(bytes: &mut Vec<u8>, value: Option<&str>) {
444    match value {
445        Some(value) => {
446            bytes.push(1);
447            encode_text(bytes, value);
448        }
449        None => bytes.push(0),
450    }
451}
452
453fn encode_optional_duration(bytes: &mut Vec<u8>, value: Option<Duration>) {
454    match value {
455        Some(value) => {
456            bytes.push(1);
457            encode_duration(bytes, value);
458        }
459        None => bytes.push(0),
460    }
461}
462
463fn encode_duration(bytes: &mut Vec<u8>, value: Duration) {
464    bytes.extend_from_slice(&value.as_secs().to_be_bytes());
465    bytes.extend_from_slice(&value.subsec_nanos().to_be_bytes());
466}
467
468#[cfg(test)]
469#[path = "contract_tests.rs"]
470mod tests;