Skip to main content

aion_server/
state.rs

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