Skip to main content

aion_server/worker/
contracts.rs

1//! Shared worker registration admission against durable package contracts.
2//!
3//! Admission holds a registering worker against the deployed `.v4` contracts
4//! its queue can still dispatch under — the REACHABLE ones, chosen by
5//! [`aion::Engine::worker_contracts_for_admission`]. Retained-but-unreachable
6//! versions are named in a refusal but never held against the worker: demanding
7//! them made one stale deploy enough to kill a whole task queue.
8
9use std::collections::BTreeSet;
10
11use aion::AdmissionReason;
12use aion_package::ActivityDescriptor;
13
14mod refusal;
15
16use refusal::render_mismatch;
17
18/// What a registering worker announced about itself, in the TWO independent
19/// forms it announces it in.
20///
21/// These are different sets and they are used for different things, which is
22/// precisely why they must travel together. `activity_types` is the NAME set the
23/// dispatcher selects on; `contracts` is the typed-descriptor set admission
24/// compares against the deployed `.v4` contracts. A worker can advertise a name
25/// with no contract behind it — it is then selectable but not admissible.
26///
27/// Before this pair existed, admission received only `contracts` while the
28/// refusal log printed only `activity_types`, and on 2026-07-30 that produced a
29/// refusal record which listed action `assistant` as advertised in the same line
30/// that reported it `<missing>`. Both facts were true of different sets and the
31/// record said which of neither. Carrying both through the gate makes the
32/// refusal state the gap instead of contradicting itself.
33#[derive(Clone, Copy, Debug)]
34pub struct WorkerAdvertisement<'a> {
35    /// Activity-type NAMES the worker advertised — the dispatcher's selection
36    /// set.
37    pub activity_types: &'a BTreeSet<String>,
38    /// Typed contracts the worker advertised — the set admission compares.
39    pub contracts: &'a [ActivityDescriptor],
40}
41
42impl WorkerAdvertisement<'_> {
43    /// Advertised names carrying no typed contract, sorted.
44    ///
45    /// Non-empty means the worker is selectable for actions it cannot be
46    /// admitted for, which is the exact shape of the 2026-07-30 refusal loop.
47    #[must_use]
48    pub fn names_without_contracts(&self) -> Vec<String> {
49        let described = self
50            .contracts
51            .iter()
52            .map(|contract| contract.name.as_str())
53            .collect::<BTreeSet<_>>();
54        self.activity_types
55            .iter()
56            .filter(|name| !described.contains(name.as_str()))
57            .cloned()
58            .collect()
59    }
60}
61
62/// Typed refusal from worker contract admission.
63#[derive(Debug, thiserror::Error)]
64pub enum ContractAdmissionError {
65    /// The durable package catalog could not be read.
66    #[error("contract catalog lookup failed: {source}")]
67    Catalog {
68        /// Engine catalog failure.
69        #[source]
70        source: aion::EngineError,
71    },
72    /// One or more reachable `.v4` contracts differ from the worker surface.
73    ///
74    /// The whole diagnosis rides in `details`: which package version, why that
75    /// version was still held against the worker, which action, which field,
76    /// what was expected, what was advertised, and what the operator can do
77    /// about it. A refused worker retries silently forever otherwise, and the
78    /// message is the only thing standing between a two-minute fix and an hour
79    /// of guessing.
80    #[error(
81        "WORKER_CONTRACT_MISMATCH: worker build `{identity}` queue `{task_queue}` refused. {details}"
82    )]
83    Mismatch {
84        /// Stable worker-build identity.
85        identity: String,
86        /// Queue being admitted.
87        task_queue: String,
88        /// Full field-level diagnosis and operator remedy.
89        details: String,
90    },
91}
92
93/// Validates a worker against every REACHABLE `.v4` contract for its queue.
94///
95/// `node` is the connection's advertised locality, `None` when it carries none.
96/// Callers holding a raw proto3 string normalize with
97/// [`super::registry::optional_node`] — the SAME normalization the registry then
98/// routes by, never a restatement of it, because admission decides which actions
99/// a connection owes from exactly the locality the dispatcher will filter on.
100///
101/// Two independent narrowings apply, and both exist because a worker that is
102/// doing its whole job must not be refused:
103///
104/// 1. Only REACHABLE package versions are held against the connection. Under
105///    content-hash namespacing a queue retains every deployed version; one
106///    that no longer routes and carries no live run can never produce a
107///    dispatch, so demanding it protects nothing and costs the queue.
108/// 2. Within a reachable version, only the actions whose dispatch can reach
109///    this connection's node are demanded of it, because the server routes by
110///    (namespace × `task_queue` × node) and a worker serving several nodes
111///    therefore opens one connection PER NODE, each advertising only that
112///    node's actions.
113///
114/// # Errors
115///
116/// Returns [`ContractAdmissionError::Catalog`] when the deployed contracts or
117/// the liveness they are judged against cannot be read — an unreadable answer
118/// refuses rather than admits — and [`ContractAdmissionError::Mismatch`] with
119/// the field-level diagnosis before the worker becomes dispatch-visible.
120pub fn validate_worker_contracts(
121    engine: &aion::Engine,
122    task_queue: &str,
123    node: Option<&str>,
124    identity: &str,
125    advertised: WorkerAdvertisement<'_>,
126) -> Result<(), ContractAdmissionError> {
127    let admission = engine
128        .worker_contracts_for_admission(task_queue)
129        .map_err(|source| ContractAdmissionError::Catalog { source })?;
130    let mut disagreements = Vec::new();
131    for required in &admission.required {
132        for diff in aion_package::contract_diffs(
133            &required.contract.package_version.to_string(),
134            &required.contract.contract,
135            node,
136            advertised.contracts,
137        ) {
138            disagreements.push(Disagreement {
139                reason: required.reason,
140                workflow_types: required.contract.workflow_types.clone(),
141                diff,
142            });
143        }
144    }
145    if disagreements.is_empty() {
146        return Ok(());
147    }
148    Err(ContractAdmissionError::Mismatch {
149        identity: identity.to_owned(),
150        task_queue: task_queue.to_owned(),
151        details: render_mismatch(&admission, node, advertised, &disagreements),
152    })
153}
154
155/// One field-level disagreement, carrying why its version was demanded.
156#[derive(Clone, Debug)]
157struct Disagreement {
158    /// Why the version this diff came from still binds the worker.
159    reason: AdmissionReason,
160    /// Workflow types the version implements, sorted.
161    workflow_types: Vec<String>,
162    /// The field-level difference itself.
163    diff: aion_package::ContractDiff,
164}
165
166#[cfg(test)]
167#[path = "contracts_tests.rs"]
168mod contracts_tests;