Skip to main content

aion_package/
compatibility.rs

1//! Worker advertisement comparison against durable package contracts.
2
3mod schema;
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use self::schema::{normalize_schema, schema_is_subset};
12use crate::{ActivityDescriptor, WorkerContract};
13
14/// One field-level incompatibility between a deployed contract and a worker.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ContractDiff {
17    /// `.v4` package identity that requires the field.
18    pub package_version: String,
19    /// Activity whose contract differs.
20    pub action: String,
21    /// Stable dotted path within the activity contract.
22    pub field: String,
23    /// Value required by the deployed workflow, absent when the field is forbidden.
24    pub expected: Option<Value>,
25    /// Value advertised by the worker, absent when it omitted the field or action.
26    pub advertised: Option<Value>,
27}
28
29impl fmt::Display for ContractDiff {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(
32            formatter,
33            "package `{}` action `{}` field `{}` expected {} but worker advertised {}",
34            self.package_version,
35            self.action,
36            self.field,
37            rendered_value(self.expected.as_ref()),
38            rendered_value(self.advertised.as_ref()),
39        )
40    }
41}
42
43/// Compares one queue contract with a worker advertisement.
44///
45/// A worker may advertise additional actions because one process can serve
46/// several concurrently deployed package versions. Input compatibility is
47/// contravariant: every value the workflow may send must be accepted by the
48/// worker. Output compatibility is covariant: every value the worker may emit
49/// must be decodable by the workflow. This admits input widening and optional
50/// output-field additions while refusing narrowing and undeclared output drift.
51///
52/// `worker_node` is the registering connection's advertised locality — `None`
53/// when it carries none. Only the actions whose dispatch can REACH that
54/// connection are demanded of it; see [`dispatch_can_reach`].
55#[must_use]
56pub fn contract_diffs(
57    package_version: &str,
58    contract: &WorkerContract,
59    worker_node: Option<&str>,
60    advertised: &[ActivityDescriptor],
61) -> Vec<ContractDiff> {
62    let advertised = advertised
63        .iter()
64        .map(|activity| (activity.name.as_str(), activity))
65        .collect::<BTreeMap<_, _>>();
66    // An action whose body the DECLARATION carries is executed by the server
67    // itself, so no registering worker serves it and none can advertise it.
68    // Demanding one would make a queue that mixes a declared body with a
69    // bodyless action unservable — the worker doing its whole job would be
70    // refused for omitting an action that was never its job.
71    let mut expected = contract
72        .actions
73        .iter()
74        .filter(|action| action.body.is_none())
75        .filter(|action| dispatch_can_reach(action.node.as_deref(), worker_node))
76        .collect::<Vec<_>>();
77    expected.sort_by(|left, right| left.name.cmp(&right.name));
78    let mut diffs = Vec::new();
79    for action in expected {
80        let Some(actual) = advertised.get(action.name.as_str()) else {
81            diffs.push(ContractDiff {
82                package_version: package_version.to_owned(),
83                action: action.name.clone(),
84                field: "action".to_owned(),
85                expected: Some(Value::String(missing_action_requirement(
86                    action.node.as_deref(),
87                ))),
88                advertised: None,
89            });
90            continue;
91        };
92
93        let expected_input = normalize_schema(&action.input_schema);
94        let advertised_input = normalize_schema(&actual.input_schema);
95        if !schema_is_subset(&expected_input, &advertised_input) {
96            diff_schema(
97                package_version,
98                &action.name,
99                "input_schema",
100                &expected_input,
101                &advertised_input,
102                &mut diffs,
103            );
104        }
105
106        let expected_output = normalize_schema(&action.output_schema);
107        let advertised_output = normalize_schema(&actual.output_schema);
108        if !schema_is_subset(&advertised_output, &expected_output) {
109            diff_schema(
110                package_version,
111                &action.name,
112                "output_schema",
113                &expected_output,
114                &advertised_output,
115                &mut diffs,
116            );
117        }
118    }
119    diffs
120}
121
122/// Whether a dispatch for an action pinned to `action_node` can REACH a worker
123/// connection advertising `worker_node`.
124///
125/// This is the admission-side mirror of the server registry's dispatch filter
126/// (`worker_matches_node`), and the two must never drift: an unpinned action
127/// (`None`) reaches every worker in the pool, while an action pinned to a node
128/// reaches ONLY a connection advertising that exact node — so a connection
129/// carrying no locality is reachable by unpinned actions alone.
130///
131/// WHY ADMISSION NEEDS IT. The server routes by (namespace × `task_queue` × node)
132/// and a worker process that serves several nodes therefore opens one
133/// connection PER NODE, each advertising only that node's actions. Checking
134/// every bodyless action of the queue against every connection demanded each
135/// connection serve the OTHER connections' actions, so a correctly partitioned
136/// worker was refused on every dial — the whole queue unservable — for omitting
137/// actions no dispatch could ever have handed it. Demanding exactly the
138/// reachable set keeps the gate's guarantee intact: any action a dispatch could
139/// land on this connection is still required of it, so an unservable dispatch is
140/// still refused at registration rather than discovered at run time.
141///
142/// This is deliberately NOT a claim that the pool is fully staffed. A node whose
143/// connection never arrives leaves its pinned actions unserved; that is an
144/// unstaffed-pool condition the census reports, not an admission failure of the
145/// connections that did arrive.
146#[must_use]
147fn dispatch_can_reach(action_node: Option<&str>, worker_node: Option<&str>) -> bool {
148    match action_node {
149        None => true,
150        Some(pin) => worker_node == Some(pin),
151    }
152}
153
154/// The requirement text for an action the connection was reachable for and did
155/// not advertise. It names WHY this connection owed the action, because the
156/// operator's next move differs entirely between the two cases: an unpinned
157/// action is owed by every worker in the pool, while a pinned one is owed by the
158/// connection on that node alone.
159fn missing_action_requirement(action_node: Option<&str>) -> String {
160    match action_node {
161        None => "advertised: the action is unpinned, so every worker in the pool must serve it"
162            .to_owned(),
163        Some(node) => format!("advertised: the action is pinned to node `{node}`"),
164    }
165}
166
167fn diff_schema(
168    package_version: &str,
169    action: &str,
170    field: &str,
171    expected: &Value,
172    advertised: &Value,
173    diffs: &mut Vec<ContractDiff>,
174) {
175    if schemas_equal(expected, advertised, None) {
176        return;
177    }
178    match (expected, advertised) {
179        (Value::Object(expected), Value::Object(advertised)) => {
180            let keys = expected
181                .keys()
182                .chain(advertised.keys())
183                .collect::<BTreeSet<_>>();
184            for key in keys {
185                let nested = format!("{field}.{key}");
186                match (expected.get(key), advertised.get(key)) {
187                    (Some(left), Some(right)) => {
188                        diff_schema(package_version, action, &nested, left, right, diffs);
189                    }
190                    (left, right) => diffs.push(ContractDiff {
191                        package_version: package_version.to_owned(),
192                        action: action.to_owned(),
193                        field: nested,
194                        expected: left.cloned(),
195                        advertised: right.cloned(),
196                    }),
197                }
198            }
199        }
200        _ => diffs.push(ContractDiff {
201            package_version: package_version.to_owned(),
202            action: action.to_owned(),
203            field: field.to_owned(),
204            expected: Some(expected.clone()),
205            advertised: Some(advertised.clone()),
206        }),
207    }
208}
209
210fn schemas_equal(left: &Value, right: &Value, parent: Option<&str>) -> bool {
211    match (left, right) {
212        (Value::Object(left), Value::Object(right)) => {
213            left.len() == right.len()
214                && left.iter().all(|(key, value)| {
215                    right
216                        .get(key)
217                        .is_some_and(|other| schemas_equal(value, other, Some(key)))
218                })
219        }
220        (Value::Array(left), Value::Array(right))
221            if matches!(parent, Some("required" | "enum" | "type")) =>
222        {
223            let mut left = left.iter().map(stable_json).collect::<Vec<_>>();
224            let mut right = right.iter().map(stable_json).collect::<Vec<_>>();
225            left.sort();
226            right.sort();
227            left == right
228        }
229        (Value::Array(left), Value::Array(right)) => {
230            left.len() == right.len()
231                && left
232                    .iter()
233                    .zip(right)
234                    .all(|(left, right)| schemas_equal(left, right, None))
235        }
236        _ => left == right,
237    }
238}
239
240fn stable_json(value: &Value) -> String {
241    match value {
242        Value::Object(values) => {
243            let fields = values
244                .iter()
245                .map(|(key, value)| format!("{key}:{}", stable_json(value)))
246                .collect::<Vec<_>>();
247            format!("{{{}}}", fields.join(","))
248        }
249        Value::Array(values) => {
250            let values = values.iter().map(stable_json).collect::<Vec<_>>();
251            format!("[{}]", values.join(","))
252        }
253        _ => value.to_string(),
254    }
255}
256
257fn rendered_value(value: Option<&Value>) -> String {
258    value.map_or_else(|| "<missing>".to_owned(), Value::to_string)
259}
260
261#[cfg(test)]
262mod tests {
263    use serde_json::json;
264
265    use super::contract_diffs;
266    use crate::{ActionContract, ActivityDescriptor, WorkerContract};
267    use serde_json::Value;
268
269    fn contract(input: serde_json::Value, output: serde_json::Value) -> WorkerContract {
270        WorkerContract {
271            task_queue: "payments".to_owned(),
272            actions: vec![ActionContract {
273                name: "charge".to_owned(),
274                input_schema: input,
275                output_schema: output,
276                node: None,
277                timeout: None,
278                retry: None,
279                advisory: false,
280                agent: false,
281                body: None,
282            }],
283        }
284    }
285
286    fn advertised(input: serde_json::Value, output: serde_json::Value) -> Vec<ActivityDescriptor> {
287        vec![ActivityDescriptor {
288            name: "charge".to_owned(),
289            input_schema: input,
290            output_schema: output,
291        }]
292    }
293
294    #[test]
295    fn mismatch_reports_the_exact_schema_field() {
296        let contract = contract(
297            json!({"type":"object","properties":{"amount":{"type":"integer"}}}),
298            json!({"type":"boolean"}),
299        );
300        let advertised = advertised(
301            json!({"properties":{"amount":{"type":"string"}},"type":"object"}),
302            json!({"type":"boolean"}),
303        );
304
305        let diffs = contract_diffs("abc", &contract, None, &advertised);
306        assert_eq!(diffs.len(), 1);
307        assert_eq!(diffs[0].field, "input_schema.properties.amount.type");
308        assert_eq!(diffs[0].expected, Some(json!("integer")));
309        assert_eq!(diffs[0].advertised, Some(json!("string")));
310    }
311
312    #[test]
313    fn input_widening_and_optional_output_addition_are_compatible() {
314        let contract = contract(
315            json!({
316                "$schema":"https://json-schema.org/draft/2020-12/schema",
317                "type":"object",
318                "properties":{"amount":{"type":"integer"}},
319                "required":["amount"]
320            }),
321            json!({
322                "type":"object",
323                "properties":{"approved":{"type":"boolean"}},
324                "required":["approved"]
325            }),
326        );
327        let advertised = advertised(
328            json!({
329                "title":"ChargeInput",
330                "type":"object",
331                "properties":{"amount":{"type":"number"}},
332                "required":["amount"]
333            }),
334            json!({
335                "title":"ChargeOutput",
336                "type":"object",
337                "properties":{
338                    "approved":{"type":"boolean"},
339                    "receipt":{"type":"string"}
340                },
341                "required":["approved"]
342            }),
343        );
344
345        assert!(contract_diffs("abc", &contract, None, &advertised).is_empty());
346    }
347
348    #[test]
349    fn input_narrowing_and_output_widening_are_refused() {
350        let contract = contract(json!({"type":"number"}), json!({"type":"integer"}));
351        let advertised = advertised(json!({"type":"integer"}), json!({"type":"number"}));
352
353        let diffs = contract_diffs("abc", &contract, None, &advertised);
354        assert_eq!(diffs.len(), 2);
355        assert_eq!(diffs[0].field, "input_schema.type");
356        assert_eq!(diffs[1].field, "output_schema.type");
357    }
358
359    #[test]
360    fn local_defs_and_inline_schemas_compare_semantically() {
361        let contract = contract(
362            json!({
363                "type":"object",
364                "properties":{"card":{"$ref":"#/$defs/Card"}},
365                "required":["card"],
366                "$defs":{"Card":{"type":"object","properties":{"last4":{"type":"string"}},"required":["last4"]}}
367            }),
368            json!({"type":"boolean"}),
369        );
370        let advertised = advertised(
371            json!({
372                "type":"object",
373                "properties":{"card":{"type":"object","properties":{"last4":{"type":"string"}},"required":["last4"]}},
374                "required":["card"]
375            }),
376            json!({"type":"boolean"}),
377        );
378
379        assert!(contract_diffs("abc", &contract, None, &advertised).is_empty());
380    }
381
382    /// `$comment` is non-validating by specification — draft 2020-12 reserves it
383    /// for schema authors and forbids any effect on validation. But the subset
384    /// check requires an unrecognised key on the CONTRACT side to appear
385    /// identically on the worker's side, so leaving `$comment` in place made a
386    /// commented schema satisfiable only by a worker that reproduced the comment
387    /// byte for byte. A remark to a human reader must never decide admission.
388    #[test]
389    fn a_comment_in_a_declared_schema_does_not_have_to_be_reproduced() {
390        let contract = contract(
391            json!({
392                "type":"object",
393                "$comment":"amount is in the smallest currency unit",
394                "properties":{"amount":{"type":"integer","$comment":"cents"}},
395                "required":["amount"]
396            }),
397            json!({"type":"boolean","$comment":"true when the charge settled"}),
398        );
399        let advertised = advertised(
400            json!({
401                "type":"object",
402                "properties":{"amount":{"type":"integer"}},
403                "required":["amount"]
404            }),
405            json!({"type":"boolean"}),
406        );
407
408        assert!(
409            contract_diffs("abc", &contract, None, &advertised).is_empty(),
410            "a non-validating comment must not decide contract admission"
411        );
412    }
413
414    /// An action whose body the DECLARATION carries is executed by the server,
415    /// not by a registering worker, so no worker can advertise it and none
416    /// should be required to. Requiring it makes a queue that mixes a declared
417    /// body with a bodyless action unservable: the worker serving the bodyless
418    /// action is refused for omitting an action that was never its job.
419    #[test]
420    fn a_declared_body_is_not_required_of_a_worker() {
421        let contract = WorkerContract {
422            task_queue: "python_box".to_owned(),
423            actions: vec![
424                ActionContract {
425                    name: "inspect".to_owned(),
426                    input_schema: json!({"type":"object"}),
427                    output_schema: json!({"type":"boolean"}),
428                    node: None,
429                    timeout: None,
430                    retry: None,
431                    advisory: false,
432                    agent: false,
433                    body: None,
434                },
435                ActionContract {
436                    name: "snapshot".to_owned(),
437                    input_schema: json!({"type":"object"}),
438                    output_schema: json!({"type":"boolean"}),
439                    node: None,
440                    timeout: None,
441                    retry: None,
442                    advisory: false,
443                    agent: false,
444                    body: Some(crate::ActionBodyContract::Run {
445                        command: "git rev-parse HEAD".to_owned(),
446                    }),
447                },
448            ],
449        };
450        // The worker serves only the bodyless action — the whole of its job.
451        let advertised = vec![ActivityDescriptor {
452            name: "inspect".to_owned(),
453            input_schema: json!({"type":"object"}),
454            output_schema: json!({"type":"boolean"}),
455        }];
456
457        assert!(
458            contract_diffs("abc", &contract, None, &advertised).is_empty(),
459            "a server-executed declared body must not be demanded of a worker"
460        );
461    }
462
463    /// A three-node queue, exactly the shape a real multi-role worker serves.
464    fn node_partitioned_contract() -> WorkerContract {
465        let action = |name: &str, node: Option<&str>| ActionContract {
466            name: name.to_owned(),
467            input_schema: json!({"type":"object"}),
468            output_schema: json!({"type":"boolean"}),
469            node: node.map(str::to_owned),
470            timeout: None,
471            retry: None,
472            advisory: false,
473            agent: false,
474            body: None,
475        };
476        WorkerContract {
477            task_queue: "staged_rounds".to_owned(),
478            actions: vec![
479                action("gate_item", Some("shell")),
480                action("dev_item", Some("developer")),
481                action("review_item", Some("reviewer")),
482                action("audit", None),
483            ],
484        }
485    }
486
487    fn descriptor(name: &str) -> ActivityDescriptor {
488        ActivityDescriptor {
489            name: name.to_owned(),
490            input_schema: json!({"type":"object"}),
491            output_schema: json!({"type":"boolean"}),
492        }
493    }
494
495    /// THE DEFECT. The server routes by (namespace × `task_queue` × node), so a
496    /// worker serving several nodes opens one connection PER NODE advertising
497    /// only that node's actions. Checking every bodyless action of the queue
498    /// against every connection demanded each connection serve the OTHER
499    /// connections' actions — so on 2026-07-30 a correctly partitioned worker
500    /// was refused on every dial, `WORKER_CONTRACT_MISMATCH` naming five actions
501    /// that were never that connection's job, and the queue was unservable.
502    #[test]
503    fn a_node_partitioned_connection_is_not_demanded_another_nodes_actions() {
504        let contract = node_partitioned_contract();
505        let shell = vec![descriptor("gate_item"), descriptor("audit")];
506
507        assert!(
508            contract_diffs("abc", &contract, Some("shell"), &shell).is_empty(),
509            "the shell connection serves its own node's actions and the unpinned \
510             one — it must not be refused for omitting the developer and reviewer \
511             nodes' actions"
512        );
513    }
514
515    /// The gate keeps its teeth on the actions that CAN reach the connection: an
516    /// action pinned to this very node and left unadvertised is still refused,
517    /// and the refusal names the node so the operator knows which connection
518    /// owed it.
519    #[test]
520    fn an_action_pinned_to_this_node_is_still_demanded() {
521        let contract = node_partitioned_contract();
522        // On the shell node, serving only the unpinned action.
523        let short = vec![descriptor("audit")];
524
525        let diffs = contract_diffs("abc", &contract, Some("shell"), &short);
526        assert_eq!(diffs.len(), 1, "{diffs:?}");
527        assert_eq!(diffs[0].action, "gate_item");
528        assert_eq!(
529            diffs[0].expected,
530            Some(Value::String(
531                "advertised: the action is pinned to node `shell`".to_owned()
532            ))
533        );
534    }
535
536    /// An UNPINNED action's dispatch reaches every worker in the pool (the
537    /// registry's `worker_matches_node` returns true for a `None` filter against
538    /// any worker), so every connection owes it no matter what node it carries.
539    /// Reachability is the whole rule — narrowing it to same-node actions would
540    /// admit a connection a dispatch could land the unpinned action on.
541    #[test]
542    fn an_unpinned_action_is_demanded_of_every_node() {
543        let contract = node_partitioned_contract();
544        let developer_only = vec![descriptor("dev_item")];
545
546        let diffs = contract_diffs("abc", &contract, Some("developer"), &developer_only);
547        assert_eq!(diffs.len(), 1, "{diffs:?}");
548        assert_eq!(diffs[0].action, "audit");
549        assert_eq!(
550            diffs[0].expected,
551            Some(Value::String(
552                "advertised: the action is unpinned, so every worker in the pool \
553                 must serve it"
554                    .to_owned()
555            ))
556        );
557    }
558
559    /// A connection carrying NO locality is reachable only by unpinned
560    /// dispatches, so it owes the unpinned action and nothing else — the same
561    /// asymmetry the registry applies when filtering candidates.
562    #[test]
563    fn a_node_less_connection_owes_only_the_unpinned_actions() {
564        let contract = node_partitioned_contract();
565
566        assert!(
567            contract_diffs("abc", &contract, None, &[descriptor("audit")]).is_empty(),
568            "a node-less connection is unreachable for pinned dispatches"
569        );
570        let diffs = contract_diffs("abc", &contract, None, &[]);
571        assert_eq!(diffs.len(), 1, "{diffs:?}");
572        assert_eq!(
573            diffs[0].action, "audit",
574            "the unpinned action is still owed by a node-less connection"
575        );
576    }
577
578    /// A connection on a node the package never mentions owes only the unpinned
579    /// actions. It is admitted rather than refused because no pinned dispatch can
580    /// reach it — the pool simply has a connection nothing routes to, which the
581    /// census reports and admission does not adjudicate.
582    #[test]
583    fn a_connection_on_an_unknown_node_owes_only_the_unpinned_actions() {
584        let contract = node_partitioned_contract();
585
586        assert!(
587            contract_diffs("abc", &contract, Some("stranger"), &[descriptor("audit")]).is_empty(),
588            "an unknown node is unreachable for every pinned action"
589        );
590    }
591}