Skip to main content

aion/engine/
delegated.rs

1//! signal/query/subscribe surface (AT/AD delegation)
2
3use aion_core::{Event, Payload, RunId, WorkflowId, current_lease_terminal, run_segment};
4use async_trait::async_trait;
5use futures::stream::{self, BoxStream};
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8
9use crate::{Engine, EngineError, SignalRouterError, WorkflowHandle};
10
11use super::api::workflow_not_found;
12
13/// Live-event subscription filter consumed by the AD/AT publisher seam.
14///
15/// The `run` field is part of the cross-cluster contract even though the
16/// current core [`Event`] envelope does not yet carry run metadata; publisher
17/// implementations that know run residency out-of-band can apply it there.
18#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
19pub struct EventFilter {
20    /// Match events for this workflow execution id.
21    pub workflow_id: Option<WorkflowId>,
22    /// Match events for this run id when the publisher has run metadata.
23    pub run: Option<RunId>,
24    /// Match events belonging to this event family.
25    pub family: Option<EventFamily>,
26}
27
28impl EventFilter {
29    /// Returns whether an event satisfies the constraints visible on [`Event`].
30    ///
31    /// Run filtering is intentionally not decided here because [`Event`] does
32    /// not currently include a [`RunId`]; the publisher seam applies that field
33    /// with its own metadata when available.
34    #[must_use]
35    pub fn matches(&self, event: &Event) -> bool {
36        self.workflow_id
37            .as_ref()
38            .is_none_or(|workflow_id| event.workflow_id() == workflow_id)
39            && self
40                .family
41                .is_none_or(|family| family == event_family(event))
42    }
43}
44
45/// Coarse event families for live subscription filtering.
46#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
47pub enum EventFamily {
48    /// Workflow lifecycle events.
49    Workflow,
50    /// Activity scheduling and completion events.
51    Activity,
52    /// Timer events owned by AT.
53    Timer,
54    /// Signal delivery events owned by AT/AD.
55    Signal,
56    /// Child-workflow lifecycle events.
57    ChildWorkflow,
58    /// Schedule lifecycle and trigger events.
59    Schedule,
60    /// Workloop cadence, iteration, retirement, and alarm events. The trigger
61    /// layer (Leg 3) subscribes here to arm on `InvariantUnconfirmed` causes.
62    Workloop,
63}
64
65/// AT-005/AT-006 signal-routing seam.
66///
67/// Implementations record the signal through the workflow recorder and deliver
68/// it to the target workflow mailbox. The engine only resolves the live target
69/// handle and delegates to this trait.
70#[async_trait]
71pub trait SignalRouter: Send + Sync {
72    /// Route a signal to the already-resolved workflow target.
73    async fn route(
74        &self,
75        target: &WorkflowHandle,
76        name: String,
77        payload: Payload,
78    ) -> Result<(), EngineError>;
79}
80
81/// AT-007 query-dispatch seam.
82///
83/// Implementations dispatch a read-only query to workflow code and map their
84/// query errors into [`EngineError`]. The engine only resolves the live target
85/// handle and delegates to this trait.
86#[async_trait]
87pub trait QueryService: Send + Sync {
88    /// Dispatch a named query, with its caller-supplied arguments, to the
89    /// already-resolved workflow target.
90    ///
91    /// `arguments` is a type-erased [`Payload`] carrying the JSON document the
92    /// workflow's query handler decodes. `null` is the canonical "no
93    /// arguments" document. Arguments are read-only inputs: they are handed to
94    /// the handler and never recorded.
95    async fn query(
96        &self,
97        target: &WorkflowHandle,
98        name: String,
99        arguments: Payload,
100    ) -> Result<Payload, EngineError>;
101}
102
103/// A live subscription fell behind the publisher and skipped events.
104///
105/// Publishers yield this as a stream item — never a silent skip and never a
106/// silent stream end — and then continue with subsequent live events, so the
107/// consumer always learns exactly how many events it missed.
108#[derive(thiserror::Error, Clone, Copy, Debug, PartialEq, Eq)]
109#[error("event subscription lagged behind the live stream and skipped {skipped} events")]
110pub struct EventStreamLagged {
111    /// Number of events the subscriber missed.
112    pub skipped: u64,
113}
114
115/// AD/AT live event publisher seam.
116///
117/// Implementations own event publication and filtering. The engine exposes the
118/// in-process subscription surface without owning publication machinery.
119pub trait EventPublisher: Send + Sync {
120    /// Subscribe to a filtered stream of live workflow events.
121    ///
122    /// A subscriber that falls behind the publisher receives one
123    /// `Err(`[`EventStreamLagged`]`)` item carrying the skipped count and then
124    /// continues with subsequent events; lag never silently drops events and
125    /// never silently ends the stream.
126    fn subscribe(
127        &self,
128        filter: EventFilter,
129    ) -> BoxStream<'static, Result<Event, EventStreamLagged>>;
130}
131
132/// Object-safe delegated seams held by the engine for AT/AD integration.
133#[derive(Clone)]
134pub struct DelegatedSeams {
135    signal_router: Arc<dyn SignalRouter>,
136    query_service: Arc<dyn QueryService>,
137    event_publisher: Arc<dyn EventPublisher>,
138}
139
140impl DelegatedSeams {
141    /// Build a seam bundle from concrete AT/AD implementations.
142    #[must_use]
143    pub const fn new(
144        signal_router: Arc<dyn SignalRouter>,
145        query_service: Arc<dyn QueryService>,
146        event_publisher: Arc<dyn EventPublisher>,
147    ) -> Self {
148        Self {
149            signal_router,
150            query_service,
151            event_publisher,
152        }
153    }
154
155    /// Signal routing seam installed for AT-005/AT-006 delegation.
156    #[must_use]
157    pub fn signal_router(&self) -> &dyn SignalRouter {
158        self.signal_router.as_ref()
159    }
160
161    /// Query dispatch seam installed for AT-007 delegation.
162    #[must_use]
163    pub fn query_service(&self) -> &dyn QueryService {
164        self.query_service.as_ref()
165    }
166
167    /// Live event publisher seam installed for AD/AT delegation.
168    #[must_use]
169    pub fn event_publisher(&self) -> &dyn EventPublisher {
170        self.event_publisher.as_ref()
171    }
172
173    pub(crate) fn signal_router_arc(&self) -> Arc<dyn SignalRouter> {
174        Arc::clone(&self.signal_router)
175    }
176
177    pub(crate) fn query_service_arc(&self) -> Arc<dyn QueryService> {
178        Arc::clone(&self.query_service)
179    }
180
181    pub(crate) fn event_publisher_arc(&self) -> Arc<dyn EventPublisher> {
182        Arc::clone(&self.event_publisher)
183    }
184}
185
186impl Default for DelegatedSeams {
187    fn default() -> Self {
188        Self::new(
189            Arc::new(DeferredSignalRouter),
190            Arc::new(DeferredQueryService),
191            Arc::new(DeferredEventPublisher),
192        )
193    }
194}
195
196/// Deferred signal seam used until AT-005/AT-006 installs a concrete router.
197#[derive(Debug, Default)]
198pub struct DeferredSignalRouter;
199
200#[async_trait]
201impl SignalRouter for DeferredSignalRouter {
202    async fn route(
203        &self,
204        target: &WorkflowHandle,
205        name: String,
206        payload: Payload,
207    ) -> Result<(), EngineError> {
208        let _ = (target, name, payload);
209        Err(EngineError::Runtime {
210            reason: "signal routing seam is not configured".to_owned(),
211        })
212    }
213}
214
215/// Deferred query seam used until AT-007 installs a concrete service.
216#[derive(Debug, Default)]
217pub struct DeferredQueryService;
218
219#[async_trait]
220impl QueryService for DeferredQueryService {
221    async fn query(
222        &self,
223        target: &WorkflowHandle,
224        name: String,
225        arguments: Payload,
226    ) -> Result<Payload, EngineError> {
227        let _ = (target, name, arguments);
228        Err(EngineError::Runtime {
229            reason: "query service seam is not configured".to_owned(),
230        })
231    }
232}
233
234/// Deferred publisher seam used until AD/AT installs a concrete publisher.
235#[derive(Debug, Default)]
236pub struct DeferredEventPublisher;
237
238impl EventPublisher for DeferredEventPublisher {
239    fn subscribe(
240        &self,
241        filter: EventFilter,
242    ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
243        let _ = filter;
244        Box::pin(stream::empty())
245    }
246}
247
248impl Engine {
249    /// Send a signal to a live workflow run through the AT routing seam.
250    ///
251    /// The signal is admitted against the signal type declared by the EXACT
252    /// package identity the target run is pinned to, BEFORE anything is
253    /// recorded and before the arrival can be consumed. A refused signal
254    /// therefore leaves the run's history byte-identical and the run parked on
255    /// exactly the wait it was parked on — the refusal reaches the caller, not
256    /// the run.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`EngineError::SignalRefused`] when the name is not declared by
261    /// the target's package or the payload does not satisfy the declared type
262    /// (nothing recorded, nothing consumed),
263    /// [`EngineError::WorkflowNotFound`] when the `(workflow, run)` pair is unknown,
264    /// [`SignalRouterError::Terminal`] when it is durably terminal, or other typed errors from
265    /// the configured signal seam.
266    pub async fn signal(
267        &self,
268        id: &WorkflowId,
269        run: &RunId,
270        name: impl Into<String>,
271        payload: Payload,
272    ) -> Result<(), EngineError> {
273        let name = name.into();
274        let handle =
275            if let Some(handle) = self.registry().get(id, run)? {
276                // BOUNDARY ADMISSION (resident): the live handle carries the exact
277                // identity this run resolved at start, so it is the ruler here.
278                crate::signal::admission::admit_against_handle(
279                    self.workflow_catalog(),
280                    &handle,
281                    &name,
282                    &payload,
283                )?;
284                handle
285            } else {
286                let history = self.store().read_history(id).await?;
287                if run_has_terminal_history(&history, run) {
288                    return Err(SignalRouterError::Terminal {
289                        workflow_id: id.clone(),
290                        run_id: run.clone(),
291                    }
292                    .into());
293                }
294                // BOUNDARY ADMISSION (non-resident): the recorded `WorkflowStarted`
295                // pins the same identity the live handle would have carried. This
296                // runs before BOTH remaining record paths below — the paused
297                // one-shot recorder and the birth-window handle's router — so no
298                // branch out of here can record a refused payload.
299                crate::signal::admission::admit_against_history(
300                    self.workflow_catalog(),
301                    id,
302                    run,
303                    &history,
304                    &name,
305                    &payload,
306                )?;
307                // Paused-but-not-resident (crashed while paused, #204): the run is
308                // durably non-terminal but was deliberately excluded from respawn, so
309                // no live handle exists and handle_after_birth_window would time out
310                // with WorkflowNotFound. Durably record SignalReceived through a
311                // one-shot recorder built from the validated read (record-before-
312                // deliver preserved); it takes effect on resume replay.
313                let segment = aion_core::run_segment(&history, run);
314                if matches!(
315                    aion_core::status_from_events(segment),
316                    aion_core::WorkflowStatus::Paused
317                ) {
318                    let head = history.last().map(Event::seq).unwrap_or_default();
319                    let mut recorder =
320                        crate::durability::Recorder::resume_at(id.clone(), self.store(), head)
321                            .with_visibility(run.clone(), self.visibility_store());
322                    recorder
323                        .record_signal_received(chrono::Utc::now(), name, payload)
324                        .await?;
325                    return Ok(());
326                }
327                // A suspended WORKLOOP (registered on the cadence service, no
328                // resident process — the R13.3 idle state): a signal is a FIRE
329                // (R2.4). Record SignalReceived through a one-shot recorder
330                // (record-before-deliver preserved), then wake the generation so
331                // the iteration runs with the signal in its history. Signals not
332                // named in the loop's arming still record — the loop observes
333                // them when it next runs — but only declared signals wake it.
334                if let Some(workloop) = &self.workloop
335                    && let Some(record) = workloop.store.get_workloop(id).await?
336                {
337                    let head = history.last().map(Event::seq).unwrap_or_default();
338                    let mut recorder =
339                        crate::durability::Recorder::resume_at(id.clone(), self.store(), head)
340                            .with_visibility(run.clone(), self.visibility_store());
341                    recorder
342                        .record_signal_received(chrono::Utc::now(), name.clone(), payload)
343                        .await?;
344                    if record.spec.arming().signals().contains(&name) {
345                        workloop.service.wake_now(id).await.map_err(|error| {
346                            EngineError::Runtime {
347                                reason: format!("signal-armed workloop wake failed: {error}"),
348                            }
349                        })?;
350                    }
351                    return Ok(());
352                }
353                self.handle_after_birth_window(id, run, &history)
354                    .await?
355                    .ok_or_else(|| workflow_not_found(id, run))?
356            };
357        self.delegated()
358            .signal_router()
359            .route(&handle, name, payload)
360            .await
361    }
362
363    /// Dispatch a read-only query, with its arguments, to a live workflow run
364    /// through the AT seam.
365    ///
366    /// `arguments` is the type-erased JSON document the workflow's registered
367    /// handler decodes; `null` is the canonical "no arguments" document.
368    /// Nothing on this path records an event — arguments are inputs to a
369    /// read-only handler, never history.
370    ///
371    /// # Errors
372    ///
373    /// Returns [`EngineError::Query`] with [`crate::query::QueryError::NotRunning`]
374    /// when the `(workflow, run)` pair is durably terminal — or durably started
375    /// with no live handle to answer from (a workloop's deferred successor
376    /// between iterations, a run whose engine is gone): the run EXISTS and is
377    /// not running, which is a different answer from unknown (#214).
378    /// [`EngineError::WorkflowNotFound`] when no history names the run,
379    /// [`crate::query::QueryError::InvalidArguments`] when `arguments` is not
380    /// a well-formed JSON document, and other typed errors from the configured
381    /// query seam.
382    pub async fn query(
383        &self,
384        id: &WorkflowId,
385        run: &RunId,
386        name: impl Into<String>,
387        arguments: Payload,
388    ) -> Result<Payload, EngineError> {
389        let handle = if let Some(handle) = self.registry().get(id, run)? {
390            handle
391        } else {
392            // Mirror Engine::signal's registry-miss handling: a completed
393            // workflow is NotRunning per the query contract, never NotFound.
394            let history = self.store().read_history(id).await?;
395            if run_has_terminal_history(&history, run) {
396                return Err(EngineError::Query(crate::query::QueryError::NotRunning(
397                    id.clone(),
398                )));
399            }
400            match self.handle_after_birth_window(id, run, &history).await? {
401                Some(handle) => handle,
402                // No handle after the birth budget. Two different truths hide
403                // behind that `None`, and the query contract owes each its own
404                // answer: a run the history never started is UNKNOWN; a run the
405                // history did start, that is not terminal and still has no
406                // handle, EXISTS and is not running — a workloop's successor
407                // deferred until its next wake (#214), or a run whose engine is
408                // gone. Reporting the second as not-found told the operator a
409                // workloop they could see in the listing did not exist.
410                None if run_segment(&history, run).is_empty() => {
411                    return Err(workflow_not_found(id, run));
412                }
413                None => {
414                    return Err(EngineError::Query(crate::query::QueryError::NotRunning(
415                        id.clone(),
416                    )));
417                }
418            }
419        };
420        self.delegated()
421            .query_service()
422            .query(&handle, name.into(), arguments)
423            .await
424    }
425
426    /// Resolve a registry miss against the registration birth window.
427    ///
428    /// The start path records `WorkflowStarted` durably *before* it inserts
429    /// the registry handle, so an embedder acting on observed history (or on
430    /// a `start_workflow` racing on another task) can legitimately arrive
431    /// here after the record and before the insert. When the requested run
432    /// is durably started and non-terminal, the registry is re-polled within
433    /// the builder-supplied delivery policy budget; `None` after the budget
434    /// means the run truly has no live handle (its start failed or its
435    /// engine is gone) and the caller fails typed.
436    pub(crate) async fn handle_after_birth_window(
437        &self,
438        id: &WorkflowId,
439        run: &RunId,
440        history: &[Event],
441    ) -> Result<Option<WorkflowHandle>, EngineError> {
442        let started = history
443            .iter()
444            .any(|event| matches!(event, Event::WorkflowStarted { run_id, .. } if run_id == run));
445        if !started {
446            return Ok(None);
447        }
448        wait_for_registered_handle(self.registry(), id, run, self.runtime().signal_delivery()).await
449    }
450
451    /// Subscribe to the live event stream through the AD/AT publisher seam.
452    ///
453    /// A subscriber that falls behind receives one `Err(`[`EventStreamLagged`]`)`
454    /// item with the skipped count and then continues with subsequent events.
455    #[must_use]
456    pub fn subscribe(
457        &self,
458        filter: EventFilter,
459    ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
460        self.delegated().event_publisher().subscribe(filter)
461    }
462}
463
464pub(crate) fn run_has_terminal_history(history: &[Event], run: &RunId) -> bool {
465    // Reset-aware: a run is terminal only if its current lease ended in a
466    // terminal event. A WorkflowReopened after a terminal reopens the run, so a
467    // reopened run is not treated as terminal (it can receive signals and
468    // complete again).
469    current_lease_terminal(run_segment(history, run)).is_some()
470}
471
472/// Poll the registry for `(id, run)` until the handle appears or the
473/// builder-supplied delivery budget is spent.
474///
475/// The budget is the policy's full persistence — `ready_timeout ×
476/// max_enqueue_attempts`, the same product the runtime's enqueue retry
477/// expresses — polled with the policy's backoff ladder. A single
478/// `ready_timeout` is the typical insert latency, but the start thread can
479/// be preempted past it under host oversubscription, and the cost of giving
480/// up early is a typed not-found for a workflow that is durably started.
481pub(crate) async fn wait_for_registered_handle(
482    registry: &crate::registry::Registry,
483    id: &WorkflowId,
484    run: &RunId,
485    policy: crate::runtime::SignalDeliveryConfig,
486) -> Result<Option<WorkflowHandle>, EngineError> {
487    let budget = policy
488        .ready_timeout
489        .saturating_mul(policy.max_enqueue_attempts.max(1));
490    let deadline = std::time::Instant::now() + budget;
491    let mut backoff = policy.initial_backoff;
492    loop {
493        if let Some(handle) = registry.get(id, run)? {
494            return Ok(Some(handle));
495        }
496        if std::time::Instant::now() >= deadline {
497            return Ok(None);
498        }
499        tokio::time::sleep(backoff).await;
500        let doubled = backoff.saturating_mul(2);
501        backoff = if doubled > policy.max_backoff {
502            policy.max_backoff
503        } else {
504            doubled
505        };
506    }
507}
508
509const fn event_family(event: &Event) -> EventFamily {
510    match event {
511        Event::WorkflowStarted { .. }
512        | Event::WorkflowCompleted { .. }
513        | Event::WorkflowFailed { .. }
514        | Event::WorkflowCancelled { .. }
515        | Event::WorkflowTimedOut { .. }
516        | Event::WorkflowContinuedAsNew { .. }
517        | Event::WorkflowReopened { .. }
518        | Event::WorkflowPaused { .. }
519        | Event::WorkflowResumed { .. }
520        | Event::SearchAttributesUpdated { .. }
521        // A hatch is a workflow-level start record — detached, not a child —
522        // so it rides the Workflow family, never ChildWorkflow.
523        | Event::WorkflowHatched { .. } => EventFamily::Workflow,
524        Event::ActivityScheduled { .. }
525        | Event::ActivityStarted { .. }
526        | Event::ActivityLeased { .. }
527        | Event::ActivityAdoptionOffered { .. }
528        | Event::ActivityCompleted { .. }
529        | Event::ActivityFailed { .. }
530        | Event::ActivityAdvisoryExhausted { .. }
531        | Event::ActivityFallbackRouted { .. }
532        | Event::ActivityCancelled { .. } => EventFamily::Activity,
533        Event::TimerStarted { .. }
534        | Event::TimerFired { .. }
535        | Event::TimerCancelled { .. }
536        | Event::WithTimeoutCompleted { .. } => EventFamily::Timer,
537        Event::SignalReceived { .. } | Event::SignalSent { .. } => EventFamily::Signal,
538        Event::ChildWorkflowStarted { .. }
539        | Event::ChildWorkflowCompleted { .. }
540        | Event::ChildWorkflowFailed { .. }
541        | Event::ChildWorkflowCancelled { .. } => EventFamily::ChildWorkflow,
542        Event::ScheduleCreated { .. }
543        | Event::ScheduleUpdated { .. }
544        | Event::SchedulePaused { .. }
545        | Event::ScheduleResumed { .. }
546        | Event::ScheduleDeleted { .. }
547        | Event::ScheduleTriggered { .. } => EventFamily::Schedule,
548        Event::CadenceFired { .. }
549        | Event::IterationClosed { .. }
550        | Event::LoopRetired { .. }
551        | Event::InvariantUnconfirmed { .. } => EventFamily::Workloop,
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use std::sync::{Arc, Mutex};
558    use std::task::{Context, Waker};
559
560    use aion_core::{EventEnvelope, WorkflowStatus};
561    use aion_package::ContentHash;
562    use aion_store::visibility::VisibilityStore;
563    use aion_store::{EventStore, InMemoryStore};
564    use futures::{StreamExt, stream};
565    use serde_json::json;
566
567    use crate::durability::Recorder;
568    use crate::engine::api::EngineComponents;
569    use crate::registry::{CompletionNotifier, HandleResidency, WorkflowHandleParts};
570    use crate::{
571        Registry, RuntimeConfig, RuntimeHandle, SupervisionTree, WorkflowCatalog, WorkflowHandle,
572    };
573
574    use super::*;
575
576    #[derive(Debug, Default)]
577    struct SignalCapture {
578        calls: Mutex<Vec<(u64, String, Payload)>>,
579    }
580
581    #[async_trait]
582    impl SignalRouter for SignalCapture {
583        async fn route(
584            &self,
585            target: &WorkflowHandle,
586            name: String,
587            payload: Payload,
588        ) -> Result<(), EngineError> {
589            self.calls
590                .lock()
591                .map_err(|_| EngineError::RegistryPoisoned)?
592                .push((target.pid(), name, payload));
593            Ok(())
594        }
595    }
596
597    #[derive(Debug)]
598    struct QueryCapture {
599        calls: Mutex<Vec<(u64, String, Payload)>>,
600        reply: Payload,
601    }
602
603    #[async_trait]
604    impl QueryService for QueryCapture {
605        async fn query(
606            &self,
607            target: &WorkflowHandle,
608            name: String,
609            arguments: Payload,
610        ) -> Result<Payload, EngineError> {
611            self.calls
612                .lock()
613                .map_err(|_| EngineError::RegistryPoisoned)?
614                .push((target.pid(), name, arguments));
615            Ok(self.reply.clone())
616        }
617    }
618
619    #[derive(Debug)]
620    struct FakePublisher {
621        events: Vec<Event>,
622    }
623
624    impl EventPublisher for FakePublisher {
625        fn subscribe(
626            &self,
627            filter: EventFilter,
628        ) -> BoxStream<'static, Result<Event, EventStreamLagged>> {
629            let events = self
630                .events
631                .iter()
632                .filter(|event| filter.matches(event))
633                .cloned()
634                .map(Ok)
635                .collect::<Vec<_>>();
636            stream::iter(events).boxed()
637        }
638    }
639
640    fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
641        Payload::from_json(&json!({ "label": label }))
642    }
643
644    fn engine_with_seams(
645        signal_router: Arc<dyn SignalRouter>,
646        query_service: Arc<dyn QueryService>,
647        event_publisher: Arc<dyn EventPublisher>,
648    ) -> Result<Engine, EngineError> {
649        let backing = Arc::new(InMemoryStore::default());
650        let store: Arc<dyn EventStore> = Arc::clone(&backing) as _;
651        let visibility_store: Arc<dyn VisibilityStore> = backing;
652        Ok(Engine::new(EngineComponents {
653            store,
654            visibility_store,
655            runtime: Arc::new(RuntimeHandle::new(RuntimeConfig::new(
656                Some(1),
657                crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
658            ))?),
659            catalog: Arc::new(WorkflowCatalog::new()),
660            registry: Arc::new(Registry::default()),
661            supervision: Arc::new(SupervisionTree::new()),
662            delegated: DelegatedSeams::new(signal_router, query_service, event_publisher),
663            signal_handoff: Arc::new(crate::signal::SignalResumeHandoff::new()),
664            search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
665            visibility_reconciliation_task: None,
666            deferred_startup_recovery: None,
667            workloop: None,
668        }))
669    }
670
671    /// Record `WorkflowStarted` durably and build the matching handle
672    /// without inserting it into the registry — the exact state of the
673    /// registration birth window.
674    async fn recorded_active_handle(
675        engine: &Engine,
676    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
677        let workflow_id = WorkflowId::new_v4();
678        let run_id = RunId::new_v4();
679        let store = engine.store();
680        let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
681        recorder
682            .record_workflow_started(
683                chrono::Utc::now(),
684                crate::durability::WorkflowStartRecord {
685                    workflow_type: "checkout".to_owned(),
686                    input: payload("input")?,
687                    run_id: run_id.clone(),
688                    parent_run_id: None,
689                    parent_workflow_id: None,
690                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
691                },
692            )
693            .await?;
694        Ok(WorkflowHandle::new(WorkflowHandleParts {
695            workflow_id,
696            run_id,
697            pid: engine.runtime().spawn_test_process_with_trap_exit(true)?,
698            workflow_type: "checkout".to_owned(),
699            namespace: String::from("default"),
700            loaded_version: ContentHash::from_bytes([1; 32]),
701            cached_status: WorkflowStatus::Running,
702            residency: HandleResidency::Resident,
703            recorder,
704            completion: CompletionNotifier::new(),
705        }))
706    }
707
708    async fn insert_active_handle(
709        engine: &Engine,
710    ) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
711        let handle = recorded_active_handle(engine).await?;
712        engine.registry().insert(
713            (handle.workflow_id().clone(), handle.run_id().clone()),
714            handle.clone(),
715        )?;
716        Ok(handle)
717    }
718
719    fn envelope(seq: u64, workflow_id: &WorkflowId) -> EventEnvelope {
720        EventEnvelope {
721            seq,
722            recorded_at: chrono::Utc::now(),
723            workflow_id: workflow_id.clone(),
724        }
725    }
726
727    /// Registration birth window (the 1/300 release-signal flake): the start
728    /// path records `WorkflowStarted` durably before it inserts the registry
729    /// handle, so a caller acting on observed history can signal before the
730    /// insert lands. The signal must wait the handle out within the delivery
731    /// policy budget — before the fix it returned `WorkflowNotFound`
732    /// immediately.
733    #[tokio::test(flavor = "multi_thread")]
734    async fn signal_inside_the_registration_birth_window_waits_for_the_handle()
735    -> Result<(), Box<dyn std::error::Error>> {
736        let signal = Arc::new(SignalCapture::default());
737        let engine = Arc::new(engine_with_seams(
738            signal.clone(),
739            Arc::new(DeferredQueryService),
740            Arc::new(DeferredEventPublisher),
741        )?);
742        let handle = recorded_active_handle(&engine).await?;
743
744        // The insert lands mid-wait, exactly as the start thread's does. The
745        // test drives the order itself: the signal is polled once, which runs
746        // it through its first registry miss and parks it on the birth-window
747        // wait, and only then is the handle inserted — no timer and no second
748        // task, so host scheduling cannot land the insert after the budget
749        // (the 205 battery of 2026-09-03 starved a 15 ms inserter past it).
750        // Before the fix this first poll was `Ready(Err(WorkflowNotFound))`.
751        let signal_call = engine.signal(
752            handle.workflow_id(),
753            handle.run_id(),
754            "approve",
755            payload("birth")?,
756        );
757        let mut signal_call = std::pin::pin!(signal_call);
758        let mut probe = Context::from_waker(Waker::noop());
759        assert!(
760            signal_call.as_mut().poll(&mut probe).is_pending(),
761            "the signal must wait the handle out, not answer before the insert"
762        );
763        engine.registry().insert(
764            (handle.workflow_id().clone(), handle.run_id().clone()),
765            handle.clone(),
766        )?;
767        signal_call.await?;
768
769        let calls = signal
770            .calls
771            .lock()
772            .map_err(|_| EngineError::RegistryPoisoned)?;
773        assert_eq!(calls.len(), 1, "the signal must reach the routed handle");
774        drop(calls);
775        engine.shutdown()?;
776        Ok(())
777    }
778
779    /// The birth wait is bounded: a durably started run whose handle never
780    /// appears (its start failed, or its engine is gone) still fails typed
781    /// after the policy budget.
782    #[tokio::test(flavor = "multi_thread")]
783    async fn signal_for_a_started_run_with_no_handle_fails_typed_after_the_budget()
784    -> Result<(), Box<dyn std::error::Error>> {
785        let engine = engine_with_seams(
786            Arc::new(SignalCapture::default()),
787            Arc::new(DeferredQueryService),
788            Arc::new(DeferredEventPublisher),
789        )?;
790        let handle = recorded_active_handle(&engine).await?;
791
792        let outcome = engine
793            .signal(
794                handle.workflow_id(),
795                handle.run_id(),
796                "approve",
797                payload("never")?,
798            )
799            .await;
800
801        assert!(matches!(outcome, Err(EngineError::WorkflowNotFound { .. })));
802        engine.shutdown()?;
803        Ok(())
804    }
805
806    #[tokio::test]
807    async fn signal_delegates_to_router_and_unknown_returns_not_found()
808    -> Result<(), Box<dyn std::error::Error>> {
809        let signal = Arc::new(SignalCapture::default());
810        let engine = engine_with_seams(
811            signal.clone(),
812            Arc::new(DeferredQueryService),
813            Arc::new(DeferredEventPublisher),
814        )?;
815        let handle = insert_active_handle(&engine).await?;
816        let sent_payload = payload("signal")?;
817
818        engine
819            .signal(
820                handle.workflow_id(),
821                handle.run_id(),
822                "approve",
823                sent_payload.clone(),
824            )
825            .await?;
826
827        {
828            let calls = signal
829                .calls
830                .lock()
831                .map_err(|_| EngineError::RegistryPoisoned)?;
832            assert_eq!(
833                calls.as_slice(),
834                &[(handle.pid(), "approve".to_owned(), sent_payload)]
835            );
836        }
837        let unknown = engine
838            .signal(
839                &WorkflowId::new_v4(),
840                &RunId::new_v4(),
841                "approve",
842                payload("unknown")?,
843            )
844            .await;
845        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
846        engine.shutdown()?;
847        Ok(())
848    }
849
850    #[tokio::test]
851    async fn query_delegates_to_service_and_returns_payload()
852    -> Result<(), Box<dyn std::error::Error>> {
853        let reply = payload("reply")?;
854        let query = Arc::new(QueryCapture {
855            calls: Mutex::new(Vec::new()),
856            reply: reply.clone(),
857        });
858        let engine = engine_with_seams(
859            Arc::new(DeferredSignalRouter),
860            query.clone(),
861            Arc::new(DeferredEventPublisher),
862        )?;
863        let handle = insert_active_handle(&engine).await?;
864
865        let arguments = payload("arguments")?;
866        let returned = engine
867            .query(
868                handle.workflow_id(),
869                handle.run_id(),
870                "state",
871                arguments.clone(),
872            )
873            .await?;
874
875        assert_eq!(returned, reply);
876        let calls = query
877            .calls
878            .lock()
879            .map_err(|_| EngineError::RegistryPoisoned)?;
880        // The engine resolves the target and forwards the caller's arguments
881        // to the seam verbatim.
882        assert_eq!(
883            calls.as_slice(),
884            &[(handle.pid(), "state".to_owned(), arguments)]
885        );
886        drop(calls);
887        engine.shutdown()?;
888        Ok(())
889    }
890
891    #[tokio::test]
892    async fn query_terminal_run_is_not_running_and_unknown_is_not_found()
893    -> Result<(), Box<dyn std::error::Error>> {
894        let engine = engine_with_seams(
895            Arc::new(DeferredSignalRouter),
896            Arc::new(DeferredQueryService),
897            Arc::new(DeferredEventPublisher),
898        )?;
899        // Durably terminal run with no registry entry: a completed workflow.
900        let workflow_id = WorkflowId::new_v4();
901        let run_id = aion_core::RunId::new_v4();
902        let mut recorder = crate::durability::Recorder::new(workflow_id.clone(), engine.store());
903        recorder
904            .record_workflow_started(
905                chrono::Utc::now(),
906                crate::durability::WorkflowStartRecord {
907                    workflow_type: "checkout".to_owned(),
908                    input: payload("input")?,
909                    run_id: run_id.clone(),
910                    parent_run_id: None,
911                    parent_workflow_id: None,
912                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
913                },
914            )
915            .await?;
916        recorder
917            .record_workflow_completed(chrono::Utc::now(), payload("result")?)
918            .await?;
919
920        let terminal = engine
921            .query(&workflow_id, &run_id, "state", Payload::json_null())
922            .await;
923        assert!(matches!(
924            terminal,
925            Err(EngineError::Query(crate::query::QueryError::NotRunning(id))) if id == workflow_id
926        ));
927
928        let unknown = engine
929            .query(
930                &WorkflowId::new_v4(),
931                &RunId::new_v4(),
932                "state",
933                Payload::json_null(),
934            )
935            .await;
936        assert!(matches!(unknown, Err(EngineError::WorkflowNotFound { .. })));
937        engine.shutdown()?;
938        Ok(())
939    }
940
941    #[tokio::test]
942    async fn subscribe_delegates_to_publisher_stream_with_filter()
943    -> Result<(), Box<dyn std::error::Error>> {
944        let workflow_id = WorkflowId::new_v4();
945        let other_id = WorkflowId::new_v4();
946        let matching = Event::SignalReceived {
947            envelope: envelope(1, &workflow_id),
948            name: "approved".to_owned(),
949            payload: payload("signal")?,
950        };
951        let filtered = Event::WorkflowStarted {
952            envelope: envelope(1, &other_id),
953            workflow_type: "checkout".to_owned(),
954            input: payload("input")?,
955            run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
956            parent_run_id: None,
957            parent_workflow_id: None,
958            package_version: aion_core::PackageVersion::new("a".repeat(64)),
959        };
960        let engine = engine_with_seams(
961            Arc::new(DeferredSignalRouter),
962            Arc::new(DeferredQueryService),
963            Arc::new(FakePublisher {
964                events: vec![matching.clone(), filtered],
965            }),
966        )?;
967
968        let events = engine
969            .subscribe(EventFilter {
970                workflow_id: Some(workflow_id),
971                run: None,
972                family: Some(EventFamily::Signal),
973            })
974            .collect::<Vec<_>>()
975            .await;
976
977        assert_eq!(events, vec![Ok(matching)]);
978        engine.shutdown()?;
979        Ok(())
980    }
981}