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;