Skip to main content

aion_server/
state.rs

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