Skip to main content

aion_package/contract/
surface.rs

1//! The contract surface: every record committed into a package's `.v4`+
2//! identity — workflow schemas, worker action declarations, children,
3//! signals, additional entries, and the workloop block.
4//!
5//! The types here are shape and meaning only. HOW they become identity
6//! bytes — the canonical encoding, its domains, and the prior-form
7//! substitution seam — lives in the sibling `identity` module, so that the
8//! surface a reader studies and the encoding an auditor studies are each
9//! one page.
10
11use std::time::Duration;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::Manifest;
17
18/// The complete contract surface committed into a package's `.v4` identity.
19#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PackageContract {
21    /// Primary workflow input schema.
22    #[serde(serialize_with = "crate::canonical::serialize_value")]
23    pub input_schema: Value,
24    /// Primary workflow output schema.
25    #[serde(serialize_with = "crate::canonical::serialize_value")]
26    pub output_schema: Value,
27    /// Queue-scoped activity declarations.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub workers: Vec<WorkerContract>,
30    /// Declared child workflow callables.
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub children: Vec<ChildContract>,
33    /// Declared signal payloads.
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub signals: Vec<SignalContract>,
36    /// Additional workflow entry schemas in this package.
37    #[serde(default, skip_serializing_if = "Vec::is_empty")]
38    pub additional_workflows: Vec<AdditionalWorkflowContract>,
39    /// Activity names retained by legacy project manifests that cannot declare a
40    /// queue or typed activity surface. They remain identity-bound but do not
41    /// create a queue declaration.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    pub unscoped_activities: Vec<String>,
44    /// The workloop declaration surface, when the package's entry document is
45    /// a `workloop`. This is what the ENGINE reads at start time to arm the
46    /// cadence, seed generation 1's carries and evaluate tolerances, so it
47    /// must travel with the deployed archive rather than only with the
48    /// in-process compile output — a server restart must not forget what a
49    /// deployed loop's tolerances were.
50    ///
51    /// IDENTITY-BOUND, and that is not a formality: a tolerance rewritten in
52    /// storage changes WHEN a loop alarms, and a retention window rewritten in
53    /// storage changes WHAT is destroyed. Both are executable authority, so
54    /// two packages that differ in them must not be one version.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub workloop: Option<WorkloopContract>,
57}
58
59/// The workloop declaration surface, as compiled from a `workloop` document
60/// (workloop design brief R2/R8/R13): everything the engine needs to arm
61/// the loop, seed the first generation's carries from the declared
62/// defaults, and evaluate tolerances without re-reading AWL source.
63///
64/// Carried on the COMPILE OUTPUT (`aion_awl::CompiledWorkflow::workloop`) and
65/// on the deployed archive's [`PackageContract`], where it is bound into
66/// package identity: the engine reads it at START time to arm the cadence,
67/// seed generation 1's carries and evaluate tolerances, so it must survive a
68/// server restart and must not be rewritable in storage without changing the
69/// package's version.
70#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct WorkloopContract {
72    /// The `every` cadence in seconds, when declared.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub cadence_seconds: Option<u64>,
75    /// The `on <signal>` arming signal names, in declaration order.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub arms: Vec<String>,
78    /// The carries: name, value schema, and the declared default the engine
79    /// seeds the FIRST generation with (later generations carry the values
80    /// `route start` minted).
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub carries: Vec<CarryContract>,
83    /// The invariants, with their declared tolerances and confirming routes.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub invariants: Vec<InvariantContract>,
86    /// The declared `retention` window in seconds (R8.1 — required, no
87    /// default).
88    pub retention_seconds: u64,
89    /// The `detached` hatch-target contracts: name and start schema.
90    #[serde(default, skip_serializing_if = "Vec::is_empty")]
91    pub detached: Vec<DetachedContract>,
92    /// The report tiles: name and value schema.
93    #[serde(default, skip_serializing_if = "Vec::is_empty")]
94    pub reports: Vec<ReportContract>,
95    /// Whether the document declares a `retire` block, and therefore whether
96    /// the deployed module exports `retire/1`.
97    ///
98    /// 🔴 A DECLARATION, NOT A CALLER PREFERENCE. Retirement has two engine
99    /// verbs — one that invokes the declared cleanup and one for a loop that
100    /// declares none — and which applies is decided by the DOCUMENT. If an
101    /// operator could choose, a declared cleanup could be skipped by passing
102    /// an argument, which is how a lease is lost. If the engine guessed, it
103    /// could not tell a module compiled before the entry existed from a loop
104    /// that declared no cleanup, which is the same failure wearing a
105    /// different hat. So the answer travels with the package, bound into its
106    /// identity like every other executable authority here.
107    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
108    pub has_retire_body: bool,
109}
110
111/// One `carry` declaration: name, schema, and the folded default seed.
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct CarryContract {
114    /// Carry name (an input-record field of every generation).
115    pub name: String,
116    /// The carry's value schema.
117    #[serde(serialize_with = "crate::canonical::serialize_value")]
118    pub schema: Value,
119    /// The declared default, folded to a JSON literal.
120    #[serde(serialize_with = "crate::canonical::serialize_value")]
121    pub default: Value,
122}
123
124/// One declared tolerance form (R2.3 — no default; both may stand).
125#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(tag = "form", rename_all = "snake_case")]
127pub enum ToleranceContract {
128    /// `tolerance <N> windows` — N consecutive unhealthy samples.
129    Windows {
130        /// The declared window count.
131        count: u64,
132    },
133    /// `tolerance unconfirmed for <duration>` — the only form evaluable
134    /// with zero samples (R2.4a).
135    UnconfirmedFor {
136        /// The declared window in seconds.
137        seconds: u64,
138    },
139}
140
141/// One `invariant` declaration.
142#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
143pub struct InvariantContract {
144    /// Invariant name.
145    pub name: String,
146    /// The DECLARED type name of the current-state record (`invariant serving:
147    /// type Board` → `"Board"`).
148    ///
149    /// Carried beside the schema, not derived from it: the engine stamps this
150    /// on every durable invariant current-state record as provenance, so a
151    /// reader can tell which declaration a stored value was written under. A
152    /// schema is structural and two different declarations can share one.
153    pub record_type: String,
154    /// The current-state record schema.
155    #[serde(serialize_with = "crate::canonical::serialize_value")]
156    pub schema: Value,
157    /// The declared tolerance forms, in declaration order (never empty for
158    /// a checked document — C3).
159    pub tolerances: Vec<ToleranceContract>,
160    /// The route whose firing confirms the invariant, when declared.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub confirms: Option<String>,
163}
164
165/// One `detached` hatch-target contract.
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
167pub struct DetachedContract {
168    /// The detached workflow's logical name (the hatch dedupe scope is
169    /// namespace + this name + the site's key).
170    pub name: String,
171    /// Start-contract schema over the declared parameters.
172    #[serde(serialize_with = "crate::canonical::serialize_value")]
173    pub input_schema: Value,
174}
175
176/// One `report` tile contract.
177#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ReportContract {
179    /// Report name (registered through the answer surface).
180    pub name: String,
181    /// The tile's value schema.
182    #[serde(serialize_with = "crate::canonical::serialize_value")]
183    pub schema: Value,
184}
185
186impl PackageContract {
187    /// Produces the most precise contract available from a legacy manifest.
188    ///
189    /// This never invents a queue or schemas for bare activity names. Such names
190    /// are committed as unscoped records and therefore cannot satisfy a
191    /// structural queue-service check.
192    #[must_use]
193    pub fn from_manifest(manifest: &Manifest) -> Self {
194        Self {
195            input_schema: manifest.input_schema.clone(),
196            output_schema: manifest.output_schema.clone(),
197            workers: Vec::new(),
198            children: Vec::new(),
199            signals: Vec::new(),
200            additional_workflows: manifest
201                .additional_workflows
202                .iter()
203                .map(|entry| AdditionalWorkflowContract {
204                    workflow_type: entry.workflow_type.clone(),
205                    input_schema: entry.input_schema.clone(),
206                    output_schema: entry.output_schema.clone(),
207                })
208                .collect(),
209            unscoped_activities: manifest
210                .activities
211                .iter()
212                .map(|activity| activity.activity_type.clone())
213                .collect(),
214            // A legacy manifest declares no workloop surface: the header a
215            // loop needs did not exist when it was built, and inventing one
216            // would arm a cadence nobody wrote.
217            workloop: None,
218        }
219    }
220}
221
222/// The actions a worker queue must serve.
223#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
224pub struct WorkerContract {
225    /// Declared task queue.
226    pub task_queue: String,
227    /// Typed activities on the queue.
228    pub actions: Vec<ActionContract>,
229}
230
231/// Typed activity surface advertised by a concrete worker build.
232#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
233pub struct ActivityDescriptor {
234    /// Activity type.
235    pub name: String,
236    /// Schema accepted by the worker.
237    #[serde(serialize_with = "crate::canonical::serialize_value")]
238    pub input_schema: Value,
239    /// Schema produced by the worker.
240    #[serde(serialize_with = "crate::canonical::serialize_value")]
241    pub output_schema: Value,
242}
243
244/// One typed activity declaration.
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub struct ActionContract {
247    /// Activity type.
248    pub name: String,
249    /// Object schema over the activity parameters.
250    #[serde(serialize_with = "crate::canonical::serialize_value")]
251    pub input_schema: Value,
252    /// Activity result schema.
253    #[serde(serialize_with = "crate::canonical::serialize_value")]
254    pub output_schema: Value,
255    /// Declared node selector.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub node: Option<String>,
258    /// Declared schedule-to-close timeout.
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub timeout: Option<Duration>,
261    /// Declaration-owned retry envelope.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub retry: Option<RetryContract>,
264    /// Whether the declaration classes this activity as ADVISORY: a side
265    /// channel whose failure warns on the run and never faults the calling
266    /// step (RUNTIME-OPERATIONS.md R5). Identity-bound — flipping it changes
267    /// what the package promises a caller.
268    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
269    pub advisory: bool,
270    /// Whether the declaration classes this activity as an AGENT seam: its one
271    /// `String` parameter carries a prompt and its `String` result carries the
272    /// reply, so a worker hands the call to an agent harness rather than to a
273    /// typed handler. The checker enforces that shape, so a worker reading this
274    /// flag may rely on it.
275    ///
276    /// Identity-bound for the same reason `advisory` is — an action that
277    /// becomes an agent seam promises a caller something different — and
278    /// skipped when false, so a document with no agent action hashes exactly as
279    /// it did before the marker existed.
280    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
281    pub agent: bool,
282    /// The declarative body the action carries, when it declares one.
283    ///
284    /// `None` is a requirement on an out-of-band worker: the action's name
285    /// and schemas are the whole promise, and something must connect to
286    /// serve it. `Some` means the package itself says what the action DOES,
287    /// so the host can execute it with no worker connected.
288    ///
289    /// Identity-bound deliberately: the body is executable authority, and an
290    /// authority that did not participate in the package hash could be
291    /// rewritten in storage without changing what the deployment claims to
292    /// be. A body edit is a new package, always.
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub body: Option<ActionBodyContract>,
295}
296
297/// A declarative action body committed into package identity.
298#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(tag = "kind", rename_all = "snake_case")]
300pub enum ActionBodyContract {
301    /// One command line, executed directly with argv-element parameter
302    /// substitution — never through a shell. The text is the authored form
303    /// with `$` interpolation intact; the executor parses it and substitutes
304    /// each referenced parameter as one whole argument.
305    Run {
306        /// The authored command text.
307        command: String,
308    },
309    /// One DECLARED command (`runs command <name>`), carried in its emitted
310    /// typed form: body lines of templated argv slots, declared environment
311    /// bindings, and the working directory.
312    ///
313    /// The difference from [`ActionBodyContract::Run`] is not spelling. A
314    /// `Run` body is a command LINE that the executor still has to split; a
315    /// `Command` body was split by the AWL emitter at compile time and travels
316    /// as an argument list, so nothing downstream ever holds a string that
317    /// could be re-split.
318    Command {
319        /// What the action's result is taken from.
320        capture: CommandBodyCapture,
321        /// The emitted command.
322        command: Box<crate::declared_command::DeclaredCommandContract>,
323    },
324}
325
326/// What a `runs command` body's result is, and therefore what the action's
327/// declared return type has to be.
328///
329/// Two forms, not three: a declared command's caller is asking for the
330/// command's OUTPUT, and the outcome record that a bare `run "…"` body yields
331/// is the shape the string form has for historical reasons. An action that
332/// wants an exit code takes it from a `run` body.
333#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum CommandBodyCapture {
336    /// Trimmed stdout becomes a JSON string. A non-zero exit is a retryable
337    /// failure carrying stderr.
338    Text,
339    /// Trimmed stdout is parsed as JSON and becomes the result. A non-zero
340    /// exit is a retryable failure carrying stderr; stdout that is not valid
341    /// JSON is a terminal one.
342    Json,
343}
344
345impl ActionContract {
346    /// Whether serving this action is a WORKER's job.
347    ///
348    /// `true` when the action carries no declared body: the declaration is a
349    /// requirement on an out-of-band worker, and nothing runs until one
350    /// connects and advertises the action. `false` when the action carries a
351    /// declared body: the package itself says what the action does, the host
352    /// executes it, and no worker is needed or admitted for it.
353    ///
354    /// This is the one place the body-exemption rule lives. Every surface
355    /// that splits a queue's actions into worker-owed and server-run —
356    /// admission diffs, availability, scaffolding, codegen — must call this
357    /// rather than restate `body.is_none()`.
358    #[must_use]
359    pub fn worker_owed(&self) -> bool {
360        self.body.is_none()
361    }
362}
363
364/// A declaration-owned retry envelope.
365#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(tag = "kind", rename_all = "snake_case")]
367pub enum RetryContract {
368    /// Constant delay between attempts.
369    Every {
370        /// Number of further attempts after the first.
371        count: u64,
372        /// Delay between attempts.
373        every: Duration,
374    },
375    /// Bounded backoff between attempts.
376    Backoff {
377        /// Number of further attempts after the first.
378        count: u64,
379        /// Minimum delay.
380        min: Duration,
381        /// Maximum delay.
382        max: Duration,
383    },
384}
385
386/// One declared child workflow callable.
387#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
388pub struct ChildContract {
389    /// Child workflow type.
390    pub name: String,
391    /// Object schema over child parameters.
392    #[serde(serialize_with = "crate::canonical::serialize_value")]
393    pub input_schema: Value,
394    /// Child result schema.
395    #[serde(serialize_with = "crate::canonical::serialize_value")]
396    pub output_schema: Value,
397}
398
399/// One declared signal payload.
400#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
401pub struct SignalContract {
402    /// Signal name.
403    pub name: String,
404    /// Signal payload schema.
405    #[serde(serialize_with = "crate::canonical::serialize_value")]
406    pub input_schema: Value,
407}
408
409/// Typed shape of an additional entry in the same package.
410#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
411pub struct AdditionalWorkflowContract {
412    /// Routing workflow type.
413    pub workflow_type: String,
414    /// Entry input schema.
415    #[serde(serialize_with = "crate::canonical::serialize_value")]
416    pub input_schema: Value,
417    /// Entry output schema.
418    #[serde(serialize_with = "crate::canonical::serialize_value")]
419    pub output_schema: Value,
420}