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