Skip to main content

aion_server/worker/
liminal_transport.rs

1//! Cross-node outbox dispatch over the liminal bus (LSUB push transport).
2//!
3//! # What this is (production push path)
4//!
5//! This module wires the durable outbox's fan-out dispatch over liminal to a
6//! REAL remote aion worker and returns the worker's result through the existing
7//! [`OutboxDeliveryCallback`](super::bridge::OutboxDeliveryCallback), behind the
8//! `liminal-transport` Cargo feature and the `outbox.transport = liminal`
9//! runtime flag. The aion-server HOSTS the liminal listener: a remote worker
10//! connects IN and self-describes in-band, the server registers it in the SAME
11//! connected-worker registry a gRPC worker joins, and a claimed row is PUSHED out
12//! on the worker's existing connection (the LSUB-0 server-push primitive).
13//!
14//! # Routing (NSTQ-5 / NODE-5)
15//!
16//! A worker is selected by the row's `(namespace, task_queue, activity_type,
17//! node)` pool key through the EXISTING registry `select_worker` โ€” the same
18//! selection the gRPC path uses, so routing semantics are shared. `activity_type`
19//! is NOT a routing dimension at the wire: it rides inside the [`DispatchRequest`]
20//! payload and is matched by the worker after delivery, exactly as the gRPC
21//! registry pushes `activity_type` in the task body while selecting the worker by
22//! pool key. See `docs/NAMESPACE-TASKQUEUE-SPLIT-DESIGN.md` ยง4.2. The
23//! [`dispatch_channel_name`] derivation remains the single source of truth for the
24//! pool-channel string, pinned for any future channel-subscription subscriber so
25//! the two sides cannot drift.
26//!
27//! # The seams it implements
28//!
29//! - [`LiminalWorkerDelivery`] is the per-worker push handle this module hands
30//!   to the registry at registration. Delivering over it is
31//!   [`LiminalTaskDelivery`](super::liminal_task_delivery::LiminalTaskDelivery),
32//!   the liminal arm of the shared delivery seam, which pushes the
33//!   [`DispatchRequest`] out on the worker's existing connection and re-enters
34//!   the worker's [`DispatchResponse`] through the SAME
35//!   [`LiminalCompletionSource`] / [`OutboxDeliveryCallback`] the gRPC
36//!   completion path uses.
37//!
38//!   ๐Ÿ”ด This module does NOT select a worker and no longer implements
39//!   [`OutboxRowDispatch`](super::outbox_dispatcher::OutboxRowDispatch). It used
40//!   to, through a `RegistryLiminalDispatch` that ran its own copy of the
41//!   selection โ€” and a server with `outbox.transport = liminal` therefore
42//!   reached EVERY selected worker over liminal, refusing the gRPC-registered
43//!   ones it had just selected. That was #52. Selection now happens once, in
44//!   [`ActivityDispatcher`](super::dispatch::ActivityDispatcher), and each
45//!   chosen worker is served over the transport IT registered on.
46//! - [`LiminalConnectionNotifier`] is the SERVER half of in-band registration:
47//!   when a worker connects with a [`WorkerRegistration`](WireWorkerRegistration)
48//!   the notifier inserts a [`WorkerDelivery::Liminal`] into the registry, and
49//!   drops it on disconnect.
50//! - [`LiminalCompletionSource`] maps a [`DispatchResponse`] onto the delivery
51//!   callback, threading `run_id` end-to-end so the existing continue-as-new run
52//!   gates apply unchanged.
53//!
54//! # The channel-subscription seam (documented, distinct from the push path)
55//!
56//! [`dispatch_channel_name`] derives the pool channel a `(namespace, task_queue)`
57//! pool addresses, optionally pinned to a `node` (NODE-2/NODE-5). The production
58//! path above does not publish to that channel โ€” it pushes to a connected worker
59//! the server already owns โ€” but the derivation is retained as the pinned contract
60//! any future channel-subscription transport MUST honour so the dispatcher and a
61//! subscriber cannot drift:
62//!
63//! - An UNPINNED worker pool addressed `(namespace, task_queue)` subscribes to
64//!   `dispatch_channel_name(namespace, task_queue, None)`.
65//! - A NODE-PINNED dispatch (the row carries `Some(node)`) maps to
66//!   `dispatch_channel_name(namespace, task_queue, Some(node))` โ€” a DISTINCT
67//!   channel that a worker on that node must ALSO subscribe to in order to serve
68//!   pinned work; the unpinned channel alone never delivers a pinned dispatch.
69//!
70//! That is the single contract the seam must honour.
71
72use std::collections::HashMap;
73use std::future::Future;
74use std::sync::{Arc, Mutex, OnceLock};
75use std::time::Duration;
76
77use aion_core::{ActivityId, ContentType, Payload, RunId, WorkflowId};
78use aion_store::OutboxRow;
79use async_trait::async_trait;
80use liminal::protocol::WorkerRegistration as WireWorkerRegistration;
81use liminal_sdk::{SchemaMetadata, SchemaValidate};
82use liminal_server::ServerError as LiminalServerError;
83use liminal_server::server::connection::{
84    ConnectionNotifier, ConnectionSupervisor, PushReplyAwaiter,
85};
86use serde::{Deserialize, Serialize};
87
88use super::bridge::OutboxDeliveryCallback;
89use super::envelope::{CompletionFences, CompletionToken};
90use super::registry::{ConnectedWorkerRegistry, WorkerDelivery, WorkerRegistration};
91use crate::error::ServerError;
92use crate::namespace::{CallerIdentity, NamespaceGuard};
93
94/// Upper bound on how long a server-initiated intervention push waits for the
95/// worker's correlated control-plane acknowledgement. Activity dispatch replies
96/// use an unbounded held wait because an activity may legitimately run a while.
97const PUSH_REPLY_TIMEOUT: Duration = Duration::from_secs(30);
98
99/// Re-arm cadence for the engine-seam bridge's UNBOUNDED reply wait
100/// ([`receive_bridge_reply`]). Each elapsed poll is a benign re-arm, never a
101/// failure: the bridge dispatch contract imposes no activity timeout of its own
102/// (agent-style activities legitimately run for over an hour), exactly like the
103/// gRPC bridge's unbounded `recv`. Worker loss still terminates the wait
104/// promptly โ€” the awaiter wakes with the typed Disconnected error the moment the
105/// connection closes.
106const BRIDGE_REPLY_POLL: Duration = Duration::from_secs(1);
107
108/// Wire request carrying one scheduled activity to a liminal worker.
109///
110/// Mirrors the dispatch half of the gRPC `ActivityTask`: the fields the worker
111/// needs to execute the activity and to correlate its result back to the exact
112/// execution (`workflow_id`, `ordinal`, `run_id`). `run_id` rides end-to-end so
113/// the existing continue-as-new run gates hold over the liminal wire (the design
114/// doc ยง3.3 requirement that `RunId` stays on the wire).
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116pub struct DispatchRequest {
117    /// Activity type the worker must execute.
118    pub activity_type: String,
119    /// Workflow that scheduled this fan-out activity. Carried in its serde form
120    /// so no fragile id parsing happens on the wire.
121    pub workflow_id: WorkflowId,
122    /// Pinned ordinal of this activity within the workflow's fan-out range.
123    pub ordinal: u64,
124    /// Run that dispatched this ordinal, when known (continue-as-new safety).
125    pub run_id: Option<RunId>,
126    /// Opaque execution-generation proof echoed verbatim by the worker.
127    pub completion_token: String,
128    /// Stable external-effect key for this run and action site.
129    pub idempotency_key: String,
130    /// Opaque activity input bytes (JSON-tagged on the aion side).
131    pub input: Vec<u8>,
132    /// One-based delivery attempt, mirroring the gRPC `ActivityTask.attempt`.
133    /// The engine-seam bridge threads the real attempt so a retry executes with
134    /// attempt-aware handler semantics identical to the gRPC transport; the
135    /// outbox path stamps the row's stored zero-based attempt as one-based.
136    /// Serde-defaulted to `1` so a frame from a pre-attempt server (or an old
137    /// recorded frame) still decodes as a first delivery.
138    #[serde(default = "first_attempt")]
139    pub attempt: u32,
140    /// Engine-provided routing/metadata labels, mirroring the gRPC
141    /// `ActivityTask.labels`. Empty (the serde default) on the outbox path,
142    /// which has no label source.
143    #[serde(default)]
144    pub labels: std::collections::BTreeMap<String, String>,
145    /// The server's heartbeat window in milliseconds when this dispatch is
146    /// tracked by the server's per-task liveness tracker (the engine-seam
147    /// bridge path), or `0` when it is not (the outbox path, whose liveness
148    /// backstop is its own retry loop). A non-zero window tells the worker to
149    /// pump automatic liveness beats at a quarter-window cadence so the
150    /// server's heartbeat sweeper never expires a healthy long-running
151    /// activity โ€” the exact liminal mirror of the gRPC worker's automatic
152    /// liveness pump.
153    #[serde(default)]
154    pub heartbeat_window_ms: u64,
155}
156
157/// Serde default for [`DispatchRequest::attempt`]: a frame that predates the
158/// attempt field is a first delivery.
159const fn first_attempt() -> u32 {
160    1
161}
162
163impl SchemaValidate for DispatchRequest {
164    fn schema_metadata() -> SchemaMetadata {
165        SchemaMetadata::new(
166            "aion.outbox.dispatch.request",
167            "1",
168            br#"{"type":"object"}"#.as_slice(),
169        )
170    }
171}
172
173/// Wire response carrying one worker result back to the outbox.
174///
175/// Mirrors the completion half of the gRPC `ActivityResult`: the correlation ids
176/// plus either a success result or a failure reason. `LiminalCompletionSource`
177/// maps this onto the existing [`OutboxDeliveryCallback`].
178#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
179pub struct DispatchResponse {
180    /// Workflow the completion belongs to.
181    pub workflow_id: WorkflowId,
182    /// Pinned ordinal the completion correlates against.
183    pub ordinal: u64,
184    /// Run that issued the dispatch, echoed back for the run gate.
185    pub run_id: Option<RunId>,
186    /// Opaque execution-generation proof echoed from the request.
187    pub completion_token: String,
188    /// Worker outcome: `Ok(result)` or `Err(reason)`.
189    pub outcome: Result<String, String>,
190}
191
192impl SchemaValidate for DispatchResponse {
193    fn schema_metadata() -> SchemaMetadata {
194        SchemaMetadata::new(
195            "aion.outbox.dispatch.response",
196            "1",
197            br#"{"type":"object"}"#.as_slice(),
198        )
199    }
200}
201
202/// Wire request carrying one neutral mid-run intervention command to a liminal
203/// worker (NOI-6, ยง6.2).
204///
205/// Rides the SAME liminal server-push channel as [`DispatchRequest`], distinguished
206/// on the wire by its unique required `intervention` field โ€” a plain
207/// [`DispatchRequest`] has no such field, so the worker demuxes the two by which
208/// one deserializes. The whole envelope is neutral: it carries an
209/// [`InterventionCommand`], never a harness type. Field-for-field mirrored by the
210/// worker's `liminal::InterventionRequest`.
211#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
212pub struct InterventionRequest {
213    /// The neutral command to route to the worker owning the target attempt.
214    pub intervention: aion_core::InterventionCommand,
215}
216
217impl SchemaValidate for InterventionRequest {
218    fn schema_metadata() -> SchemaMetadata {
219        SchemaMetadata::new(
220            "aion.intervention.request",
221            "1",
222            br#"{"type":"object"}"#.as_slice(),
223        )
224    }
225}
226
227/// Wire response carrying the worker's neutral intervention ack back to the server
228/// (NOI-6). Field-for-field mirrored by the worker's `liminal::InterventionReply`.
229#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
230pub struct InterventionReply {
231    /// The neutral applied/gated/stale outcome the operator receives.
232    pub outcome: aion_core::InterventionOutcome,
233}
234
235impl SchemaValidate for InterventionReply {
236    fn schema_metadata() -> SchemaMetadata {
237        SchemaMetadata::new(
238            "aion.intervention.reply",
239            "1",
240            br#"{"type":"object"}"#.as_slice(),
241        )
242    }
243}
244
245/// Reserved liminal channel a worker publishes automatic liveness beats on.
246///
247/// The liminal wire has no gRPC-style heartbeat frame, so per-task liveness
248/// rides a reserved publish channel exactly as the observability transcript
249/// does: the worker's runtime pumps a [`WorkerLivenessBeat`] per in-flight
250/// tracked dispatch at a quarter-window cadence, and the server's
251/// [`LiminalConnectionNotifier`] consumes the channel and refreshes the shared
252/// [`HeartbeatTracker`] โ€” so the #176 expiry sweeper is genuinely
253/// transport-agnostic: a healthy liminal worker running a long activity is
254/// never falsely expired, and a wedged one (which stops pumping) still is.
255/// Mirrored byte-for-byte by the worker crate's constant of the same name.
256pub const WORKER_LIVENESS_CHANNEL: &str = "aion.worker.liveness";
257
258/// Reserved liminal channel a worker announces its intervention capabilities on.
259///
260/// The in-band [`WireWorkerRegistration`] frame is a published liminal protocol
261/// type and cannot carry aion-level capability metadata, so a worker whose
262/// harness supports interventions publishes a [`WorkerCapabilitiesAnnouncement`]
263/// here immediately after registering (once per connection, so a redialed
264/// worker re-announces). The notifier consumes the channel and applies the
265/// announcement to the registered handle โ€” the set the intervention router
266/// gates on and the ops console's live-attempts enumeration reports. Mirrored
267/// byte-for-byte by the worker crate's constant of the same name.
268pub const WORKER_CAPABILITIES_CHANNEL: &str = "aion.worker.capabilities";
269
270/// Wire announcement of a worker's advertised intervention capabilities.
271///
272/// Field-for-field mirror of the worker crate's `WorkerCapabilitiesAnnouncement`
273/// (the same cross-crate contract the dispatch/response pairs pin). The worker
274/// is identified by its CONNECTION (the publish's pid resolves the registered
275/// worker), never by wire-supplied identity, so an announcement can only ever
276/// apply to the worker that sent it.
277#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
278pub struct WorkerCapabilitiesAnnouncement {
279    /// The neutral intervention primitives the worker's harness supports.
280    pub capabilities: aion_core::InterventionCapabilities,
281}
282
283/// Wire liveness beat for one in-flight dispatch (the liminal mirror of the
284/// gRPC `Heartbeat` frame, liveness-only โ€” progress payloads are not carried).
285///
286/// Field-for-field mirror of the worker crate's `WorkerLivenessBeat` (same
287/// serde field names + `aion-core` id types), the same cross-crate contract the
288/// dispatch/response pairs pin. The worker is identified by its CONNECTION (the
289/// publish's pid resolves the registered worker), never by wire-supplied
290/// identity, so a beat can only ever refresh tasks of the worker that sent it.
291#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
292pub struct WorkerLivenessBeat {
293    /// Workflow owning the in-flight activity being kept alive.
294    pub workflow_id: WorkflowId,
295    /// Pinned ordinal of the in-flight activity being kept alive.
296    pub ordinal: u64,
297}
298
299/// The single reserved character that separates channel segments. Because
300/// `namespace`/`task_queue` are free-form, any occurrence of this byte INSIDE a
301/// segment must be escaped so it cannot be mistaken for the segment boundary.
302const SEGMENT_SEPARATOR: char = '.';
303
304/// The escape character used by [`encode_segment`]. It must itself be escaped so
305/// the encoding stays injective (otherwise `%2E` as a literal field value would
306/// collide with an encoded `.`).
307const SEGMENT_ESCAPE: char = '%';
308
309/// Percent-encodes the two reserved characters (`.` and `%`) inside one channel
310/// segment so distinct segment values can never collide across the join.
311///
312/// This is a minimal, deterministic, per-segment escape: a literal `.` becomes
313/// `%2E` and a literal `%` becomes `%25`; every other byte (including the empty
314/// string) passes through unchanged. Because both the separator AND the escape
315/// char are encoded, the mapping `value -> encoded` is injective: it is exactly
316/// reversible by replacing `%2E -> .` and `%25 -> %`, so two distinct values
317/// can never encode to the same string. Dot-free, percent-free inputs (the
318/// normal case, e.g. `"remote"`, `"gpu"`) are returned byte-for-byte unchanged,
319/// so existing channels are stable.
320fn encode_segment(segment: &str) -> String {
321    // Fast path: nothing reserved, return an owned copy unchanged.
322    if !segment.contains([SEGMENT_SEPARATOR, SEGMENT_ESCAPE]) {
323        return segment.to_owned();
324    }
325    let mut encoded = String::with_capacity(segment.len());
326    for ch in segment.chars() {
327        match ch {
328            // Encode the escape char FIRST so an already-present `%` cannot be
329            // confused with one we introduce for the separator.
330            SEGMENT_ESCAPE => encoded.push_str("%25"),
331            SEGMENT_SEPARATOR => encoded.push_str("%2E"),
332            other => encoded.push(other),
333        }
334    }
335    encoded
336}
337
338/// Derives the liminal dispatch channel for a worker pool addressed
339/// `(namespace, task_queue)`, optionally pinned to a specific `node`.
340///
341/// This is the **single, total source of truth** for the channel string: every
342/// site that needs the channel a `(namespace, task_queue[, node])` pool
343/// dispatches to โ€” both this dispatcher and any future worker-pool subscription
344/// side โ€” MUST call this function so the two sides cannot drift. The format is
345/// `"aion.dispatch.{namespace}.{task_queue}"` for an unpinned dispatch and
346/// `"aion.dispatch.{namespace}.{task_queue}.{node}"` when a `node` is pinned;
347/// each `{segment}` is independently passed through [`encode_segment`].
348///
349/// # The subscriber contract (the seam this function pins, NODE-5 / 13-x)
350///
351/// The subscriber side remains the documented seam (it does not exist yet; 13-0
352/// uses liminal's in-server echo responder). The contract both sides MUST honour:
353///
354/// - An **unpinned** worker pool addressed `(namespace, task_queue)` subscribes
355///   to `dispatch_channel_name(namespace, task_queue, None)` and receives every
356///   unpinned dispatch for that pool.
357/// - A **node-pinned** dispatch (the row carries `Some(node)`) goes to
358///   `dispatch_channel_name(namespace, task_queue, Some(node))`, a DISTINCT
359///   channel. A worker running on that node which is meant to serve pinned work
360///   for the pool MUST ALSO subscribe to that node-specific channel โ€” the
361///   `None` channel alone will never deliver a node-pinned dispatch to it.
362///
363/// Because the `None` channel and any `Some(node)` channel are distinct strings,
364/// a node-pinned dispatch never reaches an unpinned-only subscriber and vice
365/// versa; node isolation is therefore enforced by the channel string itself.
366///
367/// # Injectivity (why the per-segment encode matters)
368///
369/// `namespace`, `task_queue` and `node` are all free-form (the design forbids
370/// preset categories), so a raw `format!` would be NON-injective: a `.` inside
371/// any field bleeds across the separator and pools the design declares disjoint
372/// collide onto one channel โ€” e.g. `("a.b", "c", None)` and `("a", "b.c", None)`
373/// would both yield `aion.dispatch.a.b.c`, a cross-pool leak on the very
374/// isolation dimension this routing exists to keep separate. Encoding each
375/// segment independently (the separator `.` and the escape `%` are escaped within
376/// a segment) makes the map from `(namespace, task_queue, node)` to channel
377/// string injective: distinct triples always yield distinct channels, ACROSS
378/// segment counts too. The node segment is appended only for `Some(node)`, and
379/// because no encoded segment can contain a bare separator, a 2-segment channel
380/// (unpinned) can never be confused with a 3-segment channel (pinned) โ€” e.g.
381/// `("a", "b", Some("c"))` and `("a", "b.c", None)` stay distinct, as do
382/// `("a.b", "c", None)` and `("a", "b", Some("c"))`.
383///
384/// `activity_type` is deliberately NOT part of the channel: it is *what to run*,
385/// matched by the worker after delivery (it rides inside [`DispatchRequest`]),
386/// not *which pool* โ€” see `docs/NAMESPACE-TASKQUEUE-SPLIT-DESIGN.md` ยง4.2. The
387/// function is total (defined for every input) and stable (the same
388/// `(namespace, task_queue, node)` always yields the same channel).
389#[must_use]
390pub fn dispatch_channel_name(namespace: &str, task_queue: &str, node: Option<&str>) -> String {
391    let namespace = encode_segment(namespace);
392    let task_queue = encode_segment(task_queue);
393    match node {
394        Some(node) => {
395            let node = encode_segment(node);
396            format!("aion.dispatch.{namespace}.{task_queue}.{node}")
397        }
398        None => format!("aion.dispatch.{namespace}.{task_queue}"),
399    }
400}
401
402/// Derives the liminal dispatch channel for a claimed outbox row.
403///
404/// Thin wrapper over [`dispatch_channel_name`] reading the row's durable
405/// `(namespace, task_queue)` (NSTQ-2 columns) and its optional `node` (NODE-2):
406/// when `row.node` is `Some`, the row dispatches to the node-pinned sub-channel;
407/// when `None`, it derives the byte-identical unpinned channel. Kept
408/// free-standing so the dispatch path and tests derive the row's channel
409/// identically.
410#[must_use]
411pub fn channel_for_row(row: &OutboxRow) -> String {
412    dispatch_channel_name(&row.namespace, &row.task_queue, row.node.as_deref())
413}
414
415/// Wraps a reason in the existing worker-dispatch error so a non-deliverable
416/// dispatch drives the outbox's unchanged retry/backoff/dead-letter path. The
417/// row-derived `channel` is surfaced as the `activity_type` field for operator
418/// diagnostics (the field is a free-form context string on this transport's
419/// error).
420fn dispatch_error(channel: &str, reason: String) -> ServerError {
421    ServerError::WorkerDispatch {
422        namespace: "liminal".to_owned(),
423        activity_type: channel.to_owned(),
424        reason,
425    }
426}
427
428/// Receives one worker result over liminal and re-enters it into aion.
429///
430/// Holds the installed [`OutboxDeliveryCallback`] (the same prod
431/// `ServerOutboxDeliveryCallback` the gRPC completion path uses) and maps a
432/// [`DispatchResponse`] onto it, threading `run_id` so the continue-as-new run
433/// gates apply unchanged.
434pub struct LiminalCompletionSource {
435    callback: Arc<dyn OutboxDeliveryCallback>,
436    completion_fences: CompletionFences,
437}
438
439impl std::fmt::Debug for LiminalCompletionSource {
440    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441        f.debug_struct("LiminalCompletionSource")
442            .finish_non_exhaustive()
443    }
444}
445
446impl LiminalCompletionSource {
447    /// Build a completion source over the shared outbox delivery callback.
448    #[must_use]
449    pub fn new(callback: Arc<dyn OutboxDeliveryCallback>) -> Self {
450        Self {
451            callback,
452            completion_fences: CompletionFences::default(),
453        }
454    }
455
456    /// Share the authoritative generation registry used by dispatch.
457    #[must_use]
458    pub fn with_completion_fences(mut self, completion_fences: CompletionFences) -> Self {
459        self.completion_fences = completion_fences;
460        self
461    }
462
463    /// Re-enter one worker result into aion through the delivery callback.
464    ///
465    /// Returns the callback's `bool`: `true` when delivered to a live run,
466    /// `false` when no run is live (the expected stale-completion drop that
467    /// recovery re-arms). A success outcome routes to `deliver_completion`; a
468    /// failure outcome to `deliver_failure`.
469    ///
470    /// # Errors
471    ///
472    /// Returns [`ServerError`] when the response carries an unparseable id or
473    /// the engine rejects the delivery.
474    pub fn deliver(&self, response: &DispatchResponse) -> Result<bool, ServerError> {
475        let run_id = response.run_id.as_ref().ok_or_else(|| {
476            dispatch_error(
477                "liminal completion",
478                "activity result run id is missing; refusing run-gate bypass".to_owned(),
479            )
480        })?;
481        let activity_id = ActivityId::from_sequence_position(response.ordinal);
482        let completion_token = CompletionToken::from_wire(
483            &response.workflow_id,
484            &activity_id,
485            response.completion_token.clone(),
486        )?;
487        match self
488            .completion_fences
489            .accept(&response.workflow_id, &activity_id, &completion_token)
490        {
491            Ok(accepted) => {
492                // The consumed generation is bound and deliberately NOT
493                // restored: unlike the bridge, this path has no settlement that
494                // can fail after acceptance โ€” the callback below IS the
495                // settlement and its failure is returned straight to the
496                // caller โ€” so there is no `restore_if_absent` site on this
497                // transport for the slip to feed.
498                tracing::debug!(
499                    workflow_id = %response.workflow_id,
500                    %activity_id,
501                    attempt = accepted.attempt(),
502                    "liminal completion consumed the execution generation"
503                );
504            }
505            Err(error) => {
506                tracing::warn!(
507                    workflow_id = %response.workflow_id,
508                    %activity_id,
509                    %error,
510                    "liminal completion fence rejected a late or stale reply"
511                );
512                return Err(error);
513            }
514        }
515        match &response.outcome {
516            Ok(result) => self.callback.deliver_completion(
517                &response.workflow_id,
518                &activity_id,
519                Some(run_id),
520                result.clone(),
521            ),
522            Err(reason) => self.callback.deliver_failure(
523                &response.workflow_id,
524                &activity_id,
525                Some(run_id),
526                reason.clone(),
527            ),
528        }
529    }
530}
531
532/// Rebuilds the activity input payload from the wire request.
533///
534/// The aion side tags activity input as JSON; the wire carries the raw bytes, so
535/// a worker (or the test responder standing in for one) reconstructs the typed
536/// [`Payload`] with the JSON content type.
537#[must_use]
538pub fn payload_from_request(request: &DispatchRequest) -> Payload {
539    Payload::new(ContentType::Json, request.input.clone())
540}
541
542/// Delivery handle for a liminal-connected worker held in the worker registry.
543///
544/// A worker that connects over liminal is a first-class registry member selected
545/// the SAME way as a gRPC worker (`select_worker` on `(namespace, task_queue,
546/// node)`); this is the delivery leg the registry holds for it. It pairs the
547/// [`ConnectionSupervisor`] that owns the worker's connection with that
548/// connection's beamr `pid`, so [`Self::push_dispatch`] can push a
549/// [`DispatchRequest`] out on the worker's existing socket (the LSUB-0
550/// server-push primitive) and [`receive_bridge_reply`] can block for the
551/// correlated [`DispatchResponse`].
552#[derive(Clone)]
553pub struct LiminalWorkerDelivery {
554    supervisor: ConnectionSupervisor,
555    pid: u64,
556}
557
558impl std::fmt::Debug for LiminalWorkerDelivery {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        f.debug_struct("LiminalWorkerDelivery")
561            .field("pid", &self.pid)
562            .finish_non_exhaustive()
563    }
564}
565
566impl LiminalWorkerDelivery {
567    /// Build a delivery handle for the worker reachable on connection `pid`
568    /// through `supervisor`.
569    #[must_use]
570    pub const fn new(supervisor: ConnectionSupervisor, pid: u64) -> Self {
571        Self { supervisor, pid }
572    }
573
574    /// The connection pid this worker is addressed on.
575    #[must_use]
576    pub const fn pid(&self) -> u64 {
577        self.pid
578    }
579
580    /// Push one dispatch and wait for its reply without an activity-duration
581    /// bound, abandoning only when `keep_waiting` becomes false at a poll
582    /// boundary โ€” [`Self::push_dispatch`] then [`receive_bridge_reply`] in one
583    /// call.
584    ///
585    /// The production arms (the outbox delivery and the engine-seam bridge) do
586    /// NOT call this: they call the two halves themselves because the lease
587    /// accept hook ([`super::task_delivery::DeliveryAccepted`]) must run between
588    /// the push being acknowledged and the reply wait โ€” on liminal the reply IS
589    /// the completion, and a lease recorded after it would be a lease recorded
590    /// after the fact. This composition is for callers outside the crate that
591    /// stand in for a dispatcher (the e2e harnesses), where the receive half is
592    /// not reachable on its own.
593    ///
594    /// # Errors
595    ///
596    /// Returns the same typed push, receive, and decode failures as the shared
597    /// bridge wait.
598    pub fn dispatch_held(
599        &self,
600        request: &DispatchRequest,
601        keep_waiting: impl Fn() -> bool,
602    ) -> Result<Option<DispatchResponse>, ServerError> {
603        let awaiter = self.push_dispatch(request)?;
604        receive_bridge_reply(&awaiter, keep_waiting)
605    }
606
607    /// Serialize and push one dispatch out on the worker's connection, returning
608    /// the awaiter for its correlated reply โ€” the shared push half of both
609    /// unbounded waits: the outbox arm (`LiminalTaskDelivery`) and the
610    /// engine-seam bridge dispatcher both push here and wait with
611    /// [`receive_bridge_reply`], matching the gRPC bridge contract. One frame
612    /// format, one push primitive, one held wait policy. The two halves are
613    /// separate calls so the attempt's lease can be recorded between them,
614    /// at the instant the push is acknowledged (WA-010 R3).
615    ///
616    /// DELIBERATELY NO REPLY DEADLINE, unlike the liveness and intervention
617    /// pushes. A dispatch's reply IS the activity's completion, so its lifetime
618    /// is the activity's โ€” `remote_gates.awl` declares legs at `timeout 45m`.
619    /// [`receive_bridge_reply`] re-arms on every poll timeout and waits
620    /// unbounded by contract, so this push NEVER abandons its slot and cannot
621    /// leak one: the slot is held for exactly as long as work is outstanding
622    /// and resolves when the reply lands. Attaching a deadline here would kill
623    /// long activities, and would be a regression, not a hardening. The leak
624    /// fixed on the other two sites came from ABANDONING a no-deadline slot,
625    /// which this site never does.
626    ///
627    /// # Errors
628    ///
629    /// Returns [`ServerError::WorkerBusy`] when liminal's typed connection cap
630    /// refuses admission, [`ServerError::WorkerConnectionLost`] for every other
631    /// push-enqueue failure, and [`ServerError::WorkerDispatch`] when the request
632    /// cannot be serialized.
633    pub(crate) fn push_dispatch(
634        &self,
635        request: &DispatchRequest,
636    ) -> Result<PushReplyAwaiter, ServerError> {
637        let payload = serde_json::to_vec(request).map_err(|error| {
638            dispatch_error("liminal-push", format!("request serialize failed: {error}"))
639        })?;
640        self.supervisor
641            .push_to_connection(self.pid, payload)
642            .map_err(|error| classify_push_error(&error))
643    }
644
645    /// Push already-encoded `payload` out on the worker's connection with an
646    /// explicit reply `deadline`, returning the awaiter for its correlated
647    /// reply.
648    ///
649    /// The raw push primitive behind the typed pushes above, used by the
650    /// connection liveness probe
651    /// ([`LivenessProbe`](super::liminal_liveness::LivenessProbe)) so the
652    /// dead-man switch rides the SAME server-push leg a dispatch does โ€” a ping
653    /// that reaches the worker proves the exact path a dispatch would take, not
654    /// a parallel one that could be healthy while the real one is not.
655    ///
656    /// The deadline is LOAD-BEARING and is why this is not
657    /// `push_to_connection`. Riding the dispatch leg means sharing its bounded
658    /// ยง5 `max_pending_pushes_per_connection` admission, and a no-deadline slot
659    /// is reclaimed only by a consumed reply or a connection close โ€” never by
660    /// the caller giving up. A probe that abandons an unanswered ping every
661    /// cadence therefore LEAKS one slot per round until the cap is exhausted,
662    /// at which point no dispatch, intervention or ping can be pushed on that
663    /// connection again. Measured live on run `dfd2117c`: 32 abandoned pings,
664    /// then permanent refusal, โ‰ˆ240 s from first blocked ping. The probe built
665    /// to prove the dispatch path works is what destroyed it. With a deadline,
666    /// expiry removes the slot and RELEASES its cap admission.
667    ///
668    /// # Errors
669    ///
670    /// Returns [`ServerError::WorkerBusy`] when liminal's typed connection cap
671    /// refuses admission, and [`ServerError::WorkerConnectionLost`] for every
672    /// other push-enqueue failure.
673    pub fn push_payload_with_deadline(
674        &self,
675        payload: Vec<u8>,
676        deadline: Duration,
677    ) -> Result<PushReplyAwaiter, ServerError> {
678        self.supervisor
679            .push_to_connection_with_deadline(self.pid, payload, deadline)
680            .map_err(|error| classify_push_error(&error))
681    }
682
683    /// Push one neutral intervention command out on the worker's connection and
684    /// block for its correlated ack reply (NOI-6, ยง6.2).
685    ///
686    /// Uses the same connection as activity dispatch but carries an
687    /// [`InterventionRequest`] and decodes an [`InterventionReply`], so it rides the SAME server-push
688    /// channel as an activity dispatch. The push is a blocking, thread-based liminal
689    /// call; the async router runs it off the runtime.
690    ///
691    /// # Errors
692    ///
693    /// Returns [`ServerError::WorkerConnectionLost`] when the worker connection was
694    /// gone at push time or closed before replying (so the router surfaces the
695    /// too-late no-op); returns [`ServerError::WorkerDispatch`] when the request
696    /// cannot be serialized, the reply times out, or the reply cannot be decoded.
697    pub fn push_intervention(
698        &self,
699        request: &InterventionRequest,
700    ) -> Result<InterventionReply, ServerError> {
701        let payload = serde_json::to_vec(request).map_err(|error| {
702            dispatch_error(
703                "liminal-push",
704                format!("intervention serialize failed: {error}"),
705            )
706        })?;
707        // Deadline-bounded, and it must be: this wait ABANDONS on timeout (the
708        // `receive` error below is terminal, not a re-arm), so a no-deadline
709        // slot would be held until the connection closed and would count
710        // against the connection's ยง5 pending-push cap forever. The deadline
711        // matches the wait, so an intervention that goes unanswered releases
712        // its admission instead of leaking it. Contrast `push_dispatch`, whose
713        // wait re-arms and is unbounded by contract, and which therefore
714        // correctly takes no deadline.
715        let awaiter = self
716            .supervisor
717            .push_to_connection_with_deadline(self.pid, payload, PUSH_REPLY_TIMEOUT)
718            .map_err(|error| {
719                ServerError::worker_connection_lost(
720                    "liminal-push",
721                    format!("push intervention to worker failed: {error}"),
722                )
723            })?;
724        let reply = awaiter.receive(PUSH_REPLY_TIMEOUT).map_err(|error| {
725            if is_connection_closed_reply_error(&error) {
726                ServerError::worker_connection_lost(
727                    "liminal-push",
728                    format!("worker connection closed before intervention ack: {error}"),
729                )
730            } else {
731                dispatch_error("liminal-push", format!("intervention ack failed: {error}"))
732            }
733        })?;
734        serde_json::from_slice(&reply).map_err(|error| {
735            dispatch_error(
736                "liminal-push",
737                format!("intervention ack decode failed: {error}"),
738            )
739        })
740    }
741}
742
743/// Decodes one correlated reply payload as a [`DispatchResponse`].
744///
745/// Shared by the outbox arm and the bridge โ€” both wait through
746/// [`receive_bridge_reply`] โ€” so the two paths can never diverge on the wire's
747/// reply shape.
748fn decode_dispatch_response(reply: &[u8]) -> Result<DispatchResponse, ServerError> {
749    serde_json::from_slice(reply).map_err(|error| {
750        dispatch_error(
751            "liminal-push",
752            format!("worker reply decode failed: {error}"),
753        )
754    })
755}
756
757/// Blocks for the correlated reply to an engine-seam BRIDGE dispatch push, with
758/// the bridge's UNBOUNDED wait contract: the engine imposes no activity timeout
759/// of its own, so an elapsed [`BRIDGE_REPLY_POLL`] merely re-arms the wait โ€” the
760/// exact liminal mirror of the gRPC bridge's unbounded `recv`, which is released
761/// only by a completion or by stream teardown. The wait terminates on exactly:
762///
763/// - **the reply** โ€” decoded as the worker's [`DispatchResponse`] and returned
764///   as `Ok(Some(response))`. A reply already buffered when a poll fires always
765///   wins over abandonment: the awaiter is drained before `keep_waiting` runs;
766/// - **abandonment** โ€” `keep_waiting()` returned `false` at a poll boundary
767///   (the bridge passes "is this dispatch still tracked in-flight?"): the
768///   dispatch was resolved by another path (expiry sweep, shutdown drain, or a
769///   cleanup), so the wait returns `Ok(None)` and the router thread exits
770///   instead of parking on the connection indefinitely;
771/// - **worker loss** โ€” the connection closed before replying: the awaiter wakes
772///   PROMPTLY with liminal's typed Disconnected error, surfaced as
773///   [`ServerError::WorkerConnectionLost`] so the bridge reports the same
774///   TRANSPORT-loss class the gRPC teardown sweep does;
775/// - **an unrecognized receive fault or a decode failure** โ€” surfaced as
776///   [`ServerError::WorkerDispatch`].
777///
778/// Runs on a dedicated bridge reply thread, never on an async runtime worker.
779///
780/// # Errors
781///
782/// Returns [`ServerError::WorkerConnectionLost`] on worker loss and
783/// [`ServerError::WorkerDispatch`] on a receive fault or reply decode failure.
784pub(crate) fn receive_bridge_reply(
785    awaiter: &PushReplyAwaiter,
786    keep_waiting: impl Fn() -> bool,
787) -> Result<Option<DispatchResponse>, ServerError> {
788    loop {
789        match awaiter.receive(BRIDGE_REPLY_POLL) {
790            Ok(reply) => return decode_dispatch_response(&reply).map(Some),
791            // A bare poll timeout re-arms the wait while the dispatch is still
792            // outstanding (the wait is unbounded by contract; see the bridge
793            // module docs), and ends it cleanly once the dispatch was resolved
794            // elsewhere โ€” the poll cadence bounds the router thread's lifetime
795            // to one poll past that resolution.
796            Err(LiminalServerError::PushReplyTimeout { .. }) => {
797                if !keep_waiting() {
798                    return Ok(None);
799                }
800            }
801            Err(error) if is_connection_closed_reply_error(&error) => {
802                return Err(ServerError::worker_connection_lost(
803                    "liminal-push",
804                    format!("worker connection closed before reply: {error}"),
805                ));
806            }
807            Err(error) => {
808                return Err(dispatch_error(
809                    "liminal-push",
810                    format!("worker reply failed: {error}"),
811                ));
812            }
813        }
814    }
815}
816
817/// Returns true when a liminal push-reply error is the *Disconnected* case (the
818/// worker's connection closed before it replied), as opposed to a genuine reply
819/// timeout (the worker is alive but slow).
820///
821/// Liminal returns these as distinct TYPED variants โ€”
822/// `ServerError::PushReplyDisconnected` vs `PushReplyTimeout` (see `liminal-server`
823/// `supervisor.rs` `PushReplyAwaiter::receive`) โ€” so this is a type match, not a
824/// message-text match: a worker that DIED (connection-lost, fast failover) is told
825/// apart from one that is merely SLOW (genuine timeout, normal backoff) by variant.
826fn is_connection_closed_reply_error(error: &LiminalServerError) -> bool {
827    matches!(error, LiminalServerError::PushReplyDisconnected { .. })
828}
829
830/// Classify a typed liminal push refusal without relying on error text.
831fn classify_push_error(error: &LiminalServerError) -> ServerError {
832    if is_connection_cap_error(error) {
833        ServerError::worker_busy(
834            "liminal-push",
835            format!("worker connection push cap reached: {error}"),
836        )
837    } else {
838        ServerError::worker_connection_lost(
839            "liminal-push",
840            format!("push to worker failed: {error}"),
841        )
842    }
843}
844
845/// Liminal's typed admission refusal when a connection has too many held pushes.
846fn is_connection_cap_error(error: &LiminalServerError) -> bool {
847    matches!(error, LiminalServerError::ConnectionCapReached { .. })
848}
849
850/// RAII guard that releases an [`AttemptOwnerIndex`](super::intervention::AttemptOwnerIndex)
851/// binding when the dispatch resolves โ€” on the reply, an error, or a panic โ€” so
852/// the back-index tracks exactly the attempts currently in flight (NOI-6).
853///
854/// Shared by both liminal dispatch arms: the outbox row wait holds it across
855/// its blocking `dispatch` call, and the engine-seam bridge hands it to the
856/// dispatch's reply-router thread, which drops it on every exit path.
857pub(crate) struct AttemptOwnerGuard {
858    owners: super::intervention::AttemptOwnerIndex,
859    key: super::intervention::AttemptKey,
860}
861
862impl AttemptOwnerGuard {
863    /// Bind `key` to `worker` in `owners` and return the guard that releases
864    /// the binding on drop.
865    pub(crate) fn bind(
866        owners: super::intervention::AttemptOwnerIndex,
867        key: super::intervention::AttemptKey,
868        worker: super::registry::WorkerId,
869    ) -> Self {
870        owners.bind(key.clone(), worker);
871        Self { owners, key }
872    }
873}
874
875impl Drop for AttemptOwnerGuard {
876    fn drop(&mut self) {
877        self.owners.release(&self.key);
878    }
879}
880
881/// Normalize a wire `node` (`Option<String>`) onto the registry's optional
882/// locality affinity, applying the SAME none-convention the gRPC registration
883/// path uses (`registry::optional_node`): an empty string carries no node, so it
884/// collapses to `None`; any non-empty value is the worker's advertised node.
885///
886/// The wire already models `node` as `Option<String>`, but a worker that joins
887/// `Some("")` (the empty-string node) must not register a distinct empty-node
888/// affinity that no pinned dispatch could ever match โ€” it is semantically
889/// unpinned, exactly as the gRPC proto3 empty default is. Folding it to `None`
890/// here keeps the two registration paths byte-for-byte equivalent.
891fn normalize_wire_node(node: Option<&str>) -> Option<String> {
892    node.filter(|value| !value.is_empty())
893        .map(ToOwned::to_owned)
894}
895
896/// Connection-keyed [`ConnectionNotifier`] that turns liminal's in-band worker
897/// registration into a first-class [`ConnectedWorkerRegistry`] membership.
898///
899/// This is the SERVER half of LSUB-L2: when a worker connects with a
900/// [`WireWorkerRegistration`] (the SDK's `connect_with_registration`), liminal's
901/// connection process invokes [`on_worker_registered`](Self::on_worker_registered)
902/// with the connection's beamr `pid` and the worker's declared
903/// `(namespaces, task_queue, node, activity_types)`. The notifier builds a
904/// [`WorkerDelivery::Liminal`] over the connection and inserts it into the
905/// registry โ€” the SAME registry entry, selected the SAME way, as a gRPC worker โ€”
906/// retiring the LSUB-1 out-of-band `active_connection_pids()` + hard-coded
907/// registration hack.
908///
909/// # Lifetime of the registration guard
910///
911/// [`ConnectedWorkerRegistry::register_delivery`] returns a
912/// [`WorkerRegistration`] guard whose drop deregisters the worker. The notifier
913/// OWNS that guard keyed by `pid` (`Mutex<HashMap<u64, WorkerRegistration>>`), so
914/// the registration lives exactly as long as the connection: it is inserted on
915/// register and removed (dropped) on
916/// [`on_worker_unregistered`](Self::on_worker_unregistered), which liminal fires
917/// on connection close.
918///
919/// # Construction-order cycle (notifier <-> supervisor)
920///
921/// [`LiminalWorkerDelivery`] needs a [`ConnectionSupervisor`] handle to push to
922/// the worker's connection, but the supervisor is itself constructed WITH this
923/// notifier ([`ConnectionSupervisor::with_services_and_notifier`]) โ€” a cycle. The
924/// notifier therefore holds the supervisor behind a [`OnceLock`], populated
925/// IMMEDIATELY after the supervisor is built via [`Self::bind_supervisor`]. The
926/// `OnceLock` is never read before it is set in correct wiring (a worker can only
927/// register after the listener โ€” built after the supervisor and after
928/// `bind_supervisor` โ€” accepts its connection); if it somehow were, registration
929/// is REJECTED with a typed error rather than panicking, so there is no
930/// production `unwrap`/`expect` and no second always-`None` code path.
931pub struct LiminalConnectionNotifier {
932    registry: ConnectedWorkerRegistry,
933    contract_catalog: Option<Arc<aion::Engine>>,
934    supervisor: OnceLock<ConnectionSupervisor>,
935    guards: Mutex<HashMap<u64, WorkerRegistration>>,
936    /// The neutral intervention primitives a liminal-connected agent worker
937    /// advertises (NOI-6, item 4). The liminal `WorkerRegistration` wire has a fixed
938    /// shape that cannot carry this, so it is configured on the notifier at the
939    /// composition root from the harness's advertised `AgentSession::capabilities()`
940    /// and recorded on every registered worker's handle, where the intervention
941    /// router gates on it. Default empty = observability-only (a plain activity
942    /// worker), so the router offers no controls for it.
943    intervention_capabilities: aion_core::InterventionCapabilities,
944    /// The transcript sequencer a worker's observability publishes drain into
945    /// (NOI-5b), plus the Tokio [`Handle`](tokio::runtime::Handle) to bridge the
946    /// synchronous connection-process callback onto the async publish. `None` (the
947    /// default, and every non-agent boot) makes the observability tap a no-op, so a
948    /// worker publish to the reserved channel is simply ignored by the notifier.
949    transcript: Option<TranscriptTap>,
950    /// The SAME per-task liveness tracker the bridge dispatcher tracks into and
951    /// the #176 expiry sweeper expires from. A worker's automatic
952    /// [`WorkerLivenessBeat`] publishes on [`WORKER_LIVENESS_CHANNEL`] refresh
953    /// their task stamps here, keeping the sweeper honest for liminal-delivered
954    /// dispatches. `None` (a wiring that never bridges dispatches, e.g. isolated
955    /// tests) consumes and drops the beats.
956    heartbeat_tracker: Option<super::heartbeat::HeartbeatTracker>,
957    /// The namespace admission every registration passes through, bound by
958    /// the commissioning path. `None` FAILS CLOSED: a notifier nobody bound an
959    /// admission to refuses every registration by name rather than admitting
960    /// unguarded, which is exactly the defect this field closes.
961    admission: Option<RegistrationAdmission>,
962}
963
964/// The observability-drain leg of the notifier: a bounded, ORDERED queue into
965/// the one drain task that publishes transcript events sequentially.
966///
967/// One consumer is load-bearing, not an implementation detail: a spawned task
968/// per event (the pre-2026-07-23 shape) made every in-flight frame a
969/// concurrent writer racing the same stream head, turning the sequencer's
970/// optimistic-append loop into an O(Nยฒ) conflict stampede under load โ€” and
971/// destroyed `worker_seq` order on the durable transcript. Sequential
972/// draining preserves arrival order and leaves the conflict-retry loop to
973/// handle only genuine cross-process races (failover adoption).
974#[derive(Clone)]
975struct TranscriptTap {
976    queue: tokio::sync::mpsc::Sender<aion_core::ActivityEvent>,
977}
978
979/// Bound on the transcript drain queue: events a slow durable append cannot
980/// keep up with are dropped (with a warning) rather than buffered without
981/// limit. Live streaming is best-effort; the bound protects server memory.
982const TRANSCRIPT_QUEUE_CAPACITY: usize = 4096;
983
984impl std::fmt::Debug for LiminalConnectionNotifier {
985    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
986        f.debug_struct("LiminalConnectionNotifier")
987            .field("supervisor_bound", &self.supervisor.get().is_some())
988            .finish_non_exhaustive()
989    }
990}
991
992impl LiminalConnectionNotifier {
993    /// Build a notifier that registers connecting workers into `registry`.
994    ///
995    /// The supervisor handle is bound separately via [`Self::bind_supervisor`]
996    /// immediately after the supervisor is constructed, resolving the
997    /// notifier <-> supervisor construction cycle (see the type docs).
998    #[must_use]
999    pub fn new(registry: ConnectedWorkerRegistry) -> Self {
1000        Self {
1001            registry,
1002            contract_catalog: None,
1003            supervisor: OnceLock::new(),
1004            guards: Mutex::new(HashMap::new()),
1005            intervention_capabilities: aion_core::InterventionCapabilities::none(),
1006            transcript: None,
1007            heartbeat_tracker: None,
1008            admission: None,
1009        }
1010    }
1011
1012    /// Bind the namespace admission a liminal registration is judged by โ€”
1013    /// the same guard, mint-or-gate and placement admission the gRPC path
1014    /// applies โ€” and the Tokio runtime `handle` the synchronous
1015    /// connection-process callback bridges onto to run it.
1016    ///
1017    /// The handle is named, never captured from ambient context: the e2e
1018    /// harnesses build this notifier in a sync `start()` BEFORE they build
1019    /// their runtime, and a capture there was `None`, refusing every
1020    /// registration on the very tests meant to exercise it while the
1021    /// production path (built inside `run_server`) happened to work. A
1022    /// listener commissioned outside any runtime has no handle to give and
1023    /// refuses to be commissioned, at the call site, by name.
1024    ///
1025    /// `auth_enabled` mirrors `auth.enabled`: the liminal protocol's
1026    /// registration frame carries an `identity` string and no credential, so
1027    /// on a server that authenticates callers a liminal registration cannot
1028    /// be scoped and is refused by name; on an auth-off server it is admitted
1029    /// as the operator identity, exactly as a gRPC registration with no
1030    /// metadata is.
1031    #[must_use]
1032    pub fn with_admission(
1033        mut self,
1034        guard: NamespaceGuard,
1035        auth_enabled: bool,
1036        handle: tokio::runtime::Handle,
1037    ) -> Self {
1038        self.admission = Some(RegistrationAdmission {
1039            guard,
1040            auth_enabled,
1041            handle,
1042        });
1043        self
1044    }
1045
1046    /// Install the live package catalog used to refuse incompatible workers
1047    /// before liminal acknowledges or publishes their registration.
1048    #[must_use]
1049    pub fn with_contract_catalog(mut self, engine: Arc<aion::Engine>) -> Self {
1050        self.contract_catalog = Some(engine);
1051        self
1052    }
1053
1054    /// Install the shared per-task liveness tracker so a worker's automatic
1055    /// [`WorkerLivenessBeat`] publishes refresh the SAME in-flight entries the
1056    /// bridge dispatcher tracks and the #176 sweeper expires.
1057    ///
1058    /// MUST be wired on every boot that hosts the engine-seam bridge (the
1059    /// production composition does), or the sweeper would expire healthy
1060    /// liminal workers running activities longer than the heartbeat window.
1061    /// Without it (isolated tests that never bridge-dispatch) beats are
1062    /// consumed and dropped.
1063    #[must_use]
1064    pub fn with_heartbeat_tracker(mut self, tracker: super::heartbeat::HeartbeatTracker) -> Self {
1065        self.heartbeat_tracker = Some(tracker);
1066        self
1067    }
1068
1069    /// Install the transcript sequencer a worker's observability publishes drain into
1070    /// (NOI-5b), capturing the CURRENT Tokio runtime handle to bridge the synchronous
1071    /// connection-process callback onto the async publish.
1072    ///
1073    /// MUST be called from within a Tokio runtime (the server boot path is), so the
1074    /// captured [`Handle`](tokio::runtime::Handle) can spawn the append+fan-out when a
1075    /// worker publishes a transcript event over the reserved channel. Without this
1076    /// builder the observability tap is a no-op (a plain, non-agent deployment).
1077    ///
1078    /// # Panics
1079    ///
1080    /// Panics if called outside a Tokio runtime โ€” a construction-time wiring error in
1081    /// the server boot, never a runtime condition (the boot path always builds the
1082    /// notifier inside the server runtime).
1083    #[must_use]
1084    pub fn with_transcript_publisher(
1085        mut self,
1086        publisher: crate::activity_publisher::ActivityEventPublisher,
1087    ) -> Self {
1088        let (queue, mut events) =
1089            tokio::sync::mpsc::channel::<aion_core::ActivityEvent>(TRANSCRIPT_QUEUE_CAPACITY);
1090        // The ONE drain task: transcript events publish sequentially in
1091        // arrival order (see the `TranscriptTap` docs for why one consumer is
1092        // load-bearing). It lives as long as any queue sender does.
1093        //
1094        // The drain COALESCES under the operator's flush policy: the events
1095        // waiting on this queue are committed as batches, so a chatty agent
1096        // costs one storage-tree commit per batch instead of one per event โ€”
1097        // and one commit is one whole-leaf rewrite (forensics 2026-08-17).
1098        // Arrival order is untouched; only the number of commits changes.
1099        tokio::runtime::Handle::current().spawn(async move {
1100            let dropped = publisher.drain(&mut events, "observability_tap").await;
1101            if dropped > 0 {
1102                tracing::warn!(
1103                    dropped,
1104                    operation = "observability_tap",
1105                    "observability tap: transcript events were not retained"
1106                );
1107            }
1108        });
1109        self.transcript = Some(TranscriptTap { queue });
1110        self
1111    }
1112
1113    /// Set the neutral intervention capability set every worker registering through
1114    /// this notifier advertises (NOI-6, item 4).
1115    ///
1116    /// The composition root wires this from the harness's advertised
1117    /// `AgentSession::capabilities()` so a liminal-connected agent worker's handle
1118    /// carries the primitives its harness supports, which the intervention router
1119    /// gates on. Without this builder the set is empty (observability-only), so a
1120    /// plain activity worker advertises no controls. Pure builder addition, mirroring
1121    /// the registry's capability-carrying registration faรงade.
1122    #[must_use]
1123    pub fn with_intervention_capabilities(
1124        mut self,
1125        capabilities: aion_core::InterventionCapabilities,
1126    ) -> Self {
1127        self.intervention_capabilities = capabilities;
1128        self
1129    }
1130
1131    /// Bind the connection supervisor the notifier pushes through, immediately
1132    /// after it is constructed with this notifier.
1133    ///
1134    /// Returns `true` when the supervisor was stored, `false` when it was already
1135    /// bound (a second bind is a wiring bug and is ignored, never overwriting the
1136    /// live handle). Call this exactly once, right after
1137    /// [`ConnectionSupervisor::with_services_and_notifier`].
1138    pub fn bind_supervisor(&self, supervisor: ConnectionSupervisor) -> bool {
1139        self.supervisor.set(supervisor).is_ok()
1140    }
1141
1142    /// Snapshot of every live liminal connection the dead-man switch must ping:
1143    /// its pid, the worker it registered, and the push leg to reach it.
1144    ///
1145    /// Built from the SAME guard map that owns each connection's registration,
1146    /// so a connection that has closed (its guard removed) is structurally
1147    /// absent from the snapshot and is never pinged. A poisoned guard map is
1148    /// recovered rather than skipped: silently returning no targets would turn
1149    /// the dead-man switch off exactly when the server is already unhealthy.
1150    #[must_use]
1151    pub fn liveness_targets(&self) -> Vec<super::liminal_liveness::LivenessTarget> {
1152        let Some(supervisor) = self.supervisor.get() else {
1153            return Vec::new();
1154        };
1155        let guards = match self.guards.lock() {
1156            Ok(guards) => guards,
1157            Err(poisoned) => poisoned.into_inner(),
1158        };
1159        guards
1160            .iter()
1161            .filter_map(|(pid, guard)| {
1162                guard
1163                    .worker_id()
1164                    .map(|worker_id| super::liminal_liveness::LivenessTarget {
1165                        pid: *pid,
1166                        worker_id,
1167                        delivery: LiminalWorkerDelivery::new(supervisor.clone(), *pid),
1168                    })
1169            })
1170            .collect()
1171    }
1172
1173    /// Refresh one worker's in-flight task stamp from a [`WorkerLivenessBeat`]
1174    /// published on [`WORKER_LIVENESS_CHANNEL`].
1175    ///
1176    /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1177    /// registration guard this notifier owns), never by wire identity, so a
1178    /// beat can only refresh tasks assigned to the worker that sent it. An
1179    /// untracked task is benign (an outbox dispatch the tracker never held, or
1180    /// a beat racing its completion) and is dropped silently; a malformed
1181    /// payload or unregistered connection is logged and dropped โ€” a bad beat
1182    /// must never tear down the connection callback.
1183    fn record_liveness_beat(&self, pid: u64, payload: &[u8]) {
1184        let Some(tracker) = &self.heartbeat_tracker else {
1185            return;
1186        };
1187        let beat: WorkerLivenessBeat = match serde_json::from_slice(payload) {
1188            Ok(beat) => beat,
1189            Err(error) => {
1190                tracing::warn!(%error, "liveness tap: malformed WorkerLivenessBeat payload");
1191                return;
1192            }
1193        };
1194        let worker_id = match self.guards.lock() {
1195            Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1196            Err(poisoned) => poisoned
1197                .into_inner()
1198                .get(&pid)
1199                .and_then(WorkerRegistration::worker_id),
1200        };
1201        let Some(worker_id) = worker_id else {
1202            tracing::warn!(
1203                connection_pid = pid,
1204                "liveness tap: beat from a connection with no registered worker"
1205            );
1206            return;
1207        };
1208        let activity_id = ActivityId::from_sequence_position(beat.ordinal);
1209        if let Err(error) = tracker.record_liveness(
1210            worker_id,
1211            &beat.workflow_id,
1212            &activity_id,
1213            std::time::Instant::now(),
1214        ) {
1215            tracing::error!(
1216                %error,
1217                connection_pid = pid,
1218                "liveness tap: heartbeat tracker refresh failed"
1219            );
1220        }
1221    }
1222
1223    /// Apply one worker's [`WorkerCapabilitiesAnnouncement`] published on
1224    /// [`WORKER_CAPABILITIES_CHANNEL`] to its registered handle.
1225    ///
1226    /// The worker is resolved by the publishing CONNECTION (`pid` -> the
1227    /// registration guard this notifier owns), never by wire identity, so an
1228    /// announcement can only ever update the worker that sent it. A malformed
1229    /// payload or unregistered connection is logged and dropped โ€” a bad
1230    /// announcement must never tear down the connection callback.
1231    fn record_capabilities_announcement(&self, pid: u64, payload: &[u8]) {
1232        let announcement: WorkerCapabilitiesAnnouncement = match serde_json::from_slice(payload) {
1233            Ok(announcement) => announcement,
1234            Err(error) => {
1235                tracing::warn!(
1236                    %error,
1237                    "capabilities tap: malformed WorkerCapabilitiesAnnouncement payload"
1238                );
1239                return;
1240            }
1241        };
1242        let worker_id = match self.guards.lock() {
1243            Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1244            Err(poisoned) => poisoned
1245                .into_inner()
1246                .get(&pid)
1247                .and_then(WorkerRegistration::worker_id),
1248        };
1249        let Some(worker_id) = worker_id else {
1250            tracing::warn!(
1251                connection_pid = pid,
1252                "capabilities tap: announcement from a connection with no registered worker"
1253            );
1254            return;
1255        };
1256        match self
1257            .registry
1258            .set_intervention_capabilities(worker_id, &announcement.capabilities)
1259        {
1260            Ok(true) => {}
1261            Ok(false) => tracing::warn!(
1262                connection_pid = pid,
1263                worker_id = ?worker_id,
1264                "capabilities tap: announcement raced the worker's deregistration"
1265            ),
1266            Err(error) => tracing::error!(
1267                %error,
1268                connection_pid = pid,
1269                "capabilities tap: registry capability update failed"
1270            ),
1271        }
1272    }
1273
1274    fn validate_registration_contract(
1275        &self,
1276        registration: &WireWorkerRegistration,
1277    ) -> Result<(), LiminalServerError> {
1278        let Some(engine) = &self.contract_catalog else {
1279            return Ok(());
1280        };
1281        let advertised =
1282            registration
1283                .activities
1284                .iter()
1285                .map(|activity| {
1286                    let input_schema =
1287                        serde_json::from_str(&activity.input_schema_json).map_err(|error| {
1288                            LiminalServerError::ListenerAccept {
1289                                message: format!(
1290                                    "liminal worker activity `{}` input schema is invalid: {error}",
1291                                    activity.name
1292                                ),
1293                            }
1294                        })?;
1295                    let output_schema = serde_json::from_str(&activity.output_schema_json)
1296                        .map_err(|error| LiminalServerError::ListenerAccept {
1297                            message: format!(
1298                                "liminal worker activity `{}` output schema is invalid: {error}",
1299                                activity.name
1300                            ),
1301                        })?;
1302                    Ok(aion_package::ActivityDescriptor {
1303                        name: activity.name.clone(),
1304                        input_schema,
1305                        output_schema,
1306                    })
1307                })
1308                .collect::<Result<Vec<_>, LiminalServerError>>()?;
1309        // Both advertised forms travel into the gate together: the NAME set the
1310        // dispatcher selects on and the CONTRACT set admission compares. A
1311        // refusal computed from one while the record printed the other is what
1312        // produced the 2026-07-30 self-contradicting log.
1313        let activity_types = registration
1314            .activity_types
1315            .iter()
1316            .cloned()
1317            .collect::<std::collections::BTreeSet<_>>();
1318        super::contracts::validate_worker_contracts(
1319            engine,
1320            self.registry.admission_audit(),
1321            &registration.task_queue,
1322            registration.node.as_deref(),
1323            &registration.identity,
1324            super::contracts::WorkerAdvertisement {
1325                activity_types: &activity_types,
1326                contracts: &advertised,
1327            },
1328        )
1329        .map_err(|error| LiminalServerError::ListenerAccept {
1330            message: error.to_string(),
1331        })
1332    }
1333
1334    /// Admit one connecting worker past the TRANSPORT's own refusals, or refuse
1335    /// it with a structured reason.
1336    ///
1337    /// Split out of [`ConnectionNotifier::on_worker_registered`] so every
1338    /// refusal this transport owns โ€” unbound supervisor, registry error, lease
1339    /// failure โ€” funnels through exactly one place that logs it. Before that
1340    /// split the rejection reason was carried in the `Rejected` ack and logged
1341    /// NOWHERE: on 2026-07-29 a worker whose `.v4` contract field-mismatched was
1342    /// refused on every single dial with the server silent on all of them, and
1343    /// the refused process looked merely asleep.
1344    ///
1345    /// **The contract check is deliberately NOT here.** That 2026-07-29 fix was
1346    /// made in this caller rather than at the shared gate, and within a week the
1347    /// gRPC transport re-manifested the identical silent refusal with nothing in
1348    /// the tree able to notice (#147). The gate now names its own refusal for
1349    /// every transport, so the contract check runs BEFORE this function and its
1350    /// error propagates from here already spoken for.
1351    fn admit_registration(
1352        &self,
1353        pid: u64,
1354        registration: &WireWorkerRegistration,
1355    ) -> Result<(), LiminalServerError> {
1356        // Admit through the SAME gate a gRPC worker passes: guard policy, each
1357        // namespace authorized, mint-or-gate, placement admission, then the
1358        // insert. A notifier with no admission bound refuses โ€” it never
1359        // inserts unguarded.
1360        let Some(admission) = &self.admission else {
1361            return Err(LiminalServerError::ListenerAccept {
1362                message: format!(
1363                    "liminal worker registration for connection {pid} rejected: this listener \
1364                     has no namespace admission bound, so it cannot judge which namespaces the \
1365                     worker may serve; a listener is commissioned with one before it accepts"
1366                ),
1367            });
1368        };
1369        if admission.auth_enabled {
1370            return Err(LiminalServerError::ListenerAccept {
1371                message: format!(
1372                    "liminal worker registration for connection {pid} (identity `{}`) rejected: \
1373                     this server authenticates callers (auth.enabled = true) and the liminal \
1374                     registration frame carries no credential to authenticate, so the worker's \
1375                     namespaces cannot be scoped to a caller; register this worker over gRPC \
1376                     with a bearer token, or run the server with auth disabled",
1377                    registration.identity
1378                ),
1379            });
1380        }
1381        // The delivery leg needs the supervisor to push to this connection. In
1382        // correct wiring it is bound before any connection is accepted; a missing
1383        // binding is a rejected registration, never a panic.
1384        let supervisor =
1385            self.supervisor
1386                .get()
1387                .ok_or_else(|| LiminalServerError::ListenerAccept {
1388                    message: format!(
1389                        "liminal worker registration for connection {pid} rejected: \
1390                     notifier supervisor handle not yet bound"
1391                    ),
1392                })?;
1393
1394        let delivery = WorkerDelivery::Liminal(LiminalWorkerDelivery::new(supervisor.clone(), pid));
1395        let caller = liminal_caller_identity(&registration.identity);
1396        let proto = proto_registration_from_wire(registration);
1397        let guard = bridge_admission(
1398            &admission.handle,
1399            self.registry.admit_delivery(
1400                &admission.guard,
1401                &caller,
1402                &proto,
1403                delivery,
1404                self.intervention_capabilities.clone(),
1405            ),
1406        )
1407        .map_err(|error| LiminalServerError::ListenerAccept {
1408            message: format!("liminal worker registration for connection {pid} rejected: {error}"),
1409        })?
1410        .map_err(|error| LiminalServerError::ListenerAccept {
1411            message: format!("liminal worker registration for connection {pid} rejected: {error}"),
1412        })?;
1413        let worker_id = guard
1414            .worker_id()
1415            .ok_or_else(|| LiminalServerError::ListenerAccept {
1416                message: format!(
1417                    "liminal worker registration for connection {pid} has no worker id"
1418                ),
1419            })?;
1420
1421        // OWN the guard for the connection's lifetime, keyed by pid. Dropping it
1422        // (on unregister) deregisters the worker, so the registration lives
1423        // exactly as long as the connection.
1424        let mut guards = self.guards.lock().map_err(|_| {
1425            // The accepted registry entry cannot be tracked for deregistration, so
1426            // reject (and drop the just-created guard, deregistering it) rather
1427            // than leak a never-deregistered association.
1428            LiminalServerError::ListenerAccept {
1429                message: format!(
1430                    "liminal worker registration for connection {pid} rejected: \
1431                     notifier guard map poisoned"
1432                ),
1433            }
1434        })?;
1435        guards.insert(pid, guard);
1436        drop(guards);
1437        if let Some(tracker) = &self.heartbeat_tracker
1438            && let Err(error) = tracker.register_connection(worker_id, std::time::Instant::now())
1439        {
1440            let removed = match self.guards.lock() {
1441                Ok(mut guards) => guards.remove(&pid),
1442                Err(poisoned) => poisoned.into_inner().remove(&pid),
1443            };
1444            drop(removed);
1445            return Err(LiminalServerError::ListenerAccept {
1446                message: format!(
1447                    "liminal worker registration for connection {pid} rejected: {error}"
1448                ),
1449            });
1450        }
1451        tracing::info!(
1452            connection_pid = pid,
1453            identity = %registration.identity,
1454            task_queue = %registration.task_queue,
1455            "registered liminal worker in-band"
1456        );
1457        Ok(())
1458    }
1459}
1460
1461/// The admission a liminal registration is judged by, bound at commissioning.
1462struct RegistrationAdmission {
1463    guard: NamespaceGuard,
1464    auth_enabled: bool,
1465    /// The runtime the async admission is bridged onto from the connection
1466    /// thread โ€” named at commissioning, never captured from ambient context.
1467    handle: tokio::runtime::Handle,
1468}
1469
1470/// Run the async admission to completion from the synchronous connection
1471/// callback, on whichever thread liminal invokes it from.
1472///
1473/// Three callers, three answers, no panic path:
1474/// - liminal's own connection thread (production) is on no runtime, so the
1475///   future is driven by `handle.block_on` directly;
1476/// - a multi-thread runtime worker (a test that drives the notifier from an
1477///   async body) cannot be blocked without stalling its scheduler, so the
1478///   worker is first handed back via `block_in_place`;
1479/// - a current-thread runtime worker has nothing to hand back to โ€” blocking
1480///   it would deadlock the very runtime the admission runs on โ€” so the call
1481///   is refused by name rather than hung.
1482///
1483/// # Errors
1484///
1485/// Returns the refusal for the current-thread case; the admission's own
1486/// verdict rides in the `Ok`.
1487fn bridge_admission<F: Future>(
1488    handle: &tokio::runtime::Handle,
1489    admission: F,
1490) -> Result<F::Output, ServerError> {
1491    match tokio::runtime::Handle::try_current() {
1492        Err(_) => Ok(handle.block_on(admission)),
1493        Ok(current) => match current.runtime_flavor() {
1494            tokio::runtime::RuntimeFlavor::MultiThread => {
1495                Ok(tokio::task::block_in_place(|| handle.block_on(admission)))
1496            }
1497            _ => Err(ServerError::Config {
1498                message: "the liminal registration callback ran on a current-thread Tokio \
1499                          runtime worker, which cannot be blocked while the namespace \
1500                          admission runs; drive the listener from a multi-thread runtime \
1501                          or from a plain thread, as liminal's connection process does"
1502                    .to_owned(),
1503            }),
1504        },
1505    }
1506}
1507
1508/// The caller a liminal registration is admitted as on an auth-off server:
1509/// the operator identity, subject = the frame's `identity` (or `operator`
1510/// when the frame left it empty) โ€” the liminal twin of the gRPC development
1511/// identity built from `x-aion-subject`.
1512fn liminal_caller_identity(identity: &str) -> CallerIdentity {
1513    let subject = identity.trim();
1514    CallerIdentity::operator(if subject.is_empty() {
1515        "operator"
1516    } else {
1517        subject
1518    })
1519}
1520
1521fn wire_activity_to_proto(
1522    activity: &liminal::protocol::WorkerActivityDescriptor,
1523) -> aion_proto::ProtoActivityDescriptor {
1524    aion_proto::ProtoActivityDescriptor {
1525        name: activity.name.clone(),
1526        input_schema_json: activity.input_schema_json.clone(),
1527        output_schema_json: activity.output_schema_json.clone(),
1528    }
1529}
1530
1531/// The liminal registration frame in the shape the namespace guard judges:
1532/// the same fields, the wire's `None` node as the proto's empty string.
1533fn proto_registration_from_wire(
1534    registration: &WireWorkerRegistration,
1535) -> aion_proto::ProtoRegisterWorker {
1536    aion_proto::ProtoRegisterWorker {
1537        namespaces: registration.namespaces.clone(),
1538        activity_types: registration.activity_types.clone(),
1539        task_queue: registration.task_queue.clone(),
1540        node: normalize_wire_node(registration.node.as_deref()).unwrap_or_default(),
1541        activities: registration
1542            .activities
1543            .iter()
1544            .map(wire_activity_to_proto)
1545            .collect(),
1546        identity: registration.identity.clone(),
1547        instance: None,
1548    }
1549}
1550
1551impl ConnectionNotifier for LiminalConnectionNotifier {
1552    fn on_worker_registered(
1553        &self,
1554        pid: u64,
1555        registration: &WireWorkerRegistration,
1556    ) -> Result<(), LiminalServerError> {
1557        // The contract gate NAMES its own refusal, for every transport, with the
1558        // queue, the node, the worker build identity and the structured reason.
1559        // So this path propagates that refusal SILENTLY rather than restating
1560        // it: a second copy of the same log is how #147 happened, and a log
1561        // written twice per refusal is #94's 12 MB/hour with a second author.
1562        self.validate_registration_contract(registration)?;
1563        // Every refusal this TRANSPORT owns is named here instead, at WARN, with
1564        // the same reason that rides back in the `Rejected` ack, so both sides'
1565        // logs carry identical text and a refused worker can be diagnosed from
1566        // either end.
1567        self.admit_registration(pid, registration)
1568            .inspect_err(|error| {
1569                // The two advertised sets are logged SEPARATELY and named for
1570                // what each one is. Logging `activity_types` alone beside a
1571                // contract-mismatch reason produced a record that listed an
1572                // action as advertised in the same line that reported it
1573                // `<missing>` โ€” true of two different sets, and unresolvable by
1574                // the operator it exists to help.
1575                let advertised_contracts = registration
1576                    .activities
1577                    .iter()
1578                    .map(|activity| activity.name.as_str())
1579                    .collect::<Vec<_>>();
1580                tracing::warn!(
1581                    connection_pid = pid,
1582                    identity = %registration.identity,
1583                    task_queue = %registration.task_queue,
1584                    namespaces = ?registration.namespaces,
1585                    advertised_activity_types = ?registration.activity_types,
1586                    advertised_contracts = ?advertised_contracts,
1587                    reason = %error,
1588                    "REFUSED liminal worker registration"
1589                );
1590            })
1591    }
1592
1593    fn on_worker_unregistered(&self, pid: u64) {
1594        // Remove + drop the guard for pid, deregistering the worker. A poisoned
1595        // lock on the close path has no peer to report to; recover the guard map
1596        // and still drop the guard so the registry does not keep routing to a
1597        // gone connection.
1598        let removed = match self.guards.lock() {
1599            Ok(mut guards) => guards.remove(&pid),
1600            Err(poisoned) => poisoned.into_inner().remove(&pid),
1601        };
1602        let worker_id = removed.as_ref().and_then(WorkerRegistration::worker_id);
1603        if let (Some(tracker), Some(worker_id)) = (&self.heartbeat_tracker, worker_id)
1604            && let Err(error) = tracker.unregister_connection(worker_id)
1605        {
1606            tracing::error!(
1607                %error,
1608                connection_pid = pid,
1609                worker_id = worker_id.value(),
1610                "failed to clear liminal worker connection lease"
1611            );
1612        }
1613        if removed.is_some() {
1614            tracing::info!(
1615                connection_pid = pid,
1616                "deregistered liminal worker on disconnect"
1617            );
1618        }
1619    }
1620
1621    fn on_channel_publish(&self, pid: u64, channel: &str, payload: &[u8]) -> bool {
1622        if let Some(tracker) = &self.heartbeat_tracker {
1623            let worker_id = match self.guards.lock() {
1624                Ok(guards) => guards.get(&pid).and_then(WorkerRegistration::worker_id),
1625                Err(poisoned) => poisoned
1626                    .into_inner()
1627                    .get(&pid)
1628                    .and_then(WorkerRegistration::worker_id),
1629            };
1630            if let Some(worker_id) = worker_id
1631                && let Err(error) =
1632                    tracker.record_connection_activity(worker_id, std::time::Instant::now())
1633            {
1634                tracing::error!(
1635                    %error,
1636                    connection_pid = pid,
1637                    worker_id = worker_id.value(),
1638                    "failed to advance liminal worker connection lease"
1639                );
1640            }
1641        }
1642        // Reserved liveness channel: refresh the beat's in-flight task stamp in
1643        // the shared heartbeat tracker (always consumed, never fanned out).
1644        if channel == WORKER_LIVENESS_CHANNEL {
1645            self.record_liveness_beat(pid, payload);
1646            return true;
1647        }
1648        // Reserved capabilities channel: apply the worker's advertised
1649        // intervention capabilities to its registered handle (always consumed).
1650        if channel == WORKER_CAPABILITIES_CHANNEL {
1651            self.record_capabilities_announcement(pid, payload);
1652            return true;
1653        }
1654        // Only consume the reserved observability channel; any other channel falls
1655        // through to liminal's normal fan-out (this returns false).
1656        if channel != liminal_sdk::OBSERVABILITY_CHANNEL {
1657            return false;
1658        }
1659        let Some(tap) = &self.transcript else {
1660            // No transcript sequencer installed (a non-agent deployment): still
1661            // CONSUME the reserved channel so it never leaks into the fan-out, but
1662            // drop the event โ€” there is nothing to persist it into.
1663            return true;
1664        };
1665        let event: aion_core::ActivityEvent = match serde_json::from_slice(payload) {
1666            Ok(event) => event,
1667            Err(error) => {
1668                tracing::warn!(%error, "observability tap: malformed ActivityEvent payload");
1669                return true;
1670            }
1671        };
1672        // Hand the event to the ordered drain queue (the synchronous
1673        // connection-process callback never blocks). A full queue means the
1674        // durable append cannot keep up: the event is dropped with a warning,
1675        // never buffered without bound.
1676        if let Err(error) = tap.queue.try_send(event) {
1677            tracing::warn!(%error, "observability tap: transcript queue rejected event");
1678        }
1679        true
1680    }
1681}
1682
1683/// The production [`InterventionTransport`](super::intervention::InterventionTransport):
1684/// pushes a routed command to the owning worker over its liminal server-push
1685/// connection (NOI-6, ยง6.2).
1686///
1687/// It reads the worker handle's [`WorkerDelivery::Liminal`] leg and pushes the
1688/// neutral [`InterventionRequest`] via [`LiminalWorkerDelivery::push_intervention`],
1689/// running the blocking push off the async runtime. A worker delivered over gRPC
1690/// (no liminal leg) surfaces the stale-target no-op via a connection-lost error, so
1691/// the router NACKs the operator rather than routing to a leg it cannot reach.
1692#[derive(Clone, Debug, Default)]
1693pub struct LiminalInterventionTransport;
1694
1695#[async_trait]
1696impl super::intervention::InterventionTransport for LiminalInterventionTransport {
1697    async fn push(
1698        &self,
1699        worker: &super::registry::WorkerHandle,
1700        command: aion_core::InterventionCommand,
1701    ) -> Result<aion_core::InterventionOutcome, ServerError> {
1702        let delivery = match worker.delivery() {
1703            WorkerDelivery::Liminal(delivery) => delivery.clone(),
1704            WorkerDelivery::Grpc(_) => {
1705                // No liminal leg to push to: the intervention transport rides the
1706                // liminal push channel only, so this is unreachable for the target.
1707                return Err(ServerError::worker_connection_lost(
1708                    "liminal-push",
1709                    "owning worker is not delivered over liminal".to_owned(),
1710                ));
1711            }
1712        };
1713        let request = InterventionRequest {
1714            intervention: command,
1715        };
1716        let reply = tokio::task::spawn_blocking(move || delivery.push_intervention(&request))
1717            .await
1718            .map_err(|error| {
1719                dispatch_error(
1720                    "liminal-push",
1721                    format!("intervention task join failed: {error}"),
1722                )
1723            })??;
1724        Ok(reply.outcome)
1725    }
1726}
1727
1728#[cfg(test)]
1729#[path = "liminal_contract_tests.rs"]
1730mod contract_tests;
1731
1732#[cfg(test)]
1733mod tests {
1734    use super::{channel_for_row, dispatch_channel_name, normalize_wire_node};
1735    use crate::worker::registry::RegistrationOptions;
1736    use aion_core::{ActivityId, ContentType, Payload, WorkflowId};
1737    use aion_store::{OutboxRow, OutboxStatus};
1738    use chrono::Utc;
1739    use uuid::Uuid;
1740
1741    /// The NOI-6 dispatch owner guard RELEASES its binding on drop, on EVERY exit path
1742    /// (reply, error, panic) โ€” so the attempt-owner back-index tracks exactly the
1743    /// attempts currently in flight. This is the invariant the dispatch path relies on
1744    /// to never leak a finished attempt's owner.
1745    #[tokio::test]
1746    async fn attempt_owner_guard_releases_on_drop() -> Result<(), Box<dyn std::error::Error>> {
1747        use super::super::intervention::{AttemptKey, AttemptOwnerIndex};
1748        use super::super::registry::{ConnectedWorkerRegistry, WorkerDelivery};
1749        use super::AttemptOwnerGuard;
1750
1751        // A real registration yields a real WorkerId (there is no fabricated id).
1752        let registry = ConnectedWorkerRegistry::default();
1753        let (tx, _rx) = tokio::sync::mpsc::channel(1);
1754        let types = [String::from("agent")];
1755        let registration = registry.register_delivery(
1756            [String::from("default")],
1757            String::from("default"),
1758            None,
1759            types.iter(),
1760            WorkerDelivery::Grpc(tx),
1761            RegistrationOptions::identified(String::from("liminal-transport-worker"))
1762                .with_intervention_capabilities(aion_core::InterventionCapabilities::none()),
1763        )?;
1764        let worker = registration
1765            .worker_id()
1766            .ok_or("registration must assign a worker id")?;
1767
1768        let owners = AttemptOwnerIndex::new();
1769        let key = AttemptKey::new(
1770            WorkflowId::new(Uuid::nil()),
1771            aion_core::RunId::new(Uuid::from_u128(0x11)),
1772            ActivityId::from_sequence_position(3),
1773            1,
1774        );
1775        owners.bind(key.clone(), worker);
1776        assert_eq!(
1777            owners.owner(&key),
1778            Some(worker),
1779            "owner bound before the guard"
1780        );
1781        {
1782            let _guard = AttemptOwnerGuard {
1783                owners: owners.clone(),
1784                key: key.clone(),
1785            };
1786            assert_eq!(
1787                owners.owner(&key),
1788                Some(worker),
1789                "still bound while in flight"
1790            );
1791        }
1792        // The guard dropped at the end of the block: the binding is released, so a
1793        // later intervention resolves no owner (the too-late no-op).
1794        assert_eq!(
1795            owners.owner(&key),
1796            None,
1797            "owner released when the dispatch returns"
1798        );
1799        Ok(())
1800    }
1801
1802    /// The channel format is pinned EXACTLY: any change is a wire-compatibility
1803    /// break (the dispatcher and any worker subscription must agree byte-for-byte).
1804    /// The UNPINNED (`None`) channel MUST stay byte-identical to the pre-NODE-5
1805    /// format so existing pool subscriptions are stable.
1806    #[test]
1807    fn channel_format_is_pinned() {
1808        assert_eq!(
1809            dispatch_channel_name("remote", "gpu", None),
1810            "aion.dispatch.remote.gpu"
1811        );
1812        assert_eq!(
1813            dispatch_channel_name("local", "norn", None),
1814            "aion.dispatch.local.norn"
1815        );
1816    }
1817
1818    /// A node-pinned dispatch appends the node as an injectively-encoded
1819    /// sub-segment: `f(ns, tq, Some(node))` == `aion.dispatch.{ns}.{tq}.{node}`.
1820    #[test]
1821    fn node_pinned_channel_appends_node_subsegment() {
1822        assert_eq!(
1823            dispatch_channel_name("remote", "gpu", Some("box-7")),
1824            "aion.dispatch.remote.gpu.box-7"
1825        );
1826    }
1827
1828    /// Same input always yields the same channel (the function is stable/total),
1829    /// for both the unpinned and node-pinned cases.
1830    #[test]
1831    fn channel_derivation_is_stable() {
1832        assert_eq!(
1833            dispatch_channel_name("default", "default", None),
1834            dispatch_channel_name("default", "default", None)
1835        );
1836        assert_eq!(
1837            dispatch_channel_name("default", "default", Some("box-1")),
1838            dispatch_channel_name("default", "default", Some("box-1"))
1839        );
1840    }
1841
1842    /// Distinct `(namespace, task_queue)` pools derive distinct channels โ€” the
1843    /// whole point of NSTQ-5: `(remote, gpu)` and `(local, norn)` never collide.
1844    #[test]
1845    fn distinct_pools_get_distinct_channels() {
1846        assert_ne!(
1847            dispatch_channel_name("remote", "gpu", None),
1848            dispatch_channel_name("local", "norn", None)
1849        );
1850    }
1851
1852    /// A node-pinned dispatch and the unpinned dispatch for the SAME pool derive
1853    /// DISTINCT channels, and two distinct nodes for the same pool also differ โ€”
1854    /// the property node isolation rests on (the subscriber contract).
1855    #[test]
1856    fn node_pin_separates_channels() {
1857        let unpinned = dispatch_channel_name("remote", "gpu", None);
1858        let box7 = dispatch_channel_name("remote", "gpu", Some("box-7"));
1859        let box8 = dispatch_channel_name("remote", "gpu", Some("box-8"));
1860        assert_ne!(
1861            unpinned, box7,
1862            "pinned dispatch must not reach unpinned pool"
1863        );
1864        assert_ne!(box7, box8, "distinct nodes must not collide");
1865    }
1866
1867    /// The core injectivity property: free-form fields containing the segment
1868    /// separator `.` must NOT bleed across the join. With the raw `format!` the
1869    /// disjoint pools `("a.b", "c")` and `("a", "b.c")` both collapsed onto
1870    /// `aion.dispatch.a.b.c` โ€” a cross-pool/cross-namespace leak. The per-segment
1871    /// encode keeps them distinct.
1872    #[test]
1873    fn dotted_fields_do_not_collide_across_segments() {
1874        assert_ne!(
1875            dispatch_channel_name("a.b", "c", None),
1876            dispatch_channel_name("a", "b.c", None),
1877            "a '.' in a field must not bleed across the segment separator"
1878        );
1879    }
1880
1881    /// Injectivity holds ACROSS segment counts: a 2-segment (unpinned) channel
1882    /// can never be confused with a 3-segment (node-pinned) channel even when a
1883    /// `.` in a field would otherwise make the raw strings line up. Both
1884    /// directions of the brief's collision cases must stay distinct.
1885    #[test]
1886    fn node_subsegment_does_not_collide_with_dotted_fields() {
1887        // A node sub-segment vs the same dot living inside task_queue.
1888        assert_ne!(
1889            dispatch_channel_name("a", "b", Some("c")),
1890            dispatch_channel_name("a", "b.c", None),
1891            "a node sub-segment must not collide with a dotted task_queue"
1892        );
1893        // The dot living inside namespace vs a node sub-segment.
1894        assert_ne!(
1895            dispatch_channel_name("a.b", "c", None),
1896            dispatch_channel_name("a", "b", Some("c")),
1897            "a dotted namespace must not collide with a node-pinned channel"
1898        );
1899    }
1900
1901    /// More reserved-char shifts that the raw `format!` collapsed but the encode
1902    /// must keep distinct โ€” the dot can sit on either side of the boundary.
1903    #[test]
1904    fn reserved_char_shifts_stay_distinct() {
1905        // Dot at the end of namespace vs start of task_queue.
1906        assert_ne!(
1907            dispatch_channel_name("ns.", "tq", None),
1908            dispatch_channel_name("ns", ".tq", None)
1909        );
1910        // Empty field vs the dot living in the other field.
1911        assert_ne!(
1912            dispatch_channel_name("", "a.b", None),
1913            dispatch_channel_name(".a", "b", None)
1914        );
1915        // The escape char itself must not let a literal `%2E` impersonate an
1916        // encoded `.`: `("%2E", "x")` (literal percent-two-E) must differ from
1917        // `(".", "x")` (an actual dot, which encodes to `%2E`).
1918        assert_ne!(
1919            dispatch_channel_name("%2E", "x", None),
1920            dispatch_channel_name(".", "x", None)
1921        );
1922    }
1923
1924    /// Encoding is injective in ALL THREE segments independently and is exactly
1925    /// reversible (the property the channel relies on), so a small exhaustive
1926    /// sweep of reserved-char arrangements โ€” INCLUDING the optional node taking
1927    /// `None` and every reserved-char value โ€” yields all-distinct channels. This
1928    /// covers cross-segment-count collisions (the `None` vs `Some` boundary) too.
1929    #[test]
1930    fn encoding_is_injective_over_reserved_char_triples() {
1931        let fields = ["a", "a.b", "a.", ".a", ".", "", "%", "%2E", "a%b", "%2."];
1932        let nodes = [
1933            None,
1934            Some("a"),
1935            Some("a.b"),
1936            Some("."),
1937            Some(""),
1938            Some("%2E"),
1939        ];
1940        let mut channels = std::collections::HashSet::new();
1941        for ns in fields {
1942            for tq in fields {
1943                for node in nodes {
1944                    let channel = dispatch_channel_name(ns, tq, node);
1945                    assert!(
1946                        channels.insert(channel.clone()),
1947                        "collision on ({ns:?}, {tq:?}, {node:?}) -> {channel}"
1948                    );
1949                }
1950            }
1951        }
1952    }
1953
1954    fn row(namespace: &str, task_queue: &str) -> OutboxRow {
1955        let workflow_id = WorkflowId::new(Uuid::new_v4());
1956        OutboxRow {
1957            dispatch_key: format!("{workflow_id}:0"),
1958            workflow_id,
1959            ordinal: 0,
1960            run_id: Some(aion_core::RunId::new_v4()),
1961            namespace: namespace.to_owned(),
1962            task_queue: task_queue.to_owned(),
1963            node: None,
1964            activity_type: "charge-card".to_owned(),
1965            input: Payload::new(ContentType::Json, Vec::new()),
1966            status: OutboxStatus::Pending,
1967            attempt: 0,
1968            started_attempt: 1,
1969            visible_after: Utc::now(),
1970            claimed_at: None,
1971            failure_delivered: false,
1972        }
1973    }
1974
1975    /// A row's channel is derived from its durable `(namespace, task_queue)`
1976    /// columns (NSTQ-2), through the same single derivation function โ€” and
1977    /// `activity_type` does NOT enter the channel. With `node = None` the channel
1978    /// is byte-identical to the pre-NODE-5 2-segment form.
1979    #[test]
1980    fn channel_for_row_uses_namespace_and_task_queue_only() {
1981        let remote_gpu = row("remote", "gpu");
1982        let local_norn = row("local", "norn");
1983        assert_eq!(channel_for_row(&remote_gpu), "aion.dispatch.remote.gpu");
1984        assert_eq!(channel_for_row(&local_norn), "aion.dispatch.local.norn");
1985        assert_ne!(channel_for_row(&remote_gpu), channel_for_row(&local_norn));
1986
1987        // Two rows that differ ONLY in activity_type derive the SAME channel:
1988        // activity_type is matched after delivery, not used to select the pool.
1989        let mut other_activity = row("remote", "gpu");
1990        other_activity.activity_type = "refund".to_owned();
1991        assert_eq!(
1992            channel_for_row(&remote_gpu),
1993            channel_for_row(&other_activity),
1994            "activity_type must not affect the channel"
1995        );
1996    }
1997
1998    /// A row carrying `Some(node)` (NODE-2) derives the node-pinned sub-channel,
1999    /// distinct from the same pool's unpinned channel; a row with `None` derives
2000    /// the 2-segment channel. `channel_for_row` threads `row.node` through the
2001    /// single derivation function.
2002    #[test]
2003    fn channel_for_row_derives_node_subchannel_when_pinned() {
2004        let mut pinned = row("remote", "gpu");
2005        pinned.node = Some("box-7".to_owned());
2006        assert_eq!(channel_for_row(&pinned), "aion.dispatch.remote.gpu.box-7");
2007
2008        let unpinned = row("remote", "gpu");
2009        assert_eq!(channel_for_row(&unpinned), "aion.dispatch.remote.gpu");
2010        assert_ne!(channel_for_row(&pinned), channel_for_row(&unpinned));
2011    }
2012
2013    /// The wire `node` is normalized onto the registry's optional affinity with
2014    /// the SAME none-convention the gRPC registration path uses: `None` and the
2015    /// empty-string node both collapse to unpinned (`None`), a non-empty value is
2016    /// the advertised node. An empty-string node must NOT register a distinct
2017    /// empty affinity no pinned dispatch could match.
2018    #[test]
2019    fn wire_node_normalizes_empty_to_none() {
2020        assert_eq!(normalize_wire_node(None), None);
2021        assert_eq!(normalize_wire_node(Some("")), None);
2022        assert_eq!(normalize_wire_node(Some("box-7")), Some("box-7".to_owned()));
2023    }
2024}