aion-package 0.31.0

Archive validation, content hashing, and namespacing for Aion workflow packages.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
use std::time::Duration;

use serde_json::json;

use crate::{
    ActionContract, BeamModule, BeamSet, ChildContract, Manifest, ManifestVersion, PackageContract,
    PackageError, RetryContract, SignalContract, WorkerContract, content_hash_with_contract,
};

fn action(name: &str, field: &str) -> ActionContract {
    ActionContract {
        name: name.to_owned(),
        input_schema: json!({
            "required": [field],
            "properties": { field: { "type": "string" } },
            "type": "object"
        }),
        output_schema: json!({"type":"boolean"}),
        node: Some("shell".to_owned()),
        timeout: Some(Duration::from_secs(30)),
        retry: Some(RetryContract::Backoff {
            count: 2,
            min: Duration::from_secs(1),
            max: Duration::from_secs(8),
        }),
        advisory: false,
        agent: false,
        body: None,
    }
}

fn contract(permuted: bool) -> PackageContract {
    let mut workers = vec![
        WorkerContract {
            task_queue: "payments".to_owned(),
            actions: vec![action("refund", "refund_id"), action("charge", "amount")],
        },
        WorkerContract {
            task_queue: "mail".to_owned(),
            actions: vec![action("send", "address")],
        },
    ];
    let mut children = vec![
        ChildContract {
            name: "receipt".to_owned(),
            input_schema: json!({"type":"string"}),
            output_schema: json!({"type":"boolean"}),
        },
        ChildContract {
            name: "audit".to_owned(),
            input_schema: json!({"type":"integer"}),
            output_schema: json!({"type":"null"}),
        },
    ];
    let mut signals = vec![
        SignalContract {
            name: "cancel".to_owned(),
            input_schema: json!({"type":"string"}),
        },
        SignalContract {
            name: "approve".to_owned(),
            input_schema: json!({"type":"boolean"}),
        },
    ];
    if permuted {
        workers.reverse();
        workers[1].actions.reverse();
        children.reverse();
        signals.reverse();
    }
    PackageContract {
        input_schema: json!({"required":["order_id","account_id"],"type":"object"}),
        output_schema: json!({"enum":["failed","paid"]}),
        workers,
        children,
        signals,
        additional_workflows: Vec::new(),
        unscoped_activities: Vec::new(),
        workloop: None,
    }
}

fn manifest() -> Manifest {
    Manifest {
        entry_module: "workflow/order".to_owned(),
        entry_function: "run".to_owned(),
        input_schema: json!({"type":"object"}),
        output_schema: json!({"type":"object"}),
        timeout: Some(Duration::from_secs(60)),
        activities: Vec::new(),
        version: ManifestVersion::new("unstamped"),
        format_version: crate::CURRENT_FORMAT_VERSION,
        additional_workflows: Vec::new(),
    }
}

#[test]
fn v4_identity_is_declaration_order_independent() -> Result<(), PackageError> {
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let first = contract(false);
    let second = contract(true);

    assert_eq!(first.canonical_bytes(), second.canonical_bytes());
    assert_eq!(
        content_hash_with_contract(&beams, &manifest(), &first),
        content_hash_with_contract(&beams, &manifest(), &second),
    );
    Ok(())
}

#[test]
fn v4_identity_ignores_json_object_and_set_array_order() -> Result<(), Box<dyn std::error::Error>> {
    let first: serde_json::Value = serde_json::from_str(
        r#"{ "type": "object", "required": ["a", "b"], "properties": {"a":{"type":"string"},"b":{"type":"integer"}} }"#,
    )?;
    let second: serde_json::Value = serde_json::from_str(
        r#"{"properties":{"b":{"type":"integer"},"a":{"type":"string"}},"required":["b","a"],"type":"object"}"#,
    )?;
    let mut left = contract(false);
    let mut right = contract(false);
    left.input_schema = first;
    right.input_schema = second;

    assert_eq!(left.canonical_bytes(), right.canonical_bytes());
    Ok(())
}

/// The `.v4` identity of a contract WITHOUT advisory actions, frozen as a
/// committed golden.
///
/// Measured on the pre-advisory tree (`aion-package` at 55a07922, before
/// `ActionContract::advisory` existed) and asserted here against the
/// post-change encoder — the two agree byte-for-byte.
///
/// `ActionContract::canonical_bytes` appends the advisory marker only when
/// the flag is true, so every package already deployed keeps the identity it
/// was deployed under — adding the class mints no redeploy wave. This pin is
/// what makes that claim checkable rather than asserted.
/// The identity this exact contract hashed to under the superseded `.v4`
/// domain, recorded when `.v5` replaced it. Kept as the negative pin below.
const SUPERSEDED_V4_IDENTITY: &str =
    "eef121798ea576fbf700b50d3fd79c3f7a979b5fcc7c85b8a02c1a71cfef1672";

#[test]
fn the_v5_domain_supersedes_every_v4_identity() -> Result<(), PackageError> {
    // Deliberate, not incidental: the `.v5` domain always encodes the action
    // body block, so no `.v4`-minted identity can verify again and every
    // deployed package re-mints on redeploy. If this hash ever matches, the
    // domain separation has been lost and a `.v4` archive could load with an
    // unverified body.
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let hash = content_hash_with_contract(&beams, &manifest(), &contract(false));
    assert_ne!(
        hash.to_string(),
        SUPERSEDED_V4_IDENTITY,
        "a `.v5` identity must never collide with the superseded `.v4` domain"
    );
    Ok(())
}

#[test]
fn a_declared_body_is_identity_bearing() -> Result<(), PackageError> {
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let plain = contract(false);
    let mut bodied = contract(false);
    bodied.workers[0].actions[0].body = Some(crate::ActionBodyContract::Run {
        command: "echo $amount".to_owned(),
    });

    assert_ne!(
        plain.canonical_bytes(),
        bodied.canonical_bytes(),
        "a declared body is executable authority and must be committed to the record"
    );
    assert_ne!(
        content_hash_with_contract(&beams, &manifest(), &plain),
        content_hash_with_contract(&beams, &manifest(), &bodied),
        "declaring a body changes what the package IS, so it must change identity"
    );
    Ok(())
}

#[test]
fn editing_a_declared_command_changes_identity() -> Result<(), PackageError> {
    // The tamper case the binding exists for: same names, same schemas, one
    // byte of command text different — the package identity must move.
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let mut first = contract(false);
    first.workers[0].actions[0].body = Some(crate::ActionBodyContract::Run {
        command: "echo safe".to_owned(),
    });
    let mut second = contract(false);
    second.workers[0].actions[0].body = Some(crate::ActionBodyContract::Run {
        command: "echo saf3".to_owned(),
    });

    assert_ne!(
        content_hash_with_contract(&beams, &manifest(), &first),
        content_hash_with_contract(&beams, &manifest(), &second),
        "a rewritten command under an unchanged identity would execute unvouched"
    );
    Ok(())
}

#[test]
fn advisory_and_body_cannot_be_confused_in_the_record() -> Result<(), PackageError> {
    // The advisory marker is conditional and the body block follows it
    // unconditionally; these four corners must all be distinct records.
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let mut corners = Vec::new();
    for advisory in [false, true] {
        for body in [
            None,
            Some(crate::ActionBodyContract::Run {
                command: "echo x".to_owned(),
            }),
        ] {
            let mut candidate = contract(false);
            candidate.workers[0].actions[0].advisory = advisory;
            candidate.workers[0].actions[0].body = body;
            corners.push(content_hash_with_contract(&beams, &manifest(), &candidate));
        }
    }
    for (left_index, left) in corners.iter().enumerate() {
        for right in &corners[left_index + 1..] {
            assert_ne!(
                left, right,
                "every advisory/body corner must have its own identity"
            );
        }
    }
    Ok(())
}

#[test]
fn advisory_is_identity_bearing() -> Result<(), PackageError> {
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let plain = contract(false);
    let mut advisory = contract(false);
    advisory.workers[0].actions[0].advisory = true;

    assert_ne!(
        plain.canonical_bytes(),
        advisory.canonical_bytes(),
        "advisory must be committed to the canonical record"
    );
    assert_ne!(
        content_hash_with_contract(&beams, &manifest(), &plain),
        content_hash_with_contract(&beams, &manifest(), &advisory),
        "flipping an action to advisory changes what the package promises, so it must \
         change the package identity"
    );
    Ok(())
}

#[test]
fn advisory_identity_is_declaration_order_independent() -> Result<(), PackageError> {
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let mut first = contract(false);
    let mut second = contract(true);
    // The SAME action is advisory in both, reached through each contract's
    // own permuted position.
    mark_advisory(&mut first, "payments", "refund");
    mark_advisory(&mut second, "payments", "refund");

    assert_eq!(first.canonical_bytes(), second.canonical_bytes());
    assert_eq!(
        content_hash_with_contract(&beams, &manifest(), &first),
        content_hash_with_contract(&beams, &manifest(), &second),
    );
    Ok(())
}

/// Flip one named action of one named queue to advisory, wherever the
/// permutation put it.
fn mark_advisory(contract: &mut PackageContract, queue: &str, action: &str) {
    for worker in &mut contract.workers {
        if worker.task_queue != queue {
            continue;
        }
        for declared in &mut worker.actions {
            if declared.name == action {
                declared.advisory = true;
            }
        }
    }
}

/// A workloop declaration is EXECUTABLE AUTHORITY, and the `.v6` domain exists
/// to bind it.
///
/// The values in it are not description. A tolerance decides WHEN a loop
/// alarms; a retention window decides WHAT is destroyed; a carry default
/// decides what generation 1 starts from; a cadence decides when it fires. A
/// package whose declaration could be rewritten in storage under an unchanged
/// identity would alarm on a threshold nobody deployed and destroy on a window
/// nobody wrote — the same class of defect as a rewritten declared command,
/// which the two tests above exist for.
///
/// Asserted as a SWEEP over one field of each kind rather than on the whole
/// record at once: a single "these two differ" assertion is satisfied by any
/// one field being bound, and would pass while seven of the eight were not.
#[test]
fn every_workloop_declaration_field_is_identity_bearing() -> Result<(), PackageError> {
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let base = workloop_contract();

    let mut mutations: Vec<(&str, crate::WorkloopContract)> = Vec::new();
    let mut cadence = base.clone();
    cadence.cadence_seconds = Some(1_501);
    mutations.push(("cadence", cadence));
    let mut arms = base.clone();
    arms.arms.push("nudge".to_owned());
    mutations.push(("arming signals", arms));
    let mut carry_default = base.clone();
    carry_default.carries[0].default = json!(["already-seen"]);
    mutations.push(("carry default", carry_default));
    let mut tolerance = base.clone();
    tolerance.invariants[0].tolerances = vec![crate::ToleranceContract::Windows { count: 9 }];
    mutations.push(("tolerance", tolerance));
    let mut confirms = base.clone();
    confirms.invariants[0].confirms = Some("dispatch".to_owned());
    mutations.push(("confirming route", confirms));
    let mut record_type = base.clone();
    record_type.invariants[0].record_type = "OtherBoard".to_owned();
    mutations.push(("invariant record type", record_type));
    let mut retention = base.clone();
    retention.retention_seconds = 60;
    mutations.push(("retention window", retention));
    let mut retire = base.clone();
    retire.has_retire_body = false;
    mutations.push(("declared retire body", retire));

    let mut declared = contract(false);
    declared.workloop = Some(base);
    let declared_hash = content_hash_with_contract(&beams, &manifest(), &declared);

    for (what, mutated) in mutations {
        let mut candidate = contract(false);
        candidate.workloop = Some(mutated);
        assert_ne!(
            declared_hash,
            content_hash_with_contract(&beams, &manifest(), &candidate),
            "rewriting the {what} of a deployed workloop must change what the package IS"
        );
    }

    // THE CONTROL, on the same reader: a package that declares no workloop at
    // all hashes differently again, so the assertions above are measuring the
    // declaration rather than an encoder that answers "different" to
    // everything.
    let mut absent = contract(false);
    absent.workloop = None;
    assert_ne!(
        declared_hash,
        content_hash_with_contract(&beams, &manifest(), &absent),
        "a package that declares a workloop is not the same package as one that does not"
    );
    Ok(())
}

/// aion#158: the AGENT marker is identity-bearing, as its own doc comment has
/// always claimed and as the encoder did not do until the `.v6` domain.
///
/// An action that becomes an agent seam promises a caller something different
/// — its `String` parameter is a prompt and its `String` result is a reply,
/// and a worker may rely on that shape — so two declarations differing only in
/// the marker must not be one version.
#[test]
fn the_agent_marker_is_identity_bearing() -> Result<(), PackageError> {
    let beams = BeamSet::new(vec![BeamModule::new("workflow/order", vec![1, 2, 3])])?;
    let plain = contract(false);
    let mut seam = contract(false);
    seam.workers[0].actions[0].agent = true;

    assert_ne!(
        plain.canonical_bytes(),
        seam.canonical_bytes(),
        "the agent marker must reach the canonical record"
    );
    assert_ne!(
        content_hash_with_contract(&beams, &manifest(), &plain),
        content_hash_with_contract(&beams, &manifest(), &seam),
        "declaring an action an agent seam changes what the package promises"
    );
    Ok(())
}

/// The workloop declaration a mutation sweep starts from: one of every kind of
/// field, populated.
fn workloop_contract() -> crate::WorkloopContract {
    crate::WorkloopContract {
        cadence_seconds: Some(1_500),
        arms: vec!["poke".to_owned()],
        carries: vec![crate::CarryContract {
            name: "seen".to_owned(),
            schema: json!({"type":"array","items":{"type":"string"}}),
            default: json!([]),
        }],
        invariants: vec![crate::InvariantContract {
            name: "serving".to_owned(),
            record_type: "Board".to_owned(),
            schema: json!({"type":"object"}),
            tolerances: vec![crate::ToleranceContract::Windows { count: 3 }],
            confirms: Some("start".to_owned()),
        }],
        retention_seconds: 1_209_600,
        detached: vec![crate::DetachedContract {
            name: "process_task".to_owned(),
            input_schema: json!({"type":"object"}),
        }],
        reports: vec![crate::ReportContract {
            name: "board".to_owned(),
            schema: json!({"type":"object"}),
        }],
        has_retire_body: true,
    }
}