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