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 `.v4` hash.
227    ///
228    /// Declaration vectors and JSON object keys are sorted before encoding.
229    /// JSON whitespace and source map insertion order therefore cannot affect
230    /// package identity.
231    #[must_use]
232    pub fn canonical_bytes(&self) -> Vec<u8> {
233        let mut bytes = Vec::new();
234        encode_json(&mut bytes, None, &self.input_schema);
235        encode_json(&mut bytes, None, &self.output_schema);
236
237        let mut workers = self
238            .workers
239            .iter()
240            .map(WorkerContract::canonical_bytes)
241            .collect::<Vec<_>>();
242        workers.sort();
243        encode_len(&mut bytes, workers.len());
244        for worker in workers {
245            update_record(&mut bytes, &worker);
246        }
247
248        let mut children = self
249            .children
250            .iter()
251            .map(ChildContract::canonical_bytes)
252            .collect::<Vec<_>>();
253        children.sort();
254        encode_len(&mut bytes, children.len());
255        for child in children {
256            update_record(&mut bytes, &child);
257        }
258
259        let mut signals = self.signals.iter().collect::<Vec<_>>();
260        signals.sort_by(|left, right| left.name.cmp(&right.name));
261        encode_len(&mut bytes, signals.len());
262        for signal in signals {
263            encode_text(&mut bytes, &signal.name);
264            encode_json(&mut bytes, None, &signal.input_schema);
265        }
266
267        let mut additional = self.additional_workflows.iter().collect::<Vec<_>>();
268        additional.sort_by(|left, right| left.workflow_type.cmp(&right.workflow_type));
269        encode_len(&mut bytes, additional.len());
270        for workflow in additional {
271            encode_text(&mut bytes, &workflow.workflow_type);
272            encode_json(&mut bytes, None, &workflow.input_schema);
273            encode_json(&mut bytes, None, &workflow.output_schema);
274        }
275
276        let mut unscoped = self.unscoped_activities.iter().collect::<Vec<_>>();
277        unscoped.sort();
278        encode_len(&mut bytes, unscoped.len());
279        for activity in unscoped {
280            encode_text(&mut bytes, activity);
281        }
282
283        // The WORKLOOP block, encoded UNCONDITIONALLY (a presence
284        // discriminant, then the whole surface) under the `.v6` domain. Every
285        // value here is executable authority the engine acts on: the cadence
286        // decides when the loop fires, the tolerances decide when it alarms,
287        // the retention window decides what is destroyed, and the carry
288        // defaults decide what generation 1 starts from.
289        match &self.workloop {
290            None => bytes.push(0),
291            Some(workloop) => {
292                bytes.push(1);
293                update_record(&mut bytes, &workloop.canonical_bytes());
294            }
295        }
296        bytes
297    }
298}
299
300impl WorkloopContract {
301    fn canonical_bytes(&self) -> Vec<u8> {
302        let mut bytes = Vec::new();
303        match self.cadence_seconds {
304            None => bytes.push(0),
305            Some(seconds) => {
306                bytes.push(1);
307                bytes.extend_from_slice(&seconds.to_be_bytes());
308            }
309        }
310        // Arming signals, carries, invariants, detached targets and reports
311        // are all encoded in DECLARATION order rather than sorted: unlike a
312        // worker's action set, these are ordered declarations in the document
313        // and reordering them is a source change the author made.
314        encode_len(&mut bytes, self.arms.len());
315        for arm in &self.arms {
316            encode_text(&mut bytes, arm);
317        }
318        encode_len(&mut bytes, self.carries.len());
319        for carry in &self.carries {
320            encode_text(&mut bytes, &carry.name);
321            encode_json(&mut bytes, None, &carry.schema);
322            encode_json(&mut bytes, None, &carry.default);
323        }
324        encode_len(&mut bytes, self.invariants.len());
325        for invariant in &self.invariants {
326            encode_text(&mut bytes, &invariant.name);
327            encode_text(&mut bytes, &invariant.record_type);
328            encode_json(&mut bytes, None, &invariant.schema);
329            encode_len(&mut bytes, invariant.tolerances.len());
330            for tolerance in &invariant.tolerances {
331                match tolerance {
332                    ToleranceContract::Windows { count } => {
333                        bytes.push(1);
334                        bytes.extend_from_slice(&count.to_be_bytes());
335                    }
336                    ToleranceContract::UnconfirmedFor { seconds } => {
337                        bytes.push(2);
338                        bytes.extend_from_slice(&seconds.to_be_bytes());
339                    }
340                }
341            }
342            encode_optional_text(&mut bytes, invariant.confirms.as_deref());
343        }
344        bytes.extend_from_slice(&self.retention_seconds.to_be_bytes());
345        encode_len(&mut bytes, self.detached.len());
346        for detached in &self.detached {
347            encode_text(&mut bytes, &detached.name);
348            encode_json(&mut bytes, None, &detached.input_schema);
349        }
350        encode_len(&mut bytes, self.reports.len());
351        for report in &self.reports {
352            encode_text(&mut bytes, &report.name);
353            encode_json(&mut bytes, None, &report.schema);
354        }
355        bytes.push(u8::from(self.has_retire_body));
356        bytes
357    }
358}
359
360/// The actions a worker queue must serve.
361#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
362pub struct WorkerContract {
363    /// Declared task queue.
364    pub task_queue: String,
365    /// Typed activities on the queue.
366    pub actions: Vec<ActionContract>,
367}
368
369impl WorkerContract {
370    fn canonical_bytes(&self) -> Vec<u8> {
371        let mut bytes = Vec::new();
372        encode_text(&mut bytes, &self.task_queue);
373        let mut actions = self
374            .actions
375            .iter()
376            .map(ActionContract::canonical_bytes)
377            .collect::<Vec<_>>();
378        actions.sort();
379        encode_len(&mut bytes, actions.len());
380        for action in actions {
381            update_record(&mut bytes, &action);
382        }
383        bytes
384    }
385}
386
387/// Typed activity surface advertised by a concrete worker build.
388#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
389pub struct ActivityDescriptor {
390    /// Activity type.
391    pub name: String,
392    /// Schema accepted by the worker.
393    #[serde(serialize_with = "crate::canonical::serialize_value")]
394    pub input_schema: Value,
395    /// Schema produced by the worker.
396    #[serde(serialize_with = "crate::canonical::serialize_value")]
397    pub output_schema: Value,
398}
399
400/// One typed activity declaration.
401#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
402pub struct ActionContract {
403    /// Activity type.
404    pub name: String,
405    /// Object schema over the activity parameters.
406    #[serde(serialize_with = "crate::canonical::serialize_value")]
407    pub input_schema: Value,
408    /// Activity result schema.
409    #[serde(serialize_with = "crate::canonical::serialize_value")]
410    pub output_schema: Value,
411    /// Declared node selector.
412    #[serde(default, skip_serializing_if = "Option::is_none")]
413    pub node: Option<String>,
414    /// Declared schedule-to-close timeout.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub timeout: Option<Duration>,
417    /// Declaration-owned retry envelope.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub retry: Option<RetryContract>,
420    /// Whether the declaration classes this activity as ADVISORY: a side
421    /// channel whose failure warns on the run and never faults the calling
422    /// step (RUNTIME-OPERATIONS.md R5). Identity-bound — flipping it changes
423    /// what the package promises a caller.
424    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
425    pub advisory: bool,
426    /// Whether the declaration classes this activity as an AGENT seam: its one
427    /// `String` parameter carries a prompt and its `String` result carries the
428    /// reply, so a worker hands the call to an agent harness rather than to a
429    /// typed handler. The checker enforces that shape, so a worker reading this
430    /// flag may rely on it.
431    ///
432    /// Identity-bound for the same reason `advisory` is — an action that
433    /// becomes an agent seam promises a caller something different — and
434    /// skipped when false, so a document with no agent action hashes exactly as
435    /// it did before the marker existed.
436    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
437    pub agent: bool,
438    /// The declarative body the action carries, when it declares one.
439    ///
440    /// `None` is a requirement on an out-of-band worker: the action's name
441    /// and schemas are the whole promise, and something must connect to
442    /// serve it. `Some` means the package itself says what the action DOES,
443    /// so the host can execute it with no worker connected.
444    ///
445    /// Identity-bound deliberately: the body is executable authority, and an
446    /// authority that did not participate in the package hash could be
447    /// rewritten in storage without changing what the deployment claims to
448    /// be. A body edit is a new package, always.
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub body: Option<ActionBodyContract>,
451}
452
453/// A declarative action body committed into package identity.
454#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
455#[serde(tag = "kind", rename_all = "snake_case")]
456pub enum ActionBodyContract {
457    /// One command line, executed directly with argv-element parameter
458    /// substitution — never through a shell. The text is the authored form
459    /// with `$` interpolation intact; the executor parses it and substitutes
460    /// each referenced parameter as one whole argument.
461    Run {
462        /// The authored command text.
463        command: String,
464    },
465}
466
467impl ActionContract {
468    /// Whether serving this action is a WORKER's job.
469    ///
470    /// `true` when the action carries no declared body: the declaration is a
471    /// requirement on an out-of-band worker, and nothing runs until one
472    /// connects and advertises the action. `false` when the action carries a
473    /// declared body: the package itself says what the action does, the host
474    /// executes it, and no worker is needed or admitted for it.
475    ///
476    /// This is the one place the body-exemption rule lives. Every surface
477    /// that splits a queue's actions into worker-owed and server-run —
478    /// admission diffs, availability, scaffolding, codegen — must call this
479    /// rather than restate `body.is_none()`.
480    #[must_use]
481    pub fn worker_owed(&self) -> bool {
482        self.body.is_none()
483    }
484
485    fn canonical_bytes(&self) -> Vec<u8> {
486        let mut bytes = Vec::new();
487        encode_text(&mut bytes, &self.name);
488        encode_json(&mut bytes, None, &self.input_schema);
489        encode_json(&mut bytes, None, &self.output_schema);
490        encode_optional_text(&mut bytes, self.node.as_deref());
491        encode_optional_duration(&mut bytes, self.timeout);
492        match &self.retry {
493            None => bytes.push(0),
494            Some(RetryContract::Every { count, every }) => {
495                bytes.push(1);
496                bytes.extend_from_slice(&count.to_be_bytes());
497                encode_duration(&mut bytes, *every);
498            }
499            Some(RetryContract::Backoff { count, min, max }) => {
500                bytes.push(2);
501                bytes.extend_from_slice(&count.to_be_bytes());
502                encode_duration(&mut bytes, *min);
503                encode_duration(&mut bytes, *max);
504            }
505        }
506        // ADVISORY is encoded ONLY when true: a single marker byte appended
507        // after the retry block, and nothing at all when false. It is
508        // injective because absence and the marker cannot be confused at the
509        // end of a positional record — the block that FOLLOWS it always
510        // begins with a body discriminant (`0`/`1`), never `ADVISORY_MARKER`.
511        if self.advisory {
512            bytes.push(ADVISORY_MARKER);
513        }
514        // The BODY block always encodes — a discriminant byte, then the
515        // command text for `Run`. Adding it consumed the record's optional
516        // tail (the advisory marker was the one tail-append the previous
517        // domain could injectively absorb), which is why this encoding lives
518        // under the bumped `.v5` identity domain rather than as a second
519        // conditional suffix: two optional tails are not injective, and a
520        // contract identity that two different declarations can share is a
521        // spoofable deployment.
522        //
523        // LAW for the next field: encode it UNCONDITIONALLY after this
524        // block and bump the identity domain again. Never append another
525        // optional tail.
526        match &self.body {
527            None => bytes.push(0),
528            Some(ActionBodyContract::Run { command }) => {
529                bytes.push(1);
530                encode_text(&mut bytes, command);
531            }
532        }
533        // 🔴 THE AGENT MARKER (aion#158). Its own doc comment above says it is
534        // identity-bound "for the same reason `advisory` is" — and it was
535        // referenced ZERO times in this encoder, so the claim was false and two
536        // declarations that differ only in whether an action is an agent seam
537        // hashed identically. An action that becomes an agent seam promises a
538        // caller something different: its `String` parameter is a prompt and
539        // its `String` result is a reply, and a worker may rely on that shape.
540        //
541        // Encoded UNCONDITIONALLY, exactly as the law above prescribes, under
542        // the bumped `.v6` domain — never as a second optional tail, which
543        // would not be injective alongside the advisory marker.
544        bytes.push(u8::from(self.agent));
545        bytes
546    }
547}
548
549/// The marker byte appended to an advisory action's canonical record.
550///
551/// Distinct from every retry-kind discriminant (`0`/`1`/`2`) it can follow,
552/// so a reader of the trailing bytes is never ambiguous.
553const ADVISORY_MARKER: u8 = 0xA0;
554
555/// A declaration-owned retry envelope.
556#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(tag = "kind", rename_all = "snake_case")]
558pub enum RetryContract {
559    /// Constant delay between attempts.
560    Every {
561        /// Number of further attempts after the first.
562        count: u64,
563        /// Delay between attempts.
564        every: Duration,
565    },
566    /// Bounded backoff between attempts.
567    Backoff {
568        /// Number of further attempts after the first.
569        count: u64,
570        /// Minimum delay.
571        min: Duration,
572        /// Maximum delay.
573        max: Duration,
574    },
575}
576
577/// One declared child workflow callable.
578#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
579pub struct ChildContract {
580    /// Child workflow type.
581    pub name: String,
582    /// Object schema over child parameters.
583    #[serde(serialize_with = "crate::canonical::serialize_value")]
584    pub input_schema: Value,
585    /// Child result schema.
586    #[serde(serialize_with = "crate::canonical::serialize_value")]
587    pub output_schema: Value,
588}
589
590impl ChildContract {
591    fn canonical_bytes(&self) -> Vec<u8> {
592        let mut bytes = Vec::new();
593        encode_text(&mut bytes, &self.name);
594        encode_json(&mut bytes, None, &self.input_schema);
595        encode_json(&mut bytes, None, &self.output_schema);
596        bytes
597    }
598}
599
600/// One declared signal payload.
601#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
602pub struct SignalContract {
603    /// Signal name.
604    pub name: String,
605    /// Signal payload schema.
606    #[serde(serialize_with = "crate::canonical::serialize_value")]
607    pub input_schema: Value,
608}
609
610/// Typed shape of an additional entry in the same package.
611#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
612pub struct AdditionalWorkflowContract {
613    /// Routing workflow type.
614    pub workflow_type: String,
615    /// Entry input schema.
616    #[serde(serialize_with = "crate::canonical::serialize_value")]
617    pub input_schema: Value,
618    /// Entry output schema.
619    #[serde(serialize_with = "crate::canonical::serialize_value")]
620    pub output_schema: Value,
621}
622
623fn encode_json(bytes: &mut Vec<u8>, parent_key: Option<&str>, value: &Value) {
624    match value {
625        Value::Null => bytes.push(0),
626        Value::Bool(value) => bytes.extend_from_slice(&[1, u8::from(*value)]),
627        Value::Number(value) => {
628            bytes.push(2);
629            encode_text(bytes, &value.to_string());
630        }
631        Value::String(value) => {
632            bytes.push(3);
633            encode_text(bytes, value);
634        }
635        Value::Array(values) => {
636            bytes.push(4);
637            let mut values = values.iter().collect::<Vec<_>>();
638            if matches!(parent_key, Some("required" | "enum")) {
639                values.sort_by_key(ToString::to_string);
640            }
641            encode_len(bytes, values.len());
642            for value in values {
643                encode_json(bytes, None, value);
644            }
645        }
646        Value::Object(values) => {
647            bytes.push(5);
648            let mut entries = values.iter().collect::<Vec<_>>();
649            entries.sort_by_key(|(left, _)| *left);
650            encode_len(bytes, entries.len());
651            for (key, value) in entries {
652                encode_text(bytes, key);
653                encode_json(bytes, Some(key), value);
654            }
655        }
656    }
657}
658
659fn encode_len(bytes: &mut Vec<u8>, len: usize) {
660    bytes.extend_from_slice(&(len as u64).to_be_bytes());
661}
662
663fn encode_text(bytes: &mut Vec<u8>, value: &str) {
664    encode_len(bytes, value.len());
665    bytes.extend_from_slice(value.as_bytes());
666}
667
668fn update_record(bytes: &mut Vec<u8>, record: &[u8]) {
669    encode_len(bytes, record.len());
670    bytes.extend_from_slice(record);
671}
672
673fn encode_optional_text(bytes: &mut Vec<u8>, value: Option<&str>) {
674    match value {
675        Some(value) => {
676            bytes.push(1);
677            encode_text(bytes, value);
678        }
679        None => bytes.push(0),
680    }
681}
682
683fn encode_optional_duration(bytes: &mut Vec<u8>, value: Option<Duration>) {
684    match value {
685        Some(value) => {
686            bytes.push(1);
687            encode_duration(bytes, value);
688        }
689        None => bytes.push(0),
690    }
691}
692
693fn encode_duration(bytes: &mut Vec<u8>, value: Duration) {
694    bytes.extend_from_slice(&value.as_secs().to_be_bytes());
695    bytes.extend_from_slice(&value.subsec_nanos().to_be_bytes());
696}
697
698#[cfg(test)]
699#[path = "contract_tests.rs"]
700mod tests;