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}