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, QueueAdmission};
12use aion_package::ActivityDescriptor;
13
14mod refusal;
15
16use super::admission_audit::{AdmissionAudit, RefusalSite};
17use refusal::render_mismatch;
18
19/// What a registering worker announced about itself, in the TWO independent
20/// forms it announces it in.
21///
22/// These are different sets and they are used for different things, which is
23/// precisely why they must travel together. `activity_types` is the NAME set the
24/// dispatcher selects on; `contracts` is the typed-descriptor set admission
25/// compares against the deployed `.v4` contracts. A worker can advertise a name
26/// with no contract behind it — it is then selectable but not admissible.
27///
28/// Before this pair existed, admission received only `contracts` while the
29/// refusal log printed only `activity_types`, and on 2026-07-30 that produced a
30/// refusal record which listed action `assistant` as advertised in the same line
31/// that reported it `<missing>`. Both facts were true of different sets and the
32/// record said which of neither. Carrying both through the gate makes the
33/// refusal state the gap instead of contradicting itself.
34#[derive(Clone, Copy, Debug)]
35pub struct WorkerAdvertisement<'a> {
36    /// Activity-type NAMES the worker advertised — the dispatcher's selection
37    /// set.
38    pub activity_types: &'a BTreeSet<String>,
39    /// Typed contracts the worker advertised — the set admission compares.
40    pub contracts: &'a [ActivityDescriptor],
41}
42
43impl WorkerAdvertisement<'_> {
44    /// Advertised names carrying no typed contract, sorted.
45    ///
46    /// Non-empty means the worker is selectable for actions it cannot be
47    /// admitted for, which is the exact shape of the 2026-07-30 refusal loop.
48    #[must_use]
49    pub fn names_without_contracts(&self) -> Vec<String> {
50        let described = self
51            .contracts
52            .iter()
53            .map(|contract| contract.name.as_str())
54            .collect::<BTreeSet<_>>();
55        self.activity_types
56            .iter()
57            .filter(|name| !described.contains(name.as_str()))
58            .cloned()
59            .collect()
60    }
61}
62
63/// Typed refusal from worker contract admission.
64#[derive(Debug, thiserror::Error)]
65pub enum ContractAdmissionError {
66    /// The durable package catalog could not be read.
67    #[error("contract catalog lookup failed: {source}")]
68    Catalog {
69        /// Engine catalog failure.
70        #[source]
71        source: aion::EngineError,
72    },
73    /// One or more reachable `.v4` contracts differ from the worker surface.
74    ///
75    /// The whole diagnosis rides in `details`: which package version, why that
76    /// version was still held against the worker, which action, which field,
77    /// what was expected, what was advertised, and what the operator can do
78    /// about it. A refused worker retries silently forever otherwise, and the
79    /// message is the only thing standing between a two-minute fix and an hour
80    /// of guessing.
81    #[error(
82        "WORKER_CONTRACT_MISMATCH: worker build `{identity}` queue `{task_queue}` refused. {details}"
83    )]
84    Mismatch {
85        /// Stable worker-build identity.
86        identity: String,
87        /// Queue being admitted.
88        task_queue: String,
89        /// Full field-level diagnosis and operator remedy.
90        details: String,
91    },
92}
93
94/// Validates a worker against every REACHABLE `.v4` contract for its queue.
95///
96/// `node` is the connection's advertised locality, `None` when it carries none.
97/// Callers holding a raw proto3 string normalize with
98/// [`super::registry::optional_node`] — the SAME normalization the registry then
99/// routes by, never a restatement of it, because admission decides which actions
100/// a connection owes from exactly the locality the dispatcher will filter on.
101///
102/// Two independent narrowings apply, and both exist because a worker that is
103/// doing its whole job must not be refused:
104///
105/// 1. Only REACHABLE package versions are held against the connection. Under
106///    content-hash namespacing a queue retains every deployed version; one
107///    that no longer routes and carries no live run can never produce a
108///    dispatch, so demanding it protects nothing and costs the queue.
109/// 2. Within a reachable version, only the actions whose dispatch can reach
110///    this connection's node are demanded of it, because the server routes by
111///    (namespace × `task_queue` × node) and a worker serving several nodes
112///    therefore opens one connection PER NODE, each advertising only that
113///    node's actions.
114///
115/// **Every refusal this gate returns, it also SAYS** — at WARN, on the server,
116/// naming the queue, the node, the worker build and the reason, through
117/// [`name_refusal`]. A caller may add transport detail of its own but must not
118/// restate the refusal: the naming is the gate's, so no transport can be added
119/// that forgets to do it.
120///
121/// # Errors
122///
123/// Returns [`ContractAdmissionError::Catalog`] when the deployed contracts or
124/// the liveness they are judged against cannot be read — an unreadable answer
125/// refuses rather than admits — and [`ContractAdmissionError::Mismatch`] with
126/// the field-level diagnosis before the worker becomes dispatch-visible.
127pub fn validate_worker_contracts(
128    engine: &aion::Engine,
129    audit: &AdmissionAudit,
130    task_queue: &str,
131    node: Option<&str>,
132    identity: &str,
133    advertised: WorkerAdvertisement<'_>,
134) -> Result<(), ContractAdmissionError> {
135    let admission = match engine.worker_contracts_for_admission(task_queue) {
136        Ok(admission) => admission,
137        Err(source) => {
138            // An unreadable catalog refuses too, so it is named too. Leaving one
139            // refusal branch voiceless is how this defect survived a fix: the
140            // gRPC path logged the SKIPPED case and swallowed the verdict.
141            let refusal = ContractAdmissionError::Catalog { source };
142            name_refusal(
143                audit,
144                &RefusalSite::CatalogUnreadable,
145                task_queue,
146                node,
147                identity,
148                &refusal.to_string(),
149            );
150            return Err(refusal);
151        }
152    };
153    let site = refusal_site(&admission, task_queue, node);
154    let mut disagreements = Vec::new();
155    for required in &admission.required {
156        for diff in aion_package::contract_diffs(
157            &required.contract.package_version.to_string(),
158            &required.contract.contract,
159            node,
160            advertised.contracts,
161        ) {
162            disagreements.push(Disagreement {
163                reason: required.reason,
164                workflow_types: required.contract.workflow_types.clone(),
165                diff,
166            });
167        }
168    }
169    if disagreements.is_empty() {
170        // This site is being SERVED. Forget its last refusal so a worker that is
171        // fixed, admitted, and then breaks again is heard immediately instead of
172        // silenced by its own history.
173        audit.clear_admitted(&site);
174        if admission.required.is_empty() {
175            // The queue demands nothing of ANYONE now — every version on it has
176            // been unloaded or has stopped being reachable. Any refusal still
177            // remembered against it was recorded when the catalog still demanded
178            // something, so it is a fossil, and a fossil would silence the first
179            // refusal after the queue is redeployed.
180            audit.clear_queue(task_queue);
181        }
182        return Ok(());
183    }
184    // Named from the ERROR's own rendering, never from `details` alone: the
185    // `WORKER_CONTRACT_MISMATCH:` prefix is what makes a refusal greppable and
186    // it lives in the Display. A log that paraphrases the error it is reporting
187    // is the 2026-07-30 self-contradicting record in a new costume.
188    let refusal = ContractAdmissionError::Mismatch {
189        identity: identity.to_owned(),
190        task_queue: task_queue.to_owned(),
191        details: render_mismatch(&admission, node, advertised, &disagreements),
192    };
193    name_refusal(
194        audit,
195        &site,
196        task_queue,
197        node,
198        identity,
199        &refusal.to_string(),
200    );
201    Err(refusal)
202}
203
204/// Which refusal site this connection belongs to, derived from the CATALOG.
205///
206/// A node names a site of its own only when some action demanded of this
207/// connection is pinned to it. Otherwise the node cannot have changed the
208/// verdict — [`aion_package::contract_diffs`] demands every unpinned action of
209/// every locality — so all such connections are one site.
210///
211/// **This is the bound.** `task_queue` and `node` both arrive on the wire, so
212/// keying on them directly lets a refused worker allocate a map entry per dial
213/// by varying either. The first cut of #147 moved `identity` out of the key and
214/// left `node` in it, which defeated half the trap it had itself named. Here
215/// the queue is bounded because a queue with no reachable contract demands
216/// nothing and is ADMITTED rather than remembered, and the node is bounded
217/// because it must match a pin the catalog carries.
218fn refusal_site(admission: &QueueAdmission, task_queue: &str, node: Option<&str>) -> RefusalSite {
219    let pinned = node.filter(|node| {
220        admission
221            .required
222            .iter()
223            .flat_map(|required| required.contract.contract.actions.iter())
224            .filter(|action| action.worker_owed())
225            .any(|action| action.node.as_deref() == Some(*node))
226    });
227    RefusalSite::Queue {
228        task_queue: task_queue.to_owned(),
229        node: pinned.map(ToOwned::to_owned),
230    }
231}
232
233/// The ONE place a contract refusal is spoken, for every transport.
234///
235/// It lives at the gate rather than in a caller because that is the whole
236/// finding behind #147: this exact defect was found, fixed and pinned on
237/// 2026-07-29 — but in the liminal CALLER — and the gRPC caller re-manifested it
238/// verbatim within a week, refusing every dial in silence while a `Rejected` ack
239/// carried the reason nobody printed. One rule maintained in two places had
240/// already drifted. A second copy of the log would have been the same mistake a
241/// third time, so the naming moved INTO the gate and the liminal caller stopped
242/// restating it.
243///
244/// Repeats are silent, and that is a transition rule rather than a rate limit:
245/// see [`AdmissionAudit`] for why an identical redial carries no information and
246/// why nothing here is a configurable cap.
247///
248/// `site` decides whether to SPEAK and is server-derived ([`refusal_site`]);
249/// `task_queue` and `node` are what the connection actually advertised and are
250/// only ever PRINTED. They diverge when the refusal is node-independent, or
251/// when the catalog could not be read at all, and the line reports the real
252/// ones because they are true facts about the dial being refused — the operator
253/// needs to know which dial produced this, even when the fault is not its own.
254fn name_refusal(
255    audit: &AdmissionAudit,
256    site: &RefusalSite,
257    task_queue: &str,
258    node: Option<&str>,
259    identity: &str,
260    reason: &str,
261) {
262    if !audit.should_name(site, identity, reason) {
263        return;
264    }
265    tracing::warn!(
266        task_queue = %task_queue,
267        node = ?node,
268        identity = %identity,
269        reason = %reason,
270        "REFUSED worker registration"
271    );
272}
273
274/// One field-level disagreement, carrying why its version was demanded.
275#[derive(Clone, Debug)]
276struct Disagreement {
277    /// Why the version this diff came from still binds the worker.
278    reason: AdmissionReason,
279    /// Workflow types the version implements, sorted.
280    workflow_types: Vec<String>,
281    /// The field-level difference itself.
282    diff: aion_package::ContractDiff,
283}
284
285#[cfg(test)]
286#[path = "contracts_tests.rs"]
287mod contracts_tests;