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