Skip to main content

aion_server/
state.rs

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