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    /// The workloop declaration surface, when the package's entry document is
50    /// a `workloop`. This is what the ENGINE reads at start time to arm the
51    /// cadence, seed generation 1's carries and evaluate tolerances, so it
52    /// must travel with the deployed archive rather than only with the
53    /// in-process compile output — a server restart must not forget what a
54    /// deployed loop's tolerances were.
55    ///
56    /// IDENTITY-BOUND, and that is not a formality: a tolerance rewritten in
57    /// storage changes WHEN a loop alarms, and a retention window rewritten in
58    /// storage changes WHAT is destroyed. Both are executable authority, so
59    /// two packages that differ in them must not be one version.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub workloop: Option<WorkloopContract>,
62}
63
64/// The workloop declaration surface, as compiled from a `workloop` document
65/// (workloop design brief R2/R8/R13): everything the engine needs to arm
66/// the loop, seed the first generation's carries from the declared
67/// defaults, and evaluate tolerances without re-reading AWL source.
68///
69/// Carried on the COMPILE OUTPUT (`aion_awl::CompiledWorkflow::workloop`) and
70/// on the deployed archive's [`PackageContract`], where it is bound into
71/// package identity: the engine reads it at START time to arm the cadence,
72/// seed generation 1's carries and evaluate tolerances, so it must survive a
73/// server restart and must not be rewritable in storage without changing the
74/// package's version.
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub struct WorkloopContract {
77    /// The `every` cadence in seconds, when declared.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub cadence_seconds: Option<u64>,
80    /// The `on <signal>` arming signal names, in declaration order.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub arms: Vec<String>,
83    /// The carries: name, value schema, and the declared default the engine
84    /// seeds the FIRST generation with (later generations carry the values
85    /// `route start` minted).
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub carries: Vec<CarryContract>,
88    /// The invariants, with their declared tolerances and confirming routes.
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub invariants: Vec<InvariantContract>,
91    /// The declared `retention` window in seconds (R8.1 — required, no
92    /// default).
93    pub retention_seconds: u64,
94    /// The `detached` hatch-target contracts: name and start schema.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    pub detached: Vec<DetachedContract>,
97    /// The report tiles: name and value schema.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub reports: Vec<ReportContract>,
100    /// Whether the document declares a `retire` block, and therefore whether
101    /// the deployed module exports `retire/1`.
102    ///
103    /// 🔴 A DECLARATION, NOT A CALLER PREFERENCE. Retirement has two engine
104    /// verbs — one that invokes the declared cleanup and one for a loop that
105    /// declares none — and which applies is decided by the DOCUMENT. If an
106    /// operator could choose, a declared cleanup could be skipped by passing
107    /// an argument, which is how a lease is lost. If the engine guessed, it
108    /// could not tell a module compiled before the entry existed from a loop
109    /// that declared no cleanup, which is the same failure wearing a
110    /// different hat. So the answer travels with the package, bound into its
111    /// identity like every other executable authority here.
112    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
113    pub has_retire_body: bool,
114}
115
116/// One `carry` declaration: name, schema, and the folded default seed.
117#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
118pub struct CarryContract {
119    /// Carry name (an input-record field of every generation).
120    pub name: String,
121    /// The carry's value schema.
122    #[serde(serialize_with = "crate::canonical::serialize_value")]
123    pub schema: Value,
124    /// The declared default, folded to a JSON literal.
125    #[serde(serialize_with = "crate::canonical::serialize_value")]
126    pub default: Value,
127}
128
129/// One declared tolerance form (R2.3 — no default; both may stand).
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(tag = "form", rename_all = "snake_case")]
132pub enum ToleranceContract {
133    /// `tolerance <N> windows` — N consecutive unhealthy samples.
134    Windows {
135        /// The declared window count.
136        count: u64,
137    },
138    /// `tolerance unconfirmed for <duration>` — the only form evaluable
139    /// with zero samples (R2.4a).
140    UnconfirmedFor {
141        /// The declared window in seconds.
142        seconds: u64,
143    },
144}
145
146/// One `invariant` declaration.
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
148pub struct InvariantContract {
149    /// Invariant name.
150    pub name: String,
151    /// The DECLARED type name of the current-state record (`invariant serving:
152    /// type Board` → `"Board"`).
153    ///
154    /// Carried beside the schema, not derived from it: the engine stamps this
155    /// on every durable invariant current-state record as provenance, so a
156    /// reader can tell which declaration a stored value was written under. A
157    /// schema is structural and two different declarations can share one.
158    pub record_type: String,
159    /// The current-state record schema.
160    #[serde(serialize_with = "crate::canonical::serialize_value")]
161    pub schema: Value,
162    /// The declared tolerance forms, in declaration order (never empty for
163    /// a checked document — C3).
164    pub tolerances: Vec<ToleranceContract>,
165    /// The route whose firing confirms the invariant, when declared.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub confirms: Option<String>,
168}
169
170/// One `detached` hatch-target contract.
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172pub struct DetachedContract {
173    /// The detached workflow's logical name (the hatch dedupe scope is
174    /// namespace + this name + the site's key).
175    pub name: String,
176    /// Start-contract schema over the declared parameters.
177    #[serde(serialize_with = "crate::canonical::serialize_value")]
178    pub input_schema: Value,
179}
180
181/// One `report` tile contract.
182#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
183pub struct ReportContract {
184    /// Report name (registered through the answer surface).
185    pub name: String,
186    /// The tile's value schema.
187    #[serde(serialize_with = "crate::canonical::serialize_value")]
188    pub schema: Value,
189}
190
191impl PackageContract {
192    /// Produces the most precise contract available from a legacy manifest.
193    ///
194    /// This never invents a queue or schemas for bare activity names. Such names
195    /// are committed as unscoped records and therefore cannot satisfy a
196    /// structural queue-service check.
197    #[must_use]
198    pub fn from_manifest(manifest: &Manifest) -> Self {
199        Self {
200            input_schema: manifest.input_schema.clone(),
201            output_schema: manifest.output_schema.clone(),
202            workers: Vec::new(),
203            children: Vec::new(),
204            signals: Vec::new(),
205            additional_workflows: manifest
206                .additional_workflows
207                .iter()
208                .map(|entry| AdditionalWorkflowContract {
209                    workflow_type: entry.workflow_type.clone(),
210                    input_schema: entry.input_schema.clone(),
211                    output_schema: entry.output_schema.clone(),
212                })
213                .collect(),
214            unscoped_activities: manifest
215                .activities
216                .iter()
217                .map(|activity| activity.activity_type.clone())
218                .collect(),
219            // A legacy manifest declares no workloop surface: the header a
220            // loop needs did not exist when it was built, and inventing one
221            // would arm a cadence nobody wrote.
222            workloop: None,
223        }
224    }
225
226    /// Returns the deterministic binary encoding committed by the current
227    /// identity domain.
228    ///
229    /// Declaration vectors and JSON object keys are sorted before encoding.
230    /// JSON whitespace and source map insertion order therefore cannot affect
231    /// package identity.
232    #[must_use]
233    pub fn canonical_bytes(&self) -> Vec<u8> {
234        self.canonical_bytes_in(ContractDomain::V6)
235    }
236
237    /// The superseded `.v5` encoding, exactly as every released cut from
238    /// v0.19 through v0.24 computed it: no per-action agent byte, no
239    /// workloop block.
240    ///
241    /// 🔴 VERIFICATION-ONLY MIGRATION SURFACE. This exists so the verifier
242    /// in [`crate::hash`] can re-attest archives those releases minted —
243    /// a store restarted under the current build must not strand its own
244    /// recorded deployments. Nothing ever MINTS a `.v5` identity again;
245    /// calling this anywhere except a verifier is a defect.
246    #[must_use]
247    pub fn legacy_v5_canonical_bytes(&self) -> Vec<u8> {
248        self.canonical_bytes_in(ContractDomain::LegacyV5)
249    }
250
251    fn canonical_bytes_in(&self, domain: ContractDomain) -> Vec<u8> {
252        let mut bytes = Vec::new();
253        encode_json(&mut bytes, None, &self.input_schema);
254        encode_json(&mut bytes, None, &self.output_schema);
255
256        let mut workers = self
257            .workers
258            .iter()
259            .map(|worker| worker.canonical_bytes_in(domain))
260            .collect::<Vec<_>>();
261        workers.sort();
262        encode_len(&mut bytes, workers.len());
263        for worker in workers {
264            update_record(&mut bytes, &worker);
265        }
266
267        let mut children = self
268            .children
269            .iter()
270            .map(ChildContract::canonical_bytes)
271            .collect::<Vec<_>>();
272        children.sort();
273        encode_len(&mut bytes, children.len());
274        for child in children {
275            update_record(&mut bytes, &child);
276        }
277
278        let mut signals = self.signals.iter().collect::<Vec<_>>();
279        signals.sort_by(|left, right| left.name.cmp(&right.name));
280        encode_len(&mut bytes, signals.len());
281        for signal in signals {
282            encode_text(&mut bytes, &signal.name);
283            encode_json(&mut bytes, None, &signal.input_schema);
284        }
285
286        let mut additional = self.additional_workflows.iter().collect::<Vec<_>>();
287        additional.sort_by(|left, right| left.workflow_type.cmp(&right.workflow_type));
288        encode_len(&mut bytes, additional.len());
289        for workflow in additional {
290            encode_text(&mut bytes, &workflow.workflow_type);
291            encode_json(&mut bytes, None, &workflow.input_schema);
292            encode_json(&mut bytes, None, &workflow.output_schema);
293        }
294
295        let mut unscoped = self.unscoped_activities.iter().collect::<Vec<_>>();
296        unscoped.sort();
297        encode_len(&mut bytes, unscoped.len());
298        for activity in unscoped {
299            encode_text(&mut bytes, activity);
300        }
301
302        // The WORKLOOP block, encoded UNCONDITIONALLY (a presence
303        // discriminant, then the whole surface) under the `.v6` domain. Every
304        // value here is executable authority the engine acts on: the cadence
305        // decides when the loop fires, the tolerances decide when it alarms,
306        // the retention window decides what is destroyed, and the carry
307        // defaults decide what generation 1 starts from. The `.v5` domain
308        // predates the field entirely, so its encoding stops here — and the
309        // verifier refuses to attest a workloop under `.v5` for the same
310        // reason this block encodes under `.v6`.
311        if domain == ContractDomain::V6 {
312            match &self.workloop {
313                None => bytes.push(0),
314                Some(workloop) => {
315                    bytes.push(1);
316                    update_record(&mut bytes, &workloop.canonical_bytes());
317                }
318            }
319        }
320        bytes
321    }
322}
323
324/// Which identity domain a canonical encoding targets.
325///
326/// `LegacyV5` exists ONLY for the verifier's migration accommodation
327/// ([`crate::hash`]); it is never a minting target.
328#[derive(Clone, Copy, PartialEq, Eq)]
329enum ContractDomain {
330    /// The superseded released domain (v0.19–v0.24): no per-action agent
331    /// byte, no workloop block.
332    LegacyV5,
333    /// The current domain.
334    V6,
335}
336
337impl WorkloopContract {
338    fn canonical_bytes(&self) -> Vec<u8> {
339        let mut bytes = Vec::new();
340        match self.cadence_seconds {
341            None => bytes.push(0),
342            Some(seconds) => {
343                bytes.push(1);
344                bytes.extend_from_slice(&seconds.to_be_bytes());
345            }
346        }
347        // Arming signals, carries, invariants, detached targets and reports
348        // are all encoded in DECLARATION order rather than sorted: unlike a
349        // worker's action set, these are ordered declarations in the document
350        // and reordering them is a source change the author made.
351        encode_len(&mut bytes, self.arms.len());
352        for arm in &self.arms {
353            encode_text(&mut bytes, arm);
354        }
355        encode_len(&mut bytes, self.carries.len());
356        for carry in &self.carries {
357            encode_text(&mut bytes, &carry.name);
358            encode_json(&mut bytes, None, &carry.schema);
359            encode_json(&mut bytes, None, &carry.default);
360        }
361        encode_len(&mut bytes, self.invariants.len());
362        for invariant in &self.invariants {
363            encode_text(&mut bytes, &invariant.name);
364            encode_text(&mut bytes, &invariant.record_type);
365            encode_json(&mut bytes, None, &invariant.schema);
366            encode_len(&mut bytes, invariant.tolerances.len());
367            for tolerance in &invariant.tolerances {
368                match tolerance {
369                    ToleranceContract::Windows { count } => {
370                        bytes.push(1);
371                        bytes.extend_from_slice(&count.to_be_bytes());
372                    }
373                    ToleranceContract::UnconfirmedFor { seconds } => {
374                        bytes.push(2);
375                        bytes.extend_from_slice(&seconds.to_be_bytes());
376                    }
377                }
378            }
379            encode_optional_text(&mut bytes, invariant.confirms.as_deref());
380        }
381        bytes.extend_from_slice(&self.retention_seconds.to_be_bytes());
382        encode_len(&mut bytes, self.detached.len());
383        for detached in &self.detached {
384            encode_text(&mut bytes, &detached.name);
385            encode_json(&mut bytes, None, &detached.input_schema);
386        }
387        encode_len(&mut bytes, self.reports.len());
388        for report in &self.reports {
389            encode_text(&mut bytes, &report.name);
390            encode_json(&mut bytes, None, &report.schema);
391        }
392        bytes.push(u8::from(self.has_retire_body));
393        bytes
394    }
395}
396
397/// The actions a worker queue must serve.
398#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399pub struct WorkerContract {
400    /// Declared task queue.
401    pub task_queue: String,
402    /// Typed activities on the queue.
403    pub actions: Vec<ActionContract>,
404}
405
406impl WorkerContract {
407    fn canonical_bytes_in(&self, domain: ContractDomain) -> Vec<u8> {
408        let mut bytes = Vec::new();
409        encode_text(&mut bytes, &self.task_queue);
410        let mut actions = self
411            .actions
412            .iter()
413            .map(|action| action.canonical_bytes_in(domain))
414            .collect::<Vec<_>>();
415        actions.sort();
416        encode_len(&mut bytes, actions.len());
417        for action in actions {
418            update_record(&mut bytes, &action);
419        }
420        bytes
421    }
422}
423
424/// Typed activity surface advertised by a concrete worker build.
425#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
426pub struct ActivityDescriptor {
427    /// Activity type.
428    pub name: String,
429    /// Schema accepted by the worker.
430    #[serde(serialize_with = "crate::canonical::serialize_value")]
431    pub input_schema: Value,
432    /// Schema produced by the worker.
433    #[serde(serialize_with = "crate::canonical::serialize_value")]
434    pub output_schema: Value,
435}
436
437/// One typed activity declaration.
438#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
439pub struct ActionContract {
440    /// Activity type.
441    pub name: String,
442    /// Object schema over the activity parameters.
443    #[serde(serialize_with = "crate::canonical::serialize_value")]
444    pub input_schema: Value,
445    /// Activity result schema.
446    #[serde(serialize_with = "crate::canonical::serialize_value")]
447    pub output_schema: Value,
448    /// Declared node selector.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub node: Option<String>,
451    /// Declared schedule-to-close timeout.
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub timeout: Option<Duration>,
454    /// Declaration-owned retry envelope.
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub retry: Option<RetryContract>,
457    /// Whether the declaration classes this activity as ADVISORY: a side
458    /// channel whose failure warns on the run and never faults the calling
459    /// step (RUNTIME-OPERATIONS.md R5). Identity-bound — flipping it changes
460    /// what the package promises a caller.
461    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
462    pub advisory: bool,
463    /// Whether the declaration classes this activity as an AGENT seam: its one
464    /// `String` parameter carries a prompt and its `String` result carries the
465    /// reply, so a worker hands the call to an agent harness rather than to a
466    /// typed handler. The checker enforces that shape, so a worker reading this
467    /// flag may rely on it.
468    ///
469    /// Identity-bound for the same reason `advisory` is — an action that
470    /// becomes an agent seam promises a caller something different — and
471    /// skipped when false, so a document with no agent action hashes exactly as
472    /// it did before the marker existed.
473    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474    pub agent: bool,
475    /// The declarative body the action carries, when it declares one.
476    ///
477    /// `None` is a requirement on an out-of-band worker: the action's name
478    /// and schemas are the whole promise, and something must connect to
479    /// serve it. `Some` means the package itself says what the action DOES,
480    /// so the host can execute it with no worker connected.
481    ///
482    /// Identity-bound deliberately: the body is executable authority, and an
483    /// authority that did not participate in the package hash could be
484    /// rewritten in storage without changing what the deployment claims to
485    /// be. A body edit is a new package, always.
486    #[serde(default, skip_serializing_if = "Option::is_none")]
487    pub body: Option<ActionBodyContract>,
488}
489
490/// A declarative action body committed into package identity.
491#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
492#[serde(tag = "kind", rename_all = "snake_case")]
493pub enum ActionBodyContract {
494    /// One command line, executed directly with argv-element parameter
495    /// substitution — never through a shell. The text is the authored form
496    /// with `$` interpolation intact; the executor parses it and substitutes
497    /// each referenced parameter as one whole argument.
498    Run {
499        /// The authored command text.
500        command: String,
501    },
502    /// One DECLARED command (`runs command <name>`), carried in its emitted
503    /// typed form: literal program words, one templated slot per argument,
504    /// declared environment bindings, working directory and timeout.
505    ///
506    /// The difference from [`ActionBodyContract::Run`] is not spelling. A
507    /// `Run` body is a command LINE that the executor still has to split; a
508    /// `Command` body was split by the AWL emitter at compile time and travels
509    /// as an argument list, so nothing downstream ever holds a string that
510    /// could be re-split.
511    Command {
512        /// What the action's result is taken from.
513        capture: CommandBodyCapture,
514        /// The emitted command.
515        command: Box<crate::declared_command::DeclaredCommandContract>,
516    },
517}
518
519/// What a `runs command` body's result is, and therefore what the action's
520/// declared return type has to be.
521///
522/// Two forms, not three: a declared command's caller is asking for the
523/// command's OUTPUT, and the outcome record that a bare `run "…"` body yields
524/// is the shape the string form has for historical reasons. An action that
525/// wants an exit code takes it from a `run` body.
526#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum CommandBodyCapture {
529    /// Trimmed stdout becomes a JSON string. A non-zero exit is a retryable
530    /// failure carrying stderr.
531    Text,
532    /// Trimmed stdout is parsed as JSON and becomes the result. A non-zero
533    /// exit is a retryable failure carrying stderr; stdout that is not valid
534    /// JSON is a terminal one.
535    Json,
536}
537
538impl CommandBodyCapture {
539    /// The byte this capture contributes to a canonical identity record.
540    const fn identity_byte(self) -> u8 {
541        match self {
542            Self::Text => 0,
543            Self::Json => 1,
544        }
545    }
546}
547
548impl ActionContract {
549    /// Whether serving this action is a WORKER's job.
550    ///
551    /// `true` when the action carries no declared body: the declaration is a
552    /// requirement on an out-of-band worker, and nothing runs until one
553    /// connects and advertises the action. `false` when the action carries a
554    /// declared body: the package itself says what the action does, the host
555    /// executes it, and no worker is needed or admitted for it.
556    ///
557    /// This is the one place the body-exemption rule lives. Every surface
558    /// that splits a queue's actions into worker-owed and server-run —
559    /// admission diffs, availability, scaffolding, codegen — must call this
560    /// rather than restate `body.is_none()`.
561    #[must_use]
562    pub fn worker_owed(&self) -> bool {
563        self.body.is_none()
564    }
565
566    fn canonical_bytes_in(&self, domain: ContractDomain) -> Vec<u8> {
567        let mut bytes = Vec::new();
568        encode_text(&mut bytes, &self.name);
569        encode_json(&mut bytes, None, &self.input_schema);
570        encode_json(&mut bytes, None, &self.output_schema);
571        encode_optional_text(&mut bytes, self.node.as_deref());
572        encode_optional_duration(&mut bytes, self.timeout);
573        match &self.retry {
574            None => bytes.push(0),
575            Some(RetryContract::Every { count, every }) => {
576                bytes.push(1);
577                bytes.extend_from_slice(&count.to_be_bytes());
578                encode_duration(&mut bytes, *every);
579            }
580            Some(RetryContract::Backoff { count, min, max }) => {
581                bytes.push(2);
582                bytes.extend_from_slice(&count.to_be_bytes());
583                encode_duration(&mut bytes, *min);
584                encode_duration(&mut bytes, *max);
585            }
586        }
587        // ADVISORY is encoded ONLY when true: a single marker byte appended
588        // after the retry block, and nothing at all when false. It is
589        // injective because absence and the marker cannot be confused at the
590        // end of a positional record — the block that FOLLOWS it always
591        // begins with a body discriminant, and no body discriminant is ever
592        // `ADVISORY_MARKER`. Stated as the rule rather than as a list of the
593        // discriminants that exist today, because the list grows.
594        if self.advisory {
595            bytes.push(ADVISORY_MARKER);
596        }
597        // The BODY block always encodes — a discriminant byte, then the
598        // command text for `Run`. Adding it consumed the record's optional
599        // tail (the advisory marker was the one tail-append the previous
600        // domain could injectively absorb), which is why this encoding lives
601        // under the bumped `.v5` identity domain rather than as a second
602        // conditional suffix: two optional tails are not injective, and a
603        // contract identity that two different declarations can share is a
604        // spoofable deployment.
605        //
606        // LAW for the next field: encode it UNCONDITIONALLY after this
607        // block and bump the identity domain again. Never append another
608        // optional tail.
609        match &self.body {
610            None => bytes.push(0),
611            Some(ActionBodyContract::Run { command }) => {
612                bytes.push(1);
613                encode_text(&mut bytes, command);
614            }
615            // A DECLARED command body encodes its whole emitted form, not a
616            // name: the argv slots, the environment, the working directory and
617            // the timeout are each executable authority, and a package whose
618            // identity named only `say_hello` could have its argument list
619            // rewritten in storage without changing what the deployment claims
620            // to be. The discriminant continues the same positional block, so
621            // no optional tail is added and the domain does not move again.
622            Some(ActionBodyContract::Command { capture, command }) => {
623                bytes.push(2);
624                bytes.push(capture.identity_byte());
625                crate::declared_command::encode_identity(&mut bytes, command);
626            }
627        }
628        // 🔴 THE AGENT MARKER (aion#158). Its own doc comment above says it is
629        // identity-bound "for the same reason `advisory` is" — and it was
630        // referenced ZERO times in this encoder, so the claim was false and two
631        // declarations that differ only in whether an action is an agent seam
632        // hashed identically. An action that becomes an agent seam promises a
633        // caller something different: its `String` parameter is a prompt and
634        // its `String` result is a reply, and a worker may rely on that shape.
635        //
636        // Encoded UNCONDITIONALLY, exactly as the law above prescribes, under
637        // the bumped `.v6` domain — never as a second optional tail, which
638        // would not be injective alongside the advisory marker. Under the
639        // `.v5` migration accommodation the byte is absent because that is
640        // what every released cut computed: a re-attested `.v5` archive's
641        // agent flags travel AS STORED, un-vouched, at exactly the trust
642        // level those releases gave them — redeploying mints the `.v6`
643        // identity that binds them.
644        if domain == ContractDomain::V6 {
645            bytes.push(u8::from(self.agent));
646        }
647        bytes
648    }
649}
650
651/// The marker byte appended to an advisory action's canonical record.
652///
653/// Distinct from every retry-kind discriminant (`0`/`1`/`2`) it can follow,
654/// so a reader of the trailing bytes is never ambiguous.
655const ADVISORY_MARKER: u8 = 0xA0;
656
657/// A declaration-owned retry envelope.
658#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
659#[serde(tag = "kind", rename_all = "snake_case")]
660pub enum RetryContract {
661    /// Constant delay between attempts.
662    Every {
663        /// Number of further attempts after the first.
664        count: u64,
665        /// Delay between attempts.
666        every: Duration,
667    },
668    /// Bounded backoff between attempts.
669    Backoff {
670        /// Number of further attempts after the first.
671        count: u64,
672        /// Minimum delay.
673        min: Duration,
674        /// Maximum delay.
675        max: Duration,
676    },
677}
678
679/// One declared child workflow callable.
680#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
681pub struct ChildContract {
682    /// Child workflow type.
683    pub name: String,
684    /// Object schema over child parameters.
685    #[serde(serialize_with = "crate::canonical::serialize_value")]
686    pub input_schema: Value,
687    /// Child result schema.
688    #[serde(serialize_with = "crate::canonical::serialize_value")]
689    pub output_schema: Value,
690}
691
692impl ChildContract {
693    fn canonical_bytes(&self) -> Vec<u8> {
694        let mut bytes = Vec::new();
695        encode_text(&mut bytes, &self.name);
696        encode_json(&mut bytes, None, &self.input_schema);
697        encode_json(&mut bytes, None, &self.output_schema);
698        bytes
699    }
700}
701
702/// One declared signal payload.
703#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
704pub struct SignalContract {
705    /// Signal name.
706    pub name: String,
707    /// Signal payload schema.
708    #[serde(serialize_with = "crate::canonical::serialize_value")]
709    pub input_schema: Value,
710}
711
712/// Typed shape of an additional entry in the same package.
713#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
714pub struct AdditionalWorkflowContract {
715    /// Routing workflow type.
716    pub workflow_type: String,
717    /// Entry input schema.
718    #[serde(serialize_with = "crate::canonical::serialize_value")]
719    pub input_schema: Value,
720    /// Entry output schema.
721    #[serde(serialize_with = "crate::canonical::serialize_value")]
722    pub output_schema: Value,
723}
724
725fn encode_json(bytes: &mut Vec<u8>, parent_key: Option<&str>, value: &Value) {
726    match value {
727        Value::Null => bytes.push(0),
728        Value::Bool(value) => bytes.extend_from_slice(&[1, u8::from(*value)]),
729        Value::Number(value) => {
730            bytes.push(2);
731            encode_text(bytes, &value.to_string());
732        }
733        Value::String(value) => {
734            bytes.push(3);
735            encode_text(bytes, value);
736        }
737        Value::Array(values) => {
738            bytes.push(4);
739            let mut values = values.iter().collect::<Vec<_>>();
740            if matches!(parent_key, Some("required" | "enum")) {
741                values.sort_by_key(ToString::to_string);
742            }
743            encode_len(bytes, values.len());
744            for value in values {
745                encode_json(bytes, None, value);
746            }
747        }
748        Value::Object(values) => {
749            bytes.push(5);
750            let mut entries = values.iter().collect::<Vec<_>>();
751            entries.sort_by_key(|(left, _)| *left);
752            encode_len(bytes, entries.len());
753            for (key, value) in entries {
754                encode_text(bytes, key);
755                encode_json(bytes, Some(key), value);
756            }
757        }
758    }
759}
760
761fn encode_len(bytes: &mut Vec<u8>, len: usize) {
762    bytes.extend_from_slice(&(len as u64).to_be_bytes());
763}
764
765fn encode_text(bytes: &mut Vec<u8>, value: &str) {
766    encode_len(bytes, value.len());
767    bytes.extend_from_slice(value.as_bytes());
768}
769
770fn update_record(bytes: &mut Vec<u8>, record: &[u8]) {
771    encode_len(bytes, record.len());
772    bytes.extend_from_slice(record);
773}
774
775fn encode_optional_text(bytes: &mut Vec<u8>, value: Option<&str>) {
776    match value {
777        Some(value) => {
778            bytes.push(1);
779            encode_text(bytes, value);
780        }
781        None => bytes.push(0),
782    }
783}
784
785fn encode_optional_duration(bytes: &mut Vec<u8>, value: Option<Duration>) {
786    match value {
787        Some(value) => {
788            bytes.push(1);
789            encode_duration(bytes, value);
790        }
791        None => bytes.push(0),
792    }
793}
794
795fn encode_duration(bytes: &mut Vec<u8>, value: Duration) {
796    bytes.extend_from_slice(&value.as_secs().to_be_bytes());
797    bytes.extend_from_slice(&value.subsec_nanos().to_be_bytes());
798}
799
800#[cfg(test)]
801#[path = "contract_tests.rs"]
802mod tests;