Skip to main content

aion_server/worker/
registry.rs

1//! Connected-worker registry keyed by worker-pool address and activity type.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::sync::{Arc, Mutex, MutexGuard};
5
6use aion_core::{ClusterEvent, InterventionCapabilities, WorkerDeathReason, WorkerTransport};
7use aion_proto::{ProtoActivityTask, ProtoRegisterWorker};
8use aion_store::{NamespaceOrigin, NamespacePlacement, NamespaceStore};
9use tokio::sync::{Notify, mpsc};
10
11use crate::cluster_publisher::ClusterEventPublisher;
12use crate::config::AutoCreate;
13use crate::error::ServerError;
14use crate::namespace::{CallerIdentity, NamespaceGuard, NamespaceMinter, NamespaceOperation};
15use crate::observability::Metrics;
16
17/// The literal task queue an empty/absent selector normalizes to.
18///
19/// A worker-pool address has two disjoint dimensions; the second one
20/// (`task_queue`) is a liveness selector, not a correctness boundary. An empty
21/// `task_queue` is normalized to this one named default pool so a producer that
22/// names no queue and a worker that advertises none both land on the same pool.
23///
24/// Re-exported from [`aion_core::DEFAULT_TASK_QUEUE`] so the server cannot drift
25/// from the canonical domain default; the name is kept stable here for existing
26/// call sites.
27pub use aion_core::DEFAULT_TASK_QUEUE;
28
29/// Server-side handle used to push activity tasks to a connected worker stream.
30pub type WorkerTaskSender = mpsc::Sender<WorkerMessage>;
31
32/// Transport through which the server delivers a dispatch to a registered worker.
33///
34/// A worker is selected the SAME way regardless of transport (`select_worker`
35/// over the `(namespace, task_queue, node)` pool key); only the delivery leg
36/// differs. The default gRPC path pushes a [`WorkerMessage`] onto the worker's
37/// stream `mpsc` ([`WorkerDelivery::Grpc`]); a liminal-connected worker is
38/// delivered to by pushing the dispatch out on its existing liminal connection
39/// ([`WorkerDelivery::Liminal`], feature-gated). This enum is the minimal
40/// transport-agnostic seam: the registry holds it on each [`WorkerHandle`], and
41/// the dispatch path reads the variant it needs. The gRPC variant carries exactly
42/// the `mpsc::Sender` it always did, so the gRPC dispatch path is unchanged.
43#[derive(Clone, Debug)]
44pub enum WorkerDelivery {
45    /// gRPC stream delivery: the dispatch path pushes a [`WorkerMessage`] onto
46    /// this `mpsc` sender, exactly as before this enum existed.
47    Grpc(WorkerTaskSender),
48    /// Liminal server-push delivery: the dispatch path pushes the serialized
49    /// dispatch out on the worker's existing liminal connection and awaits the
50    /// correlated reply. Carries the connection identity needed to address that
51    /// push.
52    #[cfg(feature = "liminal-transport")]
53    Liminal(crate::worker::liminal_transport::LiminalWorkerDelivery),
54}
55
56/// Message queued from server-side dispatch/shutdown into a worker stream writer.
57#[derive(Clone, Debug, Eq, PartialEq)]
58pub enum WorkerMessage {
59    /// Activity invocation pushed to a worker.
60    ActivityTask(ProtoActivityTask),
61    /// Graceful-shutdown notification; no new work will be dispatched.
62    DrainRequest,
63}
64
65/// Address of a worker pool: the two disjoint routing dimensions that select a
66/// pool, before an `activity_type` is matched within it.
67///
68/// `namespace` is the correctness/isolation boundary — a workflow's activities
69/// only ever reach workers in the workflow's namespace, so crossing it is a bug.
70/// `task_queue` is the pool/flavour selector within that namespace (norn /
71/// claude / cpu / gpu) — a miss is a liveness issue, never a correctness one.
72///
73/// This is a named type rather than a `(String, String)` tuple so a `node`
74/// dimension (Tier 3 affinity) can be added later without re-threading every
75/// call site.
76#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
77pub struct PoolAddress {
78    namespace: String,
79    task_queue: String,
80}
81
82impl PoolAddress {
83    /// Build a pool address, normalizing an empty `task_queue` to the named
84    /// [`DEFAULT_TASK_QUEUE`] pool. The `namespace` is the authorization
85    /// boundary and is never normalized.
86    #[must_use]
87    pub fn new(namespace: impl Into<String>, task_queue: impl Into<String>) -> Self {
88        let task_queue = task_queue.into();
89        let task_queue = if task_queue.is_empty() {
90            String::from(DEFAULT_TASK_QUEUE)
91        } else {
92            task_queue
93        };
94        Self {
95            namespace: namespace.into(),
96            task_queue,
97        }
98    }
99
100    /// The correctness/isolation boundary of this pool.
101    #[must_use]
102    pub fn namespace(&self) -> &str {
103        &self.namespace
104    }
105
106    /// The pool/flavour selector within the namespace.
107    #[must_use]
108    pub fn task_queue(&self) -> &str {
109        &self.task_queue
110    }
111}
112
113/// Registry match key: a worker-pool address plus the activity type matched
114/// within that pool. A named type (not an anonymous tuple) so the routing
115/// identity stays self-describing and extensible.
116#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
117struct ActivityKey {
118    pool: PoolAddress,
119    activity_type: String,
120}
121
122impl ActivityKey {
123    fn new(pool: PoolAddress, activity_type: impl Into<String>) -> Self {
124        Self {
125            pool,
126            activity_type: activity_type.into(),
127        }
128    }
129}
130
131type WorkerMap = HashMap<WorkerId, WorkerHandle>;
132type RegistryMap = HashMap<ActivityKey, WorkerMap>;
133
134/// Stable identifier assigned to a connected worker stream.
135#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
136pub struct WorkerId(u64);
137
138impl WorkerId {
139    /// Raw numeric value, as carried by the wire `RegisterAck.worker_id` so
140    /// workers can correlate their logs with the server's.
141    #[must_use]
142    pub const fn value(self) -> u64 {
143        self.0
144    }
145}
146
147/// Cloneable handle for a registered worker stream.
148///
149/// A worker serves a SET of namespaces under a single `task_queue`, so it is
150/// indexed under one `(namespace, task_queue, activity_type)` key per namespace
151/// in its set. `node` is an OPTIONAL locality affinity (a locality, not a
152/// process — many handles may share a node id) used as a within-pool filter at
153/// selection time; `None` means the worker advertised no locality.
154#[derive(Clone, Debug)]
155pub struct WorkerHandle {
156    id: WorkerId,
157    namespaces: BTreeSet<String>,
158    task_queue: String,
159    node: Option<String>,
160    activity_types: BTreeSet<String>,
161    delivery: WorkerDelivery,
162    /// The neutral mid-run intervention primitives this worker's harness advertises
163    /// support for (NOI-6). The server gates every intervention command on THIS set
164    /// and NEVER routes an unadvertised primitive. Empty = observability-only (the
165    /// default for every non-agent worker), so a normal activity worker advertises
166    /// no controls and the intervention router refuses every command for it.
167    intervention_capabilities: InterventionCapabilities,
168}
169
170impl WorkerHandle {
171    /// Worker identifier assigned by this server process.
172    #[must_use]
173    pub const fn id(&self) -> WorkerId {
174        self.id
175    }
176
177    /// Namespaces authorized for this worker stream. The worker is reachable for
178    /// a dispatch only when its set includes the workflow's namespace.
179    #[must_use]
180    pub const fn namespaces(&self) -> &BTreeSet<String> {
181        &self.namespaces
182    }
183
184    /// Task queue (pool/flavour) this worker serves within each namespace.
185    #[must_use]
186    pub fn task_queue(&self) -> &str {
187        &self.task_queue
188    }
189
190    /// Optional locality affinity this worker advertised. `None` means the
191    /// worker carries no node and is reachable only for unpinned dispatches.
192    #[must_use]
193    pub fn node(&self) -> Option<&str> {
194        self.node.as_deref()
195    }
196
197    /// Activity types advertised by this worker.
198    #[must_use]
199    pub fn activity_types(&self) -> &BTreeSet<String> {
200        &self.activity_types
201    }
202
203    /// The transport this worker is delivered to through.
204    #[must_use]
205    pub const fn delivery(&self) -> &WorkerDelivery {
206        &self.delivery
207    }
208
209    /// The neutral intervention primitives this worker's harness advertises (NOI-6).
210    ///
211    /// The intervention router gates on this set and never routes an unadvertised
212    /// primitive. Empty (the default for a plain activity worker) means the worker
213    /// is observability-only: the router refuses every intervention command for it.
214    #[must_use]
215    pub const fn intervention_capabilities(&self) -> &InterventionCapabilities {
216        &self.intervention_capabilities
217    }
218
219    /// gRPC stream sender used by the gRPC dispatch path to push work, or `None`
220    /// when this worker is delivered to over a non-gRPC transport (liminal).
221    ///
222    /// The gRPC dispatch path registers every worker with a [`WorkerDelivery::Grpc`]
223    /// delivery, so this is always `Some` for a gRPC-registered worker — the
224    /// behaviour the path relied on before delivery became transport-agnostic.
225    #[must_use]
226    pub fn sender(&self) -> Option<&WorkerTaskSender> {
227        match &self.delivery {
228            WorkerDelivery::Grpc(sender) => Some(sender),
229            #[cfg(feature = "liminal-transport")]
230            WorkerDelivery::Liminal(_) => None,
231        }
232    }
233}
234
235#[derive(Debug)]
236struct RegistryState {
237    next_worker_id: u64,
238    workers: BTreeMap<WorkerId, WorkerHandle>,
239    by_activity: RegistryMap,
240    /// Round-robin cursor per `(namespace, task_queue, activity_type)` triple, so
241    /// each pool rotates independently of every other pool.
242    rotation: HashMap<ActivityKey, usize>,
243}
244
245impl Default for RegistryState {
246    fn default() -> Self {
247        Self {
248            next_worker_id: 1,
249            workers: BTreeMap::new(),
250            by_activity: HashMap::new(),
251            rotation: HashMap::new(),
252        }
253    }
254}
255
256/// Cloneable registry of currently connected worker streams.
257#[derive(Clone, Debug)]
258pub struct ConnectedWorkerRegistry {
259    inner: Arc<Mutex<RegistryState>>,
260    metrics: Option<Metrics>,
261    /// WS3 cluster-event publisher: emits `WorkerConnected`/`WorkerDisconnected`
262    /// topology deltas on register/deregister. `None` keeps existing
263    /// constructions (and every test) silent, exactly like `metrics`.
264    cluster_publisher: Option<ClusterEventPublisher>,
265    /// Minted-on-use hook (Control-Plane Phase 1). `None` disables minting, so
266    /// registration is byte-identical to before the registry existed; `Some`
267    /// durably records (open) or gates (closed) each authorized namespace.
268    minter: Option<NamespaceMinter>,
269    worker_arrived: Arc<Notify>,
270}
271
272impl Default for ConnectedWorkerRegistry {
273    fn default() -> Self {
274        Self {
275            inner: Arc::new(Mutex::new(RegistryState::default())),
276            metrics: None,
277            cluster_publisher: None,
278            minter: None,
279            worker_arrived: Arc::new(Notify::new()),
280        }
281    }
282}
283
284impl ConnectedWorkerRegistry {
285    /// Build a registry that records connected-worker gauge updates.
286    #[must_use]
287    pub fn with_metrics(metrics: Metrics) -> Self {
288        Self {
289            inner: Arc::new(Mutex::new(RegistryState::default())),
290            metrics: Some(metrics),
291            cluster_publisher: None,
292            minter: None,
293            worker_arrived: Arc::new(Notify::new()),
294        }
295    }
296
297    /// Attach the WS3 cluster-event publisher so worker topology changes are
298    /// pushed to the dashboard. Pure builder addition.
299    #[must_use]
300    pub fn with_cluster_publisher(mut self, publisher: ClusterEventPublisher) -> Self {
301        self.cluster_publisher = Some(publisher);
302        self
303    }
304
305    /// Install the minted-on-use namespace hook (Control-Plane Phase 1).
306    ///
307    /// After a registration is authorized and its namespace set scoped, each
308    /// authorized namespace is durably recorded ([`AutoCreate::Open`]) or gated
309    /// ([`AutoCreate::Closed`]) through `store`. Without this builder the
310    /// registry never touches the namespace registry, so registration stays
311    /// byte-identical to before the registry existed. Pure builder addition,
312    /// mirroring [`Self::with_cluster_publisher`].
313    ///
314    /// When a cluster publisher has already been attached
315    /// ([`Self::with_cluster_publisher`], called first on the boot path), it is
316    /// threaded into the minter so a first worker-mint emits the live
317    /// `namespace created` delta to the ops console (S8). Order-independence is
318    /// not assumed: callers wire the publisher before minting on the boot path.
319    #[must_use]
320    pub fn with_namespace_minting(
321        mut self,
322        store: Arc<dyn NamespaceStore>,
323        policy: AutoCreate,
324    ) -> Self {
325        let minter = NamespaceMinter::new(store, policy);
326        let minter = match &self.cluster_publisher {
327            Some(publisher) => minter.with_cluster_publisher(publisher.clone()),
328            None => minter,
329        };
330        self.minter = Some(minter);
331        self
332    }
333
334    /// Authorize a worker registration and insert it into the connected-worker registry.
335    ///
336    /// # Errors
337    ///
338    /// Returns [`ServerError`] if namespace authorization fails or the registry lock is poisoned.
339    pub async fn accept_registration(
340        &self,
341        guard: &NamespaceGuard,
342        caller: &CallerIdentity,
343        registration: &ProtoRegisterWorker,
344        sender: WorkerTaskSender,
345    ) -> Result<WorkerRegistration, ServerError> {
346        // Verify the operation against the guard's worker-registration policy,
347        // then authorize EACH namespace in the worker's set: a worker serves a
348        // SET of correctness boundaries, so the registration is denied unless
349        // the caller is granted every one. The wire's empty `node` carries no
350        // locality affinity; a non-empty value is the worker's advertised node.
351        guard
352            .scope(caller, &NamespaceOperation::register_worker(registration))
353            .await?;
354        let namespaces = guard.scope_worker_namespaces(caller, &registration.namespaces)?;
355        // MINT HOOK (Control-Plane Phase 1). This runs strictly AFTER the
356        // per-namespace authorization above (`scope` + `scope_worker_namespaces`),
357        // so it can only ever mint a namespace the caller is already authorized
358        // for — the mint is auth-scoped by construction (CVE-2025-14986: open
359        // minting and namespace isolation only coexist when minting is
360        // auth-gated). It runs BEFORE the worker is inserted, so a `closed`
361        // rejection never leaves a half-registered worker behind.
362        self.mint_or_gate_namespaces(&namespaces).await?;
363        let node = optional_node(&registration.node);
364        // PLACEMENT-ADMISSION GATE (Control-Plane Phase 2, P2-I1). Runs strictly
365        // AFTER the mint hook (so every authorized namespace has a durable record to
366        // read a placement from) and with BOTH the worker's advertised `node` and
367        // the full authorized namespace set in scope. It rejects the WHOLE
368        // registration (Open Decision 6) when the worker's node violates any
369        // `Pinned{L}` namespace it would serve, so only L-node workers ever enter a
370        // hard-pinned namespace's pool. Auth-scoped by construction (it only ever
371        // gates a namespace already authorized above); a no-op with no minter
372        // installed, so default/test registries stay byte-identical.
373        self.enforce_pinned_placement(&namespaces, node.as_deref())
374            .await?;
375        self.register_namespaces(
376            namespaces,
377            registration.task_queue.clone(),
378            node,
379            registration.activity_types.iter(),
380            sender,
381        )
382    }
383
384    /// Apply the minted-on-use policy to an already-authorized namespace set.
385    ///
386    /// A no-op when no minter is installed (every default/test registry), so
387    /// registration stays byte-identical. With a minter, the work is delegated
388    /// to the shared [`NamespaceMinter::mint_or_gate`] — the single
389    /// transport-agnostic implementation reused by the workflow-start safety net
390    /// — with [`NamespaceOrigin::WorkerMint`] so a first mint is attributed to
391    /// worker registration. See that method for the open/closed policy, the
392    /// idempotent "namespace created" event, and the retryable `NotOwner`
393    /// surface.
394    ///
395    /// # Errors
396    ///
397    /// Returns [`ServerError::StoreBackend`] if a durable upsert/lookup fails
398    /// (including a retryable `NotOwner` fence), or [`ServerError::Namespace`]
399    /// when `closed` rejects an unknown namespace.
400    async fn mint_or_gate_namespaces(&self, namespaces: &[String]) -> Result<(), ServerError> {
401        let Some(minter) = &self.minter else {
402            return Ok(());
403        };
404        minter
405            .mint_or_gate(namespaces, NamespaceOrigin::WorkerMint)
406            .await
407    }
408
409    /// Reject the whole registration when the worker's advertised `node` violates
410    /// any `Pinned{L}` namespace it would serve (Control-Plane Phase 2, P2-I1).
411    ///
412    /// For each authorized namespace whose placement is [`NamespacePlacement::Pinned`],
413    /// the worker's advertised `node` must be `Some(n)` with `n ∈ L`; a `None` node
414    /// or an `n ∉ L` is a loud, whole-registration rejection naming the namespace,
415    /// the node, and the required set. This guarantees only L-node workers ever
416    /// serve a hard-pinned namespace's pool, which is exactly what lets the
417    /// `Some(N ∉ L)` composition case (§2.2) resolve to the correct isolation stall
418    /// at dispatch rather than needing a start-time enumeration of future nodes.
419    ///
420    /// Non-`Pinned` placements ([`NamespacePlacement::Unplaced`]/[`NamespacePlacement::Prefer`])
421    /// are UNAFFECTED — byte-identical registration. A no-op when no minter is
422    /// installed (every default/test registry), so those stay behaviour-identical:
423    /// the gate reads placement from the SAME registry record the minter/placement
424    /// endpoint writes, never a second source of truth.
425    ///
426    /// # Errors
427    ///
428    /// Returns [`ServerError::Namespace`] (placement-admission denial) when the
429    /// worker's node violates a `Pinned` namespace, or [`ServerError::StoreBackend`]
430    /// if a placement read fails at the backend.
431    async fn enforce_pinned_placement(
432        &self,
433        namespaces: &[String],
434        node: Option<&str>,
435    ) -> Result<(), ServerError> {
436        let Some(minter) = &self.minter else {
437            return Ok(());
438        };
439        for namespace in namespaces {
440            let NamespacePlacement::Pinned { nodes } = minter.placement_of(namespace).await? else {
441                continue;
442            };
443            let admitted = node.is_some_and(|n| nodes.contains(n));
444            if !admitted {
445                return Err(ServerError::placement_admission_denied(
446                    namespace, node, &nodes,
447                ));
448            }
449        }
450        Ok(())
451    }
452
453    /// Insert an already-authorized worker stream into the default task queue of
454    /// a single `namespace`, with no node affinity.
455    ///
456    /// Convenience over [`Self::register_namespaces`] for callers that serve one
457    /// namespace and do not select a task queue (notably tests of the default
458    /// pool).
459    ///
460    /// # Errors
461    ///
462    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
463    pub fn register<'a>(
464        &self,
465        namespace: impl Into<String>,
466        activity_types: impl IntoIterator<Item = &'a String>,
467        sender: WorkerTaskSender,
468    ) -> Result<WorkerRegistration, ServerError> {
469        self.register_namespaces(
470            [namespace.into()],
471            String::from(DEFAULT_TASK_QUEUE),
472            None,
473            activity_types,
474            sender,
475        )
476    }
477
478    /// Insert an already-authorized worker stream into one explicit worker pool
479    /// (single namespace + task queue), with no node affinity.
480    ///
481    /// # Errors
482    ///
483    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
484    pub fn register_pool<'a>(
485        &self,
486        pool: PoolAddress,
487        activity_types: impl IntoIterator<Item = &'a String>,
488        sender: WorkerTaskSender,
489    ) -> Result<WorkerRegistration, ServerError> {
490        let PoolAddress {
491            namespace,
492            task_queue,
493        } = pool;
494        self.register_namespaces([namespace], task_queue, None, activity_types, sender)
495    }
496
497    /// Insert an already-authorized worker stream serving a SET of namespaces
498    /// under one `task_queue`, with an optional `node` locality affinity.
499    ///
500    /// The worker is indexed under one `(namespace, task_queue, activity_type)`
501    /// key per namespace in its set, so a dispatch in any of those namespaces
502    /// can reach it. `node` is recorded on the handle and used only as a
503    /// within-pool filter at selection time — it is NOT part of [`PoolAddress`].
504    ///
505    /// # Errors
506    ///
507    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
508    pub fn register_namespaces<'a>(
509        &self,
510        namespaces: impl IntoIterator<Item = String>,
511        task_queue: impl Into<String>,
512        node: Option<String>,
513        activity_types: impl IntoIterator<Item = &'a String>,
514        sender: WorkerTaskSender,
515    ) -> Result<WorkerRegistration, ServerError> {
516        self.register_delivery(
517            namespaces,
518            task_queue,
519            node,
520            activity_types,
521            WorkerDelivery::Grpc(sender),
522        )
523    }
524
525    /// Insert an already-authorized worker serving a SET of namespaces under one
526    /// `task_queue` and optional `node`, delivered to through an explicit
527    /// [`WorkerDelivery`] transport.
528    ///
529    /// This is the transport-agnostic registration core: [`Self::register_namespaces`]
530    /// is the gRPC façade over it (it wraps the stream sender in
531    /// [`WorkerDelivery::Grpc`]). Selection (`select_worker`/`workers_for`) is
532    /// identical across transports; only the held delivery differs.
533    ///
534    /// # Errors
535    ///
536    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
537    pub fn register_delivery<'a>(
538        &self,
539        namespaces: impl IntoIterator<Item = String>,
540        task_queue: impl Into<String>,
541        node: Option<String>,
542        activity_types: impl IntoIterator<Item = &'a String>,
543        delivery: WorkerDelivery,
544    ) -> Result<WorkerRegistration, ServerError> {
545        self.register_delivery_with_capabilities(
546            namespaces,
547            task_queue,
548            node,
549            activity_types,
550            delivery,
551            InterventionCapabilities::none(),
552        )
553    }
554
555    /// Insert an already-authorized worker exactly like [`Self::register_delivery`],
556    /// additionally recording the neutral [`InterventionCapabilities`] its harness
557    /// advertises (NOI-6).
558    ///
559    /// This is the capability-carrying registration core: [`Self::register_delivery`]
560    /// is the façade over it that advertises the empty set (observability-only), so
561    /// every existing caller stays byte-identical. Selection is unchanged — the
562    /// capability set is metadata the intervention router gates on, never a routing
563    /// dimension.
564    ///
565    /// # Errors
566    ///
567    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
568    pub fn register_delivery_with_capabilities<'a>(
569        &self,
570        namespaces: impl IntoIterator<Item = String>,
571        task_queue: impl Into<String>,
572        node: Option<String>,
573        activity_types: impl IntoIterator<Item = &'a String>,
574        delivery: WorkerDelivery,
575        intervention_capabilities: InterventionCapabilities,
576    ) -> Result<WorkerRegistration, ServerError> {
577        let namespaces = namespaces.into_iter().collect::<BTreeSet<_>>();
578        let task_queue = task_queue.into();
579        let activity_types = activity_types.into_iter().cloned().collect::<BTreeSet<_>>();
580        let mut state = self.state()?;
581        let worker_id = WorkerId(state.next_worker_id);
582        state.next_worker_id = state.next_worker_id.saturating_add(1);
583
584        // Capture the node affinity for the WS3 WorkerConnected delta before the
585        // handle moves it.
586        let node_for_event = node.clone();
587        let handle = WorkerHandle {
588            id: worker_id,
589            namespaces: namespaces.clone(),
590            task_queue: task_queue.clone(),
591            node,
592            activity_types: activity_types.clone(),
593            delivery,
594            intervention_capabilities,
595        };
596
597        for namespace in &namespaces {
598            let pool = PoolAddress::new(namespace.clone(), task_queue.clone());
599            for activity_type in &activity_types {
600                state
601                    .by_activity
602                    .entry(ActivityKey::new(pool.clone(), activity_type.clone()))
603                    .or_default()
604                    .insert(worker_id, handle.clone());
605            }
606        }
607        let transport = transport_of(&handle.delivery);
608        state.workers.insert(worker_id, handle);
609        drop(state);
610
611        if let Some(metrics) = &self.metrics {
612            for namespace in &namespaces {
613                metrics.worker_connected(namespace);
614            }
615        }
616
617        // WS3: one WorkerConnected delta carrying the full namespace set (the
618        // event is namespace-list-valued; the deploy-scoped cluster channel sees
619        // it whole). Edge-triggered by the real insert, never a poll.
620        if let Some(publisher) = &self.cluster_publisher {
621            let namespaces_vec: Vec<String> = namespaces.iter().cloned().collect();
622            let task_queue_owned = task_queue.clone();
623            drop(publisher.emit(|meta| ClusterEvent::WorkerConnected {
624                meta,
625                worker_id: worker_id.value().to_string(),
626                namespaces: namespaces_vec,
627                task_queue: task_queue_owned,
628                transport,
629                node: node_for_event,
630            }));
631        }
632
633        self.worker_arrived.notify_waiters();
634
635        Ok(WorkerRegistration {
636            registry: self.clone(),
637            parts: Some(WorkerRegistrationParts {
638                worker_id,
639                namespaces,
640                task_queue,
641                activity_types,
642            }),
643        })
644    }
645
646    /// Wait until at least one new worker registers.
647    ///
648    /// Returns immediately if a registration occurred since the last call.
649    /// Callers should re-check the registry after waking — the newly arrived
650    /// worker may not serve the namespace or activity type the caller needs.
651    pub async fn wait_for_worker(&self) {
652        self.worker_arrived.notified().await;
653    }
654
655    /// Return a snapshot of workers registered for the
656    /// `(namespace, task_queue, activity_type)` pool, ordered by worker id and
657    /// then rotated so each call starts from the next worker in the pool. The
658    /// rotation cursor is per triple, so each pool round-robins independently.
659    ///
660    /// When `node` is `Some`, the result is filtered to workers whose advertised
661    /// node equals it — a dispatch pinned to a node reaches only workers on that
662    /// node (NODE affinity = require). When `node` is `None`, the behaviour is
663    /// exactly the unpinned pool: every worker in the `(namespace, task_queue)`
664    /// pool is a candidate regardless of locality. node is a within-pool filter,
665    /// NOT part of the pool key, so the per-triple rotation cursor is shared
666    /// across pinned and unpinned lookups of the same pool.
667    ///
668    /// The id sort matters: `by_activity` holds workers in a `HashMap`, whose
669    /// iteration order is unspecified. Sorting first makes the rotation below
670    /// the sole, deterministic source of ordering — true round-robin across
671    /// calls with the same membership, not a wobble layered on hash order.
672    ///
673    /// # Errors
674    ///
675    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
676    pub fn workers_for(
677        &self,
678        namespace: &str,
679        task_queue: &str,
680        activity_type: &str,
681        node: Option<&str>,
682    ) -> Result<Vec<WorkerHandle>, ServerError> {
683        let mut state = self.state()?;
684        let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
685        let mut workers: Vec<WorkerHandle> = state
686            .by_activity
687            .get(&key)
688            .map(|workers| {
689                workers
690                    .values()
691                    .filter(|worker| worker_matches_node(worker, node))
692                    .cloned()
693                    .collect()
694            })
695            .unwrap_or_default();
696        if workers.is_empty() {
697            return Ok(workers);
698        }
699        workers.sort_by_key(WorkerHandle::id);
700        let idx = state.rotation.entry(key).or_insert(0);
701        let start = *idx % workers.len();
702        *idx = idx.wrapping_add(1);
703        let mut rotated = Vec::with_capacity(workers.len());
704        rotated.extend_from_slice(&workers[start..]);
705        rotated.extend_from_slice(&workers[..start]);
706        Ok(rotated)
707    }
708
709    /// Return a snapshot of every connected worker stream.
710    ///
711    /// # Errors
712    ///
713    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
714    pub fn all_workers(&self) -> Result<Vec<WorkerHandle>, ServerError> {
715        let state = self.state()?;
716        Ok(state.workers.values().cloned().collect())
717    }
718
719    /// Return the handle for a worker by id, or `None` when it is not registered.
720    ///
721    /// The intervention router resolves the owning worker of a target attempt by id
722    /// (NOI-6): the attempt-owner back-index stores a [`WorkerId`], and the router
723    /// reads back the live handle to gate on its advertised capabilities and select
724    /// its delivery. A `None` result means the owner disconnected — the router
725    /// treats that as the attempt-scoped no-op.
726    ///
727    /// # Errors
728    ///
729    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
730    pub fn worker_by_id(&self, worker_id: WorkerId) -> Result<Option<WorkerHandle>, ServerError> {
731        Ok(self.state()?.workers.get(&worker_id).cloned())
732    }
733
734    /// Replace the advertised intervention capabilities of a registered worker
735    /// (NOI-6). The liminal registration frame cannot carry capabilities, so a
736    /// liminal agent worker announces them on the reserved capabilities channel
737    /// right after registering, and this applies the announcement to the live
738    /// handle the intervention router gates on. Returns `false` when the worker
739    /// is no longer registered (a disconnect racing the announcement — benign).
740    ///
741    /// # Errors
742    ///
743    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
744    pub fn set_intervention_capabilities(
745        &self,
746        worker_id: WorkerId,
747        capabilities: &InterventionCapabilities,
748    ) -> Result<bool, ServerError> {
749        let mut state = self.state()?;
750        if !state.workers.contains_key(&worker_id) {
751            return Ok(false);
752        }
753        if let Some(handle) = state.workers.get_mut(&worker_id) {
754            handle.intervention_capabilities = capabilities.clone();
755        }
756        // The selection index holds handle clones; keep them capability-consistent
757        // even though capabilities are never a routing dimension.
758        for workers in state.by_activity.values_mut() {
759            if let Some(handle) = workers.get_mut(&worker_id) {
760                handle.intervention_capabilities = capabilities.clone();
761            }
762        }
763        Ok(true)
764    }
765
766    /// Broadcast a graceful drain request to every connected worker stream.
767    ///
768    /// # Errors
769    ///
770    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
771    pub fn broadcast_drain(&self) -> Result<usize, ServerError> {
772        let workers = self.all_workers()?;
773        let mut delivered = 0usize;
774        for worker in workers {
775            // Only gRPC-stream workers carry a drain mpsc. A worker on a non-gRPC
776            // transport (liminal) has no drain frame in this spike, so it is left
777            // untouched rather than spuriously deregistered.
778            let Some(sender) = worker.sender() else {
779                continue;
780            };
781            if sender.try_send(WorkerMessage::DrainRequest).is_ok() {
782                delivered = delivered.saturating_add(1);
783            } else {
784                self.deregister(worker.id())?;
785            }
786        }
787        Ok(delivered)
788    }
789
790    /// Select one worker for the `(namespace, task_queue, activity_type)` pool.
791    ///
792    /// When `node` is `Some`, only workers whose advertised node equals it are
793    /// considered (NODE affinity = require); `None` considers every worker in
794    /// the pool. node is a within-pool filter, NOT part of the pool key.
795    ///
796    /// # Errors
797    ///
798    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
799    pub fn select_worker(
800        &self,
801        namespace: &str,
802        task_queue: &str,
803        activity_type: &str,
804        node: Option<&str>,
805    ) -> Result<Option<WorkerHandle>, ServerError> {
806        let state = self.state()?;
807        let key = ActivityKey::new(PoolAddress::new(namespace, task_queue), activity_type);
808        Ok(state.by_activity.get(&key).and_then(|workers| {
809            workers
810                .values()
811                .filter(|worker| worker_matches_node(worker, node))
812                .min_by_key(|worker| worker.id)
813                .cloned()
814        }))
815    }
816
817    /// Return whether a worker stream is currently registered.
818    ///
819    /// The activity dispatch path uses this after queuing a task to detect a
820    /// worker whose stream tore down concurrently: a sweep that ran before
821    /// the dispatch tracked its task can never complete it, so the dispatch
822    /// must fail the activity itself instead of waiting forever.
823    ///
824    /// # Errors
825    ///
826    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
827    pub fn is_registered(&self, worker_id: WorkerId) -> Result<bool, ServerError> {
828        Ok(self.state()?.workers.contains_key(&worker_id))
829    }
830
831    /// Remove a worker by id from every namespace/activity index it advertised.
832    ///
833    /// Emits a WS3 [`WorkerDeathReason::Disconnect`] delta — the truthful default
834    /// for a removed worker whose stream/registration went away. Callers that can
835    /// PROVE a finer reason (a liveness-timeout sweep) call
836    /// [`Self::deregister_with_reason`] instead, so the dashboard never sees a
837    /// fabricated distinction.
838    ///
839    /// # Errors
840    ///
841    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
842    pub fn deregister(&self, worker_id: WorkerId) -> Result<(), ServerError> {
843        self.deregister_with_reason(worker_id, WorkerDeathReason::Disconnect)
844    }
845
846    /// Remove a worker by id, attributing the departure to an explicit
847    /// [`WorkerDeathReason`] the caller can prove at its call site (for example a
848    /// heartbeat sweep passes [`WorkerDeathReason::Timeout`]).
849    ///
850    /// # Errors
851    ///
852    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
853    pub fn deregister_with_reason(
854        &self,
855        worker_id: WorkerId,
856        reason: WorkerDeathReason,
857    ) -> Result<(), ServerError> {
858        let mut state = self.state()?;
859        let removed_namespaces = Self::remove_worker(&mut state, worker_id);
860        drop(state);
861
862        let Some(namespaces) = removed_namespaces else {
863            // Already gone: no metrics double-count, no duplicate delta.
864            return Ok(());
865        };
866
867        if let Some(metrics) = &self.metrics {
868            for namespace in &namespaces {
869                metrics.worker_disconnected(namespace);
870            }
871        }
872        self.emit_worker_disconnected(worker_id, &namespaces, reason);
873
874        Ok(())
875    }
876
877    /// Emit a WS3 `WorkerDisconnected` delta if a publisher is attached.
878    fn emit_worker_disconnected(
879        &self,
880        worker_id: WorkerId,
881        namespaces: &BTreeSet<String>,
882        reason: WorkerDeathReason,
883    ) {
884        if let Some(publisher) = &self.cluster_publisher {
885            let namespaces_vec: Vec<String> = namespaces.iter().cloned().collect();
886            drop(publisher.emit(|meta| ClusterEvent::WorkerDisconnected {
887                meta,
888                worker_id: worker_id.value().to_string(),
889                namespaces: namespaces_vec,
890                reason,
891            }));
892        }
893    }
894
895    /// Remove a worker from every `(namespace, task_queue, activity_type)` index
896    /// it advertised. Returns the namespace set it served (for metrics), or
897    /// `None` if the worker was already gone.
898    fn remove_worker(state: &mut RegistryState, worker_id: WorkerId) -> Option<BTreeSet<String>> {
899        let handle = state.workers.remove(&worker_id)?;
900
901        for namespace in &handle.namespaces {
902            let pool = PoolAddress::new(namespace.clone(), handle.task_queue.clone());
903            for activity_type in &handle.activity_types {
904                let key = ActivityKey::new(pool.clone(), activity_type.clone());
905                if let Some(workers) = state.by_activity.get_mut(&key) {
906                    workers.remove(&worker_id);
907                    if workers.is_empty() {
908                        state.by_activity.remove(&key);
909                        // Prune the round-robin cursor in lockstep: the cursor
910                        // map is keyed on arbitrary caller-supplied strings and
911                        // is lazily created by `workers_for`, so leaving stale
912                        // entries behind leaks memory unboundedly on a
913                        // never-dying server. When the last worker for a triple
914                        // leaves, its cursor has no remaining meaning.
915                        state.rotation.remove(&key);
916                    }
917                }
918            }
919        }
920
921        Some(handle.namespaces)
922    }
923
924    fn state(&self) -> Result<MutexGuard<'_, RegistryState>, ServerError> {
925        self.inner
926            .lock()
927            .map_err(|_| ServerError::lock_poisoned("connected worker registry"))
928    }
929}
930
931/// Map a held [`WorkerDelivery`] to the wire [`WorkerTransport`] discriminant for
932/// the WS3 `WorkerConnected` delta.
933const fn transport_of(delivery: &WorkerDelivery) -> WorkerTransport {
934    match delivery {
935        WorkerDelivery::Grpc(_) => WorkerTransport::Grpc,
936        #[cfg(feature = "liminal-transport")]
937        WorkerDelivery::Liminal(_) => WorkerTransport::Liminal,
938    }
939}
940
941/// Normalize a wire `node` string into an optional locality affinity: an empty
942/// value (the proto3 default) carries no node, anything else is the worker's
943/// advertised node id.
944fn optional_node(node: &str) -> Option<String> {
945    if node.is_empty() {
946        None
947    } else {
948        Some(node.to_owned())
949    }
950}
951
952/// Whether a worker satisfies an optional node filter. `None` (unpinned) matches
953/// every worker; `Some(node)` matches only a worker advertising that exact node
954/// (NODE affinity = require). A worker with no advertised node never matches a
955/// pinned dispatch.
956fn worker_matches_node(worker: &WorkerHandle, node: Option<&str>) -> bool {
957    match node {
958        None => true,
959        Some(node) => worker.node() == Some(node),
960    }
961}
962
963#[derive(Clone, Debug)]
964struct WorkerRegistrationParts {
965    worker_id: WorkerId,
966    namespaces: BTreeSet<String>,
967    task_queue: String,
968    activity_types: BTreeSet<String>,
969}
970
971/// Registration token owned by the worker stream task.
972///
973/// Dropping the token performs best-effort cleanup for disconnect paths. Call
974/// [`WorkerRegistration::deregister`] when the caller needs a typed poison error.
975#[derive(Debug)]
976pub struct WorkerRegistration {
977    registry: ConnectedWorkerRegistry,
978    parts: Option<WorkerRegistrationParts>,
979}
980
981impl WorkerRegistration {
982    /// Worker id assigned to this registration.
983    #[must_use]
984    pub fn worker_id(&self) -> Option<WorkerId> {
985        self.parts.as_ref().map(|parts| parts.worker_id)
986    }
987
988    /// Authorized namespace set for this registration.
989    #[must_use]
990    pub fn namespaces(&self) -> Option<&BTreeSet<String>> {
991        self.parts.as_ref().map(|parts| &parts.namespaces)
992    }
993
994    /// Task queue (pool/flavour) this registration serves within each namespace.
995    #[must_use]
996    pub fn task_queue(&self) -> Option<&str> {
997        self.parts.as_ref().map(|parts| parts.task_queue.as_str())
998    }
999
1000    /// Activity types advertised by this registration.
1001    #[must_use]
1002    pub fn activity_types(&self) -> Option<&BTreeSet<String>> {
1003        self.parts.as_ref().map(|parts| &parts.activity_types)
1004    }
1005
1006    /// Explicitly remove this worker from the registry.
1007    ///
1008    /// # Errors
1009    ///
1010    /// Returns [`ServerError::LockPoisoned`] if the registry lock is poisoned.
1011    pub fn deregister(mut self) -> Result<(), ServerError> {
1012        let Some(parts) = self.parts.take() else {
1013            return Ok(());
1014        };
1015        self.registry.deregister(parts.worker_id)
1016    }
1017}
1018
1019impl Drop for WorkerRegistration {
1020    fn drop(&mut self) {
1021        let Some(parts) = self.parts.take() else {
1022            return;
1023        };
1024        let removed_namespaces = self.registry.inner.lock().ok().and_then(|mut state| {
1025            ConnectedWorkerRegistry::remove_worker(&mut state, parts.worker_id)
1026        });
1027        if let Some(namespaces) = removed_namespaces {
1028            if let Some(metrics) = &self.registry.metrics {
1029                for namespace in &namespaces {
1030                    metrics.worker_disconnected(namespace);
1031                }
1032            }
1033            // A dropped registration token means the worker's stream/connection
1034            // went away — the truthful reason is Disconnect, not a fabricated
1035            // timeout/deregister distinction this path cannot prove.
1036            self.registry.emit_worker_disconnected(
1037                parts.worker_id,
1038                &namespaces,
1039                WorkerDeathReason::Disconnect,
1040            );
1041        }
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use crate::config::NamespaceMode;
1048    use crate::namespace::{NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces};
1049
1050    use super::*;
1051
1052    fn guard() -> NamespaceGuard {
1053        NamespaceGuard::new(NamespaceResolver::authorization_only(
1054            NamespaceMode::SharedEngine,
1055            StaticWorkflowNamespaces::default(),
1056            StaticScheduleNamespaces::default(),
1057        ))
1058    }
1059
1060    fn caller(namespace: &str) -> CallerIdentity {
1061        CallerIdentity::new("worker", [namespace.to_owned()])
1062    }
1063
1064    /// A test-expectation failure as a `ServerError`, so `Result`-returning
1065    /// tests can fail on an unexpected `None` without `panic!`/`expect`.
1066    fn test_failure(message: &str) -> ServerError {
1067        ServerError::worker_dispatch("default".to_owned(), "test".to_owned(), message.to_owned())
1068    }
1069
1070    fn registration(namespace: &str, activity_types: &[&str]) -> ProtoRegisterWorker {
1071        registration_with_queue(namespace, "", activity_types)
1072    }
1073
1074    fn registration_with_queue(
1075        namespace: &str,
1076        task_queue: &str,
1077        activity_types: &[&str],
1078    ) -> ProtoRegisterWorker {
1079        registration_full(&[namespace], task_queue, "", activity_types)
1080    }
1081
1082    fn registration_full(
1083        namespaces: &[&str],
1084        task_queue: &str,
1085        node: &str,
1086        activity_types: &[&str],
1087    ) -> ProtoRegisterWorker {
1088        ProtoRegisterWorker {
1089            namespaces: namespaces.iter().map(|value| (*value).to_owned()).collect(),
1090            activity_types: activity_types
1091                .iter()
1092                .map(|value| (*value).to_owned())
1093                .collect(),
1094            task_queue: task_queue.to_owned(),
1095            node: node.to_owned(),
1096        }
1097    }
1098
1099    fn multi_caller(namespaces: &[&str]) -> CallerIdentity {
1100        CallerIdentity::new("worker", namespaces.iter().map(|value| (*value).to_owned()))
1101    }
1102
1103    /// `set_intervention_capabilities` replaces a live worker's advertised set —
1104    /// the announcement path a liminal agent worker takes after its in-band
1105    /// registration (the registration frame cannot carry capabilities) — and
1106    /// reports an unknown worker as `false` (an announcement racing a
1107    /// disconnect is benign, never an error).
1108    #[tokio::test]
1109    async fn set_intervention_capabilities_updates_live_worker() -> Result<(), ServerError> {
1110        let registry = ConnectedWorkerRegistry::default();
1111        let (sender, _receiver) = mpsc::channel(1);
1112        let types = ["scout".to_owned()];
1113        let guard = registry.register_delivery_with_capabilities(
1114            ["default".to_owned()],
1115            "default",
1116            None,
1117            types.iter(),
1118            WorkerDelivery::Grpc(sender),
1119            InterventionCapabilities::none(),
1120        )?;
1121        let Some(worker_id) = guard.worker_id() else {
1122            return Err(test_failure("registration carries an id"));
1123        };
1124
1125        let announced = InterventionCapabilities {
1126            supported: vec![aion_core::InterventionPrimitive::InjectMessage],
1127        };
1128        assert!(
1129            registry.set_intervention_capabilities(worker_id, &announced)?,
1130            "a live worker's capabilities must be updatable"
1131        );
1132        let Some(handle) = registry.worker_by_id(worker_id)? else {
1133            return Err(test_failure("worker stays registered"));
1134        };
1135        assert_eq!(handle.intervention_capabilities(), &announced);
1136
1137        assert!(
1138            !registry.set_intervention_capabilities(WorkerId(u64::MAX), &announced)?,
1139            "an unknown worker reports false, never an error"
1140        );
1141        Ok(())
1142    }
1143
1144    #[tokio::test]
1145    async fn register_and_deregister_are_namespace_isolated() -> Result<(), ServerError> {
1146        let registry = ConnectedWorkerRegistry::default();
1147        let (tenant_a_tx, _tenant_a_rx) = mpsc::channel(1);
1148        let (tenant_b_tx, _tenant_b_rx) = mpsc::channel(1);
1149
1150        let tenant_a = registry
1151            .accept_registration(
1152                &guard(),
1153                &caller("tenant-a"),
1154                &registration("tenant-a", &["charge", "charge"]),
1155                tenant_a_tx,
1156            )
1157            .await?;
1158        let tenant_b = registry
1159            .accept_registration(
1160                &guard(),
1161                &caller("tenant-b"),
1162                &registration("tenant-b", &["charge"]),
1163                tenant_b_tx,
1164            )
1165            .await?;
1166
1167        let tq = DEFAULT_TASK_QUEUE;
1168        assert_eq!(
1169            registry.workers_for("tenant-a", tq, "charge", None)?.len(),
1170            1
1171        );
1172        assert_eq!(
1173            registry.workers_for("tenant-b", tq, "charge", None)?.len(),
1174            1
1175        );
1176        assert!(
1177            registry
1178                .workers_for("tenant-a", tq, "missing", None)?
1179                .is_empty()
1180        );
1181
1182        let tenant_a_id = tenant_a.worker_id();
1183        tenant_a.deregister()?;
1184
1185        assert!(
1186            registry
1187                .workers_for("tenant-a", tq, "charge", None)?
1188                .is_empty()
1189        );
1190        assert_eq!(
1191            registry.workers_for("tenant-b", tq, "charge", None)?.len(),
1192            1
1193        );
1194        assert_ne!(tenant_a_id, tenant_b.worker_id());
1195
1196        tenant_b.deregister()?;
1197        assert!(
1198            registry
1199                .workers_for("tenant-b", tq, "charge", None)?
1200                .is_empty()
1201        );
1202        Ok(())
1203    }
1204
1205    #[tokio::test]
1206    async fn denied_namespace_is_not_registered() -> Result<(), ServerError> {
1207        let registry = ConnectedWorkerRegistry::default();
1208        let (tx, _rx) = mpsc::channel(1);
1209        let denied = registry
1210            .accept_registration(
1211                &guard(),
1212                &caller("tenant-a"),
1213                &registration("tenant-b", &["charge"]),
1214                tx,
1215            )
1216            .await;
1217
1218        assert!(denied.is_err());
1219        assert!(
1220            registry
1221                .workers_for("tenant-b", DEFAULT_TASK_QUEUE, "charge", None)?
1222                .is_empty()
1223        );
1224        Ok(())
1225    }
1226
1227    #[tokio::test]
1228    async fn task_queues_partition_disjoint_pools_within_one_namespace() -> Result<(), ServerError>
1229    {
1230        // Same namespace + same activity_type, two DIFFERENT task queues: the
1231        // pools are disjoint, a lookup for one queue never returns the other's
1232        // worker, and round-robin holds independently per (ns, tq, type) triple.
1233        let registry = ConnectedWorkerRegistry::default();
1234        let (norn_tx, _norn_rx) = mpsc::channel(1);
1235        let (claude_a_tx, _claude_a_rx) = mpsc::channel(1);
1236        let (claude_b_tx, _claude_b_rx) = mpsc::channel(1);
1237
1238        let norn = registry
1239            .accept_registration(
1240                &guard(),
1241                &caller("local"),
1242                &registration_with_queue("local", "norn", &["dev"]),
1243                norn_tx,
1244            )
1245            .await?;
1246        // Two workers on the SAME (local, claude) pool to exercise round-robin.
1247        let claude_a = registry
1248            .accept_registration(
1249                &guard(),
1250                &caller("local"),
1251                &registration_with_queue("local", "claude", &["dev"]),
1252                claude_a_tx,
1253            )
1254            .await?;
1255        let claude_b = registry
1256            .accept_registration(
1257                &guard(),
1258                &caller("local"),
1259                &registration_with_queue("local", "claude", &["dev"]),
1260                claude_b_tx,
1261            )
1262            .await?;
1263
1264        let norn_pool = registry.workers_for("local", "norn", "dev", None)?;
1265        assert_eq!(norn_pool.len(), 1, "norn pool has exactly its one worker");
1266        let norn_id = norn.worker_id().ok_or_else(missing_id)?;
1267        assert_eq!(norn_pool[0].id(), norn_id);
1268
1269        let claude_pool = registry.workers_for("local", "claude", "dev", None)?;
1270        assert_eq!(
1271            claude_pool.len(),
1272            2,
1273            "claude pool sees only its two workers"
1274        );
1275        let claude_ids: BTreeSet<WorkerId> = claude_pool.iter().map(WorkerHandle::id).collect();
1276        assert!(
1277            !claude_ids.contains(&norn_id),
1278            "the norn worker must never appear in the claude pool"
1279        );
1280
1281        // A dispatch targeting `norn` never reaches a `claude` worker, and vice
1282        // versa: the disjoint key is the boundary.
1283        assert!(
1284            !registry
1285                .workers_for("local", "norn", "dev", None)?
1286                .iter()
1287                .any(|worker| claude_ids.contains(&worker.id()))
1288        );
1289
1290        // Round-robin per triple: the (local, claude, dev) cursor advances
1291        // independently and cycles through both claude workers, while the
1292        // (local, norn, dev) cursor keeps returning its single worker.
1293        let first = registry.workers_for("local", "claude", "dev", None)?[0].id();
1294        let second = registry.workers_for("local", "claude", "dev", None)?[0].id();
1295        assert_ne!(
1296            first, second,
1297            "claude pool round-robins across both workers"
1298        );
1299        assert_eq!(
1300            registry.workers_for("local", "norn", "dev", None)?[0].id(),
1301            norn_id,
1302            "the norn pool rotation is unaffected by claude traffic"
1303        );
1304
1305        norn.deregister()?;
1306        claude_a.deregister()?;
1307        claude_b.deregister()?;
1308        Ok(())
1309    }
1310
1311    #[tokio::test]
1312    async fn same_task_queue_in_different_namespaces_is_isolated() -> Result<(), ServerError> {
1313        // Same task_queue string, two DIFFERENT namespaces: namespace is the
1314        // correctness boundary, so the pools are isolated.
1315        let registry = ConnectedWorkerRegistry::default();
1316        let (local_tx, _local_rx) = mpsc::channel(1);
1317        let (remote_tx, _remote_rx) = mpsc::channel(1);
1318
1319        let local = registry
1320            .accept_registration(
1321                &guard(),
1322                &caller("local"),
1323                &registration_with_queue("local", "gpu", &["render"]),
1324                local_tx,
1325            )
1326            .await?;
1327        let remote = registry
1328            .accept_registration(
1329                &guard(),
1330                &caller("remote"),
1331                &registration_with_queue("remote", "gpu", &["render"]),
1332                remote_tx,
1333            )
1334            .await?;
1335
1336        let local_pool = registry.workers_for("local", "gpu", "render", None)?;
1337        let remote_pool = registry.workers_for("remote", "gpu", "render", None)?;
1338        assert_eq!(local_pool.len(), 1);
1339        assert_eq!(remote_pool.len(), 1);
1340        assert_ne!(
1341            local_pool[0].id(),
1342            remote_pool[0].id(),
1343            "a shared task_queue string does not merge two namespaces"
1344        );
1345
1346        local.deregister()?;
1347        assert!(
1348            registry
1349                .workers_for("local", "gpu", "render", None)?
1350                .is_empty(),
1351            "deregistering the local worker leaves the remote namespace untouched"
1352        );
1353        assert_eq!(
1354            registry.workers_for("remote", "gpu", "render", None)?.len(),
1355            1
1356        );
1357
1358        remote.deregister()?;
1359        Ok(())
1360    }
1361
1362    #[tokio::test]
1363    async fn worker_serving_a_namespace_set_is_reachable_in_each() -> Result<(), ServerError> {
1364        // A worker advertising {a, b} is reachable for dispatch in BOTH a and b;
1365        // a worker in {a} is NOT reachable in b.
1366        let registry = ConnectedWorkerRegistry::default();
1367        let (ab_tx, _ab_rx) = mpsc::channel(1);
1368        let (a_tx, _a_rx) = mpsc::channel(1);
1369
1370        let worker_ab = registry
1371            .accept_registration(
1372                &guard(),
1373                &multi_caller(&["a", "b"]),
1374                &registration_full(&["a", "b"], "default", "", &["dev"]),
1375                ab_tx,
1376            )
1377            .await?;
1378        let worker_a = registry
1379            .accept_registration(
1380                &guard(),
1381                &caller("a"),
1382                &registration_full(&["a"], "default", "", &["dev"]),
1383                a_tx,
1384            )
1385            .await?;
1386
1387        let in_a = registry.workers_for("a", "default", "dev", None)?;
1388        let in_b = registry.workers_for("b", "default", "dev", None)?;
1389        let both_id = worker_ab.worker_id().ok_or_else(missing_id)?;
1390        let only_a_id = worker_a.worker_id().ok_or_else(missing_id)?;
1391
1392        // Namespace a sees BOTH workers; namespace b sees ONLY the {a, b} worker.
1393        let a_ids: BTreeSet<WorkerId> = in_a.iter().map(WorkerHandle::id).collect();
1394        assert_eq!(a_ids, BTreeSet::from([both_id, only_a_id]));
1395        assert_eq!(in_b.len(), 1, "only the {{a, b}} worker is reachable in b");
1396        assert_eq!(in_b[0].id(), both_id);
1397        assert!(
1398            !in_b.iter().any(|worker| worker.id() == only_a_id),
1399            "the {{a}}-only worker must not be reachable in b"
1400        );
1401
1402        // Deregistering the {a, b} worker removes it from BOTH buckets.
1403        worker_ab.deregister()?;
1404        assert!(
1405            registry
1406                .workers_for("b", "default", "dev", None)?
1407                .is_empty()
1408        );
1409        assert_eq!(registry.workers_for("a", "default", "dev", None)?.len(), 1);
1410
1411        worker_a.deregister()?;
1412        Ok(())
1413    }
1414
1415    #[tokio::test]
1416    async fn node_pin_filters_within_pool() -> Result<(), ServerError> {
1417        // Two workers in the same (namespace, task_queue) pool on different
1418        // nodes: unpinned round-robins across both; pinned to node N reaches
1419        // ONLY the worker(s) on N; pinned to a node with no worker finds none.
1420        let registry = ConnectedWorkerRegistry::default();
1421        let (n1_tx, _n1_rx) = mpsc::channel(1);
1422        let (n2_tx, _n2_rx) = mpsc::channel(1);
1423
1424        let on_n1 = registry
1425            .accept_registration(
1426                &guard(),
1427                &caller("ns"),
1428                &registration_full(&["ns"], "tq", "n1", &["dev"]),
1429                n1_tx,
1430            )
1431            .await?;
1432        let on_n2 = registry
1433            .accept_registration(
1434                &guard(),
1435                &caller("ns"),
1436                &registration_full(&["ns"], "tq", "n2", &["dev"]),
1437                n2_tx,
1438            )
1439            .await?;
1440        let n1_id = on_n1.worker_id().ok_or_else(missing_id)?;
1441        let n2_id = on_n2.worker_id().ok_or_else(missing_id)?;
1442
1443        // Unpinned: both workers are candidates and round-robin advances.
1444        let unpinned = registry.workers_for("ns", "tq", "dev", None)?;
1445        assert_eq!(unpinned.len(), 2, "unpinned reaches the whole pool");
1446        let first = registry.workers_for("ns", "tq", "dev", None)?[0].id();
1447        let second = registry.workers_for("ns", "tq", "dev", None)?[0].id();
1448        assert_ne!(first, second, "unpinned round-robins across both nodes");
1449
1450        // Pinned to n1: only the n1 worker; pinned to n2: only the n2 worker.
1451        let pinned_n1 = registry.workers_for("ns", "tq", "dev", Some("n1"))?;
1452        assert_eq!(pinned_n1.len(), 1);
1453        assert_eq!(pinned_n1[0].id(), n1_id);
1454        let pinned_n2 = registry.workers_for("ns", "tq", "dev", Some("n2"))?;
1455        assert_eq!(pinned_n2.len(), 1);
1456        assert_eq!(pinned_n2[0].id(), n2_id);
1457
1458        // select_worker honours the same filter.
1459        assert_eq!(
1460            registry
1461                .select_worker("ns", "tq", "dev", Some("n1"))?
1462                .map(|worker| worker.id()),
1463            Some(n1_id)
1464        );
1465
1466        // Pinned to a node with no worker finds no candidate (the dispatcher
1467        // then waits via the same no-worker path the existing test exercises).
1468        assert!(
1469            registry
1470                .workers_for("ns", "tq", "dev", Some("absent"))?
1471                .is_empty(),
1472            "a pin to a node with no worker yields no candidate"
1473        );
1474        assert!(
1475            registry
1476                .select_worker("ns", "tq", "dev", Some("absent"))?
1477                .is_none()
1478        );
1479
1480        on_n1.deregister()?;
1481        on_n2.deregister()?;
1482        Ok(())
1483    }
1484
1485    #[tokio::test]
1486    async fn shared_node_id_round_robins_across_workers() -> Result<(), ServerError> {
1487        // Two workers SHARING a node id in the same pool: a dispatch pinned to
1488        // that node round-robins across BOTH (node is locality, not process).
1489        let registry = ConnectedWorkerRegistry::default();
1490        let (a_tx, _a_rx) = mpsc::channel(1);
1491        let (b_tx, _b_rx) = mpsc::channel(1);
1492
1493        let worker_a = registry
1494            .accept_registration(
1495                &guard(),
1496                &caller("ns"),
1497                &registration_full(&["ns"], "tq", "shared", &["dev"]),
1498                a_tx,
1499            )
1500            .await?;
1501        let worker_b = registry
1502            .accept_registration(
1503                &guard(),
1504                &caller("ns"),
1505                &registration_full(&["ns"], "tq", "shared", &["dev"]),
1506                b_tx,
1507            )
1508            .await?;
1509        let a_id = worker_a.worker_id().ok_or_else(missing_id)?;
1510        let b_id = worker_b.worker_id().ok_or_else(missing_id)?;
1511
1512        let pinned = registry.workers_for("ns", "tq", "dev", Some("shared"))?;
1513        assert_eq!(
1514            pinned.len(),
1515            2,
1516            "both workers on the shared node are candidates"
1517        );
1518        let pinned_ids: BTreeSet<WorkerId> = pinned.iter().map(WorkerHandle::id).collect();
1519        assert_eq!(pinned_ids, BTreeSet::from([a_id, b_id]));
1520
1521        let first = registry.workers_for("ns", "tq", "dev", Some("shared"))?[0].id();
1522        let second = registry.workers_for("ns", "tq", "dev", Some("shared"))?[0].id();
1523        assert_ne!(
1524            first, second,
1525            "a pin to a shared node round-robins across both workers on it"
1526        );
1527
1528        worker_a.deregister()?;
1529        worker_b.deregister()?;
1530        Ok(())
1531    }
1532
1533    #[tokio::test]
1534    async fn rotation_cursor_is_pruned_when_last_worker_leaves() -> Result<(), ServerError> {
1535        // The round-robin cursor is keyed on arbitrary caller-supplied strings;
1536        // it must not outlive the pool it rotates, or a never-dying server leaks
1537        // memory. After the last worker for a triple deregisters, no cursor for
1538        // that triple may remain.
1539        let registry = ConnectedWorkerRegistry::default();
1540        let (tx, _rx) = mpsc::channel(1);
1541        let worker = registry
1542            .accept_registration(
1543                &guard(),
1544                &caller("ns"),
1545                &registration_full(&["ns"], "tq", "", &["dev"]),
1546                tx,
1547            )
1548            .await?;
1549
1550        // Drive the lazy cursor insert.
1551        let _ = registry.workers_for("ns", "tq", "dev", None)?;
1552        let key = ActivityKey::new(PoolAddress::new("ns", "tq"), "dev");
1553        assert!(
1554            registry.state()?.rotation.contains_key(&key),
1555            "a lookup must have created the rotation cursor"
1556        );
1557
1558        worker.deregister()?;
1559        let state = registry.state()?;
1560        assert!(
1561            !state.rotation.contains_key(&key),
1562            "the rotation cursor must be pruned once the last worker leaves"
1563        );
1564        assert!(
1565            !state.by_activity.contains_key(&key),
1566            "the activity bucket must also be gone"
1567        );
1568        Ok(())
1569    }
1570
1571    fn missing_id() -> ServerError {
1572        ServerError::lock_poisoned("registration unexpectedly missing a worker id")
1573    }
1574
1575    // ---- Minted-on-use (Control-Plane Phase 1) -----------------------------
1576
1577    fn namespace_store() -> Arc<dyn NamespaceStore> {
1578        Arc::new(aion_store::InMemoryStore::default())
1579    }
1580
1581    fn minting_registry(
1582        store: &Arc<dyn NamespaceStore>,
1583        policy: AutoCreate,
1584    ) -> ConnectedWorkerRegistry {
1585        ConnectedWorkerRegistry::default().with_namespace_minting(Arc::clone(store), policy)
1586    }
1587
1588    #[tokio::test]
1589    async fn open_register_mints_durable_record_and_is_idempotent() -> Result<(), ServerError> {
1590        let store = namespace_store();
1591        let registry = minting_registry(&store, AutoCreate::Open);
1592
1593        // First registration mints the namespace.
1594        let (tx_one, _rx_one) = mpsc::channel(1);
1595        let first = registry
1596            .accept_registration(
1597                &guard(),
1598                &caller("orders"),
1599                &registration("orders", &["charge"]),
1600                tx_one,
1601            )
1602            .await?;
1603        let record = store
1604            .get_namespace("orders")
1605            .await?
1606            .ok_or_else(|| ServerError::namespace_denied("expected a minted record"))?;
1607        assert_eq!(record.name, "orders");
1608        assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
1609
1610        // Re-registering the same namespace is idempotent: no duplicate row,
1611        // and the prior worker is unaffected.
1612        let (tx_two, _rx_two) = mpsc::channel(1);
1613        registry
1614            .accept_registration(
1615                &guard(),
1616                &caller("orders"),
1617                &registration("orders", &["refund"]),
1618                tx_two,
1619            )
1620            .await?;
1621        let all = store.list_namespaces().await?;
1622        assert_eq!(
1623            all.iter().filter(|r| r.name == "orders").count(),
1624            1,
1625            "re-register must not create a duplicate namespace row"
1626        );
1627        drop(first);
1628        Ok(())
1629    }
1630
1631    #[tokio::test]
1632    async fn open_register_mints_each_namespace_in_a_multi_namespace_worker()
1633    -> Result<(), ServerError> {
1634        let store = namespace_store();
1635        let registry = minting_registry(&store, AutoCreate::Open);
1636        let (tx, _rx) = mpsc::channel(1);
1637
1638        registry
1639            .accept_registration(
1640                &guard(),
1641                &multi_caller(&["alpha", "beta"]),
1642                &registration_full(&["alpha", "beta"], "", "", &["charge"]),
1643                tx,
1644            )
1645            .await?;
1646
1647        assert!(store.get_namespace("alpha").await?.is_some());
1648        assert!(store.get_namespace("beta").await?.is_some());
1649        Ok(())
1650    }
1651
1652    // ---- Placement admission (Control-Plane Phase 2, P2-I1) -----------------
1653
1654    /// Pre-mint `namespace` and set its placement to `Pinned{nodes}`, returning a
1655    /// minting registry over the same store so `accept_registration` reads the
1656    /// placement from the SAME durable record.
1657    async fn pinned_registry(
1658        store: &Arc<dyn NamespaceStore>,
1659        namespace: &str,
1660        nodes: &[&str],
1661    ) -> Result<ConnectedWorkerRegistry, ServerError> {
1662        store
1663            .register_namespace(namespace, NamespaceOrigin::Explicit)
1664            .await?;
1665        store
1666            .set_namespace_placement(
1667                namespace,
1668                NamespacePlacement::Pinned {
1669                    nodes: nodes.iter().map(|n| (*n).to_owned()).collect(),
1670                },
1671            )
1672            .await?;
1673        Ok(minting_registry(store, AutoCreate::Open))
1674    }
1675
1676    /// A worker on a node IN the required set registers successfully into a
1677    /// `Pinned{n1}` namespace, and is reachable in the pool.
1678    #[tokio::test]
1679    async fn pinned_admits_a_worker_on_a_required_node() -> Result<(), ServerError> {
1680        let store = namespace_store();
1681        let registry = pinned_registry(&store, "iso", &["n1"]).await?;
1682        let (tx, _rx) = mpsc::channel(1);
1683
1684        let _registration = registry
1685            .accept_registration(
1686                &guard(),
1687                &caller("iso"),
1688                &registration_full(&["iso"], "", "n1", &["charge"]),
1689                tx,
1690            )
1691            .await?;
1692
1693        assert_eq!(
1694            registry
1695                .workers_for("iso", DEFAULT_TASK_QUEUE, "charge", Some("n1"))?
1696                .len(),
1697            1,
1698            "an n1 worker must be admitted into the Pinned{{n1}} namespace's pool"
1699        );
1700        Ok(())
1701    }
1702
1703    /// A worker on a node NOT in the required set is rejected — the WHOLE
1704    /// registration fails (loud) and no worker is inserted. This would FAIL under
1705    /// no admission gate (the worker would join and steal Pinned dispatches).
1706    #[tokio::test]
1707    async fn pinned_rejects_a_wrong_node_worker() -> Result<(), ServerError> {
1708        let store = namespace_store();
1709        let registry = pinned_registry(&store, "iso", &["n1"]).await?;
1710        let (tx, _rx) = mpsc::channel(1);
1711
1712        let denied = registry
1713            .accept_registration(
1714                &guard(),
1715                &caller("iso"),
1716                &registration_full(&["iso"], "", "n2", &["charge"]),
1717                tx,
1718            )
1719            .await;
1720        assert!(
1721            matches!(denied, Err(ServerError::Namespace { .. })),
1722            "a wrong-node (n2) worker must be rejected from a Pinned{{n1}} namespace"
1723        );
1724        assert!(
1725            registry
1726                .workers_for("iso", DEFAULT_TASK_QUEUE, "charge", None)?
1727                .is_empty(),
1728            "a rejected registration must not insert a worker on any node"
1729        );
1730        Ok(())
1731    }
1732
1733    /// A worker advertising NO node (`node == ""` → `None`) is rejected from a
1734    /// `Pinned{n1}` namespace: an unlabelled worker can never satisfy a hard pin.
1735    #[tokio::test]
1736    async fn pinned_rejects_a_node_less_worker() -> Result<(), ServerError> {
1737        let store = namespace_store();
1738        let registry = pinned_registry(&store, "iso", &["n1"]).await?;
1739        let (tx, _rx) = mpsc::channel(1);
1740
1741        let denied = registry
1742            .accept_registration(
1743                &guard(),
1744                &caller("iso"),
1745                &registration_full(&["iso"], "", "", &["charge"]),
1746                tx,
1747            )
1748            .await;
1749        assert!(
1750            matches!(denied, Err(ServerError::Namespace { .. })),
1751            "a node-less worker must be rejected from a Pinned{{n1}} namespace"
1752        );
1753        assert!(
1754            registry
1755                .workers_for("iso", DEFAULT_TASK_QUEUE, "charge", None)?
1756                .is_empty(),
1757            "a rejected node-less registration must not insert a worker"
1758        );
1759        Ok(())
1760    }
1761
1762    /// Reject-WHOLE-registration (Open Decision 6): a worker serving BOTH a
1763    /// non-isolated namespace and a `Pinned{n1}` namespace on a wrong node is
1764    /// rejected entirely — the compliant namespace does NOT get a partial admit.
1765    #[tokio::test]
1766    async fn pinned_violation_rejects_the_whole_multi_namespace_registration()
1767    -> Result<(), ServerError> {
1768        let store = namespace_store();
1769        let registry = pinned_registry(&store, "iso", &["n1"]).await?;
1770        let (tx, _rx) = mpsc::channel(1);
1771
1772        let denied = registry
1773            .accept_registration(
1774                &guard(),
1775                &multi_caller(&["free", "iso"]),
1776                &registration_full(&["free", "iso"], "", "n2", &["charge"]),
1777                tx,
1778            )
1779            .await;
1780        assert!(
1781            matches!(denied, Err(ServerError::Namespace { .. })),
1782            "a wrong-node worker serving a Pinned namespace fails the WHOLE registration"
1783        );
1784        assert!(
1785            registry
1786                .workers_for("free", DEFAULT_TASK_QUEUE, "charge", None)?
1787                .is_empty(),
1788            "the compliant namespace must NOT be partially admitted"
1789        );
1790        Ok(())
1791    }
1792
1793    /// Unplaced and Prefer namespaces are UNAFFECTED: a node-less worker registers
1794    /// normally (byte-identical to the pre-P2-I1 behaviour). Only Pinned gates.
1795    #[tokio::test]
1796    async fn unplaced_and_prefer_admission_is_unaffected_by_the_pinned_gate()
1797    -> Result<(), ServerError> {
1798        let store = namespace_store();
1799        // `unpl` is left Unplaced (default); `pref` is Prefer{n1}. A node-less
1800        // worker must be admitted into BOTH.
1801        store
1802            .register_namespace("pref", NamespaceOrigin::Explicit)
1803            .await?;
1804        store
1805            .set_namespace_placement(
1806                "pref",
1807                NamespacePlacement::Prefer {
1808                    nodes: ["n1".to_owned()].into_iter().collect(),
1809                },
1810            )
1811            .await?;
1812        let registry = minting_registry(&store, AutoCreate::Open);
1813
1814        let (tx_a, _rx_a) = mpsc::channel(1);
1815        let _reg_a = registry
1816            .accept_registration(
1817                &guard(),
1818                &caller("unpl"),
1819                &registration_full(&["unpl"], "", "", &["charge"]),
1820                tx_a,
1821            )
1822            .await?;
1823        let (tx_b, _rx_b) = mpsc::channel(1);
1824        let _reg_b = registry
1825            .accept_registration(
1826                &guard(),
1827                &caller("pref"),
1828                &registration_full(&["pref"], "", "", &["charge"]),
1829                tx_b,
1830            )
1831            .await?;
1832
1833        assert_eq!(
1834            registry
1835                .workers_for("unpl", DEFAULT_TASK_QUEUE, "charge", None)?
1836                .len(),
1837            1,
1838            "an Unplaced namespace admits a node-less worker unchanged"
1839        );
1840        assert_eq!(
1841            registry
1842                .workers_for("pref", DEFAULT_TASK_QUEUE, "charge", None)?
1843                .len(),
1844            1,
1845            "a Prefer namespace admits a node-less worker unchanged (only Pinned gates)"
1846        );
1847        Ok(())
1848    }
1849
1850    /// A default (no-minter) registry is byte-identical: the placement gate is a
1851    /// no-op with no minter installed, so a node-less worker registers freely even
1852    /// though there is no way to have set a placement in the first place.
1853    #[tokio::test]
1854    async fn no_minter_registry_skips_the_placement_gate() -> Result<(), ServerError> {
1855        let registry = ConnectedWorkerRegistry::default();
1856        let (tx, _rx) = mpsc::channel(1);
1857        let _registration = registry
1858            .accept_registration(
1859                &guard(),
1860                &caller("plain"),
1861                &registration_full(&["plain"], "", "", &["charge"]),
1862                tx,
1863            )
1864            .await?;
1865        assert_eq!(
1866            registry
1867                .workers_for("plain", DEFAULT_TASK_QUEUE, "charge", None)?
1868                .len(),
1869            1,
1870            "with no minter the placement gate is a no-op — registration is unchanged"
1871        );
1872        Ok(())
1873    }
1874
1875    #[tokio::test]
1876    async fn concurrent_registrations_for_a_new_namespace_create_exactly_one_record()
1877    -> Result<(), ServerError> {
1878        let store = namespace_store();
1879        let registry = minting_registry(&store, AutoCreate::Open);
1880
1881        let mut handles = Vec::new();
1882        for _ in 0..8 {
1883            let registry = registry.clone();
1884            handles.push(tokio::spawn(async move {
1885                let (tx, rx) = mpsc::channel(1);
1886                let outcome = registry
1887                    .accept_registration(
1888                        &guard(),
1889                        &caller("rush"),
1890                        &registration("rush", &["charge"]),
1891                        tx,
1892                    )
1893                    .await;
1894                // Keep the receiver alive for the duration of the registration.
1895                drop(rx);
1896                outcome.map(|registration| registration.worker_id())
1897            }));
1898        }
1899        for handle in handles {
1900            handle
1901                .await
1902                .map_err(|_| ServerError::lock_poisoned("registration task panicked"))??;
1903        }
1904
1905        let all = store.list_namespaces().await?;
1906        assert_eq!(
1907            all.iter().filter(|r| r.name == "rush").count(),
1908            1,
1909            "racing registrations must converge on exactly one durable record"
1910        );
1911        Ok(())
1912    }
1913
1914    #[tokio::test]
1915    async fn closed_rejects_unknown_namespace_and_does_not_create_it() -> Result<(), ServerError> {
1916        let store = namespace_store();
1917        let registry = minting_registry(&store, AutoCreate::Closed);
1918        let (tx, _rx) = mpsc::channel(1);
1919
1920        let denied = registry
1921            .accept_registration(
1922                &guard(),
1923                &caller("ghost"),
1924                &registration("ghost", &["charge"]),
1925                tx,
1926            )
1927            .await;
1928        assert!(
1929            matches!(denied, Err(ServerError::Namespace { .. })),
1930            "closed policy must reject an unknown namespace"
1931        );
1932        assert!(
1933            store.get_namespace("ghost").await?.is_none(),
1934            "closed policy must NOT create the namespace it rejected"
1935        );
1936        let tq = DEFAULT_TASK_QUEUE;
1937        assert!(
1938            registry
1939                .workers_for("ghost", tq, "charge", None)?
1940                .is_empty(),
1941            "a rejected registration must not insert a worker"
1942        );
1943        Ok(())
1944    }
1945
1946    #[tokio::test]
1947    async fn closed_admits_a_known_namespace() -> Result<(), ServerError> {
1948        let store = namespace_store();
1949        // Pre-mint the namespace (the POST /namespaces escape hatch's effect).
1950        store
1951            .register_namespace("known", NamespaceOrigin::Explicit)
1952            .await?;
1953        let registry = minting_registry(&store, AutoCreate::Closed);
1954        let (tx, _rx) = mpsc::channel(1);
1955
1956        // Bind the registration token: dropping it deregisters the worker.
1957        let _registration = registry
1958            .accept_registration(
1959                &guard(),
1960                &caller("known"),
1961                &registration("known", &["charge"]),
1962                tx,
1963            )
1964            .await?;
1965        let tq = DEFAULT_TASK_QUEUE;
1966        assert_eq!(
1967            registry.workers_for("known", tq, "charge", None)?.len(),
1968            1,
1969            "a known namespace must register under closed policy"
1970        );
1971        Ok(())
1972    }
1973
1974    #[tokio::test]
1975    async fn no_minter_leaves_registration_untouched() -> Result<(), ServerError> {
1976        // The default registry installs no minter: registration succeeds and
1977        // never touches any namespace registry (byte-identical legacy path).
1978        let registry = ConnectedWorkerRegistry::default();
1979        let (tx, _rx) = mpsc::channel(1);
1980        let _registration = registry
1981            .accept_registration(
1982                &guard(),
1983                &caller("orders"),
1984                &registration("orders", &["charge"]),
1985                tx,
1986            )
1987            .await?;
1988        let tq = DEFAULT_TASK_QUEUE;
1989        assert_eq!(registry.workers_for("orders", tq, "charge", None)?.len(), 1);
1990        Ok(())
1991    }
1992}