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;