Skip to main content

aion_server/
state.rs

1//! Shared server state constructed once at startup.
2
3use std::{path::PathBuf, sync::Arc};
4
5use aion::{
6    ActivityDispatcher, EngineBuilder, RuntimeHandle, SignalRouter, signal::ConcreteSignalRouter,
7};
8use aion_store::{EventStore, NamespaceStore, OutboxStore};
9#[cfg(feature = "libsql-backend")]
10use aion_store_libsql::LibSqlStore;
11
12use crate::dev_ui::{ActivityMockRegistry, DevMockingDispatcher};
13
14#[cfg(feature = "auth")]
15use crate::auth::JwksCache;
16use crate::{
17    config::{RuntimeConfig, ServerConfig, StoreBackend, StoreConfig},
18    error::ServerError,
19    namespace::{NamespaceGuard, NamespaceMinter, resolver::NamespaceResolver},
20    observability::{
21        Metrics, health::HealthState, instrumented_store::InstrumentedEventStore,
22        metrics::MetricsError,
23    },
24    shutdown::DrainState,
25    worker::{
26        ConnectedWorkerRegistry, HeartbeatTracker, PendingActivities, WorkerActivityDispatcher,
27    },
28};
29
30/// Cloneable shared state passed to all server transports.
31#[derive(Clone)]
32pub struct ServerState {
33    inner: Arc<ServerStateInner>,
34}
35
36struct ServerStateInner {
37    namespace_guard: NamespaceGuard,
38    runtime: RuntimeConfig,
39    worker_registry: ConnectedWorkerRegistry,
40    pending_activities: PendingActivities,
41    heartbeat_tracker: HeartbeatTracker,
42    drain_state: DrainState,
43    metrics: Option<Metrics>,
44    health: Option<HealthState>,
45    /// Shared per-run activity-mock registry. Present only when the dev surface
46    /// is commissioned; the engine's dispatcher consults this exact instance.
47    activity_mock_registry: Option<ActivityMockRegistry>,
48    /// The leaf libSQL store cast as an [`OutboxStore`], shared with the engine's
49    /// `EventStore` so the outbox dispatcher writes through the same single
50    /// `libsql::Connection`. `None` for the in-memory backend (no outbox table).
51    outbox_store: Option<Arc<dyn OutboxStore>>,
52    /// The durable namespace registry, captured from the SAME concrete leaf
53    /// backend as the engine's `EventStore` BEFORE that leaf is wrapped in the
54    /// decorator chain (`PublishingEventStore` → `InstrumentedEventStore`),
55    /// which do not implement [`NamespaceStore`]. The haematite backend supplies
56    /// the quorum-replicated implementation; the libSQL and in-memory backends
57    /// supply a local-only one. Always present so the control-plane mint
58    /// (Phase 1 S5) and `GET /namespaces` (S7) can reach a real store on every
59    /// boot. Mirrors the `cluster_store` retention pattern.
60    namespace_store: Arc<dyn NamespaceStore>,
61    /// Advisory outbox wake (LSUB-2): the in-process `Notify` shared by the
62    /// engine's stage seam (the `InstrumentedEventStore`'s `append_with_outbox`)
63    /// and the [`OutboxDispatcher`](crate::worker::OutboxDispatcher) run loop, so
64    /// a committed fan-out row wakes the dispatcher in ~RTT instead of waiting up
65    /// to one poll interval. Always present (cheap, no `Option`): the handle is
66    /// harmless when the outbox is not commissioned, since nothing pulses it.
67    outbox_wake: Arc<tokio::sync::Notify>,
68    /// WS3 cluster topology/ownership publisher. Always present: the ops console's
69    /// cluster channel is served on every boot (calm state with no peers on a
70    /// single-node server). Sized from `websocket.cluster_broadcast_capacity`.
71    cluster_publisher: crate::cluster_publisher::ClusterEventPublisher,
72    /// NOI-5b agent-observability transcript sequencer + live fan-out. Always
73    /// present: the transcript channel is served on every boot. The backing
74    /// [`ObservabilityStore`](aion_store::ObservabilityStore) is the durable
75    /// `O`-keyspace impl on a haematite boot and an in-memory impl on every other
76    /// backend (libSQL / in-memory have no `O` keyspace), so the transcript path
77    /// is uniform across backends while only haematite persists across restart.
78    /// Sized from `websocket.cluster_broadcast_capacity` (the same deployment-wide
79    /// real-time channel capacity the cluster tail uses).
80    transcript_publisher: crate::activity_publisher::ActivityEventPublisher,
81    /// NOI-6 server-side intervention routing: the `attempt -> owning-worker`
82    /// back-index the intervention router resolves a command's target through.
83    /// Always present (cheap, no `Option`): the agent-dispatch path binds an owner
84    /// when it dispatches an agent attempt and releases it on completion, so the
85    /// router resolves the CURRENT owner. Empty until an agent attempt is
86    /// dispatched — a command to an unbound attempt is the attempt-scoped no-op.
87    attempt_owners: crate::worker::AttemptOwnerIndex,
88    /// R1 live unserved-queue state. Always present (cheap, no `Option`): the
89    /// bridge dispatcher publishes every parked dispatch into THIS instance, so
90    /// `unserved_queues()` answers "which addresses are unserved, why, and which
91    /// runs are waiting on them" without reading logs. Empty whenever nothing is
92    /// parked.
93    queue_service_state: crate::worker::QueueServiceState,
94    /// R1 queue-declaration source, filled in with the engine-backed reader once
95    /// the engine exists. Held so surfaces (and the bridge) share ONE reader
96    /// rather than each building their own view of the deployed contracts.
97    queue_declarations: crate::worker::QueueDeclarationSource,
98    /// This node's distribution name for the WS3 cluster snapshot self-identity.
99    /// `Some` on a distributed haematite boot (the configured `store.cluster.node_id`),
100    /// `None` on a single-node boot — the snapshot then reports the standalone
101    /// self-label so the ops console still has a node to render.
102    cluster_self_node: Option<String>,
103    /// Owns the distributed haematite inbound-write responder thread, kept alive
104    /// for the server's lifetime so a cluster node keeps answering peers'
105    /// replication/election traffic. `None` for non-distributed boots. Dropping
106    /// the state stops the responder.
107    #[cfg(feature = "haematite-backend")]
108    cluster_responder: Option<aion_store_haematite::ClusterResponder>,
109    /// The concrete distributed haematite store the SS-5b supervisor polls for
110    /// peer liveness. `None` for every non-distributed boot.
111    #[cfg(feature = "haematite-backend")]
112    cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
113    /// The peers the SS-5b supervisor watches (each with the shards this node
114    /// adopts on its death). Empty for non-distributed boots.
115    #[cfg(feature = "haematite-backend")]
116    watched_peers: Vec<crate::cluster::WatchedPeer>,
117    /// The request-routing shard directory (R-2), built over the cluster store +
118    /// static peer config. `None` for every non-distributed boot, so the routing
119    /// edge falls back to the bare R-1 ownership check (and the default path is a
120    /// no-op).
121    #[cfg(feature = "haematite-backend")]
122    shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
123    /// The request forwarder (R-3): relays a non-local signal/query/cancel to the
124    /// shard owner's gRPC address. `None` for non-distributed boots. The trait
125    /// object makes the liminal forwarder a one-line swap when 13-L0/L1 land (R-6).
126    #[cfg(feature = "haematite-backend")]
127    request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
128    #[cfg(feature = "auth")]
129    jwks_cache: Option<JwksCache>,
130}
131
132impl ServerState {
133    /// Fallback cluster broadcast capacity for the `from_parts*` embedder/test
134    /// constructors, which bypass config validation. The config-driven
135    /// [`Self::build`] path always sizes the publisher from the validated
136    /// `websocket.cluster_broadcast_capacity` instead.
137    ///
138    /// `NonZeroUsize::new(64)` is statically non-`None`, so the
139    /// [`Option::unwrap`]-free `match` keeps the value `const` without tripping
140    /// the workspace `unwrap_used`/`expect_used` deny lints.
141    const FALLBACK_CLUSTER_BROADCAST_CAPACITY: std::num::NonZeroUsize =
142        match std::num::NonZeroUsize::new(64) {
143            Some(value) => value,
144            None => std::num::NonZeroUsize::MIN,
145        };
146
147    /// Build shared state from operator configuration.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`ServerError`] if the store cannot connect or the engine cannot
152    /// be constructed.
153    pub async fn build(config: ServerConfig) -> Result<Self, ServerError> {
154        let (store_config, runtime) = config.into_parts();
155        let connected = connect_store(store_config).await?;
156        Self::build_with_connected_store(connected, runtime).await
157    }
158
159    /// Build shared state from an already-constructed store.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`ServerError::EngineCall`] if the engine cannot be constructed.
164    pub async fn build_with_store<S>(store: S, runtime: RuntimeConfig) -> Result<Self, ServerError>
165    where
166        S: EventStore + NamespaceStore,
167    {
168        // Capture the concrete leaf as BOTH the event store and the namespace
169        // registry before it is wrapped in the (NamespaceStore-unaware) decorator
170        // chain — the same leaf, two trait objects.
171        let leaf = Arc::new(store);
172        let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
173        Self::build_with_connected_store(
174            ConnectedStore::local(leaf, None, namespace_store),
175            runtime,
176        )
177        .await
178    }
179
180    async fn build_with_connected_store(
181        connected: ConnectedStore,
182        runtime: RuntimeConfig,
183    ) -> Result<Self, ServerError> {
184        let outbox_store = connected.outbox_store;
185        let bootstrap_coordinator = connected.bootstrap_coordinator;
186        #[cfg(feature = "haematite-backend")]
187        let cluster_responder = connected.cluster_responder;
188        #[cfg(feature = "haematite-backend")]
189        let cluster_store = connected.cluster_store;
190        #[cfg(feature = "haematite-backend")]
191        let watched_peers = connected.watched_peers;
192        // Capture this node's self-identity for the WS3 cluster snapshot before
193        // `self_node_id` is moved into the routing-state builder below.
194        #[cfg(feature = "haematite-backend")]
195        let cluster_self_node = connected.self_node_id.clone();
196        #[cfg(not(feature = "haematite-backend"))]
197        let cluster_self_node: Option<String> = None;
198        // Build the R-2 directory + R-3 forwarder over the (live, failover-aware)
199        // cluster store and static peer config. Both present only for a
200        // distributed boot; `None` otherwise leaves the routing edge a no-op.
201        #[cfg(feature = "haematite-backend")]
202        let RoutingState {
203            shard_directory,
204            request_forwarder,
205            mint_routing,
206        } = build_routing_state(
207            cluster_store.as_ref(),
208            connected.directory_peers,
209            connected.self_node_id,
210        );
211        let (event_broadcast_capacity, query_timeout) = required_engine_seams(&runtime)?;
212        let (cluster_publisher, transcript_publisher) =
213            build_real_time_publishers(&runtime, connected.observability_store)?;
214        let metrics = Metrics::new().map_err(|error| metrics_config_error(&error))?;
215        // LSUB-2 advisory wake: one process-wide `Notify` shared by the engine's
216        // stage seam and the outbox dispatcher. A single handle is correct here
217        // because there is exactly one in-process dispatcher that sweeps all owned
218        // shards per tick — a wake just means "something was staged; sweep".
219        let outbox_wake = Arc::new(tokio::sync::Notify::new());
220        let instrumented_store = Arc::new(
221            InstrumentedEventStore::new(
222                connected.event_store,
223                metrics.clone(),
224                runtime.default_namespace.clone(),
225            )
226            .with_outbox_wake(Arc::clone(&outbox_wake)),
227        );
228        let exported_metrics = runtime.metrics.enabled.then_some(metrics.clone());
229        #[cfg(not(feature = "haematite-backend"))]
230        let mint_routing: Option<crate::namespace::NamespaceRouting> = None;
231        let seams = build_worker_seams(
232            &runtime,
233            &cluster_publisher,
234            &connected.namespace_store,
235            mint_routing,
236        );
237        let (activity_dispatcher, activity_mock_registry, attempt_owners) =
238            build_decorated_dispatcher(&runtime, &seams);
239
240        let engine = build_engine(EngineAssembly {
241            instrumented_store: &instrumented_store,
242            event_broadcast_capacity,
243            query_timeout,
244            activity_dispatcher,
245            active_registry: Arc::new(aion::Registry::default()),
246            bootstrap_coordinator,
247            runtime: &runtime,
248        })
249        .await?;
250        let engine = Arc::new(engine);
251        install_engine_backed_seams(&seams, &engine, runtime.outbox.enabled);
252        let resolver = NamespaceResolver::from_config(runtime.namespace.clone(), engine);
253        #[cfg(feature = "auth")]
254        let jwks_cache = build_jwks_cache(&runtime).await?;
255        Ok(Self {
256            inner: Arc::new(ServerStateInner {
257                namespace_guard: NamespaceGuard::new(resolver),
258                runtime,
259                worker_registry: seams.worker_registry,
260                pending_activities: seams.pending_activities,
261                heartbeat_tracker: seams.heartbeat_tracker,
262                drain_state: seams.drain_state,
263                metrics: exported_metrics,
264                health: Some(HealthState::new(instrumented_store, true)),
265                activity_mock_registry,
266                outbox_store,
267                namespace_store: connected.namespace_store,
268                outbox_wake,
269                cluster_publisher,
270                transcript_publisher,
271                attempt_owners,
272                queue_service_state: seams.queue_service_state,
273                queue_declarations: seams.queue_declarations,
274                cluster_self_node,
275                #[cfg(feature = "haematite-backend")]
276                cluster_responder,
277                #[cfg(feature = "haematite-backend")]
278                cluster_store,
279                #[cfg(feature = "haematite-backend")]
280                watched_peers,
281                #[cfg(feature = "haematite-backend")]
282                shard_directory,
283                #[cfg(feature = "haematite-backend")]
284                request_forwarder,
285                #[cfg(feature = "auth")]
286                jwks_cache,
287            }),
288        })
289    }
290
291    /// Build shared state from explicit parts with a default worker registry.
292    #[must_use]
293    pub fn from_parts(namespace_resolver: NamespaceResolver, runtime: RuntimeConfig) -> Self {
294        // No durable store was supplied (this constructor builds state from a
295        // resolver only), so the registry is a local-only in-memory store —
296        // present so `namespace_store()` is always reachable, never mutating any
297        // durable backend.
298        Self::from_parts_with_namespace_store(
299            namespace_resolver,
300            runtime,
301            Arc::new(aion_store::InMemoryStore::default()),
302        )
303    }
304
305    /// Build shared state from explicit parts with a caller-supplied durable
306    /// namespace registry.
307    ///
308    /// Identical to [`Self::from_parts`] except the namespace registry is the
309    /// supplied store rather than a fresh in-memory one, so a caller can seed the
310    /// durable set the control-plane read/create paths (`GET`/`POST
311    /// /namespaces`) observe.
312    #[must_use]
313    pub fn from_parts_with_namespace_store(
314        namespace_resolver: NamespaceResolver,
315        runtime: RuntimeConfig,
316        namespace_store: Arc<dyn NamespaceStore>,
317    ) -> Self {
318        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
319        // Bound here, beside the tracker, for the same reason: `runtime` moves
320        // into the state below, and the transport-loss budget must be read from
321        // the operator's heartbeat window BEFORE it does. A default ledger here
322        // would give an embedder-booted server a ZERO budget — exactly one
323        // re-dispatchable worker loss, then transport-exhausted — while the
324        // full-boot path got the real one.
325        let pending_activities =
326            PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
327        // Computed before `runtime` moves into the state: the retention bounds
328        // flow from `[observability]` config on the embedder path too, so a
329        // from-parts server enforces the same truncation/cap as a full boot.
330        let bounds = transcript_bounds(&runtime);
331        Self {
332            inner: Arc::new(ServerStateInner {
333                namespace_guard: NamespaceGuard::new(namespace_resolver),
334                runtime,
335                worker_registry: ConnectedWorkerRegistry::default(),
336                pending_activities,
337                heartbeat_tracker,
338                drain_state: DrainState::default(),
339                metrics: None,
340                health: None,
341                activity_mock_registry: None,
342                outbox_store: None,
343                namespace_store,
344                outbox_wake: Arc::new(tokio::sync::Notify::new()),
345                cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
346                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
347                ),
348                // NOI-5b: a from-parts / embedder state has no durable store, so
349                // the transcript sequencer runs over an in-memory `O`-keyspace
350                // impl — the transcript channel is served on every boot.
351                transcript_publisher: build_transcript_publisher(
352                    None,
353                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
354                    bounds,
355                ),
356                attempt_owners: crate::worker::AttemptOwnerIndex::new(),
357                queue_service_state: crate::worker::QueueServiceState::default(),
358                queue_declarations: crate::worker::QueueDeclarationSource::default(),
359                cluster_self_node: None,
360                #[cfg(feature = "haematite-backend")]
361                cluster_responder: None,
362                #[cfg(feature = "haematite-backend")]
363                cluster_store: None,
364                #[cfg(feature = "haematite-backend")]
365                watched_peers: Vec::new(),
366                #[cfg(feature = "haematite-backend")]
367                shard_directory: None,
368                #[cfg(feature = "haematite-backend")]
369                request_forwarder: None,
370                #[cfg(feature = "auth")]
371                jwks_cache: None,
372            }),
373        }
374    }
375
376    /// Build shared state from explicit parts with BOTH a caller-supplied
377    /// durable namespace registry AND a caller-supplied JWKS cache.
378    ///
379    /// The combined seam of [`Self::from_parts_with_namespace_store`] (seed the
380    /// durable registry the control-plane read/create paths observe) and
381    /// [`Self::from_parts_with_jwks`] (validate bearer tokens against an injected
382    /// issuer): an enumerated caller can exercise the real JWT authorization path
383    /// against a seeded registry without a full [`Self::build`] boot.
384    #[cfg(feature = "auth")]
385    #[must_use]
386    pub fn from_parts_with_namespace_store_and_jwks(
387        namespace_resolver: NamespaceResolver,
388        runtime: RuntimeConfig,
389        namespace_store: Arc<dyn NamespaceStore>,
390        jwks_cache: JwksCache,
391    ) -> Self {
392        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
393        // Bound here, beside the tracker, for the same reason: `runtime` moves
394        // into the state below, and the transport-loss budget must be read from
395        // the operator's heartbeat window BEFORE it does. A default ledger here
396        // would give an embedder-booted server a ZERO budget — exactly one
397        // re-dispatchable worker loss, then transport-exhausted — while the
398        // full-boot path got the real one.
399        let pending_activities =
400            PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
401        // Computed before `runtime` moves into the state: the retention bounds
402        // flow from `[observability]` config on the embedder path too, so a
403        // from-parts server enforces the same truncation/cap as a full boot.
404        let bounds = transcript_bounds(&runtime);
405        Self {
406            inner: Arc::new(ServerStateInner {
407                namespace_guard: NamespaceGuard::new(namespace_resolver),
408                runtime,
409                worker_registry: ConnectedWorkerRegistry::default(),
410                pending_activities,
411                heartbeat_tracker,
412                drain_state: DrainState::default(),
413                metrics: None,
414                health: None,
415                activity_mock_registry: None,
416                outbox_store: None,
417                namespace_store,
418                outbox_wake: Arc::new(tokio::sync::Notify::new()),
419                cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
420                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
421                ),
422                // NOI-5b: a from-parts / embedder state has no durable store, so
423                // the transcript sequencer runs over an in-memory `O`-keyspace
424                // impl — the transcript channel is served on every boot.
425                transcript_publisher: build_transcript_publisher(
426                    None,
427                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
428                    bounds,
429                ),
430                attempt_owners: crate::worker::AttemptOwnerIndex::new(),
431                queue_service_state: crate::worker::QueueServiceState::default(),
432                queue_declarations: crate::worker::QueueDeclarationSource::default(),
433                cluster_self_node: None,
434                #[cfg(feature = "haematite-backend")]
435                cluster_responder: None,
436                #[cfg(feature = "haematite-backend")]
437                cluster_store: None,
438                #[cfg(feature = "haematite-backend")]
439                watched_peers: Vec::new(),
440                #[cfg(feature = "haematite-backend")]
441                shard_directory: None,
442                #[cfg(feature = "haematite-backend")]
443                request_forwarder: None,
444                jwks_cache: Some(jwks_cache),
445            }),
446        }
447    }
448
449    /// Build shared state from explicit parts with a caller-supplied JWKS cache.
450    ///
451    /// Embedders that construct their own [`JwksCache`] (for example against a
452    /// private issuer) can install it here; transports then validate bearer
453    /// tokens against it exactly as with a [`Self::build`]-constructed state.
454    #[cfg(feature = "auth")]
455    #[must_use]
456    pub fn from_parts_with_jwks(
457        namespace_resolver: NamespaceResolver,
458        runtime: RuntimeConfig,
459        jwks_cache: JwksCache,
460    ) -> Self {
461        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
462        // Bound here, beside the tracker, for the same reason: `runtime` moves
463        // into the state below, and the transport-loss budget must be read from
464        // the operator's heartbeat window BEFORE it does. A default ledger here
465        // would give an embedder-booted server a ZERO budget — exactly one
466        // re-dispatchable worker loss, then transport-exhausted — while the
467        // full-boot path got the real one.
468        let pending_activities =
469            PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
470        // Computed before `runtime` moves into the state: the retention bounds
471        // flow from `[observability]` config on the embedder path too, so a
472        // from-parts server enforces the same truncation/cap as a full boot.
473        let bounds = transcript_bounds(&runtime);
474        Self {
475            inner: Arc::new(ServerStateInner {
476                namespace_guard: NamespaceGuard::new(namespace_resolver),
477                runtime,
478                worker_registry: ConnectedWorkerRegistry::default(),
479                pending_activities,
480                heartbeat_tracker,
481                drain_state: DrainState::default(),
482                metrics: None,
483                health: None,
484                activity_mock_registry: None,
485                outbox_store: None,
486                // No durable store was supplied (these constructors build state
487                // from a resolver only), so the registry is a local-only
488                // in-memory store — present so `namespace_store()` is always
489                // reachable, never mutating any durable backend.
490                namespace_store: Arc::new(aion_store::InMemoryStore::default()),
491                outbox_wake: Arc::new(tokio::sync::Notify::new()),
492                cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
493                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
494                ),
495                // NOI-5b: a from-parts / embedder state has no durable store, so
496                // the transcript sequencer runs over an in-memory `O`-keyspace
497                // impl — the transcript channel is served on every boot.
498                transcript_publisher: build_transcript_publisher(
499                    None,
500                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
501                    bounds,
502                ),
503                attempt_owners: crate::worker::AttemptOwnerIndex::new(),
504                queue_service_state: crate::worker::QueueServiceState::default(),
505                queue_declarations: crate::worker::QueueDeclarationSource::default(),
506                cluster_self_node: None,
507                #[cfg(feature = "haematite-backend")]
508                cluster_responder: None,
509                #[cfg(feature = "haematite-backend")]
510                cluster_store: None,
511                #[cfg(feature = "haematite-backend")]
512                watched_peers: Vec::new(),
513                #[cfg(feature = "haematite-backend")]
514                shard_directory: None,
515                #[cfg(feature = "haematite-backend")]
516                request_forwarder: None,
517                jwks_cache: Some(jwks_cache),
518            }),
519        }
520    }
521
522    /// Build shared state from explicit parts with a caller-supplied registry.
523    #[must_use]
524    pub fn from_parts_with_registry(
525        namespace_resolver: NamespaceResolver,
526        runtime: RuntimeConfig,
527        worker_registry: ConnectedWorkerRegistry,
528    ) -> Self {
529        let heartbeat_tracker = HeartbeatTracker::new(runtime.worker.heartbeat_window);
530        // Bound here, beside the tracker, for the same reason: `runtime` moves
531        // into the state below, and the transport-loss budget must be read from
532        // the operator's heartbeat window BEFORE it does. A default ledger here
533        // would give an embedder-booted server a ZERO budget — exactly one
534        // re-dispatchable worker loss, then transport-exhausted — while the
535        // full-boot path got the real one.
536        let pending_activities =
537            PendingActivities::default().with_heartbeat_window(runtime.worker.heartbeat_window);
538        // Computed before `runtime` moves into the state: the retention bounds
539        // flow from `[observability]` config on the embedder path too, so a
540        // from-parts server enforces the same truncation/cap as a full boot.
541        let bounds = transcript_bounds(&runtime);
542        Self {
543            inner: Arc::new(ServerStateInner {
544                namespace_guard: NamespaceGuard::new(namespace_resolver),
545                runtime,
546                worker_registry,
547                pending_activities,
548                heartbeat_tracker,
549                drain_state: DrainState::default(),
550                metrics: None,
551                health: None,
552                activity_mock_registry: None,
553                outbox_store: None,
554                // No durable store was supplied (these constructors build state
555                // from a resolver only), so the registry is a local-only
556                // in-memory store — present so `namespace_store()` is always
557                // reachable, never mutating any durable backend.
558                namespace_store: Arc::new(aion_store::InMemoryStore::default()),
559                outbox_wake: Arc::new(tokio::sync::Notify::new()),
560                cluster_publisher: crate::cluster_publisher::ClusterEventPublisher::new(
561                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
562                ),
563                // NOI-5b: a from-parts / embedder state has no durable store, so
564                // the transcript sequencer runs over an in-memory `O`-keyspace
565                // impl — the transcript channel is served on every boot.
566                transcript_publisher: build_transcript_publisher(
567                    None,
568                    Self::FALLBACK_CLUSTER_BROADCAST_CAPACITY,
569                    bounds,
570                ),
571                attempt_owners: crate::worker::AttemptOwnerIndex::new(),
572                queue_service_state: crate::worker::QueueServiceState::default(),
573                queue_declarations: crate::worker::QueueDeclarationSource::default(),
574                cluster_self_node: None,
575                #[cfg(feature = "haematite-backend")]
576                cluster_responder: None,
577                #[cfg(feature = "haematite-backend")]
578                cluster_store: None,
579                #[cfg(feature = "haematite-backend")]
580                watched_peers: Vec::new(),
581                #[cfg(feature = "haematite-backend")]
582                shard_directory: None,
583                #[cfg(feature = "haematite-backend")]
584                request_forwarder: None,
585                #[cfg(feature = "auth")]
586                jwks_cache: None,
587            }),
588        }
589    }
590
591    /// Borrow the namespace guard shared by all transports.
592    #[must_use]
593    pub fn namespace_guard(&self) -> &NamespaceGuard {
594        &self.inner.namespace_guard
595    }
596
597    /// Build the deploy authorization guard over the shared resolver.
598    #[must_use]
599    pub fn deploy_guard(&self) -> crate::deploy::DeployGuard {
600        crate::deploy::DeployGuard::new(self.inner.namespace_guard.resolver().clone())
601    }
602
603    /// Borrow non-secret runtime settings needed by transports.
604    #[must_use]
605    pub fn runtime_config(&self) -> &RuntimeConfig {
606        &self.inner.runtime
607    }
608
609    /// Borrow the connected-worker registry shared by worker transports and dispatch.
610    #[must_use]
611    pub fn worker_registry(&self) -> &ConnectedWorkerRegistry {
612        &self.inner.worker_registry
613    }
614
615    /// Borrow the WS3 cluster-event publisher shared by the cluster state-change
616    /// sites (supervisor, worker registry) and the cluster subscription endpoint.
617    /// Always present, on every boot.
618    #[must_use]
619    pub fn cluster_publisher(&self) -> &crate::cluster_publisher::ClusterEventPublisher {
620        &self.inner.cluster_publisher
621    }
622
623    /// Borrow the NOI-5b transcript sequencer shared by the worker->server
624    /// ingestion seam (which publishes a running activity's `ActivityEvent`s) and
625    /// the transcript subscription endpoint (which tails + resumes them). Always
626    /// present, on every boot.
627    #[must_use]
628    pub fn transcript_publisher(&self) -> &crate::activity_publisher::ActivityEventPublisher {
629        &self.inner.transcript_publisher
630    }
631
632    /// Borrow the NOI-6 `attempt -> owning-worker` back-index. The agent-dispatch
633    /// path binds an owner when it dispatches an agent attempt and releases it on
634    /// completion, so the intervention router always resolves the CURRENT owner.
635    #[must_use]
636    pub fn attempt_owners(&self) -> &crate::worker::AttemptOwnerIndex {
637        &self.inner.attempt_owners
638    }
639
640    /// Borrow the R1 live unserved-queue state — the same instance the bridge
641    /// dispatcher publishes every parked dispatch into.
642    #[must_use]
643    pub fn queue_service_state(&self) -> &crate::worker::QueueServiceState {
644        &self.inner.queue_service_state
645    }
646
647    /// Borrow the R1 queue-declaration source — the engine-backed reader the
648    /// bridge classifies against. Answers `Unknown` on a state built without an
649    /// engine, which never refuses anything.
650    #[must_use]
651    pub fn queue_declarations(&self) -> &crate::worker::QueueDeclarationSource {
652        &self.inner.queue_declarations
653    }
654
655    /// Every queue address currently unserved, with its taxonomy reason, the
656    /// policy it is held under, the live poller census behind the verdict, and
657    /// the runs parked on it.
658    ///
659    /// This is the server-side answer to "is anything stuck, and on what" — the
660    /// question the pre-R1 seam could only be asked by reading logs.
661    ///
662    /// # Errors
663    ///
664    /// Returns [`ServerError::LockPoisoned`] if the state lock is poisoned.
665    pub fn unserved_queues(&self) -> Result<Vec<crate::worker::UnservedQueue>, ServerError> {
666        self.inner.queue_service_state.unserved()
667    }
668
669    /// Every run this engine process could not make resident, with the reason it
670    /// could not and when the failure was observed (#117).
671    ///
672    /// This is the fleet half of the degraded-residency question. `POST
673    /// /workflows/describe` answers it for a run an operator can already name;
674    /// this answers it for the operator who cannot, which is the case that made
675    /// the original defect unrecoverable in practice — the id was only ever
676    /// printed in a boot log line that had scrolled away.
677    ///
678    /// The set is per-process and self-clearing: an entry disappears the moment
679    /// the engine observes that run resident, so an EMPTY list is the healthy
680    /// answer and never a stale one.
681    ///
682    /// # Errors
683    ///
684    /// Returns [`ServerError`] when the state carries no engine handle, or when
685    /// the registry lock is poisoned.
686    pub fn unrecoverable_runs(
687        &self,
688    ) -> Result<Vec<(aion_core::WorkflowId, aion::registry::UnrecoverableRun)>, ServerError> {
689        self.engine()?
690            .registry()
691            .unrecoverable()
692            .list()
693            .map_err(ServerError::from)
694    }
695
696    /// Build the NOI-6 intervention router over the connected-worker registry, the
697    /// attempt-owner back-index, and the active intervention transport.
698    ///
699    /// The transport is the liminal server-push
700    /// ([`LiminalInterventionTransport`](crate::worker::LiminalInterventionTransport))
701    /// when the `liminal-transport` feature is compiled in — the production path
702    /// that pushes a routed command out on the owning worker's connection — and a
703    /// null transport otherwise, which reports the target unreachable so every
704    /// command NACKs the attempt-scoped no-op rather than silently vanishing. The
705    /// router is cheap to build (it clones cloneable handles), so it is constructed
706    /// per request at the endpoint rather than stored.
707    #[must_use]
708    pub fn intervention_router(&self) -> crate::worker::InterventionRouter {
709        let transport: std::sync::Arc<dyn crate::worker::InterventionTransport> = {
710            #[cfg(feature = "liminal-transport")]
711            {
712                std::sync::Arc::new(crate::worker::LiminalInterventionTransport)
713            }
714            #[cfg(not(feature = "liminal-transport"))]
715            {
716                std::sync::Arc::new(NullInterventionTransport)
717            }
718        };
719        crate::worker::InterventionRouter::new(
720            self.inner.worker_registry.clone(),
721            self.inner.attempt_owners.clone(),
722            transport,
723        )
724        // Lane #229: an APPLIED InjectMessage is teed into the durable
725        // transcript, so the retained record holds the operator's words.
726        .with_transcript_publisher(self.inner.transcript_publisher.clone())
727    }
728
729    /// This node's configured cluster distribution name for the WS3 snapshot
730    /// self-identity, or `None` on a single-node boot (the snapshot then reports
731    /// the standalone self-label).
732    #[must_use]
733    pub fn cluster_self_node(&self) -> Option<&str> {
734        self.inner.cluster_self_node.as_deref()
735    }
736
737    /// Clone the live engine handle the completion path records terminals through.
738    ///
739    /// This is the SAME `Arc<Engine>` the gRPC completion callback is built over
740    /// (state.rs installs `ServerOutboxDeliveryCallback::new(engine)` on the
741    /// pending tracker when `outbox.enabled`), so the liminal completion path
742    /// re-enters worker results through the identical `record_fan_out_completion`
743    /// seam rather than inventing a second one.
744    ///
745    /// # Errors
746    ///
747    /// Returns [`ServerError`] when the namespace resolver has no engine handle
748    /// (a state built from parts without an engine).
749    pub fn engine(&self) -> Result<Arc<aion::Engine>, ServerError> {
750        self.inner
751            .namespace_guard
752            .resolver()
753            .engine()
754            .map(Arc::clone)
755    }
756
757    /// Borrow the pending-activities tracker shared by the NIF bridge and worker stream handler.
758    #[must_use]
759    pub fn pending_activities(&self) -> &PendingActivities {
760        &self.inner.pending_activities
761    }
762
763    /// Borrow the heartbeat/liveness tracker shared by dispatch and worker streams.
764    #[must_use]
765    pub fn heartbeat_tracker(&self) -> &HeartbeatTracker {
766        &self.inner.heartbeat_tracker
767    }
768
769    /// Borrow the drain gate shared by transports and worker dispatch.
770    #[must_use]
771    pub fn drain_state(&self) -> &DrainState {
772        &self.inner.drain_state
773    }
774
775    /// Borrow the prometheus metrics handle when this state was built with a store.
776    #[must_use]
777    pub fn metrics(&self) -> Option<&Metrics> {
778        self.inner.metrics.as_ref()
779    }
780
781    /// Borrow health probe state when this state was built with a store.
782    #[must_use]
783    pub fn health(&self) -> Option<&HealthState> {
784        self.inner.health.as_ref()
785    }
786
787    /// Borrow the shared per-run activity-mock registry when the dev surface is
788    /// commissioned. Returns [`None`] on a server with the dev surface dark, so
789    /// the dev handlers refuse cleanly rather than mocking on a production
790    /// server.
791    #[must_use]
792    pub fn activity_mock_registry(&self) -> Option<&ActivityMockRegistry> {
793        self.inner.activity_mock_registry.as_ref()
794    }
795
796    /// Borrow the outbox store the dispatcher claims rows from, when the durable
797    /// (libSQL) backend is in use. This is the SAME leaf `Arc<LibSqlStore>` the
798    /// engine writes through, so the dispatcher shares its single
799    /// `libsql::Connection` rather than opening a second contending one. Returns
800    /// [`None`] for the in-memory backend, which has no outbox table.
801    #[must_use]
802    pub fn outbox_store(&self) -> Option<Arc<dyn OutboxStore>> {
803        self.inner.outbox_store.clone()
804    }
805
806    /// Borrow the durable namespace registry shared by the control plane.
807    ///
808    /// This is the SAME concrete leaf backend the engine writes events through
809    /// (haematite quorum-replicated, or libSQL / in-memory local-only),
810    /// captured as a [`NamespaceStore`] before the decorator chain wrapped it.
811    /// Always present on every boot, so the mint-on-register path (Phase 1 S5)
812    /// and `GET /namespaces` (S7) can reach a real registry regardless of
813    /// backend.
814    #[must_use]
815    pub fn namespace_store(&self) -> &Arc<dyn NamespaceStore> {
816        &self.inner.namespace_store
817    }
818
819    /// Build the shared minted-on-use hook over the durable namespace store and
820    /// the configured [`AutoCreate`](crate::config::AutoCreate) policy.
821    ///
822    /// This is the SAME policy logic the worker-registration seam applies (S5);
823    /// the workflow-start safety net (S6) calls it after authorization so a
824    /// client that starts a workflow before any worker registers still gets a
825    /// durable namespace record. Cheap to build (clones an `Arc` + a `Copy`
826    /// policy), so transports construct it per request rather than holding it.
827    #[must_use]
828    pub fn namespace_minter(&self) -> NamespaceMinter {
829        let minter = NamespaceMinter::new(
830            Arc::clone(&self.inner.namespace_store),
831            self.inner.runtime.auto_create,
832        )
833        // Thread the deployment-global cluster channel so the start-time safety
834        // net (S6) and the explicit `POST /namespaces` path (S7) emit the same
835        // live "namespace created" delta the worker-mint seam (S5) does — all
836        // three mint choke-points surface on the one ops-console push channel.
837        .with_cluster_publisher(self.inner.cluster_publisher.clone());
838        // And the namespace-mint routing context on a clustered boot, so a
839        // namespace whose registry shard this node does not own is minted by the
840        // node that does rather than being fenced forever. `None` off-cluster,
841        // where the minter is byte-identical to before routing existed.
842        match self.namespace_routing() {
843            Some(routing) => minter.with_routing(routing),
844            None => minter,
845        }
846    }
847
848    /// The namespace-mint routing context for this boot, or `None` when there is
849    /// nothing to route to.
850    ///
851    /// Present only when ALL THREE handles exist: the distributed store (which
852    /// hashes a namespace to its registry shard), the R-2 shard directory (which
853    /// resolves that shard's current owner), and the R-3 request forwarder (which
854    /// dials it). Those three are populated together by `build_routing_state` on
855    /// a `[store.cluster]` boot and are all `None` otherwise, so a partial
856    /// context can never arise — but each is checked rather than assumed.
857    #[cfg(feature = "haematite-backend")]
858    #[must_use]
859    pub fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
860        build_namespace_routing(
861            self.cluster_store(),
862            self.shard_directory(),
863            self.request_forwarder(),
864        )
865    }
866
867    /// No cluster backend compiled in: nothing to route a mint to.
868    #[cfg(not(feature = "haematite-backend"))]
869    #[must_use]
870    pub const fn namespace_routing(&self) -> Option<crate::namespace::NamespaceRouting> {
871        None
872    }
873
874    /// Clone the advisory outbox wake (LSUB-2) shared with the engine's stage
875    /// seam. The outbox dispatcher installs this handle so a committed fan-out row
876    /// wakes its run loop in ~RTT rather than waiting for the next poll tick. The
877    /// handle is always present; it is simply never pulsed when the outbox is not
878    /// commissioned, so wiring it is free and behaviour is unchanged.
879    #[must_use]
880    pub fn outbox_wake(&self) -> Arc<tokio::sync::Notify> {
881        Arc::clone(&self.inner.outbox_wake)
882    }
883
884    /// Whether this server is a node in a distributed haematite cluster.
885    ///
886    /// `true` when boot constructed the distributed backend (a `[store.cluster]`
887    /// section was present) and is holding its inbound-write responder alive;
888    /// `false` for every single-node / non-haematite boot.
889    #[cfg(feature = "haematite-backend")]
890    #[must_use]
891    pub fn is_clustered(&self) -> bool {
892        self.inner.cluster_responder.is_some()
893    }
894
895    /// The concrete distributed haematite store the request-routing edge consults
896    /// for shard ownership (`shard_for_workflow` / `owns_workflow_shard`) and
897    /// unsteered-start remint. `None` for every single-node / non-clustered boot,
898    /// so the routing pre-step is a no-op and the default path is unchanged.
899    #[cfg(feature = "haematite-backend")]
900    #[must_use]
901    pub fn cluster_store(&self) -> Option<&Arc<aion_store_haematite::HaematiteStore>> {
902        self.inner.cluster_store.as_ref()
903    }
904
905    /// The request-routing shard directory (R-2) the edge consults to resolve a
906    /// non-owned shard's owner. `None` for single-node / non-clustered boots, so
907    /// the edge falls back to the bare R-1 ownership check.
908    #[cfg(feature = "haematite-backend")]
909    #[must_use]
910    pub fn shard_directory(&self) -> Option<&Arc<crate::routing::StaticShardDirectory>> {
911        self.inner.shard_directory.as_ref()
912    }
913
914    /// The R-3 request forwarder used to relay a non-local signal/query/cancel to
915    /// the shard owner. `None` for single-node / non-clustered boots.
916    #[cfg(feature = "haematite-backend")]
917    #[must_use]
918    pub fn request_forwarder(&self) -> Option<&Arc<dyn crate::routing::RequestForwarder>> {
919        self.inner.request_forwarder.as_ref()
920    }
921
922    /// Spawn the worker heartbeat expiry sweeper (#176): the production driver
923    /// of [`HeartbeatTracker::fail_expired_workers`], failing every worker with
924    /// an in-flight task beyond the operator's `worker.heartbeat_window` and
925    /// deregistering it with the provable
926    /// [`WorkerDeathReason::Timeout`](aion_core::WorkerDeathReason::Timeout).
927    ///
928    /// Always spawned on the server boot path — dead-worker detection is a
929    /// liveness correctness property, not an opt-in feature. The cadence is
930    /// derived from the heartbeat window
931    /// ([`sweep_interval`](crate::worker::sweep_interval): a quarter of the
932    /// window clamped to `[1s, window]`, so the default 30s window sweeps every
933    /// 7.5s); there is deliberately no separate config knob. The task exits
934    /// when `shutdown` flips to `true`, exactly like the transports; the
935    /// returned handle may be dropped to detach it (dropping a tokio
936    /// `JoinHandle` never cancels the task) and is returned so tests can await
937    /// clean shutdown.
938    #[must_use]
939    pub fn spawn_heartbeat_sweeper(
940        &self,
941        shutdown: tokio::sync::watch::Receiver<bool>,
942    ) -> tokio::task::JoinHandle<()> {
943        let sweeper = crate::worker::HeartbeatSweeper::new(
944            self.inner.heartbeat_tracker.clone(),
945            self.inner.worker_registry.clone(),
946            self.inner.pending_activities.clone(),
947            self.inner.drain_state.clone(),
948            self.inner.runtime.worker.heartbeat_window,
949        )
950        // The SAME queue-service state the bridge parks dispatches into, so a
951        // deregistration names how many dispatches are already stranded on the
952        // queue the dead worker was serving.
953        .with_queue_state(self.inner.queue_service_state.clone());
954        tokio::spawn(sweeper.run(shutdown))
955    }
956
957    /// Spawn the liminal connection dead-man switch (the liveness probe) over
958    /// `notifier`.
959    ///
960    /// Always spawned on a boot that hosts the liminal worker listener:
961    /// connection liveness is a correctness property of the transport, not an
962    /// opt-in feature. Both timings derive from the operator's
963    /// `worker.heartbeat_window` — see
964    /// [`LivenessProbe`](crate::worker::LivenessProbe) — so there is no separate
965    /// knob. The task exits when `shutdown` flips to `true`, exactly like the
966    /// heartbeat sweeper and the transports.
967    #[cfg(feature = "liminal-transport")]
968    #[must_use]
969    pub fn spawn_liminal_liveness_probe(
970        &self,
971        notifier: std::sync::Arc<crate::worker::LiminalConnectionNotifier>,
972        shutdown: tokio::sync::watch::Receiver<bool>,
973    ) -> tokio::task::JoinHandle<()> {
974        let probe = crate::worker::LivenessProbe::new(
975            notifier,
976            self.inner.heartbeat_tracker.clone(),
977            self.inner.worker_registry.clone(),
978            self.inner.runtime.worker.heartbeat_window,
979        );
980        tokio::spawn(probe.run(shutdown))
981    }
982
983    /// Spawn the SS-5b cluster supervisor: a background task that watches every
984    /// declared peer's replication liveness and, on a confirmed peer death,
985    /// calls `adopt_shards` for that peer's shards on THIS node's live engine —
986    /// automatic failover with no manual trigger.
987    ///
988    /// Does nothing (returns `Ok(())` without spawning) unless this is a
989    /// distributed boot whose cluster config declared at least one peer with
990    /// `owned_shards`. A single-node / non-clustered server therefore never runs
991    /// a supervisor, so default behaviour is unchanged.
992    ///
993    /// The spawned task drains on `shutdown` exactly like the transports.
994    ///
995    /// # Errors
996    ///
997    /// Returns [`ServerError`] when the engine handle cannot be resolved.
998    #[cfg(feature = "haematite-backend")]
999    pub fn spawn_cluster_supervisor(
1000        &self,
1001        config: crate::cluster::SupervisorConfig,
1002        shutdown: tokio::sync::watch::Receiver<bool>,
1003    ) -> Result<bool, ServerError> {
1004        let Some(cluster_store) = self.inner.cluster_store.clone() else {
1005            return Ok(false);
1006        };
1007        if self.inner.watched_peers.is_empty() {
1008            return Ok(false);
1009        }
1010        let engine = Arc::clone(self.inner.namespace_guard.resolver().engine()?);
1011        // WS3: feed cluster topology deltas from the supervisor's existing
1012        // decision points into the ops console channel. `self_node` is the
1013        // configured distribution name (already captured for the snapshot).
1014        let publisher = Arc::new(self.inner.cluster_publisher.clone());
1015        let self_node = self.inner.cluster_self_node.clone().unwrap_or_default();
1016        // #253: adoption re-runs the terminal-workflow outbox settlement sweep
1017        // over the widened owned-shard scope, so a dead peer's stranded row for
1018        // a terminal workflow is settled — never re-armed — by its adopter.
1019        // With no outbox commissioned there is nothing to settle and the
1020        // adopter delegates straight to the engine.
1021        let adopter = Arc::new(crate::cluster::OutboxSettlingAdopter::new(
1022            engine,
1023            self.inner.outbox_store.clone(),
1024        ));
1025        let supervisor = crate::cluster::ClusterSupervisor::new(
1026            cluster_store,
1027            adopter,
1028            self.inner.watched_peers.clone(),
1029            config,
1030        )
1031        .with_publisher(publisher, self_node);
1032        if !supervisor.watches_any() {
1033            return Ok(false);
1034        }
1035        tokio::spawn(supervisor.run(shutdown));
1036        Ok(true)
1037    }
1038
1039    /// Borrow the shared JWKS cache when authentication is enabled.
1040    #[cfg(feature = "auth")]
1041    #[must_use]
1042    pub fn jwks_cache(&self) -> Option<&JwksCache> {
1043        self.inner.jwks_cache.as_ref()
1044    }
1045
1046    /// Shut down the embedded engine so in-flight durable appends can finish.
1047    ///
1048    /// # Errors
1049    ///
1050    /// Returns [`ServerError`] if the namespace resolver has no engine handle or the engine rejects
1051    /// shutdown.
1052    pub fn shutdown(&self) -> Result<(), ServerError> {
1053        self.inner.namespace_guard.resolver().shutdown_engine()
1054    }
1055}
1056
1057#[cfg(feature = "auth")]
1058async fn build_jwks_cache(runtime: &RuntimeConfig) -> Result<Option<JwksCache>, ServerError> {
1059    if !runtime.auth.enabled {
1060        return Ok(None);
1061    }
1062    let Some(url) = runtime.auth.jwks_url.clone() else {
1063        return Err(ServerError::Config {
1064            message: "auth.jwks_url must not be empty when auth.enabled is true".to_owned(),
1065        });
1066    };
1067    let interval = std::time::Duration::from_secs(runtime.auth.jwks_refresh_seconds);
1068    let cache = JwksCache::new(url, interval)
1069        .await
1070        .map_err(|error| ServerError::Config {
1071            message: format!("auth jwks initial fetch failed: {error}"),
1072        })?;
1073    Ok(Some(cache))
1074}
1075
1076fn metrics_config_error(error: &MetricsError) -> ServerError {
1077    ServerError::Config {
1078        message: error.to_string(),
1079    }
1080}
1081
1082/// Borrowed inputs assembled into the embedded engine by [`build_engine`].
1083struct EngineAssembly<'a> {
1084    /// The metrics-instrumented store the engine writes through.
1085    instrumented_store: &'a Arc<InstrumentedEventStore>,
1086    /// Explicitly-sized broadcast channel capacity for `/events/stream`.
1087    event_broadcast_capacity: std::num::NonZeroUsize,
1088    /// Explicit workflow-query reply deadline for `/workflows/query`.
1089    query_timeout: std::time::Duration,
1090    /// The activity dispatcher (optionally dev-mock-decorated) the engine uses.
1091    activity_dispatcher: Arc<dyn ActivityDispatcher>,
1092    /// The shared active-workflow registry server dispatchers correlate against.
1093    active_registry: Arc<aion::Registry>,
1094    /// Whether THIS node seeds the schedule coordinator (SS-2 ownership gate).
1095    bootstrap_coordinator: bool,
1096    /// Non-secret runtime settings driving scheduler/outbox/package/shard knobs.
1097    runtime: &'a RuntimeConfig,
1098}
1099
1100/// Assemble the embedded engine from the server's runtime configuration.
1101///
1102/// Factored out of [`ServerState::build_with_connected_store`] to keep that
1103/// method within length bounds; it carries the SS-2 wiring — the coordinator
1104/// bootstrap gate fed from real ownership and the `owned_shards` hook that drives
1105/// both scoping and the per-shard election before recovery.
1106async fn build_engine(assembly: EngineAssembly<'_>) -> Result<aion::Engine, ServerError> {
1107    let mut search_attribute_schema = aion_core::SearchAttributeSchema::new();
1108    search_attribute_schema
1109        .register(
1110            crate::namespace::NAMESPACE_ATTRIBUTE,
1111            aion_core::SearchAttributeType::String,
1112        )
1113        .map_err(|error| ServerError::Config {
1114            message: format!("failed to register namespace search attribute: {error}"),
1115        })?;
1116    search_attribute_schema
1117        .register(
1118            crate::namespace::TASK_QUEUE_ATTRIBUTE,
1119            aion_core::SearchAttributeType::String,
1120        )
1121        .map_err(|error| ServerError::Config {
1122            message: format!("failed to register task_queue search attribute: {error}"),
1123        })?;
1124    let runtime = assembly.runtime;
1125    let builder = EngineBuilder::new()
1126        .store_arc(assembly.instrumented_store.clone())
1127        .event_streaming(assembly.event_broadcast_capacity)
1128        .in_memory_visibility()
1129        .search_attribute_schema(search_attribute_schema)
1130        .scheduler_threads(runtime.scheduler_threads)
1131        .outbox_enabled(runtime.outbox.enabled)
1132        .activity_dispatcher(assembly.activity_dispatcher)
1133        .active_registry(assembly.active_registry)
1134        .production_recovery_seam()
1135        .signal_router_factory(|runtime: Arc<RuntimeHandle>, handoff| {
1136            Arc::new(ConcreteSignalRouter::new(runtime, handoff)) as Arc<dyn SignalRouter>
1137        })
1138        .query_timeout(assembly.query_timeout)
1139        // SS-2: only the node owning the schedule-coordinator's shard seeds and
1140        // serves it. `true` for every non-distributed boot (owns all shards); a
1141        // distributed non-owner passes `false` so it does not fence the
1142        // coordinator stream (AA-4-4). Default `true`, so a single-node boot is
1143        // byte-identical to today.
1144        .bootstrap_schedule_coordinator(assembly.bootstrap_coordinator)
1145        .load_workflow_sources(runtime.workflow_packages.iter().map(PathBuf::as_path));
1146    // Owned-shard assignment: when the operator pins this node to a shard subset,
1147    // scope the engine to it AND (SS-2) elect those shards before recovery — the
1148    // builder's `owned_shards` hook drives both. Empty (the default) leaves the
1149    // builder untouched, so single-node boot owns ALL shards, elects nothing, and
1150    // is byte-identical to today.
1151    let builder = if runtime.owned_shards.is_empty() {
1152        builder
1153    } else {
1154        builder.owned_shards(runtime.owned_shards.iter().copied())
1155    };
1156    builder.build().await.map_err(ServerError::from)
1157}
1158
1159/// Validate the two engine seams the server unconditionally mounts: the event
1160/// broadcast channel capacity (`/events/stream`) and the query reply deadline
1161/// (`/workflows/query`). Both are explicit-no-default — a mounted-but-
1162/// unconfigured surface is never acceptable.
1163fn required_engine_seams(
1164    runtime: &RuntimeConfig,
1165) -> Result<(std::num::NonZeroUsize, std::time::Duration), ServerError> {
1166    let event_broadcast_capacity = runtime
1167        .websocket
1168        .event_broadcast_capacity
1169        .and_then(std::num::NonZeroUsize::new)
1170        .ok_or_else(|| ServerError::Config {
1171            message: crate::config::EVENT_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1172        })?;
1173    let query_timeout = runtime
1174        .query_timeout
1175        .filter(|timeout| !timeout.is_zero())
1176        .ok_or_else(|| ServerError::Config {
1177            message: crate::config::QUERY_TIMEOUT_REQUIRED.to_owned(),
1178        })?;
1179    Ok((event_broadcast_capacity, query_timeout))
1180}
1181
1182/// Install the outbox delivery callback when the durable outbox is commissioned.
1183///
1184/// Routes unmatched worker completions arriving at the sink into the live
1185/// workflow's mailbox. Flag-off, no callback is installed and the sink's
1186/// unmatched branch stays a silent drop. The dispatcher is not rebuilt — it
1187/// shares this exact pending tracker.
1188fn install_outbox_delivery(
1189    pending_activities: &PendingActivities,
1190    engine: &Arc<aion::Engine>,
1191    outbox_enabled: bool,
1192) {
1193    if outbox_enabled {
1194        let callback = Arc::new(crate::worker::ServerOutboxDeliveryCallback::new(
1195            Arc::clone(engine),
1196        ));
1197        pending_activities.set_outbox_delivery(callback);
1198    }
1199}
1200
1201/// The per-boot worker-side seams every dispatch path shares.
1202struct WorkerSeams {
1203    worker_registry: ConnectedWorkerRegistry,
1204    pending_activities: PendingActivities,
1205    heartbeat_tracker: HeartbeatTracker,
1206    drain_state: DrainState,
1207    queue_declarations: crate::worker::QueueDeclarationSource,
1208    queue_service_state: crate::worker::QueueServiceState,
1209    declared_bodies: crate::worker::DeclaredBodySource,
1210}
1211
1212/// Build the worker-side seams: the connected-worker registry (WS3 topology
1213/// deltas + the Control-Plane Phase 1 mint hook), the shared completion tracker,
1214/// worker liveness, the drain gate, and the two R1 queue-service handles the
1215/// bridge publishes into and the state reads from.
1216fn build_worker_seams(
1217    runtime: &RuntimeConfig,
1218    cluster_publisher: &crate::cluster_publisher::ClusterEventPublisher,
1219    namespace_store: &Arc<dyn NamespaceStore>,
1220    mint_routing: Option<crate::namespace::NamespaceRouting>,
1221) -> WorkerSeams {
1222    let worker_registry = ConnectedWorkerRegistry::default()
1223        .with_cluster_publisher(cluster_publisher.clone())
1224        .with_namespace_minting(namespace_store.clone(), runtime.auto_create);
1225    // The worker-registration mint is the OTHER `mint_or_gate` choke-point, so
1226    // it gets the same routing the start seams get: a worker registering for a
1227    // namespace whose registry shard this node does not own must not be refused
1228    // `NotOwner` forever either. `None` off-cluster leaves the registry
1229    // byte-identical.
1230    let worker_registry = match mint_routing {
1231        Some(routing) => worker_registry.with_namespace_routing(routing),
1232        None => worker_registry,
1233    };
1234    WorkerSeams {
1235        worker_registry,
1236        // The transport-loss budget derives from the operator's heartbeat
1237        // window (the one value declaring what silence means), so a worker that
1238        // keeps dying under an activity is re-dispatched attempt-neutrally for
1239        // a bounded span and then terminates naming the TRANSPORT.
1240        pending_activities: PendingActivities::default()
1241            .with_heartbeat_window(runtime.worker.heartbeat_window),
1242        heartbeat_tracker: HeartbeatTracker::new(runtime.worker.heartbeat_window),
1243        drain_state: DrainState::default(),
1244        queue_declarations: crate::worker::QueueDeclarationSource::default(),
1245        queue_service_state: crate::worker::QueueServiceState::default(),
1246        declared_bodies: crate::worker::DeclaredBodySource::default(),
1247    }
1248}
1249
1250/// Point the bridge's R1 classifier at the engine's live workflow catalog.
1251///
1252/// Installed only after the engine exists (the dispatcher is built before it),
1253/// exactly like the outbox delivery callback: the dispatcher is not rebuilt, it
1254/// shares this handle. Until this runs — and on any state built without an
1255/// engine — the classifier answers `Unknown`, which never refuses anything.
1256fn install_queue_declarations(
1257    queue_declarations: &crate::worker::QueueDeclarationSource,
1258    engine: &Arc<aion::Engine>,
1259) {
1260    queue_declarations.install(Arc::new(crate::worker::EngineQueueDeclarations::new(
1261        Arc::clone(engine),
1262    )));
1263}
1264
1265/// Point the declared-body executor at the engine's live workflow catalog.
1266///
1267/// Installed only after the engine exists, exactly like the queue-declaration
1268/// reader above: the dispatcher already holds a clone of this handle. Until
1269/// this runs, every lookup answers `None` and every dispatch takes the worker
1270/// path — nothing is deployed before the engine exists, so no declared body
1271/// can be missed.
1272fn install_declared_bodies(
1273    declared_bodies: &crate::worker::DeclaredBodySource,
1274    engine: &Arc<aion::Engine>,
1275) {
1276    declared_bodies.install(Arc::new(crate::worker::EngineDeclaredBodies::new(
1277        Arc::clone(engine),
1278    )));
1279}
1280
1281/// Hand every engine-backed seam its reader once the engine exists.
1282///
1283/// One boot-path step for the three handles built before the engine and
1284/// filled in after it: outbox completion delivery, the R1 queue-declaration
1285/// classifier, and the declared-body executor.
1286fn install_engine_backed_seams(seams: &WorkerSeams, engine: &Arc<aion::Engine>, outbox: bool) {
1287    install_outbox_delivery(&seams.pending_activities, engine, outbox);
1288    install_queue_declarations(&seams.queue_declarations, engine);
1289    install_declared_bodies(&seams.declared_bodies, engine);
1290}
1291
1292/// Decorate the worker activity dispatcher with the per-run activity-mock layer
1293/// when the dev surface is commissioned, returning the dispatcher and the shared
1294/// mock registry (if any).
1295///
1296/// Dark by default: with the dev surface off the engine gets the bare production
1297/// dispatcher and there is no mocking path at all (CN4).
1298/// Compose the engine-seam bridge dispatcher over the state's shared parts.
1299///
1300/// Also mints the NOI-6 attempt→owner index and returns it alongside: the
1301/// bridge binds each liminal-delivered attempt into it for the dispatch's
1302/// lifetime, and the state stores the SAME instance for the intervention
1303/// router to read, so the ops console can enumerate and target live attempts.
1304fn build_bridge_dispatcher(
1305    runtime: &RuntimeConfig,
1306    seams: &WorkerSeams,
1307) -> (WorkerActivityDispatcher, crate::worker::AttemptOwnerIndex) {
1308    let attempt_owners = crate::worker::AttemptOwnerIndex::new();
1309    let dispatcher = WorkerActivityDispatcher::new(
1310        seams.worker_registry.clone(),
1311        runtime.default_namespace.clone(),
1312        seams.heartbeat_tracker.clone(),
1313    )
1314    .with_pending(seams.pending_activities.clone())
1315    .with_drain_state(seams.drain_state.clone())
1316    .with_tokio_handle(tokio::runtime::Handle::current())
1317    .with_attempt_owners(attempt_owners.clone())
1318    .with_queue_service(runtime.worker.queue_service.clone())
1319    .with_queue_declarations(seams.queue_declarations.clone())
1320    .with_queue_state(seams.queue_service_state.clone());
1321    (dispatcher, attempt_owners)
1322}
1323
1324/// Build the production bridge dispatcher and its decoration stack in one
1325/// step: declared-body execution always, the dev mock when commissioned.
1326fn build_decorated_dispatcher(
1327    runtime: &RuntimeConfig,
1328    seams: &WorkerSeams,
1329) -> (
1330    Arc<dyn ActivityDispatcher>,
1331    Option<ActivityMockRegistry>,
1332    crate::worker::AttemptOwnerIndex,
1333) {
1334    let (dispatcher, attempt_owners) = build_bridge_dispatcher(runtime, seams);
1335    let (activity_dispatcher, activity_mock_registry) = decorate_activity_dispatcher(
1336        dispatcher,
1337        seams.declared_bodies.clone(),
1338        runtime.dev.enabled,
1339    );
1340    (activity_dispatcher, activity_mock_registry, attempt_owners)
1341}
1342
1343fn decorate_activity_dispatcher(
1344    dispatcher: WorkerActivityDispatcher,
1345    declared_bodies: crate::worker::DeclaredBodySource,
1346    dev_enabled: bool,
1347) -> (Arc<dyn ActivityDispatcher>, Option<ActivityMockRegistry>) {
1348    // The declared-body layer wraps the production dispatcher UNCONDITIONALLY:
1349    // an action whose deployed contract declares a body executes at the
1350    // server, everything else falls through to the worker path untouched. The
1351    // dev mock (when commissioned) stays outermost so a mocked activity
1352    // short-circuits before either real execution path.
1353    let declared = crate::worker::DeclaredCommandDispatcher::new(
1354        Arc::new(dispatcher),
1355        declared_bodies,
1356        tokio::runtime::Handle::current(),
1357    );
1358    if dev_enabled {
1359        let registry = ActivityMockRegistry::new();
1360        let decorated = DevMockingDispatcher::new(Arc::new(declared), registry.clone());
1361        (Arc::new(decorated), Some(registry))
1362    } else {
1363        (Arc::new(declared), None)
1364    }
1365}
1366
1367/// Validate the WS3 cluster broadcast capacity the server unconditionally mounts
1368/// (the `cluster` subscription on `/events/stream`). Explicit-no-default with the
1369/// same non-zero startup guard as the workflow event channel: the lag contract
1370/// has no buffer to lag against unless sized.
1371fn required_cluster_broadcast_capacity(
1372    runtime: &RuntimeConfig,
1373) -> Result<std::num::NonZeroUsize, ServerError> {
1374    runtime
1375        .websocket
1376        .cluster_broadcast_capacity
1377        .and_then(std::num::NonZeroUsize::new)
1378        .ok_or_else(|| ServerError::Config {
1379            message: crate::config::CLUSTER_BROADCAST_CAPACITY_REQUIRED.to_owned(),
1380        })
1381}
1382
1383/// Build the deployment-wide real-time publishers the server mounts on every
1384/// boot — the WS3 cluster topology channel and the NOI-5b agent-observability
1385/// transcript channel — from the validated `websocket.cluster_broadcast_capacity`.
1386///
1387/// The transcript sequencer runs over `observability_store` (the durable
1388/// `O`-keyspace impl on a haematite boot) or an in-memory impl when the backend
1389/// has none — see [`build_transcript_publisher`].
1390///
1391/// # Errors
1392///
1393/// Returns [`ServerError`] when `websocket.cluster_broadcast_capacity` is unset
1394/// or zero (the same explicit-no-default guard the cluster channel already had).
1395fn build_real_time_publishers(
1396    runtime: &RuntimeConfig,
1397    observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1398) -> Result<
1399    (
1400        crate::cluster_publisher::ClusterEventPublisher,
1401        crate::activity_publisher::ActivityEventPublisher,
1402    ),
1403    ServerError,
1404> {
1405    let capacity = required_cluster_broadcast_capacity(runtime)?;
1406    Ok((
1407        crate::cluster_publisher::ClusterEventPublisher::new(capacity),
1408        build_transcript_publisher(observability_store, capacity, transcript_bounds(runtime)),
1409    ))
1410}
1411
1412/// The operator-configured transcript retention bounds from `[observability]`.
1413fn transcript_bounds(runtime: &RuntimeConfig) -> crate::activity_bounds::TranscriptBounds {
1414    crate::activity_bounds::TranscriptBounds {
1415        max_event_bytes: runtime.observability.max_event_bytes,
1416        max_stream_events: runtime.observability.max_stream_events,
1417    }
1418}
1419
1420/// Build the NOI-5b transcript sequencer over `observability_store` (the durable
1421/// `O`-keyspace impl when the backend has one, an in-memory impl otherwise) with
1422/// a live-tail buffer of `capacity` and the `[observability]` retention bounds.
1423///
1424/// The publisher is ALWAYS constructed (the transcript channel is served on every
1425/// boot); only the durability of the backing store varies by backend. A backend
1426/// with no `O` keyspace (libSQL / in-memory) gets the in-memory
1427/// [`InMemoryObservabilityStore`](aion_store::InMemoryObservabilityStore), so the
1428/// live-tail + resume path behaves identically and only cross-restart durability
1429/// differs — exactly the "keep the no-observability path uniform" contract.
1430fn build_transcript_publisher(
1431    observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1432    capacity: std::num::NonZeroUsize,
1433    bounds: crate::activity_bounds::TranscriptBounds,
1434) -> crate::activity_publisher::ActivityEventPublisher {
1435    let store = observability_store
1436        .unwrap_or_else(|| Arc::new(aion_store::InMemoryObservabilityStore::default()));
1437    crate::activity_publisher::ActivityEventPublisher::new(store, capacity).with_bounds(bounds)
1438}
1439
1440/// The request-routing pieces built from the cluster store + peer config.
1441#[cfg(feature = "haematite-backend")]
1442struct RoutingState {
1443    shard_directory: Option<Arc<crate::routing::StaticShardDirectory>>,
1444    request_forwarder: Option<Arc<dyn crate::routing::RequestForwarder>>,
1445    /// The namespace-mint routing context assembled from the two handles above
1446    /// plus the cluster store, so the boot path can thread it into the worker
1447    /// registry's minter (the second of the two minter construction sites)
1448    /// without re-deriving it.
1449    mint_routing: Option<crate::namespace::NamespaceRouting>,
1450}
1451
1452/// Build the R-2 shard directory and R-3 request forwarder over the cluster
1453/// store and static peer config, or all-`None` when this is not a distributed
1454/// boot (no cluster store) so the routing edge is a no-op (default path).
1455#[cfg(feature = "haematite-backend")]
1456fn build_routing_state(
1457    cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1458    directory_peers: Vec<crate::routing::DirectoryPeer>,
1459    self_node_id: Option<String>,
1460) -> RoutingState {
1461    let Some(store) = cluster_store else {
1462        return RoutingState {
1463            shard_directory: None,
1464            request_forwarder: None,
1465            mint_routing: None,
1466        };
1467    };
1468    let shard_directory = Arc::new(crate::routing::StaticShardDirectory::new(
1469        Arc::clone(store),
1470        directory_peers,
1471        self_node_id,
1472    ));
1473    let request_forwarder: Arc<dyn crate::routing::RequestForwarder> =
1474        Arc::new(crate::routing::GrpcRequestForwarder::new());
1475    let mint_routing = build_namespace_routing(
1476        Some(store),
1477        Some(&shard_directory),
1478        Some(&request_forwarder),
1479    );
1480    RoutingState {
1481        shard_directory: Some(shard_directory),
1482        request_forwarder: Some(request_forwarder),
1483        mint_routing,
1484    }
1485}
1486
1487/// Assemble the namespace-mint routing context from the three handles a
1488/// distributed boot produces, or `None` when any is absent.
1489///
1490/// The single place the context is built, shared by the boot path (which wires
1491/// it into the worker registry's minter before the state exists) and
1492/// [`ServerState::namespace_routing`] (which serves the per-request minters).
1493/// Each handle is checked rather than assumed present: `build_routing_state`
1494/// populates them together, but a partial context would silently route mints to
1495/// nowhere.
1496#[cfg(feature = "haematite-backend")]
1497fn build_namespace_routing(
1498    cluster_store: Option<&Arc<aion_store_haematite::HaematiteStore>>,
1499    shard_directory: Option<&Arc<crate::routing::StaticShardDirectory>>,
1500    request_forwarder: Option<&Arc<dyn crate::routing::RequestForwarder>>,
1501) -> Option<crate::namespace::NamespaceRouting> {
1502    use crate::namespace::{
1503        GrpcMintForwarder, MintForwarder, MintShardOwners, NamespaceRouting, NamespaceShardResolver,
1504    };
1505    let store = Arc::clone(cluster_store?);
1506    let directory = Arc::clone(shard_directory?);
1507    let shards: Arc<dyn NamespaceShardResolver> = store;
1508    let owners: Arc<dyn MintShardOwners> = directory;
1509    let forwarder: Arc<dyn MintForwarder> =
1510        Arc::new(GrpcMintForwarder::new(Arc::clone(request_forwarder?)));
1511    Some(NamespaceRouting::new(shards, owners, forwarder))
1512}
1513
1514/// A connected durable store plus the lifecycle pieces the boot path needs.
1515///
1516/// `outbox_store` is the SAME leaf store cast as an [`OutboxStore`] for backends
1517/// with a durable outbox table (libSQL, haematite); the in-memory backend yields
1518/// `None`. `bootstrap_coordinator` gates the schedule-coordinator seed on real
1519/// ownership (SS-2 / AA-4-4): `true` for every non-distributed boot (single-node
1520/// owns the coordinator's shard), and for a distributed node only when it owns
1521/// that shard. `cluster_responder` owns the distributed inbound-write responder
1522/// thread, kept alive for the server's lifetime; `None` for non-distributed boots.
1523struct ConnectedStore {
1524    event_store: Arc<dyn EventStore>,
1525    outbox_store: Option<Arc<dyn OutboxStore>>,
1526    /// The SAME concrete leaf store as `event_store`, captured as a
1527    /// [`NamespaceStore`] before the decorator chain wraps it (the decorators
1528    /// are `NamespaceStore`-unaware). The control plane mints and lists through
1529    /// this handle. Every backend populates it: haematite supplies the
1530    /// quorum-replicated implementation, libSQL and in-memory the local-only
1531    /// one.
1532    namespace_store: Arc<dyn NamespaceStore>,
1533    /// NOI-5b: the SAME concrete leaf store captured as an
1534    /// [`ObservabilityStore`](aion_store::ObservabilityStore) when the backend
1535    /// implements the durable `O` keyspace (haematite). `None` for backends with
1536    /// no `O` keyspace (libSQL / in-memory), where the transcript sequencer runs
1537    /// over an in-memory impl instead. Captured before the leaf is wrapped in the
1538    /// (`ObservabilityStore`-unaware) decorator chain, exactly like
1539    /// `namespace_store`.
1540    observability_store: Option<Arc<dyn aion_store::ObservabilityStore>>,
1541    bootstrap_coordinator: bool,
1542    #[cfg(feature = "haematite-backend")]
1543    cluster_responder: Option<aion_store_haematite::ClusterResponder>,
1544    /// The concrete distributed haematite store (the SAME leaf as `event_store`),
1545    /// retained for the SS-5b cluster supervisor's peer-liveness polling. `None`
1546    /// for every non-distributed boot.
1547    #[cfg(feature = "haematite-backend")]
1548    cluster_store: Option<Arc<aion_store_haematite::HaematiteStore>>,
1549    /// The peers the SS-5b supervisor watches, each with the shards this node
1550    /// adopts on its death. Empty for non-distributed boots.
1551    #[cfg(feature = "haematite-backend")]
1552    watched_peers: Vec<crate::cluster::WatchedPeer>,
1553    /// The static shard-directory peer entries (name + declared shards + gRPC
1554    /// forward address) used to build the request-routing directory (R-2). Empty
1555    /// for non-distributed boots.
1556    #[cfg(feature = "haematite-backend")]
1557    directory_peers: Vec<crate::routing::DirectoryPeer>,
1558    /// This node's own distribution name (cluster `node_id`), so the SS-3
1559    /// directory can resolve a shard-owner record naming THIS node to `Local`.
1560    /// `None` for non-distributed boots.
1561    #[cfg(feature = "haematite-backend")]
1562    self_node_id: Option<String>,
1563}
1564
1565impl ConnectedStore {
1566    /// A non-distributed connected store: owns the coordinator's shard (so it
1567    /// bootstraps the coordinator) and has no cluster responder.
1568    ///
1569    /// `namespace_store` is the SAME concrete leaf as `event_store`, captured as
1570    /// a [`NamespaceStore`] by the caller (where the concrete type is still
1571    /// known) before the decorator chain wraps the event store.
1572    fn local(
1573        event_store: Arc<dyn EventStore>,
1574        outbox_store: Option<Arc<dyn OutboxStore>>,
1575        namespace_store: Arc<dyn NamespaceStore>,
1576    ) -> Self {
1577        Self {
1578            event_store,
1579            outbox_store,
1580            namespace_store,
1581            // A `local` connected store is the memory / libSQL / embedder path,
1582            // none of which implement the durable `O` keyspace: the transcript
1583            // sequencer falls back to an in-memory impl (NOI-5b).
1584            observability_store: None,
1585            bootstrap_coordinator: true,
1586            #[cfg(feature = "haematite-backend")]
1587            cluster_responder: None,
1588            #[cfg(feature = "haematite-backend")]
1589            cluster_store: None,
1590            #[cfg(feature = "haematite-backend")]
1591            watched_peers: Vec::new(),
1592            #[cfg(feature = "haematite-backend")]
1593            directory_peers: Vec::new(),
1594            #[cfg(feature = "haematite-backend")]
1595            self_node_id: None,
1596        }
1597    }
1598}
1599
1600/// Connect the durable store, yielding the engine's [`EventStore`] handle and,
1601/// for the libSQL backend, the SAME leaf store cast as an [`OutboxStore`].
1602///
1603/// Both handles are clones of one `Arc<LibSqlStore>`, which holds a single
1604/// `libsql::Connection`. Sharing that connection with the outbox dispatcher
1605/// serializes the engine's `append_with_outbox` and the dispatcher's
1606/// `claim_outbox_rows` writes, so the two never contend across separate
1607/// connections and never raise `SQLITE_BUSY`. The in-memory backend has no
1608/// outbox table, so it yields `None`.
1609async fn connect_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1610    match config.backend {
1611        StoreBackend::Memory => {
1612            // One leaf store, captured as both the engine's event store and the
1613            // namespace registry (in-memory backends have no outbox table).
1614            let leaf = Arc::new(aion_store::InMemoryStore::default());
1615            let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1616            Ok(ConnectedStore::local(leaf, None, namespace_store))
1617        }
1618        StoreBackend::LibSql => {
1619            #[cfg(feature = "libsql-backend")]
1620            {
1621                connect_libsql_store(config).await
1622            }
1623            #[cfg(not(feature = "libsql-backend"))]
1624            {
1625                let _ = config;
1626                connect_libsql_store_unavailable()
1627            }
1628        }
1629        StoreBackend::Haematite => {
1630            #[cfg(feature = "haematite-backend")]
1631            {
1632                connect_haematite_store(config).await
1633            }
1634            #[cfg(not(feature = "haematite-backend"))]
1635            {
1636                let _ = config;
1637                connect_haematite_store_unavailable()
1638            }
1639        }
1640    }
1641}
1642
1643/// Connect the libSQL backend, opening the embedded database at `store.url` and
1644/// sharing the SAME leaf `Arc<LibSqlStore>` (one `libsql::Connection`) as both the
1645/// engine's [`EventStore`] and the dispatcher's [`OutboxStore`].
1646#[cfg(feature = "libsql-backend")]
1647async fn connect_libsql_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1648    let Some(url) = config.url else {
1649        return Err(ServerError::Config {
1650            message: "store.url must not be empty when store.backend is libsql".to_owned(),
1651        });
1652    };
1653    let store = LibSqlStore::open(url.clone())
1654        .await
1655        .map_err(ServerError::from)?;
1656    store
1657        .validate_event_compatibility()
1658        .await
1659        .map_err(|error| match error {
1660            aion_store::StoreError::Serialization(_) => ServerError::Config {
1661                message: format!(
1662                    "Database schema mismatch — delete {url} and restart, or run migrations."
1663                ),
1664            },
1665            other => ServerError::from(other),
1666        })?;
1667    let leaf = Arc::new(store);
1668    let event_store: Arc<dyn EventStore> = leaf.clone();
1669    let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1670    let outbox_store: Arc<dyn OutboxStore> = leaf;
1671    Ok(ConnectedStore::local(
1672        event_store,
1673        Some(outbox_store),
1674        namespace_store,
1675    ))
1676}
1677
1678/// Reject `backend = libsql` cleanly when the optional `libsql-backend` feature
1679/// is not compiled in, so a default (ablative-stack) build gives a precise
1680/// operator error instead of a silent fallthrough.
1681#[cfg(not(feature = "libsql-backend"))]
1682fn connect_libsql_store_unavailable() -> Result<ConnectedStore, ServerError> {
1683    Err(ServerError::Config {
1684        message: "store.backend = libsql requires the aion-server `libsql-backend` feature"
1685            .to_owned(),
1686    })
1687}
1688
1689/// Connect the haematite backend, opening the on-disk database if `store.data_dir`
1690/// already holds one and otherwise creating it with `store.shard_count` shards.
1691///
1692/// Without a `[store.cluster]` section this is the SINGLE-NODE path
1693/// ([`HaematiteStore::open`] / [`create_with_shard_count`]), byte-identical to
1694/// before: no endpoint, no election, owns everything, bootstraps the coordinator.
1695/// With a cluster section this is the DISTRIBUTED path
1696/// ([`HaematiteStore::open_or_create_distributed`]): it binds the replication
1697/// endpoint, builds the quorum membership, dials peers, starts the responder, and
1698/// computes whether THIS node owns the schedule-coordinator's shard so the engine
1699/// boot path seeds the coordinator on exactly one owner cluster-wide (SS-2).
1700///
1701/// The SAME leaf `Arc<HaematiteStore>` is shared as both the engine's
1702/// [`EventStore`] and the dispatcher's [`OutboxStore`] (one inner haematite
1703/// database), mirroring the libSQL backend.
1704///
1705/// [`HaematiteStore::open`]: aion_store_haematite::HaematiteStore::open
1706/// [`create_with_shard_count`]: aion_store_haematite::HaematiteStore::create_with_shard_count
1707/// [`HaematiteStore::open_or_create_distributed`]: aion_store_haematite::HaematiteStore::open_or_create_distributed
1708#[cfg(feature = "haematite-backend")]
1709async fn connect_haematite_store(config: StoreConfig) -> Result<ConnectedStore, ServerError> {
1710    let Some(data_dir) = config.data_dir else {
1711        return Err(ServerError::Config {
1712            message: "store.data_dir must not be empty when store.backend is haematite".to_owned(),
1713        });
1714    };
1715    let shard_count = config.shard_count;
1716    let owned_shards = config.owned_shards.clone();
1717    let cluster = config.cluster.clone();
1718    // The peers the SS-5b supervisor watches, captured before `cluster` is moved
1719    // into the blocking build. A peer with declared `owned_shards` becomes a
1720    // watch target; peers without are kept out of the watch set (the supervisor
1721    // would have nothing to adopt for them).
1722    let watched_peers: Vec<crate::cluster::WatchedPeer> = cluster
1723        .as_ref()
1724        .map(|cluster| {
1725            cluster
1726                .peers
1727                .iter()
1728                .map(|peer| crate::cluster::WatchedPeer {
1729                    name: peer.name.clone(),
1730                    owned_shards: peer.owned_shards.clone(),
1731                })
1732                .collect()
1733        })
1734        .unwrap_or_default();
1735    // The static shard-directory entries (R-2): each peer's declared shards plus
1736    // its gRPC forward address. Built from the same config the supervisor uses.
1737    let directory_peers: Vec<crate::routing::DirectoryPeer> = cluster
1738        .as_ref()
1739        .map(|cluster| {
1740            cluster
1741                .peers
1742                .iter()
1743                .map(|peer| crate::routing::DirectoryPeer {
1744                    name: peer.name.clone(),
1745                    owned_shards: peer.owned_shards.clone(),
1746                    grpc_addr: peer.grpc_address,
1747                })
1748                .collect()
1749        })
1750        .unwrap_or_default();
1751    // This node's own distribution name, so the SS-3 directory resolves a
1752    // shard-owner record naming THIS node to `Local`.
1753    let self_node_id: Option<String> = cluster.as_ref().map(|cluster| cluster.node_id.clone());
1754    // Construction (and, for the distributed path, the off-runtime endpoint bind)
1755    // must not stall the async runtime, so run it on the blocking pool. The
1756    // distributed constructor itself steps onto a bare thread for the bind.
1757    let (store, responder) =
1758        tokio::task::spawn_blocking(move || build_haematite_store(&data_dir, shard_count, cluster))
1759            .await
1760            .map_err(|error| ServerError::Config {
1761                message: format!("haematite store initialization task failed: {error}"),
1762            })??;
1763
1764    // Gate the coordinator bootstrap on real ownership: a distributed node that
1765    // does NOT own the coordinator's shard must not seed/fence it (AA-4-4). A
1766    // single-node boot owns all shards, so it always bootstraps.
1767    let bootstrap_coordinator = if owned_shards.is_empty() {
1768        true
1769    } else {
1770        store.set_owned_shards(owned_shards.iter().copied());
1771        store.owns_workflow_shard(&aion::schedule_coordinator_workflow_id())
1772    };
1773
1774    let leaf = Arc::new(store);
1775    let event_store: Arc<dyn EventStore> = leaf.clone();
1776    let outbox_store: Arc<dyn OutboxStore> = leaf.clone();
1777    // The namespace registry is the SAME concrete `HaematiteStore` leaf (the
1778    // quorum-replicated implementation), captured before the leaf is moved into
1779    // the cluster-store retention below.
1780    let namespace_store: Arc<dyn NamespaceStore> = leaf.clone();
1781    // NOI-5b: the SAME concrete leaf captured as the durable `O`-keyspace
1782    // observability store, so the transcript sequencer persists to haematite and
1783    // survives restart/failover. Captured here (before the decorator chain wraps
1784    // the event store) exactly like the namespace registry.
1785    let observability_store: Arc<dyn aion_store::ObservabilityStore> = leaf.clone();
1786    // Retain the concrete store ONLY for a distributed boot (responder present),
1787    // where the SS-5b supervisor will poll it for peer liveness. A single-node
1788    // boot has no peers, so it carries no cluster store and never supervises.
1789    let cluster_store = responder.as_ref().map(|_| leaf);
1790    let (watched_peers, directory_peers, self_node_id) = if cluster_store.is_some() {
1791        (watched_peers, directory_peers, self_node_id)
1792    } else {
1793        (Vec::new(), Vec::new(), None)
1794    };
1795    Ok(ConnectedStore {
1796        event_store,
1797        outbox_store: Some(outbox_store),
1798        namespace_store,
1799        observability_store: Some(observability_store),
1800        bootstrap_coordinator,
1801        cluster_responder: responder,
1802        cluster_store,
1803        watched_peers,
1804        directory_peers,
1805        self_node_id,
1806    })
1807}
1808
1809/// Build the haematite store: the distributed path when a cluster section is
1810/// present, otherwise the single-node path. Returns the store and (for the
1811/// distributed path) its inbound-write responder. Restart-safe: an existing
1812/// on-disk database is reused (its shard count wins) rather than re-created.
1813///
1814/// Linux/Android give Haematite a descriptor-authoritative `/proc/self/fd` path.
1815/// On path-ambient Unix targets such as macOS, startup instead resolves the held
1816/// descriptor's current path and refuses any ancestor owned by an unprivileged
1817/// principal other than the server euid or writable by group/world. That policy
1818/// prevents a second principal from renaming a parent after startup and replacing
1819/// the old name with a symlink that redirects Haematite's normal reads/commits.
1820/// Every shard is still eagerly materialized and the capability retained, but on
1821/// those targets neither action confines later pathname I/O. A descriptor-relative
1822/// Haematite constructor and backend I/O remain the long-term fix.
1823#[cfg(feature = "haematite-backend")]
1824fn build_haematite_store(
1825    data_dir: &str,
1826    shard_count: usize,
1827    cluster: Option<crate::config::ClusterConfig>,
1828) -> Result<
1829    (
1830        aion_store_haematite::HaematiteStore,
1831        Option<aion_store_haematite::ClusterResponder>,
1832    ),
1833    ServerError,
1834> {
1835    build_haematite_store_with_hook(data_dir, shard_count, cluster, || Ok(()))
1836}
1837
1838#[cfg(feature = "haematite-backend")]
1839fn build_haematite_store_with_hook(
1840    data_dir: &str,
1841    shard_count: usize,
1842    cluster: Option<crate::config::ClusterConfig>,
1843    before_backend_touch: impl FnOnce() -> Result<(), std::io::Error>,
1844) -> Result<
1845    (
1846        aion_store_haematite::HaematiteStore,
1847        Option<aion_store_haematite::ClusterResponder>,
1848    ),
1849    ServerError,
1850> {
1851    use aion_store_haematite::{ClusterBootstrap, HaematiteStore};
1852
1853    // Acquire the data root through the same no-follow component walk used by
1854    // authoring. New components are created 0700 on Unix, and an existing root
1855    // that the server's own user owns is tightened to 0700 rather than refused —
1856    // provisioning our own directory is Aion's job, not the operator's. Only a
1857    // root Aion cannot make safe (foreign owner, a filesystem without Unix
1858    // modes) is a loud startup failure here; an unsafe ANCESTOR is caught
1859    // separately below and is never repaired.
1860    let private_root = crate::filesystem::ConfinedDir::open_or_create(std::path::Path::new(
1861        data_dir,
1862    ))
1863    .map_err(|error| ServerError::Config {
1864        message: format!("unsafe store.data_dir `{data_dir}`: {error}"),
1865    })?;
1866
1867    // Haematite 0.5 creates shard directories lazily. Pre-create every configured
1868    // directory descriptor-relatively, then force the backend's actual shard
1869    // spawn/recovery path below while this checked-and-hardened window is held.
1870    for shard in 0..shard_count {
1871        private_root
1872            .create_dir_all(std::path::Path::new(&format!("shard-{shard}")))
1873            .map_err(|error| ServerError::Config {
1874                message: format!(
1875                    "failed to materialize shard-{shard} under store.data_dir `{data_dir}`: {error}"
1876                ),
1877            })?;
1878    }
1879    private_root
1880        .harden_tree()
1881        .map_err(|error| private_store_mode_error(data_dir, &error))?;
1882
1883    // Deterministic regression seam: the capability and shard directories exist,
1884    // but Haematite has not touched any path yet.
1885    before_backend_touch().map_err(|error| ServerError::Config {
1886        message: format!("store.data_dir pre-open hook failed: {error}"),
1887    })?;
1888
1889    #[cfg(unix)]
1890    let backend_path = private_root
1891        .backend_path()
1892        .map_err(|error| ServerError::Config {
1893            message: format!("failed to resolve held store.data_dir `{data_dir}`: {error}"),
1894        })?;
1895    #[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))]
1896    crate::filesystem::validate_ambient_backend_ancestors(&backend_path).map_err(|error| {
1897        let (component, reason) = error.into_parts();
1898        ServerError::UnsafeDataRootAncestor {
1899            data_root: backend_path.clone(),
1900            component,
1901            reason,
1902        }
1903    })?;
1904    #[cfg(not(unix))]
1905    let backend_path = std::path::PathBuf::from(data_dir);
1906
1907    let Some(cluster) = cluster else {
1908        let store = if backend_path.join("config.json").exists() {
1909            HaematiteStore::open(&backend_path).map_err(ServerError::from)?
1910        } else {
1911            HaematiteStore::create_with_shard_count(&backend_path, shard_count)
1912                .map_err(ServerError::from)?
1913        };
1914        store.materialize_all_shards().map_err(ServerError::from)?;
1915        private_root
1916            .harden_tree()
1917            .map_err(|error| private_store_mode_error(data_dir, &error))?;
1918        let store = store.retain_data_root_capability(private_root);
1919        return Ok((store, None));
1920    };
1921
1922    let boot = ClusterBootstrap {
1923        node_id: cluster.node_id,
1924        bind_address: cluster.bind_address,
1925        members: cluster.members,
1926        peers: cluster
1927            .peers
1928            .into_iter()
1929            .map(|peer| (peer.name, peer.address))
1930            .collect(),
1931        timeout: HAEMATITE_CLUSTER_OP_TIMEOUT,
1932    };
1933    let (store, responder) =
1934        HaematiteStore::open_or_create_distributed(&backend_path, shard_count, boot)
1935            .map_err(ServerError::from)?;
1936    store.materialize_all_shards().map_err(ServerError::from)?;
1937    private_root
1938        .harden_tree()
1939        .map_err(|error| private_store_mode_error(data_dir, &error))?;
1940    let store = store.retain_data_root_capability(private_root);
1941    Ok((store, Some(responder)))
1942}
1943
1944#[cfg(feature = "haematite-backend")]
1945fn private_store_mode_error(data_dir: &str, error: &std::io::Error) -> ServerError {
1946    ServerError::Config {
1947        message: format!(
1948            "failed to apply private modes under store.data_dir `{data_dir}`: {error}"
1949        ),
1950    }
1951}
1952
1953/// Per-operation quorum/election timeout for the distributed haematite backend.
1954#[cfg(feature = "haematite-backend")]
1955const HAEMATITE_CLUSTER_OP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1956
1957/// Reject `backend = haematite` cleanly when the optional `haematite-backend`
1958/// feature is not compiled in, so a default build gives a precise operator
1959/// error instead of a silent fallthrough.
1960#[cfg(not(feature = "haematite-backend"))]
1961fn connect_haematite_store_unavailable() -> Result<ConnectedStore, ServerError> {
1962    Err(ServerError::Config {
1963        message: "store.backend = haematite requires the aion-server `haematite-backend` feature"
1964            .to_owned(),
1965    })
1966}
1967
1968/// The NOI-6 intervention transport used when no push transport is compiled in.
1969///
1970/// Without the `liminal-transport` feature there is no way to reach a worker's
1971/// out-of-band connection, so every routed command reports the owning worker
1972/// unreachable — which the router maps onto the attempt-scoped stale-target no-op.
1973/// This keeps the intervention endpoint honest on a transport-less build (an
1974/// operator gets a NACK, never a false "applied") without gating the endpoint on a
1975/// feature.
1976#[cfg(not(feature = "liminal-transport"))]
1977#[derive(Clone, Debug)]
1978struct NullInterventionTransport;
1979
1980#[cfg(not(feature = "liminal-transport"))]
1981#[async_trait::async_trait]
1982impl crate::worker::InterventionTransport for NullInterventionTransport {
1983    async fn push(
1984        &self,
1985        _worker: &crate::worker::WorkerHandle,
1986        _command: aion_core::InterventionCommand,
1987    ) -> Result<aion_core::InterventionOutcome, ServerError> {
1988        Err(ServerError::worker_connection_lost(
1989            "intervention",
1990            "no intervention push transport is compiled in".to_owned(),
1991        ))
1992    }
1993}
1994
1995#[cfg(test)]
1996mod tests {
1997    use std::{net::SocketAddr, time::Duration};
1998
1999    use aion_store::InMemoryStore;
2000
2001    use super::ServerState;
2002    use crate::config::{
2003        AuthConfig, AuthoringConfig, DeployConfig, DevConfig, ListenConfig, MetricsConfig,
2004        NamespaceConfig, NamespaceMode, OpsConsoleAssetSource, OpsConsoleConfig, OutboxConfig,
2005        RuntimeConfig, WebSocketConfig, WorkerConfig,
2006    };
2007
2008    fn runtime_config() -> RuntimeConfig {
2009        RuntimeConfig {
2010            listen: ListenConfig {
2011                grpc: SocketAddr::from(([127, 0, 0, 1], 50051)),
2012                http: SocketAddr::from(([127, 0, 0, 1], 8080)),
2013            },
2014            tls: None,
2015            auth: AuthConfig {
2016                enabled: false,
2017                jwks_url: None,
2018                jwks_refresh_seconds: 300,
2019            },
2020            ops_console: OpsConsoleConfig {
2021                source: OpsConsoleAssetSource::Embedded,
2022            },
2023            namespace: NamespaceConfig {
2024                mode: NamespaceMode::SharedEngine,
2025            },
2026            worker: WorkerConfig {
2027                heartbeat_window: Duration::from_secs(30),
2028                ..WorkerConfig::default()
2029            },
2030            websocket: WebSocketConfig {
2031                outbound_buffer_bound: 32,
2032                event_broadcast_capacity: Some(64),
2033                cluster_broadcast_capacity: Some(64),
2034            },
2035            workflow_packages: Vec::new(),
2036            deploy: DeployConfig::default(),
2037            authoring: AuthoringConfig::default(),
2038            dev: DevConfig::default(),
2039            outbox: OutboxConfig::default(),
2040            observability: crate::config::ObservabilityConfig::default(),
2041            scheduler_threads: 1,
2042            query_timeout: Some(Duration::from_secs(10)),
2043            default_namespace: "default".to_owned(),
2044            auto_create: crate::config::AutoCreate::Open,
2045            max_in_flight_activities: crate::config::DEFAULT_MAX_IN_FLIGHT_ACTIVITIES,
2046            drain_timeout: Duration::from_secs(30),
2047            metrics: MetricsConfig { enabled: true },
2048            owned_shards: Vec::new(),
2049            cors_allowed_origins: Vec::new(),
2050        }
2051    }
2052
2053    #[tokio::test]
2054    async fn builds_state_with_in_memory_store() -> Result<(), Box<dyn std::error::Error>> {
2055        let state =
2056            ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2057
2058        std::hint::black_box(state.namespace_guard());
2059        std::hint::black_box(state.worker_registry());
2060
2061        Ok(())
2062    }
2063
2064    /// R1 surfacing: a real boot exposes the unserved-queue state, the bridge
2065    /// publishes parked dispatches into THAT instance, and the address leaves
2066    /// the state the moment the dispatch resolves.
2067    ///
2068    /// The dispatch is driven through a dispatcher built over the state's OWN
2069    /// registry, queue state, and engine-backed declaration source — the same
2070    /// three handles `build_bridge_dispatcher` hands the production bridge.
2071    #[tokio::test]
2072    async fn unserved_queues_surfaces_a_parked_dispatch_and_clears_it()
2073    -> Result<(), Box<dyn std::error::Error>> {
2074        use aion::{ActivityDispatch, ActivityDispatcher as _};
2075        use aion_core::{ActivityId, RunId, WorkflowId};
2076        use std::sync::Arc;
2077
2078        let state =
2079            ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2080        assert!(
2081            state.unserved_queues()?.is_empty(),
2082            "a calm boot has no unserved queues"
2083        );
2084        // The engine-backed reader IS installed on a real boot; with no
2085        // queue-declaring package deployed it can contradict nothing, so it must
2086        // answer Unknown rather than manufacture a structural refusal.
2087        assert!(state.queue_declarations().is_installed());
2088        assert_eq!(
2089            state
2090                .queue_declarations()
2091                .declaration_for("nobody-serves-this"),
2092            crate::worker::QueueDeclaration::Unknown
2093        );
2094
2095        let dispatcher = Arc::new(
2096            crate::worker::WorkerActivityDispatcher::new(
2097                state.worker_registry().clone(),
2098                "default",
2099                crate::worker::HeartbeatTracker::new(Duration::from_secs(5)),
2100            )
2101            .with_queue_state(state.queue_service_state().clone())
2102            .with_queue_declarations(state.queue_declarations().clone()),
2103        );
2104        let workflow_id = WorkflowId::new_v4();
2105        let request = ActivityDispatch {
2106            namespace: "default".to_owned(),
2107            task_queue: "nobody-serves-this".to_owned(),
2108            node: None,
2109            workflow_id: workflow_id.clone(),
2110            run_id: RunId::new_v4(),
2111            activity_id: ActivityId::from_sequence_position(0),
2112            name: "greet".to_owned(),
2113            input: "{}".to_owned(),
2114            config: "{}".to_owned(),
2115            attempt: 1,
2116            advisory: false,
2117            labels: std::collections::BTreeMap::new(),
2118        };
2119        let parked = std::thread::spawn(move || dispatcher.dispatch(request));
2120
2121        let mut unserved = Vec::new();
2122        for _ in 0..30 {
2123            unserved = state.unserved_queues()?;
2124            if !unserved.is_empty() {
2125                break;
2126            }
2127            tokio::time::sleep(Duration::from_millis(100)).await;
2128        }
2129        assert_eq!(unserved.len(), 1, "the parked dispatch is not surfaced");
2130        assert_eq!(
2131            unserved[0].reason,
2132            crate::worker::QueueServiceReason::NoLivePollers,
2133            "an empty catalog must not be read as a structural refusal"
2134        );
2135        assert_eq!(unserved[0].key.task_queue, "nobody-serves-this");
2136        assert_eq!(unserved[0].waiting.len(), 1);
2137        assert_eq!(unserved[0].waiting[0].workflow_id, workflow_id);
2138
2139        // Release the dispatch: a worker arrives whose receiver is already gone.
2140        let (worker_tx, worker_rx) = tokio::sync::mpsc::channel(1);
2141        drop(worker_rx);
2142        let registration = state.worker_registry().register_namespaces(
2143            [String::from("default")],
2144            "nobody-serves-this",
2145            None,
2146            [String::from("greet")].iter(),
2147            worker_tx,
2148        )?;
2149        let outcome = parked.join().map_err(|_| "parked dispatch panicked")?;
2150        assert!(outcome.is_err(), "the released dispatch must resolve");
2151        assert!(
2152            state.unserved_queues()?.is_empty(),
2153            "a resolved dispatch must leave the unserved state"
2154        );
2155        registration.deregister()?;
2156        Ok(())
2157    }
2158
2159    #[tokio::test]
2160    async fn namespace_store_is_reachable_and_functional_after_default_boot()
2161    -> Result<(), Box<dyn std::error::Error>> {
2162        use aion_store::{MintOutcome, NamespaceOrigin};
2163
2164        // A default single-node (in-memory) boot must expose a real, functional
2165        // namespace registry through `state.namespace_store()` — the control
2166        // plane's mint (S5) and `GET /namespaces` (S7) reach the store this way.
2167        let state =
2168            ServerState::build_with_store(InMemoryStore::default(), runtime_config()).await?;
2169
2170        let store = state.namespace_store();
2171
2172        // Mint a fresh namespace: the first reference creates it.
2173        let outcome = store
2174            .register_namespace("orders", NamespaceOrigin::WorkerMint)
2175            .await?;
2176        assert_eq!(
2177            outcome,
2178            MintOutcome::Created,
2179            "the first reference to a namespace mints it"
2180        );
2181
2182        // Re-referencing is idempotent: the record already exists.
2183        let again = store
2184            .register_namespace("orders", NamespaceOrigin::WorkerMint)
2185            .await?;
2186        assert_eq!(
2187            again,
2188            MintOutcome::AlreadyExisted,
2189            "a second reference touches the existing record rather than re-creating it"
2190        );
2191
2192        // Single lookup returns the durable record.
2193        let fetched = store.get_namespace("orders").await?;
2194        let record = fetched.ok_or("registered namespace must be retrievable via get_namespace")?;
2195        assert_eq!(record.name, "orders");
2196        assert_eq!(record.origin, NamespaceOrigin::WorkerMint);
2197
2198        // The live set lists the namespace.
2199        let listed = store.list_namespaces().await?;
2200        assert!(
2201            listed.iter().any(|record| record.name == "orders"),
2202            "list_namespaces returns the minted namespace"
2203        );
2204
2205        Ok(())
2206    }
2207
2208    #[cfg(feature = "haematite-backend")]
2209    #[tokio::test(flavor = "multi_thread")]
2210    async fn connect_store_haematite_round_trips_through_event_store()
2211    -> Result<(), Box<dyn std::error::Error>> {
2212        use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2213        use aion_store::WriteToken;
2214        use chrono::Utc;
2215
2216        use crate::config::{StoreBackend, StoreConfig};
2217
2218        let data_dir = crate::test_support::private_tempdir()?;
2219        // Single shard, a fresh temp data_dir: the production connect path opens
2220        // an existing haematite database or creates one, then shares the leaf as
2221        // both the engine EventStore and the dispatcher OutboxStore.
2222        let connected = super::connect_store(StoreConfig {
2223            backend: StoreBackend::Haematite,
2224            url: None,
2225            owned_shards: Vec::new(),
2226            data_dir: Some(data_dir.path().to_string_lossy().into_owned()),
2227            shard_count: 1,
2228            cluster: None,
2229        })
2230        .await?;
2231        let event_store = connected.event_store;
2232        assert!(
2233            connected.outbox_store.is_some(),
2234            "the haematite backend shares its leaf store as the dispatcher's outbox store"
2235        );
2236        assert!(
2237            connected.bootstrap_coordinator,
2238            "a single-node haematite boot owns all shards and bootstraps the coordinator"
2239        );
2240        assert!(
2241            connected.cluster_responder.is_none(),
2242            "a single-node (no [cluster]) haematite boot has no distributed responder"
2243        );
2244
2245        let workflow_id = WorkflowId::new_v4();
2246        let event = aion_core::Event::WorkflowStarted {
2247            envelope: EventEnvelope {
2248                seq: 1,
2249                recorded_at: Utc::now(),
2250                workflow_id: workflow_id.clone(),
2251            },
2252            workflow_type: String::from("checkout"),
2253            input: Payload::new(ContentType::Json, b"{}".to_vec()),
2254            run_id: RunId::new_v4(),
2255            parent_run_id: None,
2256            package_version: PackageVersion::new("a".repeat(64)),
2257        };
2258        event_store
2259            .append(
2260                WriteToken::recorder(),
2261                &workflow_id,
2262                std::slice::from_ref(&event),
2263                0,
2264            )
2265            .await?;
2266        let history = event_store.read_history(&workflow_id).await?;
2267        assert_eq!(
2268            history.len(),
2269            1,
2270            "an event appended through the server's dyn EventStore reads back"
2271        );
2272        Ok(())
2273    }
2274
2275    #[cfg(all(feature = "haematite-backend", unix))]
2276    #[test]
2277    fn haematite_root_swap_before_first_backend_touch_cannot_redirect_writes()
2278    -> Result<(), Box<dyn std::error::Error>> {
2279        use std::os::unix::fs::symlink;
2280
2281        let sandbox = crate::test_support::private_tempdir()?;
2282        let configured_root = sandbox.path().join("data");
2283        let held_root = sandbox.path().join("held-data");
2284        let outside = sandbox.path().join("outside");
2285        std::fs::create_dir(&outside)?;
2286        let configured = configured_root
2287            .to_str()
2288            .ok_or("temporary data path was not UTF-8")?;
2289
2290        let (store, responder) =
2291            super::build_haematite_store_with_hook(configured, 4, None, || {
2292                // The server has acquired and hardened `configured_root`, but
2293                // Haematite has not opened or created anything. Replace the
2294                // ambient name with an attacker-controlled symlink at exactly
2295                // the old check/use boundary.
2296                std::fs::rename(&configured_root, &held_root)?;
2297                symlink(&outside, &configured_root)?;
2298                Ok(())
2299            })?;
2300        assert!(responder.is_none());
2301
2302        let outside_entries = std::fs::read_dir(&outside)?.collect::<Result<Vec<_>, _>>()?;
2303        assert!(
2304            outside_entries.is_empty(),
2305            "Haematite followed the replaced ambient root and wrote outside"
2306        );
2307        assert!(held_root.join("config.json").is_file());
2308        for shard in 0..4 {
2309            let shard_path = held_root.join(format!("shard-{shard}"));
2310            assert!(shard_path.is_dir(), "shard {shard} was not materialized");
2311            assert!(
2312                std::fs::read_dir(&shard_path)?
2313                    .next()
2314                    .transpose()?
2315                    .is_some(),
2316                "shard {shard} did not run Haematite's materialization path"
2317            );
2318        }
2319
2320        drop(store);
2321        Ok(())
2322    }
2323
2324    #[cfg(all(
2325        feature = "haematite-backend",
2326        any(target_os = "linux", target_os = "android")
2327    ))]
2328    #[tokio::test]
2329    async fn proc_fd_backend_path_survives_a_post_startup_root_swap()
2330    -> Result<(), Box<dyn std::error::Error>> {
2331        use std::os::unix::fs::symlink;
2332
2333        use aion_core::{ContentType, EventEnvelope, PackageVersion, Payload, RunId, WorkflowId};
2334        use aion_store::{WritableEventStore as _, WriteToken};
2335        use chrono::Utc;
2336
2337        let sandbox = crate::test_support::private_tempdir()?;
2338        let configured_root = sandbox.path().join("data");
2339        let held_root = sandbox.path().join("held-data");
2340        let capture = sandbox.path().join("capture");
2341        std::fs::create_dir(&capture)?;
2342        let configured = configured_root
2343            .to_str()
2344            .ok_or("temporary data path was not UTF-8")?;
2345
2346        let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2347        assert!(responder.is_none());
2348        std::fs::rename(&configured_root, &held_root)?;
2349        symlink(&capture, &configured_root)?;
2350
2351        let workflow_id = WorkflowId::new_v4();
2352        let event = aion_core::Event::WorkflowStarted {
2353            envelope: EventEnvelope {
2354                seq: 1,
2355                recorded_at: Utc::now(),
2356                workflow_id: workflow_id.clone(),
2357            },
2358            workflow_type: String::from("post-startup-root-swap"),
2359            input: Payload::new(ContentType::Json, b"{}".to_vec()),
2360            run_id: RunId::new_v4(),
2361            parent_run_id: None,
2362            package_version: PackageVersion::new("a".repeat(64)),
2363        };
2364        store
2365            .append(
2366                WriteToken::recorder(),
2367                &workflow_id,
2368                std::slice::from_ref(&event),
2369                0,
2370            )
2371            .await?;
2372
2373        let captured = std::fs::read_dir(&capture)?.collect::<Result<Vec<_>, _>>()?;
2374        assert!(
2375            captured.is_empty(),
2376            "post-startup append followed the replacement symlink into capture"
2377        );
2378        assert!(held_root.join("config.json").is_file());
2379        drop(store);
2380        Ok(())
2381    }
2382
2383    #[cfg(all(
2384        feature = "haematite-backend",
2385        unix,
2386        not(any(target_os = "linux", target_os = "android"))
2387    ))]
2388    #[test]
2389    fn path_ambient_haematite_refuses_group_or_world_writable_ancestors()
2390    -> Result<(), Box<dyn std::error::Error>> {
2391        use std::os::unix::fs::PermissionsExt as _;
2392
2393        let sandbox = crate::test_support::private_tempdir()?;
2394        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2395
2396        for mode in [0o770, 0o1777] {
2397            let shared = sandbox.path().join(format!("shared-{mode:o}"));
2398            let data_root = shared.join("data");
2399            std::fs::create_dir(&shared)?;
2400            std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(mode))?;
2401            std::fs::create_dir(&data_root)?;
2402            std::fs::set_permissions(&data_root, std::fs::Permissions::from_mode(0o700))?;
2403            let configured = data_root
2404                .to_str()
2405                .ok_or("temporary data path was not UTF-8")?;
2406
2407            let Err(error) = super::build_haematite_store(configured, 4, None) else {
2408                return Err(format!("mode {mode:04o} ancestor was accepted").into());
2409            };
2410            let message = error.to_string();
2411            let crate::ServerError::UnsafeDataRootAncestor {
2412                data_root: resolved_root,
2413                component,
2414                reason,
2415            } = error
2416            else {
2417                return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2418            };
2419            assert_eq!(resolved_root, std::fs::canonicalize(&data_root)?);
2420            assert_eq!(component, std::fs::canonicalize(&shared)?);
2421            assert!(
2422                reason.contains(&format!("mode {mode:04o}")),
2423                "unexpected reason: {reason}"
2424            );
2425            if mode & 0o1000 != 0 {
2426                assert!(reason.contains("sticky bit is not accepted"));
2427            }
2428            assert!(message.contains("private Aion home"));
2429            assert!(
2430                !data_root.join("config.json").exists(),
2431                "Haematite touched its ambient path before the refusal"
2432            );
2433        }
2434        Ok(())
2435    }
2436
2437    #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2438    #[test]
2439    fn path_ambient_haematite_refuses_mutating_allow_acl_ancestor()
2440    -> Result<(), Box<dyn std::error::Error>> {
2441        use std::os::unix::fs::PermissionsExt as _;
2442
2443        let sandbox = crate::test_support::private_tempdir()?;
2444        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2445        let shared = sandbox.path().join("acl-shared");
2446        let data_root = shared.join("data");
2447        std::fs::create_dir(&shared)?;
2448        std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2449        let acl = "everyone allow list,search,add_file,add_subdirectory,delete_child";
2450        let status = std::process::Command::new("chmod")
2451            .arg("+a")
2452            .arg(acl)
2453            .arg(&shared)
2454            .status()?;
2455        assert!(status.success(), "failed to install Darwin regression ACL");
2456        let configured = data_root
2457            .to_str()
2458            .ok_or("temporary data path was not UTF-8")?;
2459
2460        let result = super::build_haematite_store(configured, 4, None);
2461        let cleanup = std::process::Command::new("chmod")
2462            .arg("-RN")
2463            .arg(&shared)
2464            .status()?;
2465        assert!(cleanup.success(), "failed to clean Darwin regression ACL");
2466
2467        let Err(error) = result else {
2468            return Err("mutating non-euid allow ACL ancestor was accepted".into());
2469        };
2470        let message = error.to_string();
2471        let crate::ServerError::UnsafeDataRootAncestor {
2472            component, reason, ..
2473        } = error
2474        else {
2475            return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2476        };
2477        assert_eq!(component, std::fs::canonicalize(&shared)?);
2478        assert!(
2479            reason.contains("allow"),
2480            "reason did not name the ACE: {reason}"
2481        );
2482        assert!(
2483            reason.contains("everyone"),
2484            "reason did not name the ACE principal: {reason}"
2485        );
2486        assert!(
2487            !data_root.join("config.json").exists(),
2488            "Haematite touched its ambient path before the ACL refusal"
2489        );
2490        Ok(())
2491    }
2492
2493    #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2494    #[test]
2495    fn path_ambient_haematite_accepts_the_euid_uuid_allow_ace()
2496    -> Result<(), Box<dyn std::error::Error>> {
2497        use std::os::unix::fs::PermissionsExt as _;
2498
2499        use exacl::{AclEntry, AclOption, Perm};
2500
2501        let sandbox = crate::test_support::private_tempdir()?;
2502        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2503        let private_parent = sandbox.path().join("euid-uuid-allow");
2504        let data_root = private_parent.join("data");
2505        std::fs::create_dir(&private_parent)?;
2506        std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2507
2508        let server_uid = rustix::process::geteuid().as_raw();
2509        let ace_qualifier = crate::filesystem::darwin_user_uuid_for_test(server_uid)?;
2510        let entry = AclEntry::allow_user(
2511            &ace_qualifier.to_string(),
2512            Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2513            None,
2514        );
2515        exacl::setfacl(
2516            &[private_parent.as_path()],
2517            &[entry],
2518            AclOption::SYMLINK_ACL,
2519        )?;
2520        let configured = data_root
2521            .to_str()
2522            .ok_or("temporary data path was not UTF-8")?;
2523
2524        let result = super::build_haematite_store(configured, 4, None);
2525        let cleanup = std::process::Command::new("chmod")
2526            .arg("-RN")
2527            .arg(&private_parent)
2528            .status()?;
2529        assert!(cleanup.success(), "failed to clean euid UUID allow ACL");
2530
2531        let (store, responder) = result?;
2532        assert!(responder.is_none());
2533        assert!(data_root.join("config.json").is_file());
2534        drop(store);
2535        Ok(())
2536    }
2537
2538    #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2539    #[test]
2540    fn path_ambient_haematite_refuses_a_non_euid_user_uuid_allow_ace()
2541    -> Result<(), Box<dyn std::error::Error>> {
2542        use std::os::unix::fs::PermissionsExt as _;
2543
2544        use exacl::{AclEntry, AclOption, Perm};
2545
2546        let sandbox = crate::test_support::private_tempdir()?;
2547        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2548        let shared = sandbox.path().join("non-euid-uuid-allow");
2549        let data_root = shared.join("data");
2550        std::fs::create_dir(&shared)?;
2551        std::fs::set_permissions(&shared, std::fs::Permissions::from_mode(0o700))?;
2552
2553        let server_uid = rustix::process::geteuid().as_raw();
2554        let foreign_uid = u32::from(server_uid == 0);
2555        let foreign_qualifier = crate::filesystem::darwin_user_uuid_for_test(foreign_uid)?;
2556        let entry = AclEntry::allow_user(
2557            &foreign_qualifier.to_string(),
2558            Perm::EXECUTE | Perm::WRITE | Perm::APPEND | Perm::DELETE_CHILD,
2559            None,
2560        );
2561        exacl::setfacl(&[shared.as_path()], &[entry], AclOption::SYMLINK_ACL)?;
2562        let configured = data_root
2563            .to_str()
2564            .ok_or("temporary data path was not UTF-8")?;
2565
2566        let result = super::build_haematite_store(configured, 4, None);
2567        let cleanup = std::process::Command::new("chmod")
2568            .arg("-RN")
2569            .arg(&shared)
2570            .status()?;
2571        assert!(cleanup.success(), "failed to clean non-euid UUID allow ACL");
2572
2573        let Err(error) = result else {
2574            return Err("mutating non-euid user UUID allow ACE was accepted".into());
2575        };
2576        let message = error.to_string();
2577        let crate::ServerError::UnsafeDataRootAncestor {
2578            component, reason, ..
2579        } = error
2580        else {
2581            return Err(format!("expected typed unsafe-ancestor error, got {message}").into());
2582        };
2583        assert_eq!(component, std::fs::canonicalize(&shared)?);
2584        assert!(
2585            reason.contains("allow") && reason.contains(&format!("server euid {server_uid}")),
2586            "reason did not name the rejected ACE: {reason}"
2587        );
2588        assert!(
2589            !data_root.join("config.json").exists(),
2590            "Haematite touched its ambient path before the UUID ACL refusal"
2591        );
2592        Ok(())
2593    }
2594
2595    #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2596    #[test]
2597    fn path_ambient_haematite_accepts_a_deny_only_acl_ancestor()
2598    -> Result<(), Box<dyn std::error::Error>> {
2599        use std::os::unix::fs::PermissionsExt as _;
2600
2601        let sandbox = crate::test_support::private_tempdir()?;
2602        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2603        let private_parent = sandbox.path().join("deny-only");
2604        let data_root = private_parent.join("data");
2605        std::fs::create_dir(&private_parent)?;
2606        std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2607        let status = std::process::Command::new("chmod")
2608            .arg("+a")
2609            .arg("everyone deny delete")
2610            .arg(&private_parent)
2611            .status()?;
2612        assert!(status.success(), "failed to install Darwin deny-only ACL");
2613        let configured = data_root
2614            .to_str()
2615            .ok_or("temporary data path was not UTF-8")?;
2616
2617        let result = super::build_haematite_store(configured, 4, None);
2618        let cleanup = std::process::Command::new("chmod")
2619            .arg("-RN")
2620            .arg(&private_parent)
2621            .status()?;
2622        assert!(cleanup.success(), "failed to clean Darwin deny-only ACL");
2623
2624        let (store, responder) = result?;
2625        assert!(responder.is_none());
2626        assert!(data_root.join("config.json").is_file());
2627        drop(store);
2628        Ok(())
2629    }
2630
2631    #[cfg(all(feature = "haematite-backend", target_os = "macos"))]
2632    #[test]
2633    fn path_ambient_haematite_accepts_the_stock_home_acl_chain()
2634    -> Result<(), Box<dyn std::error::Error>> {
2635        use std::os::unix::fs::PermissionsExt as _;
2636        use users::os::unix::UserExt as _;
2637
2638        let effective_uid = rustix::process::geteuid().as_raw();
2639        let effective_user = users::get_user_by_uid(effective_uid)
2640            .ok_or_else(|| format!("server euid {effective_uid} has no account record"))?;
2641        let sandbox = tempfile::Builder::new()
2642            .prefix(".aion-acl-home-proof-")
2643            .tempdir_in(effective_user.home_dir())?;
2644        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2645        let data_root = sandbox.path().join("data");
2646        let configured = data_root
2647            .to_str()
2648            .ok_or("temporary data path was not UTF-8")?;
2649
2650        let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2651        assert!(responder.is_none());
2652        assert!(data_root.join("config.json").is_file());
2653        drop(store);
2654        Ok(())
2655    }
2656
2657    #[cfg(all(
2658        feature = "haematite-backend",
2659        unix,
2660        not(any(target_os = "linux", target_os = "android"))
2661    ))]
2662    #[test]
2663    fn path_ambient_haematite_accepts_an_owner_controlled_chain()
2664    -> Result<(), Box<dyn std::error::Error>> {
2665        use std::os::unix::fs::PermissionsExt as _;
2666
2667        let sandbox = crate::test_support::private_tempdir()?;
2668        std::fs::set_permissions(sandbox.path(), std::fs::Permissions::from_mode(0o700))?;
2669        let private_parent = sandbox.path().join("private");
2670        let data_root = private_parent.join("data");
2671        std::fs::create_dir(&private_parent)?;
2672        std::fs::set_permissions(&private_parent, std::fs::Permissions::from_mode(0o700))?;
2673        let configured = data_root
2674            .to_str()
2675            .ok_or("temporary data path was not UTF-8")?;
2676
2677        let (store, responder) = super::build_haematite_store(configured, 4, None)?;
2678        assert!(responder.is_none());
2679        assert!(data_root.join("config.json").is_file());
2680        for shard in 0..4 {
2681            assert!(data_root.join(format!("shard-{shard}")).is_dir());
2682        }
2683        drop(store);
2684        Ok(())
2685    }
2686
2687    #[tokio::test]
2688    async fn connect_store_memory_backend_exposes_no_outbox_store()
2689    -> Result<(), Box<dyn std::error::Error>> {
2690        use crate::config::{StoreBackend, StoreConfig};
2691
2692        // Memory backend: no durable outbox table, so no outbox store handle —
2693        // and `outbox.enabled` over memory is rejected at dispatcher commission.
2694        let connected = super::connect_store(StoreConfig {
2695            backend: StoreBackend::Memory,
2696            url: None,
2697            owned_shards: Vec::new(),
2698            data_dir: None,
2699            shard_count: 1,
2700            cluster: None,
2701        })
2702        .await?;
2703        assert!(
2704            connected.outbox_store.is_none(),
2705            "the in-memory backend exposes no outbox store"
2706        );
2707        Ok(())
2708    }
2709
2710    // The libSQL connect path is now an opt-in backend (`libsql-backend`), so this
2711    // libSQL-specific outbox-sharing assertion compiles and runs only under that
2712    // feature. The memory case is covered above, unconditionally.
2713    #[cfg(feature = "libsql-backend")]
2714    #[tokio::test]
2715    async fn connect_store_shares_outbox_store_only_for_libsql()
2716    -> Result<(), Box<dyn std::error::Error>> {
2717        use crate::config::{StoreBackend, StoreConfig};
2718
2719        // LibSql backend: the leaf Arc<LibSqlStore> is shared as BOTH the engine's
2720        // EventStore and the dispatcher's OutboxStore (one libsql::Connection), so
2721        // the dispatcher reuses the engine's connection rather than opening a
2722        // second contending one (the inc-8 contention fix).
2723        let path = std::env::temp_dir().join(format!(
2724            "aion-connect-store-{}-{}.db",
2725            std::process::id(),
2726            std::time::SystemTime::now()
2727                .duration_since(std::time::UNIX_EPOCH)
2728                .map(|elapsed| elapsed.as_nanos())
2729                .unwrap_or_default()
2730        ));
2731        let connected = super::connect_store(StoreConfig {
2732            backend: StoreBackend::LibSql,
2733            url: Some(path.to_string_lossy().into_owned()),
2734            owned_shards: Vec::new(),
2735            data_dir: None,
2736            shard_count: 1,
2737            cluster: None,
2738        })
2739        .await?;
2740        assert!(
2741            connected.outbox_store.is_some(),
2742            "the libSQL backend shares its leaf store as the dispatcher's outbox store"
2743        );
2744        Ok(())
2745    }
2746
2747    #[tokio::test]
2748    async fn state_build_fails_without_event_broadcast_capacity()
2749    -> Result<(), Box<dyn std::error::Error>> {
2750        let mut runtime = runtime_config();
2751        runtime.websocket.event_broadcast_capacity = None;
2752
2753        let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
2754            .await
2755            .err()
2756            .ok_or("state build must fail when event streaming is unsized")?;
2757
2758        assert!(error.is_config(), "expected a config error, got {error}");
2759        assert!(
2760            error
2761                .to_string()
2762                .contains("websocket.event_broadcast_capacity"),
2763            "error must name the missing key: {error}"
2764        );
2765        Ok(())
2766    }
2767
2768    #[tokio::test]
2769    async fn state_build_fails_without_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
2770        let mut runtime = runtime_config();
2771        runtime.query_timeout = None;
2772
2773        let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
2774            .await
2775            .err()
2776            .ok_or("state build must fail when the query reply deadline is unset")?;
2777
2778        assert!(error.is_config(), "expected a config error, got {error}");
2779        assert!(
2780            error.to_string().contains("runtime.query_timeout_ms"),
2781            "error must name the missing key: {error}"
2782        );
2783        assert!(
2784            error.to_string().contains("AION_RUNTIME_QUERY_TIMEOUT_MS"),
2785            "error must name the environment override: {error}"
2786        );
2787        Ok(())
2788    }
2789
2790    #[tokio::test]
2791    async fn state_build_fails_with_zero_query_timeout() -> Result<(), Box<dyn std::error::Error>> {
2792        let mut runtime = runtime_config();
2793        runtime.query_timeout = Some(Duration::ZERO);
2794
2795        let error = ServerState::build_with_store(InMemoryStore::default(), runtime)
2796            .await
2797            .err()
2798            .ok_or("state build must fail when the query reply deadline is zero")?;
2799
2800        assert!(error.is_config(), "expected a config error, got {error}");
2801        assert!(
2802            error.to_string().contains("runtime.query_timeout_ms"),
2803            "error must name the zero-valued key: {error}"
2804        );
2805        Ok(())
2806    }
2807}