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;