Skip to main content

aion_server/
state.rs

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