Skip to main content

aion_package/contract/
identity.rs

1//! The canonical identity encoding of the contract surface.
2//!
3//! Everything here answers one question: what bytes does a
4//! [`PackageContract`] contribute to the package hash, under which identity
5//! domain, and how does the verifier reproduce what a prior release minted.
6//! The shapes being encoded live in the sibling `surface` module.
7
8use std::time::Duration;
9
10use serde_json::Value;
11
12use super::surface::{
13    ActionBodyContract, ActionContract, ChildContract, CommandBodyCapture, PackageContract,
14    RetryContract, ToleranceContract, WorkerContract, WorkloopContract,
15};
16
17/// Refusal returned when a stored package predates contract-bound identity.
18#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
19pub enum ContractIdentityError {
20    /// The package is integrity-valid but its identity commits to no contract.
21    #[error(
22        "package identity `{stored_version}` predates `.v4` worker-contract commitment; re-deploy this package under `.v4`"
23    )]
24    RedeployRequired {
25        /// Stored pre-`.v4` package identity.
26        stored_version: String,
27    },
28}
29
30impl PackageContract {
31    /// Returns the deterministic binary encoding committed by the current
32    /// identity domain.
33    ///
34    /// Declaration vectors and JSON object keys are sorted before encoding.
35    /// JSON whitespace and source map insertion order therefore cannot affect
36    /// package identity.
37    #[must_use]
38    pub fn canonical_bytes(&self) -> Vec<u8> {
39        self.canonical_bytes_in(
40            ContractDomain::V6,
41            &crate::declared_command::PriorCommandIdentities::new(),
42        )
43    }
44
45    /// The superseded `.v5` encoding, exactly as every released cut from
46    /// v0.19 through v0.24 computed it: no per-action agent byte, no
47    /// workloop block.
48    ///
49    /// 🔴 VERIFICATION-ONLY MIGRATION SURFACE. This exists so the verifier
50    /// in [`crate::hash`] can re-attest archives those releases minted —
51    /// a store restarted under the current build must not strand its own
52    /// recorded deployments. Nothing ever MINTS a `.v5` identity again;
53    /// calling this anywhere except a verifier is a defect.
54    #[must_use]
55    pub fn legacy_v5_canonical_bytes(&self) -> Vec<u8> {
56        self.canonical_bytes_in(
57            ContractDomain::LegacyV5,
58            &crate::declared_command::PriorCommandIdentities::new(),
59        )
60    }
61
62    /// The current-domain encoding with each prior-form declared command's
63    /// identity record substituted from `prior` — the bytes the minting
64    /// release actually hashed.
65    ///
66    /// 🔴 VERIFICATION-ONLY MIGRATION SURFACE, one level deeper than
67    /// [`Self::legacy_v5_canonical_bytes`]: the v0.27.0 command reshape moved
68    /// no identity domain, so a prior-form archive's stored hash was computed
69    /// under the SAME domain constants over the PRIOR command encoding. The
70    /// verifier reads that encoding back from the raw archive bytes (see
71    /// [`crate::declared_command::prior_command_identities`]) and substitutes
72    /// it here, wholesale, at the one site a command encodes. With `prior`
73    /// empty this is exactly [`Self::canonical_bytes`]. Nothing ever MINTS
74    /// prior bytes; calling this anywhere except a verifier is a defect.
75    pub(crate) fn canonical_bytes_with_prior_commands(
76        &self,
77        prior: &crate::declared_command::PriorCommandIdentities,
78    ) -> Vec<u8> {
79        self.canonical_bytes_in(ContractDomain::V6, prior)
80    }
81
82    /// The `.v5`-domain encoding with prior-form command substitution — see
83    /// [`Self::canonical_bytes_with_prior_commands`]. Declared commands
84    /// postdate every `.v5` mint, so a genuine `.v5` archive always reaches
85    /// this with `prior` empty; the substitution seam is threaded uniformly
86    /// so the two domains cannot disagree about what a command's identity is.
87    pub(crate) fn legacy_v5_canonical_bytes_with_prior_commands(
88        &self,
89        prior: &crate::declared_command::PriorCommandIdentities,
90    ) -> Vec<u8> {
91        self.canonical_bytes_in(ContractDomain::LegacyV5, prior)
92    }
93
94    fn canonical_bytes_in(
95        &self,
96        domain: ContractDomain,
97        prior: &crate::declared_command::PriorCommandIdentities,
98    ) -> Vec<u8> {
99        let mut bytes = Vec::new();
100        encode_json(&mut bytes, None, &self.input_schema);
101        encode_json(&mut bytes, None, &self.output_schema);
102
103        let mut workers = self
104            .workers
105            .iter()
106            .map(|worker| worker.canonical_bytes_in(domain, prior.get(&worker.task_queue)))
107            .collect::<Vec<_>>();
108        workers.sort();
109        encode_len(&mut bytes, workers.len());
110        for worker in workers {
111            update_record(&mut bytes, &worker);
112        }
113
114        let mut children = self
115            .children
116            .iter()
117            .map(ChildContract::canonical_bytes)
118            .collect::<Vec<_>>();
119        children.sort();
120        encode_len(&mut bytes, children.len());
121        for child in children {
122            update_record(&mut bytes, &child);
123        }
124
125        let mut signals = self.signals.iter().collect::<Vec<_>>();
126        signals.sort_by(|left, right| left.name.cmp(&right.name));
127        encode_len(&mut bytes, signals.len());
128        for signal in signals {
129            encode_text(&mut bytes, &signal.name);
130            encode_json(&mut bytes, None, &signal.input_schema);
131        }
132
133        let mut additional = self.additional_workflows.iter().collect::<Vec<_>>();
134        additional.sort_by(|left, right| left.workflow_type.cmp(&right.workflow_type));
135        encode_len(&mut bytes, additional.len());
136        for workflow in additional {
137            encode_text(&mut bytes, &workflow.workflow_type);
138            encode_json(&mut bytes, None, &workflow.input_schema);
139            encode_json(&mut bytes, None, &workflow.output_schema);
140        }
141
142        let mut unscoped = self.unscoped_activities.iter().collect::<Vec<_>>();
143        unscoped.sort();
144        encode_len(&mut bytes, unscoped.len());
145        for activity in unscoped {
146            encode_text(&mut bytes, activity);
147        }
148
149        // The WORKLOOP block, encoded UNCONDITIONALLY (a presence
150        // discriminant, then the whole surface) under the `.v6` domain. Every
151        // value here is executable authority the engine acts on: the cadence
152        // decides when the loop fires, the tolerances decide when it alarms,
153        // the retention window decides what is destroyed, and the carry
154        // defaults decide what generation 1 starts from. The `.v5` domain
155        // predates the field entirely, so its encoding stops here — and the
156        // verifier refuses to attest a workloop under `.v5` for the same
157        // reason this block encodes under `.v6`.
158        if domain == ContractDomain::V6 {
159            match &self.workloop {
160                None => bytes.push(0),
161                Some(workloop) => {
162                    bytes.push(1);
163                    update_record(&mut bytes, &workloop.canonical_bytes());
164                }
165            }
166        }
167        bytes
168    }
169}
170
171/// Which identity domain a canonical encoding targets.
172///
173/// `LegacyV5` exists ONLY for the verifier's migration accommodation
174/// ([`crate::hash`]); it is never a minting target.
175#[derive(Clone, Copy, PartialEq, Eq)]
176enum ContractDomain {
177    /// The superseded released domain (v0.19–v0.24): no per-action agent
178    /// byte, no workloop block.
179    LegacyV5,
180    /// The current domain.
181    V6,
182}
183
184impl WorkloopContract {
185    fn canonical_bytes(&self) -> Vec<u8> {
186        let mut bytes = Vec::new();
187        match self.cadence_seconds {
188            None => bytes.push(0),
189            Some(seconds) => {
190                bytes.push(1);
191                bytes.extend_from_slice(&seconds.to_be_bytes());
192            }
193        }
194        // Arming signals, carries, invariants, detached targets and reports
195        // are all encoded in DECLARATION order rather than sorted: unlike a
196        // worker's action set, these are ordered declarations in the document
197        // and reordering them is a source change the author made.
198        encode_len(&mut bytes, self.arms.len());
199        for arm in &self.arms {
200            encode_text(&mut bytes, arm);
201        }
202        encode_len(&mut bytes, self.carries.len());
203        for carry in &self.carries {
204            encode_text(&mut bytes, &carry.name);
205            encode_json(&mut bytes, None, &carry.schema);
206            encode_json(&mut bytes, None, &carry.default);
207        }
208        encode_len(&mut bytes, self.invariants.len());
209        for invariant in &self.invariants {
210            encode_text(&mut bytes, &invariant.name);
211            encode_text(&mut bytes, &invariant.record_type);
212            encode_json(&mut bytes, None, &invariant.schema);
213            encode_len(&mut bytes, invariant.tolerances.len());
214            for tolerance in &invariant.tolerances {
215                match tolerance {
216                    ToleranceContract::Windows { count } => {
217                        bytes.push(1);
218                        bytes.extend_from_slice(&count.to_be_bytes());
219                    }
220                    ToleranceContract::UnconfirmedFor { seconds } => {
221                        bytes.push(2);
222                        bytes.extend_from_slice(&seconds.to_be_bytes());
223                    }
224                }
225            }
226            encode_optional_text(&mut bytes, invariant.confirms.as_deref());
227        }
228        bytes.extend_from_slice(&self.retention_seconds.to_be_bytes());
229        encode_len(&mut bytes, self.detached.len());
230        for detached in &self.detached {
231            encode_text(&mut bytes, &detached.name);
232            encode_json(&mut bytes, None, &detached.input_schema);
233        }
234        encode_len(&mut bytes, self.reports.len());
235        for report in &self.reports {
236            encode_text(&mut bytes, &report.name);
237            encode_json(&mut bytes, None, &report.schema);
238        }
239        bytes.push(u8::from(self.has_retire_body));
240        bytes
241    }
242}
243
244impl WorkerContract {
245    fn canonical_bytes_in(
246        &self,
247        domain: ContractDomain,
248        prior: Option<&std::collections::BTreeMap<String, Vec<u8>>>,
249    ) -> Vec<u8> {
250        let mut bytes = Vec::new();
251        encode_text(&mut bytes, &self.task_queue);
252        let mut actions = self
253            .actions
254            .iter()
255            .map(|action| {
256                action.canonical_bytes_in(
257                    domain,
258                    prior
259                        .and_then(|actions| actions.get(&action.name))
260                        .map(Vec::as_slice),
261                )
262            })
263            .collect::<Vec<_>>();
264        actions.sort();
265        encode_len(&mut bytes, actions.len());
266        for action in actions {
267            update_record(&mut bytes, &action);
268        }
269        bytes
270    }
271}
272
273impl CommandBodyCapture {
274    /// The byte this capture contributes to a canonical identity record.
275    const fn identity_byte(self) -> u8 {
276        match self {
277            Self::Text => 0,
278            Self::Json => 1,
279        }
280    }
281}
282
283impl ActionContract {
284    fn canonical_bytes_in(&self, domain: ContractDomain, prior_command: Option<&[u8]>) -> Vec<u8> {
285        let mut bytes = Vec::new();
286        encode_text(&mut bytes, &self.name);
287        encode_json(&mut bytes, None, &self.input_schema);
288        encode_json(&mut bytes, None, &self.output_schema);
289        encode_optional_text(&mut bytes, self.node.as_deref());
290        encode_optional_duration(&mut bytes, self.timeout);
291        match &self.retry {
292            None => bytes.push(0),
293            Some(RetryContract::Every { count, every }) => {
294                bytes.push(1);
295                bytes.extend_from_slice(&count.to_be_bytes());
296                encode_duration(&mut bytes, *every);
297            }
298            Some(RetryContract::Backoff { count, min, max }) => {
299                bytes.push(2);
300                bytes.extend_from_slice(&count.to_be_bytes());
301                encode_duration(&mut bytes, *min);
302                encode_duration(&mut bytes, *max);
303            }
304        }
305        // ADVISORY is encoded ONLY when true: a single marker byte appended
306        // after the retry block, and nothing at all when false. It is
307        // injective because absence and the marker cannot be confused at the
308        // end of a positional record — the block that FOLLOWS it always
309        // begins with a body discriminant, and no body discriminant is ever
310        // `ADVISORY_MARKER`. Stated as the rule rather than as a list of the
311        // discriminants that exist today, because the list grows.
312        if self.advisory {
313            bytes.push(ADVISORY_MARKER);
314        }
315        // The BODY block always encodes — a discriminant byte, then the
316        // command text for `Run`. Adding it consumed the record's optional
317        // tail (the advisory marker was the one tail-append the previous
318        // domain could injectively absorb), which is why this encoding lives
319        // under the bumped `.v5` identity domain rather than as a second
320        // conditional suffix: two optional tails are not injective, and a
321        // contract identity that two different declarations can share is a
322        // spoofable deployment.
323        //
324        // LAW for the next field: encode it UNCONDITIONALLY after this
325        // block and bump the identity domain again. Never append another
326        // optional tail.
327        match &self.body {
328            None => bytes.push(0),
329            Some(ActionBodyContract::Run { command }) => {
330                bytes.push(1);
331                encode_text(&mut bytes, command);
332            }
333            // A DECLARED command body encodes its whole emitted form, not a
334            // name: the body's lines, their argv slots, the environment and
335            // the working directory are each executable authority, and a
336            // package whose identity named only `say_hello` could have its
337            // argument list rewritten in storage without changing what the
338            // deployment claims to be. (A per-command timeout was authority
339            // here too, until the surface that declared one was deleted; the
340            // encoding no longer carries a bound because no command declares
341            // one.) The discriminant continues the same positional block, so
342            // no optional tail is added and the domain does not move again.
343            Some(ActionBodyContract::Command { capture, command }) => {
344                bytes.push(2);
345                bytes.push(capture.identity_byte());
346                // The verification-only prior-form substitution seam: when
347                // the archive minted this command under the prior encoding,
348                // its identity IS that prior record — read back from the raw
349                // archive bytes, never re-derived from the translated shape —
350                // so the recompute reproduces exactly what the minting
351                // release hashed. Every freshly minted contract takes the
352                // current encoder.
353                match prior_command {
354                    Some(prior) => bytes.extend_from_slice(prior),
355                    None => crate::declared_command::encode_identity(&mut bytes, command),
356                }
357            }
358        }
359        // 🔴 THE AGENT MARKER (aion#158). Its own doc comment above says it is
360        // identity-bound "for the same reason `advisory` is" — and it was
361        // referenced ZERO times in this encoder, so the claim was false and two
362        // declarations that differ only in whether an action is an agent seam
363        // hashed identically. An action that becomes an agent seam promises a
364        // caller something different: its `String` parameter is a prompt and
365        // its `String` result is a reply, and a worker may rely on that shape.
366        //
367        // Encoded UNCONDITIONALLY, exactly as the law above prescribes, under
368        // the bumped `.v6` domain — never as a second optional tail, which
369        // would not be injective alongside the advisory marker. Under the
370        // `.v5` migration accommodation the byte is absent because that is
371        // what every released cut computed: a re-attested `.v5` archive's
372        // agent flags travel AS STORED, un-vouched, at exactly the trust
373        // level those releases gave them — redeploying mints the `.v6`
374        // identity that binds them.
375        if domain == ContractDomain::V6 {
376            bytes.push(u8::from(self.agent));
377        }
378        bytes
379    }
380}
381
382/// The marker byte appended to an advisory action's canonical record.
383///
384/// Distinct from every retry-kind discriminant (`0`/`1`/`2`) it can follow,
385/// so a reader of the trailing bytes is never ambiguous.
386const ADVISORY_MARKER: u8 = 0xA0;
387
388impl ChildContract {
389    fn canonical_bytes(&self) -> Vec<u8> {
390        let mut bytes = Vec::new();
391        encode_text(&mut bytes, &self.name);
392        encode_json(&mut bytes, None, &self.input_schema);
393        encode_json(&mut bytes, None, &self.output_schema);
394        bytes
395    }
396}
397
398fn encode_json(bytes: &mut Vec<u8>, parent_key: Option<&str>, value: &Value) {
399    match value {
400        Value::Null => bytes.push(0),
401        Value::Bool(value) => bytes.extend_from_slice(&[1, u8::from(*value)]),
402        Value::Number(value) => {
403            bytes.push(2);
404            encode_text(bytes, &value.to_string());
405        }
406        Value::String(value) => {
407            bytes.push(3);
408            encode_text(bytes, value);
409        }
410        Value::Array(values) => {
411            bytes.push(4);
412            let mut values = values.iter().collect::<Vec<_>>();
413            if matches!(parent_key, Some("required" | "enum")) {
414                values.sort_by_key(ToString::to_string);
415            }
416            encode_len(bytes, values.len());
417            for value in values {
418                encode_json(bytes, None, value);
419            }
420        }
421        Value::Object(values) => {
422            bytes.push(5);
423            let mut entries = values.iter().collect::<Vec<_>>();
424            entries.sort_by_key(|(left, _)| *left);
425            encode_len(bytes, entries.len());
426            for (key, value) in entries {
427                encode_text(bytes, key);
428                encode_json(bytes, Some(key), value);
429            }
430        }
431    }
432}
433
434fn encode_len(bytes: &mut Vec<u8>, len: usize) {
435    bytes.extend_from_slice(&(len as u64).to_be_bytes());
436}
437
438fn encode_text(bytes: &mut Vec<u8>, value: &str) {
439    encode_len(bytes, value.len());
440    bytes.extend_from_slice(value.as_bytes());
441}
442
443fn update_record(bytes: &mut Vec<u8>, record: &[u8]) {
444    encode_len(bytes, record.len());
445    bytes.extend_from_slice(record);
446}
447
448fn encode_optional_text(bytes: &mut Vec<u8>, value: Option<&str>) {
449    match value {
450        Some(value) => {
451            bytes.push(1);
452            encode_text(bytes, value);
453        }
454        None => bytes.push(0),
455    }
456}
457
458fn encode_optional_duration(bytes: &mut Vec<u8>, value: Option<Duration>) {
459    match value {
460        Some(value) => {
461            bytes.push(1);
462            encode_duration(bytes, value);
463        }
464        None => bytes.push(0),
465    }
466}
467
468fn encode_duration(bytes: &mut Vec<u8>, value: Duration) {
469    bytes.extend_from_slice(&value.as_secs().to_be_bytes());
470    bytes.extend_from_slice(&value.subsec_nanos().to_be_bytes());
471}