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;