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