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