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