Skip to main content

aion_server/
state.rs

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