Skip to main content

aion_server/
run.rs

1//! Run loop for the Aion workflow server: tracing initialization,
2//! configuration load, transport startup, and signal-driven graceful
3//! shutdown.
4//!
5//! This is the library entry point behind the `aion server` command. It
6//! preserves the operational contract of the former standalone
7//! `aion-server` binary: exit code 2 for configuration errors, the drain
8//! outcome's exit code on shutdown, and 130 when a second termination
9//! signal forces immediate exit.
10
11use std::{net::SocketAddr, process::ExitCode};
12
13use tokio::net::TcpListener;
14use tonic::transport::Server as TonicServer;
15use tracing::{error, info, warn};
16
17use std::sync::Arc;
18
19use crate::{
20    ServerConfig, ServerError, ServerState, api,
21    config::{CliOverrides, NamespaceMode, OutboxConfig, OutboxTransport, StoreBackend},
22    observability,
23    shutdown::{self, ShutdownOutcome},
24    worker::{
25        ActivityDispatcher, DeliveryGate, OutboxDeliveryCallback, OutboxDispatcher,
26        OutboxDispatcherConfig, OutboxReconciler, OutboxReconcilerConfig, OutboxRowDispatch,
27        ServerOutboxDeliveryCallback, WorkerOutboxDispatch,
28    },
29};
30
31/// Short TTL for the dispatcher's per-namespace placement cache (Control-Plane
32/// Phase 2, P2-P3). Kept small so an operator's `PUT /namespaces/{name}/placement`
33/// takes effect on the hot claim loop within a couple of seconds, while still
34/// collapsing a per-sweep quorum `get_namespace` into a cheap in-process lookup.
35/// A stale entry under `Prefer` only mis-prefers a worker for at most one window
36/// and self-corrects — it never affects correctness or replay.
37const PLACEMENT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
38
39/// Short TTL for the dispatcher's per-namespace quota cache (Control-Plane Phase 2,
40/// P2-Q2). Kept small so an operator raising/lowering a tenant's
41/// `max_in_flight_activities` takes effect on the hot claim loop within a couple of
42/// seconds, while still collapsing a per-sweep quorum `get_namespace` into a cheap
43/// in-process lookup. A stale entry only over- or under-admits slightly for one
44/// window and self-corrects — backpressure never drops a row, so it cannot affect
45/// correctness or replay.
46const QUOTA_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(2);
47
48/// Cadence of the ops-console quota-state broadcaster (Control-Plane Phase 2,
49/// P2-Q3). Each tick samples every registry namespace's durable Claimed-row count
50/// and cluster-wide ceiling, then pushes one `NamespaceQuotaState` per namespace
51/// onto the cluster channel, so the console badge tracks live load. Kept at 1s:
52/// brisk enough that the badge visibly ticks as work flows, throttled enough that
53/// it is never a per-row firehose (in-flight changes on every claim/settle). It is
54/// a server-side push on a timer, NOT a client poll — the dashboard rule bans the
55/// latter, not a throttled server snapshot of REAL durable state.
56const QUOTA_BROADCAST_CADENCE: std::time::Duration = std::time::Duration::from_secs(1);
57
58/// Resolved keyed-backpressure inputs for the outbox dispatcher (Control-Plane
59/// Phase 2, P2-Q2): the generous platform-default ceiling and this node's
60/// owned-shard fraction of the cluster shard space.
61#[derive(Clone, Copy, Debug)]
62struct BackpressureSettings {
63    /// The `[namespaces] max_in_flight_activities` platform default, applied to any
64    /// namespace carrying no explicit per-tenant override.
65    platform_default: u32,
66    /// This node's owned-shard fraction of the cluster's virtual shard space,
67    /// derived from `[store] owned_shards` and `[store] shard_count`.
68    fraction: crate::worker::OwnedShardFraction,
69}
70
71impl BackpressureSettings {
72    /// Derive the backpressure inputs from the merged server config.
73    ///
74    /// An empty `[store] owned_shards` means own-all (the single-node default), so
75    /// the fraction is 1 and per-node ceilings equal the cluster-wide quota. A
76    /// declared owned set enforces the proportional per-node slice
77    /// `|owned| / shard_count` (CP-Phase-2 §3.6).
78    fn from_config(config: &ServerConfig) -> Self {
79        let total = u32::try_from(config.store.shard_count).unwrap_or(u32::MAX);
80        let fraction = if config.store.owned_shards.is_empty() {
81            crate::worker::OwnedShardFraction::own_all()
82        } else {
83            let owned = u32::try_from(config.store.owned_shards.len()).unwrap_or(u32::MAX);
84            crate::worker::OwnedShardFraction::new(owned, total)
85        };
86        Self {
87            platform_default: config.namespaces.max_in_flight_activities,
88            fraction,
89        }
90    }
91}
92
93/// Owns the liminal worker listener for the server's lifetime when the outbox is
94/// commissioned over the liminal transport.
95///
96/// The aion-server HOSTS the liminal listener that remote workers connect IN to;
97/// its inner [`ServerListener`](liminal_server::server::listener::ServerListener)
98/// owns the accept worker. Held as a local in [`run_server`] across the whole
99/// serve `select!`, so it is dropped exactly at server shutdown — and the
100/// listener's own `Drop` stops the accept worker cleanly (no leaked thread, no
101/// orphaned listener). Every non-liminal boot (the default) carries the `None`
102/// guard, which holds nothing and drops to a no-op, so behaviour is unchanged.
103#[derive(Debug, Default)]
104struct OutboxWorkerListener {
105    /// Held purely for its `Drop` side-effect (stopping the accept worker on
106    /// server shutdown); never read after construction, hence the leading
107    /// underscore.
108    #[cfg(feature = "liminal-transport")]
109    _inner: Option<liminal_server::server::listener::ServerListener>,
110}
111
112/// Run the Aion workflow server until it shuts down, returning the process
113/// exit code.
114///
115/// Initializes the JSON tracing subscriber, loads and validates the merged
116/// configuration (file, environment, then `overrides`), serves the gRPC and
117/// HTTP transports, and drains gracefully after the first termination
118/// signal. Every failure is logged through tracing and mapped to the exit
119/// code contract above; the caller only has to exit with the returned code.
120pub async fn run(overrides: CliOverrides) -> ExitCode {
121    match run_server(overrides).await {
122        Ok(code) => code,
123        Err(error) => {
124            error!(%error, "aion-server failed");
125            if error.is_config() {
126                ExitCode::from(2)
127            } else {
128                ExitCode::FAILURE
129            }
130        }
131    }
132}
133
134async fn run_server(cli: CliOverrides) -> Result<ExitCode, ServerError> {
135    observability::tracing::init()?;
136
137    let loaded = ServerConfig::load_resolved(&cli)?;
138    loaded.resolution.ensure_private_home()?;
139    loaded.resolution.log_startup();
140    let config = loaded.config;
141    reject_auth_without_feature(&config)?;
142    let store_backend = config.store.backend;
143    // Static shard assignment (SS-1): read the operator's pinned shard set from
144    // `[store] owned_shards`. Empty means own ALL shards (single-node default).
145    // The set is carried into `RuntimeConfig` by `into_parts` and applied to the
146    // `EngineBuilder` during state construction; surface it here so the boot
147    // banner records which shards this node serves. No election is performed.
148    let owned_shards = config.store.owned_shards.clone();
149    // Capture the outbox settings before `build` consumes `config`, so the
150    // (default-off) outbox dispatcher can be wired after state is up. The
151    // dispatcher shares the engine's already-opened libSQL store (one
152    // connection) via `state.outbox_store()`, so no store settings are needed.
153    let outbox_config = config.outbox.clone();
154    // Control-Plane Phase 2 (P2-Q2): capture the keyed-backpressure inputs — the
155    // generous platform-default ceiling and this node's owned-shard fraction —
156    // before `build` consumes `config`. On a single-node / own-all boot the fraction
157    // is 1, so per-node ceilings equal the cluster-wide quota and, with the generous
158    // default and no tenant override, the ceiling never engages (byte-identical claim).
159    let backpressure_settings = BackpressureSettings::from_config(&config);
160    // Capture the SS-5b failover supervisor knobs before `build` consumes config.
161    // Only a distributed haematite boot carries a `[store.cluster]` section; this
162    // is `None` for every single-node boot, so no supervisor is ever spawned.
163    #[cfg(feature = "haematite-backend")]
164    let cluster_config = config.store.cluster.clone();
165    let state = ServerState::build(config).await?;
166    reject_tls_until_supported(&state)?;
167
168    let runtime = state.runtime_config();
169    let grpc_address = runtime.listen.grpc;
170    let http_address = runtime.listen.http;
171    let workflow_packages: Vec<String> = runtime
172        .workflow_packages
173        .iter()
174        .map(|path| path.display().to_string())
175        .collect();
176    // The revision, not just the version. A crate version cannot distinguish
177    // two builds from different commits of the same version, and that is the
178    // distinction an operator needs when deciding whether a restart restores
179    // what was running or substitutes something else (#123). The endpoint
180    // answers this too, but a crashed server leaves only its log.
181    let build = crate::build_identity::BuildIdentity::current();
182    // #139: the server-resolved workspace root (the aion home's `clones/`
183    // directory) that declared bodies expand `{workspace_root}` with. Reported
184    // here so composition points (setup.sh today, the workspace verb later)
185    // READ the value from the server that will use it instead of re-deriving
186    // it. An unresolvable root is reported as exactly that — never fabricated;
187    // a placeholder-bearing dispatch will refuse terminally with this reason.
188    // The rendering itself is `WorkspaceRoot::banner_value`, pinned by its own
189    // two-case test, so the banner and the tests cannot drift apart.
190    let workspace_root = state.workspace_root().banner_value();
191    info!(
192        version = env!("CARGO_PKG_VERSION"),
193        build = %build.line(),
194        commit = build.commit,
195        grpc_address = %grpc_address,
196        http_address = %http_address,
197        default_namespace = %runtime.default_namespace,
198        namespace_mode = namespace_mode_label(&runtime.namespace.mode),
199        store_backend = store_backend_label(store_backend),
200        auth_enabled = runtime.auth.enabled,
201        deploy_enabled = runtime.deploy.enabled,
202        metrics_enabled = runtime.metrics.enabled,
203        workspace_root = %workspace_root,
204        workflow_package_count = workflow_packages.len(),
205        workflow_packages = ?workflow_packages,
206        owned_shards = ?owned_shards,
207        owns_all_shards = owned_shards.is_empty(),
208        "aion-server startup banner"
209    );
210    let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
211    // LSUB-4-1: a distributed haematite boot carries a `[store.cluster]` section.
212    // The single outbox dispatcher task is spawned in BOTH modes; the difference
213    // is only how ownership is enforced. Single-node (`None`) owns all shards by
214    // construction (`owned_shard_scope() == None`), so its claim sweeps see every
215    // row. Clustered (`Some`) relies on `claim_outbox_rows`' `owned_shard_scope()`
216    // filter — already seeded by `set_owned_shards` during `ServerState::build`,
217    // which runs before this point — so each node only ever claims rows on the
218    // shards it owns. Compute the flag here where the (feature-gated) cluster
219    // section is in scope; pass it to the gate so the boot banner records the mode.
220    #[cfg(feature = "haematite-backend")]
221    let outbox_clustered = cluster_config.is_some();
222    #[cfg(not(feature = "haematite-backend"))]
223    let outbox_clustered = false;
224    // Dormant by default: only when `outbox.enabled` is set does the
225    // non-replayed outbox dispatcher task start. With the flag off (the
226    // default) nothing here runs and server behaviour is unchanged.
227    // Hold the liminal worker listener (if any) for the server's lifetime: it is
228    // dropped at the end of `run_server`, after the serve `select!` completes, so
229    // its accept worker stops cleanly on shutdown via the listener's own `Drop`.
230    // #204/#253: rebuild the pause dispatch-hold and settle terminal
231    // workflows' stranded outbox rows BEFORE the dispatcher's first claim.
232    rebuild_outbox_boot_state(&state, &outbox_config).await;
233    let _outbox_worker_listener = maybe_spawn_outbox_dispatcher(
234        &state,
235        &outbox_config,
236        outbox_clustered,
237        backpressure_settings,
238        &shutdown_rx,
239    )?;
240    // SS-5b: a distributed boot whose peers declare owned shards runs the cluster
241    // supervisor — automatic failover detection. A single-node boot spawns
242    // nothing here (the method returns `false`), so default behaviour is
243    // unchanged.
244    #[cfg(feature = "haematite-backend")]
245    maybe_spawn_cluster_supervisor(&state, cluster_config.as_ref(), &shutdown_rx)?;
246    // #176: the worker heartbeat expiry sweeper is ALWAYS commissioned —
247    // dead-worker detection is a liveness correctness property, not an opt-in
248    // feature. It is the production caller of `fail_expired_workers`: a worker
249    // whose stream stays open while its process wedges (stops heartbeating
250    // without disconnecting) is expired, deregistered with the provable Timeout
251    // reason, and its in-flight tasks surface as TRANSPORT losses, re-dispatched
252    // attempt-neutrally rather than charged to the action's retry budget.
253    // Cadence derives from `worker.heartbeat_window` (quarter-window, clamped to
254    // [1s, window]; the default 30s window sweeps every 7.5s) — deliberately no
255    // separate config knob. It drains on the same shutdown watch as the
256    // transports; dropping the JoinHandle only detaches the task.
257    drop(state.spawn_heartbeat_sweeper(shutdown_rx.clone()));
258    let mut grpc = tokio::spawn(serve_grpc(state.clone(), grpc_address, shutdown_rx.clone()));
259    let mut http = tokio::spawn(serve_http(state.clone(), http_address, shutdown_rx));
260
261    let outcome = tokio::select! {
262        result = &mut grpc => {
263            transport_result("gRPC", result)?;
264            state.shutdown()?;
265            ShutdownOutcome::Clean
266        },
267        result = &mut http => {
268            transport_result("HTTP", result)?;
269            state.shutdown()?;
270            ShutdownOutcome::Clean
271        },
272        result = shutdown_signal() => {
273            result?;
274            let _receiver_count = shutdown_tx.send(true);
275            let outcome = shutdown::drain_after_first_signal(state.clone(), async {
276                let _ = shutdown_signal().await;
277            }).await?;
278            if !matches!(outcome, ShutdownOutcome::Forced) {
279                transport_result("gRPC", grpc.await)?;
280                transport_result("HTTP", http.await)?;
281            }
282            outcome
283        },
284    };
285
286    Ok(outcome.exit_code())
287}
288
289fn transport_result(
290    transport: &'static str,
291    result: Result<Result<(), ServerError>, tokio::task::JoinError>,
292) -> Result<(), ServerError> {
293    match result {
294        Ok(transport_outcome) => transport_outcome,
295        Err(join_error) => Err(ServerError::Transport {
296            transport,
297            message: join_error.to_string(),
298        }),
299    }
300}
301
302async fn serve_grpc(
303    state: ServerState,
304    address: SocketAddr,
305    shutdown: tokio::sync::watch::Receiver<bool>,
306) -> Result<(), ServerError> {
307    let workflow = api::grpc::workflow_service(state.clone());
308    let worker = api::worker_grpc::worker_service(state.clone());
309    let mut router = TonicServer::builder()
310        .add_service(workflow)
311        .add_service(worker);
312    // Dark by default: the deploy service joins the listener only when the
313    // operator commissioned it; otherwise the surface answers Unimplemented.
314    if state.runtime_config().deploy.enabled {
315        router = router.add_service(api::deploy_grpc::deploy_service(state)?);
316    }
317    router
318        .serve_with_shutdown(address, shutdown_requested(shutdown))
319        .await
320        .map_err(|source| transport_bind("grpc", address, source))?;
321    Ok(())
322}
323
324async fn serve_http(
325    state: ServerState,
326    address: SocketAddr,
327    shutdown: tokio::sync::watch::Receiver<bool>,
328) -> Result<(), ServerError> {
329    let listener = TcpListener::bind(address)
330        .await
331        .map_err(|source| transport_bind("http", address, source))?;
332    axum::serve(listener, api::http::http_router(state)?)
333        .with_graceful_shutdown(shutdown_requested(shutdown))
334        .await
335        .map_err(|source| transport_bind("http", address, source))?;
336    Ok(())
337}
338
339async fn shutdown_requested(mut shutdown: tokio::sync::watch::Receiver<bool>) {
340    while !*shutdown.borrow_and_update() {
341        if shutdown.changed().await.is_err() {
342            break;
343        }
344    }
345}
346
347async fn shutdown_signal() -> Result<(), ServerError> {
348    #[cfg(unix)]
349    {
350        use tokio::signal::unix::{SignalKind, signal};
351
352        let mut terminate = signal(SignalKind::terminate())
353            .map_err(|source| signal_listener("SIGTERM", &source))?;
354        let mut interrupt =
355            signal(SignalKind::interrupt()).map_err(|source| signal_listener("SIGINT", &source))?;
356        tokio::select! {
357            _ = terminate.recv() => Ok(()),
358            _ = interrupt.recv() => Ok(()),
359        }
360    }
361
362    #[cfg(not(unix))]
363    {
364        tokio::signal::ctrl_c()
365            .await
366            .map_err(|source| signal_listener("shutdown signal", &source))
367    }
368}
369
370fn signal_listener(listener: &'static str, source: &std::io::Error) -> ServerError {
371    ServerError::SignalListener {
372        listener,
373        message: source.to_string(),
374    }
375}
376
377fn reject_auth_without_feature(config: &ServerConfig) -> Result<(), ServerError> {
378    if cfg!(not(feature = "auth")) && config.auth.enabled {
379        return Err(ServerError::Config {
380            message: "auth.enabled=true but binary compiled without auth feature".to_owned(),
381        });
382    }
383    Ok(())
384}
385
386/// Rebuild the outbox-related boot state BEFORE the dispatcher's first claim,
387/// when (and only when) the outbox is commissioned:
388///
389/// - #204: repopulate the durable pause dispatch-hold from `list_paused`, so a
390///   run paused before a restart keeps its outbox rows held (never claimed)
391///   after recovery. A run projecting `Paused` is excluded from `list_active`
392///   respawn for free; this repopulates the hold that would otherwise be empty
393///   in memory after a crash.
394/// - #253: settle terminal workflows' stranded outbox rows. A workflow that
395///   reached a durable terminal without its rows being settled (a settle-hook
396///   failure, or a crash between the terminal append and the settle) must not
397///   have those rows re-armed and redelivered after restart — that is the
398///   zombie-round incident. A sweep error is loud but non-fatal: the
399///   settle-at-terminal hook and the reconciler's liveness gate remain as
400///   repair paths, and the residual window is one bounded dispatch whose
401///   completion drops unmatched, never a re-arm loop.
402async fn rebuild_outbox_boot_state(state: &ServerState, outbox_config: &OutboxConfig) {
403    if !outbox_config.enabled {
404        return;
405    }
406    let Ok(engine) = state.engine() else {
407        return;
408    };
409    if let Err(error) = engine.rebuild_paused_runs().await {
410        warn!(%error, "failed to rebuild paused-runs dispatch hold at startup");
411    }
412    let Some(outbox_store) = state.outbox_store() else {
413        return;
414    };
415    match crate::worker::settle_terminal_outbox_rows(engine.store().as_ref(), outbox_store.as_ref())
416        .await
417    {
418        Ok(settled) if settled.is_empty() => {}
419        Ok(settled) => {
420            info!(
421                settled = settled.len(),
422                "boot sweep settled stranded outbox rows for terminal workflows"
423            );
424        }
425        Err(error) => {
426            error!(
427                %error,
428                "boot sweep failed to settle terminal workflows' outbox rows; \
429                 the reconciler liveness gate remains the backstop"
430            );
431        }
432    }
433}
434
435/// Spawn the durable-outbox fan-out dispatcher when, and only when, the
436/// operator commissioned it (`outbox.enabled = true`).
437///
438/// This is the single gate that keeps Phase 2 dormant: with the flag off (the
439/// default) the function returns immediately without spawning a task, so
440/// default server behaviour — and the live workflow dispatch path — is entirely
441/// unchanged. When commissioned, the dispatcher claims rows through the engine's
442/// own shared `Arc<LibSqlStore>` (one `libsql::Connection`), so its writes
443/// serialize with the engine's rather than contending across a second
444/// connection. The dispatcher shares the server's shutdown watch, so it drains
445/// on the same signal as the transports.
446///
447/// NOTE (Phase boundary): the spawned dispatcher dispatches claimed rows and
448/// records each row's terminal outbox state (done / retry / failed). Routing the
449/// worker completion back into workflow history through the Recorder is Phase 3
450/// and is not wired here.
451fn maybe_spawn_outbox_dispatcher(
452    state: &ServerState,
453    outbox_config: &OutboxConfig,
454    clustered: bool,
455    backpressure_settings: BackpressureSettings,
456    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
457) -> Result<OutboxWorkerListener, ServerError> {
458    if !outbox_config.enabled {
459        return Ok(OutboxWorkerListener::default());
460    }
461    let dispatcher_config = resolve_outbox_config(outbox_config)?;
462    // Share the engine's already-opened store: one backing connection. The
463    // dispatcher's `claim_outbox_rows` writes then serialize against the engine's
464    // `append_with_outbox` on that single connection instead of contending across
465    // a second one. Both the libSQL and the haematite backends provide an
466    // `OutboxStore` (the haematite leaf is wired as the outbox store at boot); the
467    // in-memory backend has no outbox table, so `outbox_store()` is `None` and
468    // commissioning the dispatcher against it is a configuration error (LSUB-4-2).
469    let outbox_store = state.outbox_store().ok_or_else(|| ServerError::Config {
470        message: "outbox.enabled=true requires store.backend=libsql or store.backend=haematite: \
471                  the durable outbox dispatcher claims rows from the store's outbox table, which \
472                  the in-memory store does not provide"
473            .to_owned(),
474    })?;
475    let dispatcher_builder = OutboxDispatcher::new(Arc::clone(&outbox_store), dispatcher_config);
476    let delivery_gate = dispatcher_builder.delivery_gate();
477    let engine = state.engine()?;
478    let delivery_callback: Arc<dyn OutboxDeliveryCallback> =
479        Arc::new(ServerOutboxDeliveryCallback::new(engine));
480    let (row_dispatch, worker_listener) = select_outbox_row_dispatch(
481        state,
482        outbox_config,
483        shutdown_rx,
484        delivery_gate.clone(),
485        Arc::clone(&delivery_callback),
486    )?;
487    // LSUB-2: share the engine's advisory wake so the stage seam pulses this
488    // dispatcher the instant a fan-out row commits, dispatching in ~RTT instead of
489    // up to one poll interval. The wake is always-on and free; the interval poll is
490    // untouched, so it remains the correctness backstop for any lost wake.
491    // Control-Plane Phase 2 (P2-Q2): attach per-tenant keyed backpressure so each
492    // sweep claims per-namespace, round-robin, capped at each tenant's CLAIMED-only
493    // headroom (`per_node_ceiling − claimed`). The quota cache front-runs a per-sweep
494    // quorum `get_namespace`. With the generous platform default and no tenant
495    // override the ceiling never engages, so a default deployment's claim behaviour is
496    // byte-identical to the pre-Phase-2 single unscoped claim.
497    let quota_cache = crate::worker::QuotaCache::new(
498        Arc::clone(state.namespace_store()),
499        backpressure_settings.platform_default,
500        QUOTA_CACHE_TTL,
501    );
502    let backpressure =
503        crate::worker::Backpressure::new(quota_cache.clone(), backpressure_settings.fraction);
504    let mut dispatcher = dispatcher_builder
505        .with_dispatch(row_dispatch)
506        .with_delivery_callback(delivery_callback)
507        .with_wake(state.outbox_wake())
508        .with_backpressure(backpressure);
509    // #204: attach the engine's durable pause dispatch-hold so a held (paused)
510    // run's rows are never claimed. The hold set is rebuilt from `list_paused`
511    // BEFORE this spawn (see `run_server`), so the dispatcher's first claim
512    // already excludes pre-pause rows after a restart.
513    if let Ok(engine) = state.engine() {
514        dispatcher = dispatcher.with_paused_runs(engine.paused_runs());
515    }
516    tokio::spawn(dispatcher.run(shutdown_rx.clone()));
517    // Control-Plane Phase 2 (P2-Q3): commission the ops-console quota-state
518    // broadcaster on the SAME durable stores + quota cache the dispatcher enforces
519    // against, so the console badge is a faithful window onto the live per-tenant
520    // in-flight/ceiling the backpressure caps. It shares the shutdown watch, so it
521    // drains with the dispatcher. Only spawned alongside the (default-off)
522    // dispatcher: quota state is meaningless without the outbox fan-out path, and
523    // `in_flight` is the durable Claimed outbox count that path produces.
524    let quota_broadcaster = crate::worker::QuotaBroadcaster::new(
525        Arc::clone(state.namespace_store()),
526        Arc::clone(&outbox_store),
527        quota_cache,
528        state.cluster_publisher().clone(),
529        QUOTA_BROADCAST_CADENCE,
530    );
531    tokio::spawn(quota_broadcaster.run(shutdown_rx.clone()));
532    // LSUB-4-1: the single dispatcher task is spawned in both modes. In a
533    // single-node boot it owns all shards by construction; in an active-active
534    // clustered boot it claims ONLY the shards this node owns, enforced by
535    // `claim_outbox_rows`' owned-shard scope (already seeded before this point).
536    info!(
537        clustered,
538        "outbox dispatcher commissioned (active-active per-shard ownership enforced by claim scope \
539         when clustered; single-node owns all shards)"
540    );
541    // LSUB-4-4: the stale-claim reconciler is the in-flight recovery backstop. It
542    // is only configured when BOTH reconcile knobs are set, so on a clustered boot
543    // that left them unset, owner-kill in-flight recovery latency is bounded only
544    // by re-residency replay (a survivor adopting the shard re-residents from
545    // history and re-arms via `rearm_outbox_pending`), NOT by `stale_after`. Warn
546    // so the operator knows the backstop is absent.
547    if let Some(reconciler_config) = resolve_outbox_reconciler_config(outbox_config)? {
548        // #253: the reconciler's liveness gate projects each stale candidate's
549        // workflow status from the engine's event store before any re-arm, so
550        // a terminal workflow's stranded row settles instead of redelivering.
551        let event_store = state.engine()?.store();
552        let reconciler = OutboxReconciler::new(outbox_store, event_store, reconciler_config)
553            .with_delivery_gate(delivery_gate);
554        tokio::spawn(reconciler.run(shutdown_rx.clone()));
555        info!("outbox reconciler commissioned (terminal-workflow liveness gate active)");
556    } else if clustered {
557        warn!(
558            "outbox reconciler is UNCONFIGURED on a clustered boot (outbox.reconcile_interval_ms \
559             and outbox.reconcile_stale_after_ms are both unset): in-flight recovery after an \
560             owner is killed is then bounded only by re-residency replay on the adopting node, \
561             not by a stale-claim backstop; set both knobs to bound stale-claim recovery latency"
562        );
563    }
564    Ok(worker_listener)
565}
566
567/// Spawn the SS-5b cluster supervisor when, and only when, this is a distributed
568/// haematite boot whose `[store.cluster]` declared peers with owned shards.
569///
570/// Reads the failover cadence + debounce from the cluster config (or the
571/// documented defaults), then asks the state to spawn the supervisor over its
572/// retained concrete store and live engine. With no `[store.cluster]` section —
573/// or with no peer declaring `owned_shards` — nothing is spawned and behaviour
574/// is unchanged.
575#[cfg(feature = "haematite-backend")]
576fn maybe_spawn_cluster_supervisor(
577    state: &ServerState,
578    cluster_config: Option<&crate::config::ClusterConfig>,
579    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
580) -> Result<(), ServerError> {
581    let Some(cluster) = cluster_config else {
582        return Ok(());
583    };
584    let poll_interval = std::time::Duration::from_millis(
585        cluster
586            .failover_poll_interval_ms
587            .unwrap_or(crate::config::DEFAULT_FAILOVER_POLL_INTERVAL_MS),
588    );
589    let confirmations = cluster
590        .failover_confirmations
591        .unwrap_or(crate::config::DEFAULT_FAILOVER_CONFIRMATIONS);
592    let supervisor_config = crate::cluster::SupervisorConfig {
593        poll_interval,
594        confirmations,
595    };
596    let spawned = state.spawn_cluster_supervisor(supervisor_config, shutdown_rx.clone())?;
597    if spawned {
598        info!(
599            poll_interval_ms = %poll_interval.as_millis(),
600            confirmations,
601            "SS-5b cluster supervisor commissioned (automatic peer-down failover)"
602        );
603    }
604    Ok(())
605}
606
607/// Select the outbox row-dispatch sink by the configured `outbox.transport`,
608/// returning the sink plus the worker listener whose lifetime the caller must
609/// hold.
610///
611/// `grpc` (the default) builds the unchanged [`WorkerOutboxDispatch`] over the
612/// connected-worker registry and carries the empty [`OutboxWorkerListener`], so a
613/// default server is byte-identical. `liminal` builds the cross-node
614/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) AND stands
615/// up the liminal worker listener the aion-server hosts (returned in the guard);
616/// it is only reachable when the `liminal-transport` feature is compiled in, and
617/// selecting it without that feature is a configuration error rather than a
618/// silent fall-through to gRPC.
619fn select_outbox_row_dispatch(
620    state: &ServerState,
621    outbox_config: &OutboxConfig,
622    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
623    delivery_gate: DeliveryGate,
624    delivery_callback: Arc<dyn OutboxDeliveryCallback>,
625) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
626    match outbox_config.transport {
627        OutboxTransport::Grpc => {
628            let push_dispatcher = ActivityDispatcher::new(state.worker_registry().clone())
629                .with_drain_state(state.drain_state().clone())
630                .with_completion_fences(state.pending_activities().completion_fences())
631                // Share the SAME queue-service seams the direct dispatch path
632                // uses, so a row parked on this leg reaches `GET
633                // /queues/unserved` and `describe`'s `unserved` list rather
634                // than being invisible to both.
635                .with_queue_service(
636                    state.queue_declarations().clone(),
637                    state.queue_service_state().clone(),
638                    state.runtime_config().worker.queue_service.clone(),
639                );
640            // Control-Plane Phase 2 (P2-P3): attach the short-TTL placement cache
641            // so an unpinned row in a `Prefer{L}` namespace prefers an L-labelled
642            // worker (spilling to any live worker). The cache front-runs a per-row
643            // quorum `get_namespace` on the hot claim loop; a default-`Unplaced`
644            // deployment is byte-identical (every row falls through to any-worker).
645            let placement_cache = crate::worker::PlacementCache::new(
646                Arc::clone(state.namespace_store()),
647                PLACEMENT_CACHE_TTL,
648            );
649            let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
650                WorkerOutboxDispatch::new(push_dispatcher).with_placement_cache(placement_cache),
651            );
652            Ok((dispatch, OutboxWorkerListener::default()))
653        }
654        OutboxTransport::Liminal => build_liminal_row_dispatch(
655            state,
656            outbox_config,
657            shutdown_rx,
658            delivery_gate,
659            delivery_callback,
660        ),
661    }
662}
663
664/// Build the production liminal row-dispatch sink and host the worker listener, or
665/// fail with the missing-feature error.
666///
667/// This lifts the tested cross-node wiring (the `lsub1`/`lsub5` e2e blueprint)
668/// into the production boot. The aion-server HOSTS the liminal listener that
669/// remote workers connect IN to, so its
670/// [`ConnectionSupervisor`](liminal_server::server::connection::ConnectionSupervisor)
671/// owns each worker's connection and can push a dispatch out on it. The
672/// constructor cycle resolves the notifier <-> supervisor dependency:
673///
674/// 1. Reuse the registry already in [`ServerState`] — gRPC and liminal workers
675///    share ONE registry and the same `select_worker`, so routing is identical.
676/// 2. Build the [`LiminalConnectionNotifier`] over that registry (no supervisor
677///    yet).
678/// 3. Build the [`LiminalConnectionServices`] from the liminal listen config.
679/// 4. Build the [`ConnectionSupervisor`] WITH the services + notifier.
680/// 5. Bind the supervisor back into the notifier (must succeed).
681/// 6. Bind the [`ServerListener`] on the configured listen address — workers
682///    connect IN here.
683/// 7. Reuse the SAME completion callback the gRPC completion path installs
684///    ([`ServerOutboxDeliveryCallback`] over the live engine), so a liminal
685///    completion re-enters aion through the identical terminal-recording seam.
686/// 8. Build the [`RegistryLiminalDispatch`] over the registry + callback (it
687///    constructs the [`LiminalCompletionSource`] internally).
688///
689/// The returned listener is held by the caller for the server's lifetime; its
690/// `Drop` stops the accept worker on shutdown.
691///
692/// [`LiminalConnectionServices`]: liminal_server::server::connection::LiminalConnectionServices
693/// [`ServerListener`]: liminal_server::server::listener::ServerListener
694/// [`ServerOutboxDeliveryCallback`]: crate::worker::ServerOutboxDeliveryCallback
695/// [`LiminalCompletionSource`]: crate::worker::LiminalCompletionSource
696/// [`LiminalConnectionNotifier`]: crate::worker::LiminalConnectionNotifier
697#[cfg(feature = "liminal-transport")]
698fn build_liminal_row_dispatch(
699    state: &ServerState,
700    outbox_config: &OutboxConfig,
701    shutdown_rx: &tokio::sync::watch::Receiver<bool>,
702    delivery_gate: DeliveryGate,
703    callback: Arc<dyn OutboxDeliveryCallback>,
704) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
705    use liminal_server::config::ServerConfig as LiminalServerConfig;
706    use liminal_server::config::{LimitsConfig, ServicesConfig};
707    use liminal_server::server::connection::{ConnectionSupervisor, LiminalConnectionServices};
708    use liminal_server::server::listener::ServerListener;
709
710    use crate::worker::{LiminalConnectionNotifier, RegistryLiminalDispatch};
711
712    let listen_address = outbox_config
713        .liminal_listen_address
714        .as_ref()
715        .ok_or_else(|| ServerError::Config {
716            message: "outbox.transport=liminal requires outbox.liminal_listen_address \
717                      (host:port the aion-server listens on for inbound liminal worker \
718                      connections)"
719                .to_owned(),
720        })?;
721    let listen_address: SocketAddr =
722        listen_address
723            .parse()
724            .map_err(|error| ServerError::Config {
725                message: format!(
726                    "outbox.liminal_listen_address must be a host:port socket address: {error}"
727                ),
728            })?;
729
730    // The liminal listener is the worker-connection front door only: it binds the
731    // wire listen address and serves the connection supervisor. `from_config` and
732    // `ServerListener::bind` read neither `health_listen_address` nor `channels`
733    // (the health probe is bound only by the standalone liminal server's full
734    // boot, not this embedded path), so no separate health port is bound here;
735    // it is set structurally to the listen address and never used.
736    let liminal_config = LiminalServerConfig {
737        listen_address,
738        health_listen_address: listen_address,
739        drain_timeout_ms: 30_000,
740        channels: Vec::new(),
741        routing_rules: Vec::new(),
742        persistence_path: None,
743        cluster: None,
744        // liminal 0.2.3 (H4) added an optional shared-token Connect gate. `None`
745        // keeps this embedded worker front door open at the liminal layer —
746        // identical to the pre-0.2.3 wire behavior; worker identity/authorization
747        // stays aion's job (x-aion-* registration metadata). Threading an
748        // operator-configured token through aion's outbox config is a separate
749        // feature decision, not part of the dependency alignment.
750        auth: None,
751        // liminal 0.2.4 (D2/§5): service profile + operational bounds. Defaults =
752        // full profile + the certifying-pair-signed caps — byte-equivalent to the
753        // 0.2.3 behaviour this embedded front door always had. A worker-front-door
754        // profile election here is a future feature decision, not this migration.
755        services: ServicesConfig::default(),
756        limits: LimitsConfig::default(),
757        // liminal 0.3.0 (LP-WS-TRANSPORT R1 / LP Part B): optional WebSocket
758        // acceptor and participant lifecycle activation. `None` for both starts
759        // no WebSocket listener and leaves the participant capability disabled —
760        // documented as byte-identical to the pre-0.3.0 build. Electing either
761        // for this embedded worker front door is a feature decision, not part of
762        // the dependency alignment.
763        websocket: None,
764        participant: None,
765    };
766
767    // (1) Reuse the registry already in ServerState: gRPC + liminal workers share
768    // ONE registry and the same `select_worker`.
769    let registry = state.worker_registry().clone();
770    // (2) Notifier over that registry (supervisor bound after it is built), with the
771    // NOI-5b transcript tap: a worker's observability publishes on the reserved
772    // channel drain into the SAME transcript sequencer the transcript socket serves,
773    // so a live agent's transcript is persisted + fanned out. (Captures the current
774    // runtime handle to bridge the sync connection callback onto the async append.)
775    let notifier = Arc::new(
776        LiminalConnectionNotifier::new(registry.clone())
777            .with_contract_catalog(state.engine()?)
778            .with_transcript_publisher(state.transcript_publisher().clone())
779            // The SAME per-task liveness tracker the engine-seam bridge tracks
780            // into: a liminal worker's automatic liveness beats refresh it, so
781            // the #176 expiry sweeper never falsely expires a healthy liminal
782            // worker running an activity longer than the heartbeat window.
783            .with_heartbeat_tracker(state.heartbeat_tracker().clone()),
784    );
785    // (3) Connection services from the liminal listen config.
786    let services = Arc::new(
787        LiminalConnectionServices::from_config(&liminal_config).map_err(|error| {
788            ServerError::Config {
789                message: format!("liminal connection services build failed: {error}"),
790            }
791        })?,
792    );
793    // (4) Supervisor WITH the services + notifier (the cycle's forward edge).
794    let supervisor = ConnectionSupervisor::with_services_and_notifier(services, notifier.clone())
795        .map_err(|error| ServerError::Config {
796        message: format!("liminal connection supervisor build failed: {error}"),
797    })?;
798    // (5) Bind the supervisor back into the notifier (the cycle's back edge); a
799    // failure here is a wiring bug, surfaced rather than silently ignored.
800    if !notifier.bind_supervisor(supervisor.clone()) {
801        return Err(ServerError::Config {
802            message: "liminal notifier supervisor handle was already bound during boot".to_owned(),
803        });
804    }
805    // (5b) Commission the connection dead-man switch over the SAME notifier. It
806    // pings every connected worker on a derived quarter-window cadence: the
807    // answers keep a healthy IDLE connection's lease alive (so the idle expiry
808    // cannot fire on a live worker), and the pings themselves are what a worker
809    // measures silence against (so a wedged half-open socket becomes a declared,
810    // logged death on the worker side instead of an unbounded blind wait). Not
811    // opt-in: liveness detection is a correctness property of this transport.
812    // The handle is detached — dropping a tokio `JoinHandle` never cancels the
813    // task — exactly as the heartbeat sweeper is spawned.
814    drop(state.spawn_liminal_liveness_probe(notifier.clone(), shutdown_rx.clone()));
815    // (6) Bind the listener on the configured address — workers connect IN here.
816    let listener =
817        ServerListener::bind(&liminal_config, supervisor).map_err(|error| ServerError::Config {
818            message: format!("liminal worker listener failed to bind {listen_address}: {error}"),
819        })?;
820    // (7) Reuse the SAME completion callback the gRPC completion path uses, over
821    // the live engine, so a liminal completion re-enters aion through the
822    // identical terminal-recording seam (`record_fan_out_completion`).
823    // (8) The registry-backed dispatch builds its LiminalCompletionSource from the
824    // shared callback internally. Attach the SAME short-TTL placement cache the
825    // gRPC arm installs (Control-Plane Phase 2, P2-P3), so an unpinned row in a
826    // `Prefer{L}` namespace prefers an L-labelled worker (spilling to any live
827    // worker) on the cross-node liminal transport too — the cluster-failover
828    // demo behaviour. A default-`Unplaced` deployment is byte-identical.
829    let placement_cache = crate::worker::PlacementCache::new(
830        Arc::clone(state.namespace_store()),
831        PLACEMENT_CACHE_TTL,
832    );
833    // NOI-6: install the SAME attempt-owner back-index the server's intervention
834    // router resolves through, so each dispatched agent attempt binds its owning
835    // worker and a pushed command reaches the worker this dispatcher sent it to.
836    let dispatch: Arc<dyn OutboxRowDispatch> = Arc::new(
837        RegistryLiminalDispatch::new(registry, callback, delivery_gate)
838            .with_placement_cache(placement_cache)
839            .with_attempt_owners(state.attempt_owners().clone()),
840    );
841
842    info!(
843        listen_address = %listen_address,
844        "liminal outbox worker listener commissioned (remote workers connect in and self-register)"
845    );
846    Ok((
847        dispatch,
848        OutboxWorkerListener {
849            _inner: Some(listener),
850        },
851    ))
852}
853
854/// Feature-off stub: selecting the liminal transport without the
855/// `liminal-transport` feature is a configuration error, never a silent
856/// fall-through to gRPC.
857#[cfg(not(feature = "liminal-transport"))]
858fn build_liminal_row_dispatch(
859    _state: &ServerState,
860    _outbox_config: &OutboxConfig,
861    _shutdown_rx: &tokio::sync::watch::Receiver<bool>,
862    _delivery_gate: DeliveryGate,
863    _delivery_callback: Arc<dyn OutboxDeliveryCallback>,
864) -> Result<(Arc<dyn OutboxRowDispatch>, OutboxWorkerListener), ServerError> {
865    Err(ServerError::Config {
866        message: "outbox.transport=liminal requires the aion-server `liminal-transport` \
867                  Cargo feature, which is not enabled in this build"
868            .to_owned(),
869    })
870}
871
872/// Resolve the validated, all-present outbox knobs into the dispatcher's
873/// non-optional config. Validation already guaranteed each value is set and in
874/// range when `outbox.enabled` is true, so an absent value here is a defensive
875/// configuration error, not a default to invent.
876fn resolve_outbox_config(outbox: &OutboxConfig) -> Result<OutboxDispatcherConfig, ServerError> {
877    let poll_interval_ms = outbox.poll_interval_ms.ok_or_else(|| ServerError::Config {
878        message: crate::config::OUTBOX_POLL_INTERVAL_REQUIRED.to_owned(),
879    })?;
880    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
881        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
882    })?;
883    let max_attempts = outbox.max_attempts.ok_or_else(|| ServerError::Config {
884        message: crate::config::OUTBOX_MAX_ATTEMPTS_REQUIRED.to_owned(),
885    })?;
886    let backoff_base_ms = outbox.backoff_base_ms.ok_or_else(|| ServerError::Config {
887        message: crate::config::OUTBOX_BACKOFF_BASE_REQUIRED.to_owned(),
888    })?;
889    let backoff_multiplier = outbox
890        .backoff_multiplier
891        .ok_or_else(|| ServerError::Config {
892            message: crate::config::OUTBOX_BACKOFF_MULTIPLIER_REQUIRED.to_owned(),
893        })?;
894    let backoff_max_ms = outbox.backoff_max_ms.ok_or_else(|| ServerError::Config {
895        message: crate::config::OUTBOX_BACKOFF_MAX_REQUIRED.to_owned(),
896    })?;
897    Ok(OutboxDispatcherConfig {
898        poll_interval: std::time::Duration::from_millis(poll_interval_ms),
899        batch_size,
900        max_attempts,
901        backoff_base: std::time::Duration::from_millis(backoff_base_ms),
902        backoff_multiplier,
903        backoff_max: std::time::Duration::from_millis(backoff_max_ms),
904    })
905}
906
907fn resolve_outbox_reconciler_config(
908    outbox: &OutboxConfig,
909) -> Result<Option<OutboxReconcilerConfig>, ServerError> {
910    let (Some(interval_ms), Some(stale_after_ms)) = (
911        outbox.reconcile_interval_ms,
912        outbox.reconcile_stale_after_ms,
913    ) else {
914        return Ok(None);
915    };
916    let batch_size = outbox.batch_size.ok_or_else(|| ServerError::Config {
917        message: crate::config::OUTBOX_BATCH_SIZE_REQUIRED.to_owned(),
918    })?;
919    Ok(Some(OutboxReconcilerConfig {
920        interval: std::time::Duration::from_millis(interval_ms),
921        stale_after: std::time::Duration::from_millis(stale_after_ms),
922        batch_size,
923    }))
924}
925
926fn reject_tls_until_supported(state: &ServerState) -> Result<(), ServerError> {
927    if state.runtime_config().tls.is_some() {
928        return Err(ServerError::Config {
929            message: "configured TLS material cannot be served until transport TLS is wired"
930                .to_owned(),
931        });
932    }
933    Ok(())
934}
935
936fn store_backend_label(backend: StoreBackend) -> &'static str {
937    match backend {
938        StoreBackend::Memory => "memory",
939        StoreBackend::LibSql => "libsql",
940        StoreBackend::Haematite => "haematite",
941    }
942}
943
944fn namespace_mode_label(mode: &NamespaceMode) -> &'static str {
945    match mode {
946        NamespaceMode::SharedEngine => "SharedEngine",
947        NamespaceMode::SingleTenant { .. } => "SingleTenant",
948    }
949}
950
951fn transport_bind<E>(transport: &'static str, address: SocketAddr, source: E) -> ServerError
952where
953    E: std::error::Error,
954{
955    ServerError::TransportBind {
956        transport,
957        address,
958        message: source.to_string(),
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    #![allow(clippy::expect_used)]
965
966    use super::{
967        BackpressureSettings, OutboxConfig, OutboxTransport, maybe_spawn_outbox_dispatcher,
968        resolve_outbox_reconciler_config,
969    };
970    use crate::ServerState;
971    use crate::config::RuntimeConfig;
972    use aion_store::InMemoryStore;
973    use std::net::SocketAddr;
974    use std::time::Duration;
975
976    /// Own-all, generous-default backpressure settings for the gate tests (the
977    /// single-node default: fraction 1, so the ceiling never engages).
978    fn test_backpressure_settings() -> BackpressureSettings {
979        BackpressureSettings {
980            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
981            fraction: crate::worker::OwnedShardFraction::own_all(),
982        }
983    }
984
985    /// A minimal `RuntimeConfig` for building an in-memory `ServerState` in unit
986    /// tests (mirrors `state.rs`'s test `runtime_config`).
987    fn runtime_config() -> RuntimeConfig {
988        use crate::config::{
989            AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
990            NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig,
991            WebSocketConfig, WorkerConfig,
992        };
993        RuntimeConfig {
994            listen: ListenConfig {
995                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
996                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
997            },
998            tls: None,
999            auth: AuthConfig {
1000                enabled: false,
1001                jwks_url: None,
1002                jwks_refresh_seconds: 300,
1003            },
1004            ops_console: OpsConsoleConfig {
1005                source: OpsConsoleAssetSource::Embedded,
1006            },
1007            namespace: NamespaceConfig {
1008                mode: NamespaceMode::SharedEngine,
1009            },
1010            worker: WorkerConfig {
1011                heartbeat_window: Duration::from_secs(30),
1012                ..WorkerConfig::default()
1013            },
1014            websocket: WebSocketConfig {
1015                outbound_buffer_bound: 32,
1016                event_broadcast_capacity: Some(64),
1017                cluster_broadcast_capacity: Some(64),
1018            },
1019            workflow_packages: Vec::new(),
1020            deploy: DeployConfig::default(),
1021            authoring: AuthoringConfig::default(),
1022            dev: DevConfig::default(),
1023            outbox: OutboxConfig::default(),
1024            observability: crate::config::ObservabilityConfig::default(),
1025            scheduler_threads: 1,
1026            query_timeout: Some(Duration::from_secs(10)),
1027            default_namespace: "default".to_owned(),
1028            auto_create: crate::config::AutoCreate::Open,
1029            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1030            drain_timeout: Duration::from_secs(30),
1031            metrics: MetricsConfig { enabled: true },
1032            owned_shards: Vec::new(),
1033            cors_allowed_origins: Vec::new(),
1034        }
1035    }
1036
1037    /// An `OutboxConfig` with `enabled = true` and every required knob present, so
1038    /// the only remaining gate is the store-backend / outbox-table availability.
1039    fn enabled_outbox_config() -> OutboxConfig {
1040        OutboxConfig {
1041            enabled: true,
1042            poll_interval_ms: Some(250),
1043            batch_size: Some(64),
1044            max_attempts: Some(5),
1045            backoff_base_ms: Some(100),
1046            backoff_multiplier: Some(2),
1047            backoff_max_ms: Some(30_000),
1048            reconcile_interval_ms: None,
1049            reconcile_stale_after_ms: None,
1050            transport: OutboxTransport::Grpc,
1051            liminal_listen_address: None,
1052        }
1053    }
1054
1055    /// LSUB-4-2 / LSUB-4-6 (Memory-backend guard): commissioning the outbox
1056    /// dispatcher against the in-memory backend (which has no outbox table, so
1057    /// `outbox_store()` is `None`) is a configuration error, and the message names
1058    /// BOTH supported backends (libsql / haematite), not just libsql.
1059    #[tokio::test]
1060    async fn outbox_enabled_on_memory_backend_is_a_config_error() {
1061        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1062            .await
1063            .expect("build in-memory state");
1064        let (_tx, rx) = tokio::sync::watch::channel(false);
1065        let error = maybe_spawn_outbox_dispatcher(
1066            &state,
1067            &enabled_outbox_config(),
1068            false,
1069            test_backpressure_settings(),
1070            &rx,
1071        )
1072        .expect_err("outbox.enabled on the memory backend must be a config error");
1073        assert!(
1074            error.is_config(),
1075            "memory-backend outbox error must be Config"
1076        );
1077        let message = error.to_string();
1078        assert!(
1079            message.contains("libsql") && message.contains("haematite"),
1080            "corrected message must name both supported backends, got: {message}"
1081        );
1082    }
1083
1084    /// LSUB-4-1 (Fork-B fast path): with the outbox disabled (the default), the
1085    /// gate is a no-op even on a memory backend — nothing is spawned and no error
1086    /// is produced, so a default single-node boot is unchanged.
1087    #[tokio::test]
1088    async fn disabled_outbox_is_a_noop_on_any_backend() {
1089        let state = ServerState::build_with_store(InMemoryStore::default(), runtime_config())
1090            .await
1091            .expect("build in-memory state");
1092        let (_tx, rx) = tokio::sync::watch::channel(false);
1093        maybe_spawn_outbox_dispatcher(
1094            &state,
1095            &OutboxConfig::default(),
1096            false,
1097            test_backpressure_settings(),
1098            &rx,
1099        )
1100        .expect("disabled outbox gate must be an infallible no-op");
1101    }
1102
1103    /// LSUB-4-4: the reconciler config resolves to `None` unless BOTH knobs are
1104    /// set — the condition under which the clustered-boot WARN fires.
1105    #[test]
1106    fn reconciler_config_absent_unless_both_knobs_set() {
1107        let mut config = enabled_outbox_config();
1108        // Neither knob: absent.
1109        assert!(
1110            resolve_outbox_reconciler_config(&config)
1111                .expect("resolve")
1112                .is_none()
1113        );
1114        // Only interval: still absent (the silent-backstop-absent default).
1115        config.reconcile_interval_ms = Some(1_000);
1116        assert!(
1117            resolve_outbox_reconciler_config(&config)
1118                .expect("resolve")
1119                .is_none()
1120        );
1121        // Both set: present.
1122        config.reconcile_stale_after_ms = Some(60_000);
1123        assert!(
1124            resolve_outbox_reconciler_config(&config)
1125                .expect("resolve")
1126                .is_some()
1127        );
1128    }
1129
1130    /// LSUB-PROD (13-6): the liminal transport requires `liminal_listen_address`.
1131    /// Commissioning the dispatcher with `transport = liminal` but no listen
1132    /// address is a configuration error naming the missing knob, rather than a
1133    /// panic or a silent fall-through to gRPC. Built over the libSQL backend (so
1134    /// the outbox-store gate passes and the missing-address check is actually
1135    /// reached). (Feature-gated: the liminal arm of `build_liminal_row_dispatch`
1136    /// only exists with `liminal-transport` on; in a feature-off build the same
1137    /// selection is the missing-feature error instead, covered by the type system
1138    /// rather than this test.)
1139    // Also gated on `libsql-backend`: it boots a real libSQL-backed `ServerState`
1140    // to obtain an outbox-bearing store, and the libSQL connect path is now an
1141    // opt-in feature. The listen-address guard itself is backend-agnostic.
1142    #[cfg(all(feature = "liminal-transport", feature = "libsql-backend"))]
1143    #[tokio::test]
1144    async fn liminal_transport_requires_listen_address() {
1145        use crate::config::{
1146            RuntimeSection, ServerConfig, StoreBackend, StoreConfig, WebSocketConfig,
1147        };
1148
1149        let db_path = std::env::temp_dir().join(format!(
1150            "aion-lsub-prod-listen-guard-{}-{}.db",
1151            std::process::id(),
1152            std::time::SystemTime::now()
1153                .duration_since(std::time::UNIX_EPOCH)
1154                .map(|elapsed| elapsed.as_nanos())
1155                .unwrap_or_default()
1156        ));
1157        let mut outbox = enabled_outbox_config();
1158        outbox.transport = OutboxTransport::Liminal;
1159        outbox.liminal_listen_address = None;
1160        let config = ServerConfig {
1161            store: StoreConfig {
1162                backend: StoreBackend::LibSql,
1163                url: Some(db_path.to_string_lossy().into_owned()),
1164                ..StoreConfig::default()
1165            },
1166            runtime: RuntimeSection {
1167                scheduler_threads: 1,
1168                query_timeout_ms: Some(10_000),
1169            },
1170            websocket: WebSocketConfig {
1171                outbound_buffer_bound: 32,
1172                event_broadcast_capacity: Some(64),
1173                cluster_broadcast_capacity: Some(64),
1174            },
1175            outbox: outbox.clone(),
1176            ..ServerConfig::default()
1177        };
1178        let state = ServerState::build(config)
1179            .await
1180            .expect("build libsql state");
1181        let (_tx, rx) = tokio::sync::watch::channel(false);
1182
1183        let error = maybe_spawn_outbox_dispatcher(
1184            &state,
1185            &outbox,
1186            false,
1187            test_backpressure_settings(),
1188            &rx,
1189        )
1190        .expect_err("liminal transport without a listen address must be a config error");
1191        assert!(
1192            error.is_config(),
1193            "missing-listen-address error must be Config"
1194        );
1195        assert!(
1196            error.to_string().contains("liminal_listen_address"),
1197            "error must name the missing knob, got: {error}"
1198        );
1199    }
1200}
1201
1202/// LSUB-PROD (13-6): production-boot cross-node round-trip over the REAL wiring.
1203///
1204/// This is the proof that the production boot now does the full round-trip the
1205/// retired stub could not. It drives the EXACT production commissioning function
1206/// `run_server` calls — [`maybe_spawn_outbox_dispatcher`] — over a real
1207/// [`ServerState`] built with `outbox.enabled`, `transport = liminal`, and a
1208/// `liminal_listen_address`. That function lifts the full push wiring
1209/// (`build_liminal_row_dispatch`): it hosts the liminal worker listener, builds
1210/// [`RegistryLiminalDispatch`](crate::worker::RegistryLiminalDispatch) over the
1211/// SAME registry the gRPC path uses and the SAME
1212/// [`ServerOutboxDeliveryCallback`](crate::worker::ServerOutboxDeliveryCallback)
1213/// (over the live engine), and spawns the real [`OutboxDispatcher`].
1214///
1215/// A REAL remote [`LiminalActivityWorker`](aion_worker::LiminalActivityWorker)
1216/// connects IN to the listener and self-registers in-band. A `collect_four`
1217/// fan-out is started over the REAL HTTP transport, which stages four pending
1218/// outbox rows; the production-wired dispatcher claims and pushes each to the
1219/// worker, the worker executes it, and its completion re-enters aion through the
1220/// production engine callback — `record_fan_out_completion` — driving the
1221/// workflow to a recorded terminal. The proof asserts BOTH: the worker observably
1222/// executed the activities, AND the terminals were recorded in history (four
1223/// `ActivityCompleted` + one `WorkflowCompleted`), which the stub's
1224/// publish-and-mark-done path never achieved.
1225// Also gated on `libsql-backend`: this production-boot round-trip stands up a
1226// real libSQL-backed server (the durable outbox path it exercises), and the
1227// libSQL connect path is now an opt-in feature.
1228#[cfg(all(test, feature = "liminal-transport", feature = "libsql-backend"))]
1229mod lsub_prod_xnode_e2e {
1230    #![allow(clippy::expect_used)]
1231
1232    use std::net::SocketAddr;
1233    use std::path::PathBuf;
1234    use std::sync::Arc;
1235    use std::sync::atomic::{AtomicUsize, Ordering};
1236    use std::time::{Duration, Instant};
1237
1238    use aion_core::Event;
1239    use aion_package::{
1240        ActionContract, BeamModule, BeamSet, CURRENT_FORMAT_VERSION, DeclaredActivity, Manifest,
1241        ManifestVersion, PackageBuilder, PackageContract, WorkerContract,
1242    };
1243    use aion_store::ReadableEventStore;
1244    use aion_store_libsql::LibSqlStore;
1245    use aion_worker::{ActivityRegistry, LiminalActivityWorker, WorkerConfig};
1246    use axum::body;
1247    use axum::http::{Request, StatusCode};
1248    use serde_json::json;
1249    use tower::ServiceExt;
1250
1251    use super::{BackpressureSettings, maybe_spawn_outbox_dispatcher};
1252    use crate::ServerState;
1253    use crate::api::http::http_router;
1254    use crate::config::{
1255        OutboxConfig, OutboxTransport, RuntimeSection, ServerConfig, StoreBackend, StoreConfig,
1256        WebSocketConfig,
1257    };
1258
1259    type TestError = Box<dyn std::error::Error + Send + Sync>;
1260
1261    /// The `collect_four` fixture passes each member the JSON string `"in"` as
1262    /// activity input, so the worker handler decodes a [`String`], not a struct.
1263    type FanInput = String;
1264
1265    const NAMESPACE: &str = "default";
1266    const TASK_QUEUE: &str = "default";
1267    const OUTBOX_MODULE: &str = "aion_outbox_fixture";
1268    const OUTBOX_BEAM: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.beam");
1269    const OUTBOX_SOURCE: &[u8] = include_bytes!("../tests/fixtures/aion_outbox_fixture.erl");
1270    const FAN_OUT: usize = 4;
1271    const FAN_ACTIVITY_TYPES: [&str; FAN_OUT] = ["fan:0", "fan:1", "fan:2", "fan:3"];
1272    const POLL_DEADLINE: Duration = Duration::from_secs(20);
1273
1274    fn test_error(message: impl std::fmt::Display) -> TestError {
1275        message.to_string().into()
1276    }
1277
1278    /// Reserve a loopback port and return it: the liminal listener binds this exact
1279    /// address (the production path binds the configured `liminal_listen_address`,
1280    /// so the test must commit to a concrete port the worker can also dial).
1281    fn reserve_loopback_port() -> Result<SocketAddr, TestError> {
1282        let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(test_error)?;
1283        let address = listener.local_addr().map_err(test_error)?;
1284        drop(listener);
1285        Ok(address)
1286    }
1287
1288    /// The fixture's queue-scoped `.v4` contract: the four `fan:N` activities
1289    /// `collect_four` schedules, declared on the queue its worker actually polls.
1290    ///
1291    /// Why the archive cannot just carry the manifest-derived record: by design
1292    /// `PackageContract::from_manifest` "never invents a queue", so a manifest's
1293    /// bare activity names land in `unscoped_activities` — and this server boots
1294    /// queue-routed, where an unscoped catalog is a terminal
1295    /// `NO_QUEUE_DECLARATION` at start admission
1296    /// (`aion::lifecycle::start_admission`). That refusal is EARNED: an unserved
1297    /// queue would otherwise wait silently forever. So the derived record is
1298    /// amended rather than bypassed — the same four names move out of
1299    /// `unscoped_activities` and onto the queue that serves them — and the
1300    /// package still loads through the production boot path with the `.v4`
1301    /// identity `PackageBuilder` stamps over this exact contract.
1302    ///
1303    /// The action schemas come from the SAME generator the worker's typed
1304    /// registry uses, for the SAME Rust types: `collect_four` passes each member
1305    /// the JSON string `"in"` and the handler returns a [`String`]. Deriving both
1306    /// sides from `activity_descriptor::<FanInput, String>` means the package's
1307    /// declaration and the worker's advertisement cannot drift apart, so
1308    /// registration admission (`WORKER_CONTRACT_MISMATCH`) compares two schemas
1309    /// with one source.
1310    fn fixture_contract(manifest: &Manifest) -> Result<PackageContract, TestError> {
1311        let mut actions = Vec::with_capacity(FAN_ACTIVITY_TYPES.len());
1312        for activity_type in FAN_ACTIVITY_TYPES {
1313            let descriptor = aion_worker::activity_descriptor::<FanInput, String>(activity_type)
1314                .map_err(test_error)?;
1315            actions.push(ActionContract {
1316                name: descriptor.name,
1317                input_schema: descriptor.input_schema,
1318                output_schema: descriptor.output_schema,
1319                node: None,
1320                timeout: None,
1321                retry: None,
1322                advisory: false,
1323                // A typed `String -> String` handler serves these, not an agent
1324                // harness — the fan fixture's shape merely coincides with an
1325                // agent seam's, and marking it would route it somewhere no
1326                // handler is.
1327                agent: false,
1328                // A connected worker serves this fixture's queue, so the
1329                // declaration carries no body of its own.
1330                body: None,
1331            });
1332        }
1333        let mut contract = PackageContract::from_manifest(manifest);
1334        contract.workers = vec![WorkerContract {
1335            task_queue: TASK_QUEUE.to_owned(),
1336            actions,
1337        }];
1338        contract.unscoped_activities.clear();
1339        Ok(contract)
1340    }
1341
1342    /// Build the `collect_four` package on disk so the production state-build path
1343    /// loads it exactly as it loads operator-supplied `workflow_packages`.
1344    fn write_package_archive(dir: &std::path::Path) -> Result<PathBuf, TestError> {
1345        let beams =
1346            BeamSet::new(vec![BeamModule::new(OUTBOX_MODULE, OUTBOX_BEAM)]).map_err(test_error)?;
1347        let manifest = Manifest {
1348            entry_module: OUTBOX_MODULE.to_owned(),
1349            entry_function: "collect_four".to_owned(),
1350            input_schema: json!({ "type": "object" }),
1351            output_schema: json!({}),
1352            timeout: Some(Duration::from_secs(30)),
1353            // The four ordinals `collect_four` actually fans out. This manifest
1354            // used to name one invented activity, `fixture_activity`, that the
1355            // fixture never schedules and no worker ever served.
1356            activities: FAN_ACTIVITY_TYPES
1357                .iter()
1358                .map(|activity_type| DeclaredActivity {
1359                    activity_type: (*activity_type).to_owned(),
1360                })
1361                .collect(),
1362            version: ManifestVersion::new("stamped-by-builder"),
1363            format_version: CURRENT_FORMAT_VERSION,
1364            additional_workflows: Vec::new(),
1365        };
1366        let contract = fixture_contract(&manifest)?;
1367        let archive =
1368            PackageBuilder::with_source(manifest, beams, [(OUTBOX_MODULE, OUTBOX_SOURCE.to_vec())])
1369                .with_contract(contract)
1370                .write_to_bytes()
1371                .map_err(test_error)?;
1372        let path = dir.join("collect_four.aion");
1373        std::fs::write(&path, archive).map_err(test_error)?;
1374        Ok(path)
1375    }
1376
1377    /// A production-shaped `ServerConfig`: the libSQL backend (so the boot store
1378    /// path shares the leaf as the dispatcher's outbox store, exactly as
1379    /// `ServerState::build` does in production), `outbox.enabled`,
1380    /// `transport = liminal`, the reserved `liminal_listen_address`, and the
1381    /// `collect_four` package. Built through `ServerState::build` (not
1382    /// `build_with_store`), so this is the real boot store seam, not a test stand-in.
1383    fn server_config(
1384        db_path: &std::path::Path,
1385        package_path: PathBuf,
1386        listen_address: SocketAddr,
1387    ) -> ServerConfig {
1388        ServerConfig {
1389            store: StoreConfig {
1390                backend: StoreBackend::LibSql,
1391                url: Some(db_path.to_string_lossy().into_owned()),
1392                ..StoreConfig::default()
1393            },
1394            runtime: RuntimeSection {
1395                scheduler_threads: 1,
1396                query_timeout_ms: Some(10_000),
1397            },
1398            websocket: WebSocketConfig {
1399                outbound_buffer_bound: 32,
1400                event_broadcast_capacity: Some(64),
1401                cluster_broadcast_capacity: Some(64),
1402            },
1403            workflow_packages: vec![package_path],
1404            outbox: OutboxConfig {
1405                enabled: true,
1406                poll_interval_ms: Some(20),
1407                batch_size: Some(16),
1408                max_attempts: Some(5),
1409                backoff_base_ms: Some(50),
1410                backoff_multiplier: Some(2),
1411                backoff_max_ms: Some(1_000),
1412                reconcile_interval_ms: None,
1413                reconcile_stale_after_ms: None,
1414                transport: OutboxTransport::Liminal,
1415                liminal_listen_address: Some(listen_address.to_string()),
1416            },
1417            ..ServerConfig::default()
1418        }
1419    }
1420
1421    /// The remote worker self-describes for the fixture's pool `(default, default)`
1422    /// and registers a handler for every `fan:N` activity type, counting executions
1423    /// so the test proves it genuinely ran the pushed dispatches.
1424    fn worker_config() -> Result<WorkerConfig, TestError> {
1425        WorkerConfig::builder()
1426            .endpoint("unused-direct-address")
1427            .namespace(NAMESPACE)
1428            .task_queue(TASK_QUEUE)
1429            .identity("lsub-prod-worker")
1430            .max_concurrency(4)
1431            .reconnect_initial_backoff(Duration::from_millis(5))
1432            .reconnect_max_backoff(Duration::from_millis(20))
1433            .reconnect_max_attempts(3)
1434            .build()
1435            .map_err(test_error)
1436    }
1437
1438    fn worker_registry(executions: &Arc<AtomicUsize>) -> Result<Arc<ActivityRegistry>, TestError> {
1439        let mut registry = ActivityRegistry::new();
1440        for activity_type in FAN_ACTIVITY_TYPES {
1441            let executions = Arc::clone(executions);
1442            // `register_activity_with_contract`, not `register_activity`: the
1443            // bare form registers a handler with NO descriptor, so the worker
1444            // advertises four names and zero typed contracts, and admission —
1445            // which compares CONTRACTS — refuses the registration outright
1446            // (`WORKER_CONTRACT_MISMATCH`). Deriving the advertisement from
1447            // `<FanInput, String>` is what makes it the same source the
1448            // package's `fixture_contract` declares from, so the two sides
1449            // cannot drift.
1450            registry = registry
1451                .register_activity_with_contract(
1452                    activity_type,
1453                    move |_input: FanInput, _context| {
1454                        let executions = Arc::clone(&executions);
1455                        Box::pin(async move {
1456                            executions.fetch_add(1, Ordering::SeqCst);
1457                            Ok(activity_type.to_owned())
1458                        })
1459                    },
1460                )
1461                .map_err(test_error)?;
1462        }
1463        Ok(Arc::new(registry))
1464    }
1465
1466    /// Spawns the remote worker on its own OS thread with a current-thread runtime
1467    /// (the push receive is blocking), connecting IN to the production listener.
1468    struct WorkerThread {
1469        stop: Arc<std::sync::atomic::AtomicBool>,
1470        handle: Option<std::thread::JoinHandle<()>>,
1471    }
1472
1473    impl WorkerThread {
1474        fn spawn(address: String, config: WorkerConfig, registry: Arc<ActivityRegistry>) -> Self {
1475            let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
1476            let thread_stop = Arc::clone(&stop);
1477            let handle = std::thread::spawn(move || {
1478                let runtime = match tokio::runtime::Builder::new_current_thread()
1479                    .enable_all()
1480                    .build()
1481                {
1482                    Ok(runtime) => runtime,
1483                    Err(error) => {
1484                        eprintln!("worker runtime build failed: {error}");
1485                        return;
1486                    }
1487                };
1488                runtime.block_on(async move {
1489                    let worker = match LiminalActivityWorker::connect(&address, &config, registry) {
1490                        Ok(worker) => worker,
1491                        Err(error) => {
1492                            eprintln!("worker connect failed: {error}");
1493                            return;
1494                        }
1495                    };
1496                    if let Err(error) = worker
1497                        .serve_until(|| thread_stop.load(Ordering::SeqCst))
1498                        .await
1499                    {
1500                        eprintln!("worker serve loop ended with error: {error}");
1501                    }
1502                });
1503            });
1504            Self {
1505                stop,
1506                handle: Some(handle),
1507            }
1508        }
1509
1510        fn stop(mut self) {
1511            self.stop.store(true, Ordering::SeqCst);
1512            if let Some(handle) = self.handle.take() {
1513                handle.join().ok();
1514            }
1515        }
1516    }
1517
1518    fn count_completed(history: &[Event]) -> usize {
1519        history
1520            .iter()
1521            .filter(|event| matches!(event, Event::ActivityCompleted { .. }))
1522            .count()
1523    }
1524
1525    fn count_workflow_completed(history: &[Event]) -> usize {
1526        history
1527            .iter()
1528            .filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
1529            .count()
1530    }
1531
1532    async fn wait_for_history<F>(
1533        store: &LibSqlStore,
1534        workflow_id: &aion_core::WorkflowId,
1535        description: &str,
1536        predicate: F,
1537    ) -> Result<Vec<Event>, TestError>
1538    where
1539        F: Fn(&[Event]) -> bool,
1540    {
1541        let deadline = Instant::now() + POLL_DEADLINE;
1542        loop {
1543            let history = store.read_history(workflow_id).await.map_err(test_error)?;
1544            if predicate(&history) {
1545                return Ok(history);
1546            }
1547            if Instant::now() > deadline {
1548                return Err(test_error(format!(
1549                    "timed out waiting for {description}: {history:#?}"
1550                )));
1551            }
1552            tokio::time::sleep(Duration::from_millis(25)).await;
1553        }
1554    }
1555
1556    /// Start the loaded `collect_four` workflow over the REAL HTTP transport.
1557    async fn start_over_http(router: &axum::Router) -> Result<aion_core::WorkflowId, TestError> {
1558        let build_request = || -> Result<Request<body::Body>, TestError> {
1559            Request::builder()
1560                .uri("/workflows/start")
1561                .method("POST")
1562                .header("content-type", "application/json")
1563                .header("x-aion-subject", "ci")
1564                .header("x-aion-namespaces", NAMESPACE)
1565                .body(body::Body::from(
1566                    serde_json::to_vec(&json!({
1567                        "namespace": NAMESPACE,
1568                        "workflow_type": OUTBOX_MODULE,
1569                        "input": { "fixture": "input" },
1570                    }))
1571                    .map_err(test_error)?,
1572                ))
1573                .map_err(test_error)
1574        };
1575        let response = router
1576            .clone()
1577            .oneshot(build_request()?)
1578            .await
1579            .map_err(test_error)?;
1580        let status = response.status();
1581        let bytes = body::to_bytes(response.into_body(), usize::MAX)
1582            .await
1583            .map_err(test_error)?
1584            .to_vec();
1585        if status != StatusCode::OK {
1586            return Err(test_error(format!(
1587                "workflow start over HTTP must succeed, got {status}: {}",
1588                String::from_utf8_lossy(&bytes)
1589            )));
1590        }
1591        let body: serde_json::Value = serde_json::from_slice(&bytes).map_err(test_error)?;
1592        // The HTTP wire contract (`clean_dtos::StartWorkflowResponse`) serializes
1593        // `workflow_id` as a plain UUID string, not a nested `{ uuid }` object.
1594        let workflow_id = body["workflow_id"]
1595            .as_str()
1596            .ok_or_else(|| test_error("start response missing workflow id"))?
1597            .parse::<uuid::Uuid>()
1598            .map_err(test_error)?;
1599        Ok(aion_core::WorkflowId::new(workflow_id))
1600    }
1601
1602    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1603    async fn production_boot_dispatches_executes_and_records_over_liminal() -> Result<(), TestError>
1604    {
1605        let dir = crate::test_support::private_tempdir().map_err(test_error)?;
1606        let db_path = dir.path().join("aion.db");
1607        let package_path = write_package_archive(dir.path())?;
1608        // The production path binds the CONFIGURED listen address, so commit to a
1609        // concrete reserved loopback port the worker can also dial.
1610        let listen_address = reserve_loopback_port()?;
1611
1612        // (A) Build a real ServerState through the production boot path
1613        // (ServerState::build over a libSQL ServerConfig): outbox enabled,
1614        // transport = liminal, the listen address set, collect_four loaded. This
1615        // shares the libSQL leaf as the dispatcher's outbox store (the real boot
1616        // store seam) and installs the production ServerOutboxDeliveryCallback over
1617        // the live engine (gated on outbox.enabled).
1618        let config = server_config(&db_path, package_path, listen_address);
1619        let outbox_config = config.outbox.clone();
1620        let state = ServerState::build(config).await.map_err(test_error)?;
1621
1622        // (B) Drive the EXACT production commissioning function run_server calls:
1623        // it hosts the liminal listener, builds RegistryLiminalDispatch over the
1624        // shared registry + engine callback, and spawns the real OutboxDispatcher.
1625        // Hold the returned listener guard for the test's lifetime, exactly as
1626        // run_server holds it.
1627        let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
1628        // Own-all, generous-default backpressure (single-node e2e): fraction 1 and
1629        // the platform default, so the ceiling never engages — the claim behaves
1630        // exactly as before, proving the production path is byte-identical on default.
1631        let backpressure_settings = BackpressureSettings {
1632            platform_default: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
1633            fraction: crate::worker::OwnedShardFraction::own_all(),
1634        };
1635        let listener_guard = maybe_spawn_outbox_dispatcher(
1636            &state,
1637            &outbox_config,
1638            false,
1639            backpressure_settings,
1640            &shutdown_rx,
1641        )
1642        .map_err(test_error)?;
1643
1644        // (C) A REAL remote worker connects IN to the production listener and
1645        // self-registers in-band for the fixture's pool.
1646        let executions = Arc::new(AtomicUsize::new(0));
1647        let worker = WorkerThread::spawn(
1648            listen_address.to_string(),
1649            worker_config()?,
1650            worker_registry(&executions)?,
1651        );
1652
1653        // Wait until the in-band registration landed in the SAME registry the
1654        // dispatch path selects from (every fan-out activity type is eligible).
1655        let registry = state.worker_registry().clone();
1656        let deadline = Instant::now() + Duration::from_secs(5);
1657        loop {
1658            let ready = FAN_ACTIVITY_TYPES.iter().all(|activity_type| {
1659                registry
1660                    .select_worker(NAMESPACE, TASK_QUEUE, activity_type, None)
1661                    .ok()
1662                    .flatten()
1663                    .is_some()
1664            });
1665            if ready {
1666                break;
1667            }
1668            if Instant::now() > deadline {
1669                worker.stop();
1670                return Err(test_error("worker never registered in-band for the pool"));
1671            }
1672            tokio::time::sleep(Duration::from_millis(10)).await;
1673        }
1674
1675        // (D) Start collect_four over the REAL HTTP transport: the engine stages
1676        // four pending outbox rows; the production-wired dispatcher claims and
1677        // pushes each to the worker.
1678        let router = http_router(state.clone()).map_err(test_error)?;
1679        let workflow_id = start_over_http(&router).await?;
1680
1681        // (E) THE PROOF: the worker executed all four activities AND every terminal
1682        // was recorded through the production engine callback (record_fan_out_completion)
1683        // — four ActivityCompleted + one WorkflowCompleted in durable history. This
1684        // is the full round-trip the retired stub never achieved.
1685        let reader = LibSqlStore::open(db_path.clone())
1686            .await
1687            .map_err(test_error)?;
1688        let settled = wait_for_history(&reader, &workflow_id, "fan-out settled", |events| {
1689            count_completed(events) == FAN_OUT && count_workflow_completed(events) == 1
1690        })
1691        .await?;
1692        assert_eq!(
1693            count_completed(&settled),
1694            FAN_OUT,
1695            "every fan-out member must record a terminal through the production callback"
1696        );
1697        assert_eq!(
1698            count_workflow_completed(&settled),
1699            1,
1700            "the workflow must complete exactly once"
1701        );
1702        assert_eq!(
1703            executions.load(Ordering::SeqCst),
1704            FAN_OUT,
1705            "the remote worker must have executed every pushed dispatch exactly once"
1706        );
1707
1708        // Teardown: stop the dispatcher + worker, drop the listener guard (its Drop
1709        // stops the accept worker), shut the engine down so durable appends finish.
1710        shutdown_tx.send(true).ok();
1711        worker.stop();
1712        drop(listener_guard);
1713        state.shutdown().map_err(test_error)?;
1714        Ok(())
1715    }
1716}