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}