Skip to main content

aion_client/transport/
embedded.rs

1//! Transport backed by an in-process [`aion::Engine`].
2//!
3//! Event subscriptions honour the same resume/replay-splice contract as the
4//! server's `/events/stream` endpoint, built directly on [`aion::Engine`]
5//! seams (`Engine::subscribe` for the live broadcast, `engine.store()` for
6//! the history snapshot — never a client-held stream over engine internals):
7//!
8//! 1. attach the live broadcast subscription FIRST (time T0);
9//! 2. snapshot recorded history via `engine.store().read_history` (T1 > T0);
10//! 3. validate the cursor against the snapshot head;
11//! 4. splice: replay `[resume_from_seq ..= head]` from the snapshot, then the
12//!    live tail filtered to `seq > head`.
13//!
14//! Gap-free: publish strictly follows durable commit, so every event with
15//! `seq > head` was committed — and therefore broadcast — after T0.
16//! Duplicate-free: the live filter drops every `seq <= head`, so an event
17//! present in both the snapshot and the broadcast is emitted exactly once,
18//! from the snapshot. Engine-side lag is never silent: each
19//! `Err(EventStreamLagged)` item surfaces as `Err(ClientError::Unavailable)`
20//! so the resume loop reconnects with its cursor.
21
22use std::sync::Arc;
23
24use aion_core::Event;
25use async_trait::async_trait;
26use futures::stream::BoxStream;
27use futures::{StreamExt, stream};
28
29use crate::error::ClientError;
30use crate::transport::contract::{SubscriptionAttempt, WorkflowTransport};
31
32/// Transport backed by an in-process [`aion::Engine`].
33pub struct EmbeddedWorkflowTransport {
34    engine: Arc<aion::Engine>,
35}
36
37impl EmbeddedWorkflowTransport {
38    /// Creates an embedded transport for `engine`.
39    #[must_use]
40    pub fn new(engine: Arc<aion::Engine>) -> Self {
41        Self { engine }
42    }
43
44    /// Resolve the target run id: the supplied one, or the latest run from the
45    /// workflow's run chain when omitted (mirrors the server's `resolve_run_id`).
46    async fn resolve_run_id(
47        &self,
48        workflow_id: &aion_core::WorkflowId,
49        run_id: Option<aion_proto::ProtoRunId>,
50    ) -> Result<aion_core::RunId, ClientError> {
51        if let Some(run_id) = run_id {
52            return run_id.try_into().map_err(ClientError::from_wire_error);
53        }
54        let chain = self
55            .engine
56            .store()
57            .read_run_chain(workflow_id)
58            .await
59            .map_err(|error| store_error_class(&error, error.to_string()))?;
60        chain
61            .last()
62            .map(|summary| summary.run_id.clone())
63            .ok_or_else(|| ClientError::not_found(format!("workflow {workflow_id} not found")))
64    }
65}
66
67#[async_trait]
68impl WorkflowTransport for EmbeddedWorkflowTransport {
69    async fn start_workflow(
70        &self,
71        request: aion_proto::ProtoStartWorkflowRequest,
72    ) -> Result<aion_proto::ProtoStartWorkflowResponse, ClientError> {
73        let input = request
74            .input
75            .ok_or_else(|| ClientError::invalid_argument("start request input payload is missing"))
76            .and_then(|payload| {
77                aion_core::Payload::try_from(payload).map_err(ClientError::from_wire_error)
78            })?;
79        // The embedded engine is single-tenant and in-process: there is no
80        // namespace authority stamping visibility attributes, so the start
81        // carries no search attributes.
82        let handle = self
83            .engine
84            .start_workflow(
85                &request.workflow_type,
86                input,
87                std::collections::HashMap::new(),
88                String::from("default"),
89            )
90            .await
91            .map_err(|error| map_engine_error(&error))?;
92        Ok(aion_proto::ProtoStartWorkflowResponse {
93            workflow_id: Some(aion_proto::ProtoWorkflowId::from(
94                handle.workflow_id().clone(),
95            )),
96            run_id: Some(aion_proto::ProtoRunId::from(handle.run_id().clone())),
97        })
98    }
99
100    async fn signal(
101        &self,
102        request: aion_proto::ProtoSignalRequest,
103    ) -> Result<aion_proto::ProtoSignalResponse, ClientError> {
104        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
105        let run_id = decode_required_run_id(request.run_id)?;
106        let payload = request
107            .payload
108            .ok_or_else(|| ClientError::invalid_argument("signal request payload is missing"))
109            .and_then(|payload| {
110                aion_core::Payload::try_from(payload).map_err(ClientError::from_wire_error)
111            })?;
112        self.engine
113            .signal(&workflow_id, &run_id, request.signal_name, payload)
114            .await
115            .map_err(|error| map_engine_error(&error))?;
116        Ok(aion_proto::ProtoSignalResponse {})
117    }
118
119    async fn query(
120        &self,
121        request: aion_proto::ProtoQueryRequest,
122    ) -> Result<aion_proto::ProtoQueryResponse, ClientError> {
123        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
124        let run_id = decode_required_run_id(request.run_id)?;
125        // Absent arguments mean the caller supplied none; the handler still
126        // receives one well-formed document.
127        let arguments = request.arguments.map_or_else(
128            || Ok(aion_core::Payload::json_null()),
129            |arguments| {
130                aion_core::Payload::try_from(arguments).map_err(ClientError::from_wire_error)
131            },
132        )?;
133        let payload = self
134            .engine
135            .query(&workflow_id, &run_id, request.query_name, arguments)
136            .await
137            .map_err(|error| map_engine_error(&error))?;
138        Ok(aion_proto::ProtoQueryResponse {
139            outcome: Some(aion_proto::proto_query_response::Outcome::Result(
140                aion_proto::ProtoPayload::from(payload),
141            )),
142        })
143    }
144
145    async fn cancel(
146        &self,
147        request: aion_proto::ProtoCancelRequest,
148    ) -> Result<aion_proto::ProtoCancelResponse, ClientError> {
149        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
150        let run_id = decode_required_run_id(request.run_id)?;
151        self.engine
152            .cancel(&workflow_id, &run_id, request.reason)
153            .await
154            .map_err(|error| map_engine_error(&error))?;
155        Ok(aion_proto::ProtoCancelResponse {})
156    }
157
158    async fn retire_workloop(
159        &self,
160        request: aion_proto::ProtoRetireWorkloopRequest,
161    ) -> Result<aion_proto::ProtoRetireWorkloopResponse, ClientError> {
162        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
163        self.engine
164            .retire_declared_workloop(
165                &workflow_id,
166                request.reason.clone(),
167                // The retirement RESULT is the operator's, never the retire
168                // body's return value.
169                aion_core::Payload::new(aion_core::ContentType::Json, b"null".to_vec()),
170            )
171            .await
172            .map_err(|error| map_engine_error(&error))?;
173        Ok(aion_proto::ProtoRetireWorkloopResponse {
174            reason: request.reason,
175        })
176    }
177
178    async fn reopen(
179        &self,
180        request: aion_proto::ProtoReopenRequest,
181    ) -> Result<aion_proto::ProtoReopenResponse, ClientError> {
182        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
183        let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
184        let handle = self
185            .engine
186            .reopen_workflow(&workflow_id, &run_id)
187            .await
188            .map_err(|error| map_engine_error(&error))?;
189        Ok(aion_proto::ProtoReopenResponse {
190            run_id: Some(handle.run_id().clone().into()),
191            status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
192        })
193    }
194
195    async fn pause(
196        &self,
197        request: aion_proto::ProtoPauseRequest,
198    ) -> Result<aion_proto::ProtoPauseResponse, ClientError> {
199        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
200        let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
201        let reason = if request.reason.is_empty() {
202            None
203        } else {
204            Some(request.reason)
205        };
206        let handle = self
207            .engine
208            .pause_workflow(&workflow_id, &run_id, reason, None)
209            .await
210            .map_err(|error| map_engine_error(&error))?;
211        Ok(aion_proto::ProtoPauseResponse {
212            run_id: Some(handle.run_id().clone().into()),
213            status: aion_proto::ProtoWorkflowStatus::Paused as i32,
214        })
215    }
216
217    async fn resume(
218        &self,
219        request: aion_proto::ProtoResumeRequest,
220    ) -> Result<aion_proto::ProtoResumeResponse, ClientError> {
221        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
222        let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
223        let handle = self
224            .engine
225            .resume_paused_workflow(&workflow_id, &run_id, None)
226            .await
227            .map_err(|error| map_engine_error(&error))?;
228        Ok(aion_proto::ProtoResumeResponse {
229            run_id: Some(handle.run_id().clone().into()),
230            status: aion_proto::ProtoWorkflowStatus::Running as i32,
231        })
232    }
233
234    async fn list_workflows(
235        &self,
236        request: aion_proto::ProtoListWorkflowsRequest,
237    ) -> Result<aion_proto::ProtoListWorkflowsResponse, ClientError> {
238        // The same rules the server's shared handler applies: the envelope is
239        // required, and it cannot re-target the page to another namespace.
240        let envelope = request.request.as_ref().ok_or_else(|| {
241            ClientError::invalid_argument(
242                "list request is missing: a list names its filter, sort, cursor, and limit",
243            )
244        })?;
245        let list_request =
246            aion_proto::decode_core_value::<aion_core::WorkflowListRequest>(envelope)
247                .map_err(ClientError::from_wire_error)?;
248        if list_request.namespace != request.namespace {
249            return Err(ClientError::invalid_argument(format!(
250                "list request names namespace `{}` but the call is scoped to `{}`",
251                list_request.namespace, request.namespace
252            )));
253        }
254        let page = self
255            .engine
256            .list_workflows(&list_request)
257            .await
258            .map_err(|error| map_engine_error(&error))?;
259        let page = aion_proto::encode_core_value(request.namespace, None, &page)
260            .map_err(ClientError::from_wire_error)?;
261        Ok(aion_proto::ProtoListWorkflowsResponse { page: Some(page) })
262    }
263
264    async fn describe_workflow(
265        &self,
266        request: aion_proto::ProtoDescribeWorkflowRequest,
267    ) -> Result<aion_proto::ProtoDescribeWorkflowResponse, ClientError> {
268        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
269        let run_id = self.resolve_run_id(&workflow_id, request.run_id).await?;
270        let history = self
271            .engine
272            .store()
273            .read_history(&workflow_id)
274            .await
275            // This read goes straight to the store, so the refusal arrives with
276            // no `EngineError` around it — but it is the same refusal, and a
277            // `NotOwner` here means exactly what it means everywhere else: ask a
278            // different owner. Collapsing it into `server` told the caller its
279            // request was unanswerable when a re-route would have served it.
280            .map_err(|error| store_error_class(&error, error.to_string()))?;
281        let Some(summary) = aion_core::WorkflowSummary::from_history(&history) else {
282            return Err(ClientError::not_found(format!(
283                "workflow {workflow_id} has no recorded history"
284            )));
285        };
286        let summary = Some(
287            aion_proto::encode_workflow_summary(request.namespace.clone(), None, &summary)
288                .map_err(ClientError::from_wire_error)?,
289        );
290        let history_head_seq = history.last().map_or(0, aion_core::Event::seq);
291        let terminal_event = aion_core::current_lease_terminal(&history)
292            .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
293            .transpose()
294            .map_err(ClientError::from_wire_error)?;
295        let history = if request.include_history {
296            history
297                .iter()
298                .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
299                .map(|result| result.map_err(ClientError::from_wire_error))
300                .collect::<Result<Vec<_>, _>>()?
301        } else {
302            Vec::new()
303        };
304        Ok(aion_proto::ProtoDescribeWorkflowResponse {
305            summary,
306            history,
307            run_id: Some(run_id.into()),
308            history_head_seq,
309            terminal_event,
310            provenance: Some(aion_proto::ProtoReadProvenance::default()),
311            lease_recording: None,
312        })
313    }
314
315    async fn read_history(
316        &self,
317        request: aion_proto::ProtoReadHistoryRequest,
318    ) -> Result<aion_proto::ProtoReadHistoryResponse, ClientError> {
319        let workflow_id = decode_required_workflow_id(request.workflow_id)?;
320        let from_seq = request.from_seq.unwrap_or(0);
321        let mut events = self
322            .engine
323            .store()
324            .read_history_from(&workflow_id, from_seq)
325            .await
326            .map_err(|error| store_error_class(&error, error.to_string()))?;
327        let head_seq = events.last().map_or(0, aion_core::Event::seq);
328        let next_from_seq = request
329            .limit
330            .and_then(|limit| events.get(limit as usize).map(aion_core::Event::seq));
331        if let Some(limit) = request.limit {
332            events.truncate(limit as usize);
333        }
334        let events = events
335            .iter()
336            .map(|event| aion_proto::encode_event(request.namespace.clone(), None, event))
337            .map(|result| result.map_err(ClientError::from_wire_error))
338            .collect::<Result<Vec<_>, _>>()?;
339        Ok(aion_proto::ProtoReadHistoryResponse {
340            events,
341            next_from_seq,
342            head_seq,
343        })
344    }
345
346    async fn subscribe(
347        &self,
348        request: aion_proto::SubscriptionRequest,
349        resume_from_sequence: Option<u64>,
350    ) -> Result<SubscriptionAttempt, ClientError> {
351        let (workflow_target, filter) = embedded_subscription_target(request)?;
352        // T0: attach to the live broadcast BEFORE any history snapshot — one
353        // half of the gap-free splice proof (mirrors the server's
354        // subscribe-then-snapshot ordering).
355        let live = self.engine.subscribe(filter);
356        let events = match (&workflow_target, resume_from_sequence) {
357            (Some(workflow_id), Some(resume_from_seq)) => {
358                // T1 (> T0): snapshot recorded history, then validate the
359                // cursor against its head and build the dedupe splice.
360                let history = self
361                    .engine
362                    .store()
363                    .read_history(workflow_id)
364                    .await
365                    .map_err(|error| store_error_class(&error, error.to_string()))?;
366                splice_resume(live, history, resume_from_seq)?
367            }
368            (None, Some(_)) => {
369                return Err(ClientError::invalid_argument(
370                    "filtered and firehose event streams are live-only by design; resume \
371                     cursors are valid for per-workflow subscriptions only",
372                ));
373            }
374            (_, None) => map_lag(live),
375        };
376        // Per-workflow streams end at the run's terminal event, exactly like
377        // the server socket; callers walk continue-as-new chains by
378        // resubscribing with their cursor.
379        Ok(SubscriptionAttempt::new(match workflow_target {
380            Some(_) => close_after_terminal(events),
381            None => events,
382        }))
383    }
384}
385
386/// Validates a resume cursor against a history snapshot and builds the
387/// replay/live splice (see the module docs for the gap/duplicate proof).
388fn splice_resume(
389    live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
390    history: Vec<Event>,
391    resume_from_seq: u64,
392) -> Result<BoxStream<'static, Result<Event, ClientError>>, ClientError> {
393    if resume_from_seq == 0 {
394        return Err(ClientError::invalid_argument(
395            "resume_from_seq must be >= 1 (the first sequence number wanted)",
396        ));
397    }
398    let head = history.last().map_or(0, Event::seq);
399    if resume_from_seq > head.saturating_add(1) {
400        return Err(ClientError::invalid_argument(format!(
401            "resume_from_seq {resume_from_seq} is ahead of recorded history (head seq {head}); \
402             the largest valid cursor is {}",
403            head.saturating_add(1)
404        )));
405    }
406
407    let mut history = history;
408    let replay_start = history.partition_point(|event| event.seq() < resume_from_seq);
409    let replay = history.split_off(replay_start);
410    let tail = live.filter(move |item| {
411        let keep = match item {
412            Ok(event) => event.seq() > head,
413            // Lag is information, never filtered away.
414            Err(aion::EventStreamLagged { .. }) => true,
415        };
416        futures::future::ready(keep)
417    });
418
419    Ok(stream::iter(replay.into_iter().map(Ok))
420        .chain(map_lag(tail.boxed()))
421        .boxed())
422}
423
424/// Maps engine-side lag items to retryable [`ClientError::Unavailable`] so
425/// the resume loop reconnects with its cursor instead of silently gapping.
426fn map_lag(
427    live: BoxStream<'static, Result<Event, aion::EventStreamLagged>>,
428) -> BoxStream<'static, Result<Event, ClientError>> {
429    live.map(|item| {
430        item.map_err(|lagged| {
431            ClientError::from_wire_error(aion_proto::WireError::lagged(lagged.to_string()))
432        })
433    })
434    .boxed()
435}
436
437/// Ends the stream after the first terminal workflow event, mirroring the
438/// server socket's per-workflow run-boundary close.
439fn close_after_terminal(
440    events: BoxStream<'static, Result<Event, ClientError>>,
441) -> BoxStream<'static, Result<Event, ClientError>> {
442    stream::unfold(Some(events), |state| async move {
443        let mut events = state?;
444        let item = events.next().await?;
445        // The terminal event is delivered and the inner stream is dropped
446        // immediately afterwards (releasing the broadcast receiver), so the
447        // close is eager — it never waits for a further event to be polled.
448        let closed = matches!(&item, Ok(event) if is_terminal_workflow_event(event));
449        Some((item, if closed { None } else { Some(events) }))
450    })
451    .boxed()
452}
453
454fn is_terminal_workflow_event(event: &Event) -> bool {
455    matches!(
456        event,
457        Event::WorkflowCompleted { .. }
458            | Event::WorkflowFailed { .. }
459            | Event::WorkflowCancelled { .. }
460            | Event::WorkflowTimedOut { .. }
461            | Event::WorkflowContinuedAsNew { .. }
462    )
463}
464
465fn decode_required_workflow_id(
466    value: Option<aion_proto::ProtoWorkflowId>,
467) -> Result<aion_core::WorkflowId, ClientError> {
468    value
469        .ok_or_else(|| ClientError::invalid_argument("request workflow id is missing"))?
470        .try_into()
471        .map_err(ClientError::from_wire_error)
472}
473
474fn decode_required_run_id(
475    value: Option<aion_proto::ProtoRunId>,
476) -> Result<aion_core::RunId, ClientError> {
477    value
478        .ok_or_else(|| ClientError::invalid_argument("request run id is missing"))?
479        .try_into()
480        .map_err(ClientError::from_wire_error)
481}
482
483/// Maps a wire subscription request onto the engine filter surface plus the
484/// per-workflow target the splice and run-boundary close key on.
485fn embedded_subscription_target(
486    request: aion_proto::SubscriptionRequest,
487) -> Result<(Option<aion_core::WorkflowId>, aion::EventFilter), ClientError> {
488    match request.subscription {
489        Some(aion_proto::subscription_request::Subscription::PerWorkflow(subscription)) => {
490            let workflow_id = subscription
491                .workflow_id
492                .ok_or_else(|| {
493                    ClientError::invalid_argument(
494                        "per-workflow subscription requires a workflow id",
495                    )
496                })?
497                .try_into()
498                .map_err(ClientError::from_wire_error)?;
499            Ok((
500                Some(aion_core::WorkflowId::clone(&workflow_id)),
501                aion::EventFilter {
502                    workflow_id: Some(workflow_id),
503                    run: None,
504                    family: None,
505                },
506            ))
507        }
508        Some(
509            aion_proto::subscription_request::Subscription::Filtered(_)
510            | aion_proto::subscription_request::Subscription::Firehose(_),
511        ) => Ok((None, aion::EventFilter::default())),
512        Some(aion_proto::subscription_request::Subscription::Cluster(_)) => {
513            // The WS3 cluster topology/ownership channel is a server-side
514            // projection of distributed cluster state. The embedded in-process
515            // transport drives a single local engine with no cluster topology to
516            // project, so a cluster subscription is not serviceable here; reject it
517            // cleanly rather than silently degrading to a workflow event stream.
518            Err(ClientError::invalid_argument(
519                "cluster topology subscriptions are not supported by the embedded in-process \
520                 transport; connect to an aion-server over gRPC/WebSocket to subscribe to the \
521                 cluster channel",
522            ))
523        }
524        Some(aion_proto::subscription_request::Subscription::Transcript(_)) => {
525            // The NOI-5b agent-observability transcript channel is a server-side
526            // projection over the durable `O` keyspace + the server's transcript
527            // sequencer. The embedded in-process transport has no such server
528            // bridge, so a transcript subscription is not serviceable here; reject
529            // it cleanly rather than degrading to a workflow event stream.
530            Err(ClientError::invalid_argument(
531                "agent-observability transcript subscriptions are not supported by the embedded \
532                 in-process transport; connect to an aion-server over gRPC/WebSocket to subscribe \
533                 to the transcript channel",
534            ))
535        }
536        None => Err(ClientError::invalid_argument(
537            "subscription request is missing its subscription variant",
538        )),
539    }
540}
541
542/// Translate an engine failure into the client-facing error class.
543///
544/// # The `_` arm is a family default, and it is not free
545///
546/// `EngineError` has 51 variants and is not `#[non_exhaustive]`, so an
547/// exhaustive match here *would* compile-fail on every new variant. That is
548/// deliberately not what this does: a 51-arm list in a transport adapter is a
549/// list nothing reads, and it would go stale as silently as the wildcard does.
550/// The wildcard is chosen, with its cost stated — **a new variant lands in the
551/// generic server bucket and the compiler will not say so.**
552///
553/// What makes that acceptable is the direction it fails in. `server` is the
554/// "something went wrong, this is not your request's fault and not a wait"
555/// class; landing there is uninformative, never *wrong* in a way that makes a
556/// caller retry something unretryable or give up on something transient. Every
557/// arm above it exists because its variant would be actively mis-served by that
558/// default — a not-found retried forever, a shutdown reported as an engine bug.
559///
560/// So the rule for adding an arm is not "is this variant new" but "would the
561/// generic bucket mislead a caller about what to DO". Only those get named.
562fn map_engine_error(error: &aion::EngineError) -> ClientError {
563    match error {
564        aion::EngineError::WorkflowNotFound { .. } => ClientError::not_found(error.to_string()),
565        // Reopen precondition failure (AD-012): distinct typed variant, never
566        // conflated with not-found or the generic server bucket.
567        aion::EngineError::InvalidState { .. } => ClientError::invalid_state(error.to_string()),
568        // The caller's own payload did not satisfy the contract the target
569        // package declares. Both are raised BEFORE anything is recorded, so
570        // there is nothing to clean up and nothing to retry — the remedy is to
571        // send different bytes. `server` would say the opposite: "not your
572        // fault, try again", which is how a caller ends up retrying a payload
573        // that can never be accepted. `aion-server` classifies these two
574        // through `declared_contract_wire` for the same reason; this is the
575        // embedded surface reaching the same verdict, not a second opinion.
576        aion::EngineError::StartInputRefused { .. } | aion::EngineError::SignalRefused { .. } => {
577            ClientError::invalid_argument(error.to_string())
578        }
579        // Six refusals about the state of a run, or of the package a run would
580        // start from, rather than the health of the engine — all reachable
581        // through the embedded surface, all classified `invalid_state` by
582        // `aion-server`, and all erased into "engine bug" by the generic
583        // bucket, which is the wrong thing to say about any of them.
584        //
585        // Two are transient by construction (`TerminalWriterUnavailable`,
586        // `TerminalWriterHeld`): a writer reservation lives across one terminal
587        // transition. Two name a precondition the caller can act on: redeploy
588        // and cancel through the ordinary path (`RunIsRecoverable`), or address
589        // this engine's missing startup verdict (`NoResidencyVerdict`). The
590        // last two are admission refusals raised before the run starts — the
591        // package's declared contract does not identify what the request names
592        // (`ContractIdentity`), or it declares no task queue to serve from
593        // (`NoQueueDeclaration`) — and the remedy for both is a redeploy.
594        aion::EngineError::TerminalWriterUnavailable { .. }
595        | aion::EngineError::TerminalWriterHeld { .. }
596        | aion::EngineError::RunIsRecoverable { .. }
597        | aion::EngineError::NoResidencyVerdict { .. }
598        | aion::EngineError::ContractIdentity { .. }
599        | aion::EngineError::NoQueueDeclaration { .. } => {
600            ClientError::invalid_state(error.to_string())
601        }
602        // 🔴 BOTH SHAPES A STORE REFUSAL ARRIVES IN, CLASSIFIED IN ONE PLACE.
603        //
604        // An earlier revision matched only the bare `Store(..)` shape and its
605        // test hand-built that shape, so the arm looked covered and fired on
606        // nothing a caller could provoke: every durable write these operations
607        // trigger goes through the `Recorder`, which returns `DurabilityError`,
608        // and the engine wraps that as `EngineError::Durability` — so the
609        // production shape of a lost-ownership refusal is
610        // `Durability(Store(NotOwner))`. Both shapes now route through
611        // `store_error_class`, which is the only place the rule is written.
612        aion::EngineError::Store(store)
613        | aion::EngineError::Durability(aion::durability::DurabilityError::Store(store)) => {
614            store_error_class(store, error.to_string())
615        }
616        // Live-query dispatch. `aion-server` gives this family five distinct
617        // wire codes and the caller taxonomy has a class for each; collapsing
618        // them into `server` left `unknown_query`, `query_timeout`,
619        // `query_failed` and the query half of `not_running` unreachable
620        // in-process — four classes that exist solely for this operation. The
621        // same caller code branches correctly over the wire and could not
622        // branch at all here.
623        aion::EngineError::Query(query) => query_error_class(query, error),
624        // This engine has stopped serving. `aion-server`'s `wire_from_engine`
625        // (`error.rs:645`) answers `not_running`, and an over-the-wire caller of
626        // these operations therefore sees `ClientError::NotRunning`; an earlier
627        // revision of this arm said `unavailable`, which is the transport-level
628        // class the SDK also raises for a dial failure. That told an in-process
629        // caller its ENDPOINT was unreachable when what had happened was that
630        // the engine it holds is shutting down.
631        //
632        // ⚠️ "Mirror the server" is not one answer — the server gives two, and
633        // saying otherwise would hide a real divergence. `wire_from_engine` is
634        // the mapping that governs the eight engine operations THIS transport
635        // exposes, and it is the one mirrored here. Two control-plane handlers
636        // answer `Unavailable` for the same variant —
637        // `aion-server/src/authoring/handlers.rs:404-406` and
638        // `api/handlers/deploy.rs:415` — and both serve deploy/authoring
639        // operations that this transport does not expose, so neither is
640        // reachable through a caller holding an `EmbeddedWorkflowTransport`.
641        // Recorded rather than reconciled: a shared classifier is what would
642        // actually force the two into agreement, and that needs a crate both
643        // sides can depend on.
644        //
645        // `EngineTaskEpochClosed` deliberately has no arm. It is constructed at
646        // exactly one site — the terminal-append boundary in the engine's
647        // process-exit path — which is reached by the completion monitor, never
648        // by any of the operations this transport exposes. An arm for it would
649        // be a classification nobody can observe, and a wrong claim about what
650        // this surface can return.
651        // 🔴 CARRY THE DISCRIMINATOR, because the class alone is a false hint.
652        // `not_running` is the right CLASS and mirrors `wire_from_engine` — but
653        // the operator-facing hint for a bare `not_running` says the run is no
654        // longer running and points at `aion list --status running`, which is
655        // false twice here: the run is fine, it is the ENGINE that stopped
656        // serving, and `aion list` would fail identically. That is the same
657        // defect this lane's F4 fix cited when it moved `EngineTaskEpochClosed`
658        // OFF `not_running`; leaving the discriminator behind would have this
659        // lane arguing both sides of one rule.
660        //
661        // The wire surface already carries it — `wire_from_engine` builds
662        // `not_running_with_type("ShuttingDown", …)` — so attaching it here
663        // makes the embedded and wire surfaces agree rather than diverge, and
664        // gives `render.rs` the one fact it needs to say something true.
665        aion::EngineError::ShuttingDown => ClientError::NotRunning {
666            detail: crate::ErrorDetail::with_type(error.to_string(), "ShuttingDown"),
667        },
668        _ => ClientError::server(error.to_string()),
669    }
670}
671
672/// The class a store-layer refusal carries, whichever shape it reached
673/// [`map_engine_error`] in.
674///
675/// Mirrors `aion-server`'s `wire_from_store` composed with
676/// [`ClientError::from_wire_error`], so the same underlying refusal names the
677/// same class whether the caller is in-process or across the wire.
678///
679/// `not_owner` is the one class that names a ROUTING failure — the endpoint
680/// answered, it simply does not own this target's shard, so the remedy is a
681/// DIFFERENT owner rather than a retry against this one. Collapsing it into
682/// `server` costs the caller that distinction entirely: `server` says the
683/// request is unanswerable, and a caller that believes it abandons work a
684/// re-route would have served.
685///
686/// ⚠️ Nothing in this crate retries it AUTOMATICALLY, and an earlier revision of
687/// this comment claimed otherwise. `aion-client`'s only retry classifier is
688/// `stream.rs`'s `is_retryable`, which is `matches!(error, ClientError::
689/// Unavailable { .. })` — `NotOwner` is not in it, so `ResumingEventStream`
690/// terminates the stream on one. The distinction this arm preserves is
691/// therefore one the CALLER acts on, and the surface that already does is the
692/// CLI's operator hint (`aion-cli/src/render.rs:175-178`), which tells the
693/// operator to retry or point `--endpoint` at another node instead of sending
694/// them hunting a network fault that does not exist.
695///
696/// Takes the caller-facing `message` separately rather than deriving it, because
697/// a store refusal reaches this transport in three shapes and only two of them
698/// have an `EngineError` to render: wrapped as `EngineError::Store`, wrapped as
699/// `EngineError::Durability(Store(..))`, and — at the three call sites that read
700/// the store directly rather than through an engine API — as a bare
701/// [`aion_store::StoreError`] with no wrapper at all. The rule is written once
702/// and all three shapes are held to it.
703fn store_error_class(error: &aion_store::StoreError, message: String) -> ClientError {
704    match error {
705        aion_store::StoreError::NotOwner { .. } => ClientError::not_owner(message),
706        // A session that does not exist and a workflow that does not exist are
707        // one answer to a caller: the thing you named is not here. The embedded
708        // transport never reaches the assistant surface, but the match is
709        // exhaustive on purpose — a store variant with no class of its own
710        // would be a refusal this transport could not report.
711        aion_store::StoreError::NotFound { .. }
712        | aion_store::StoreError::AssistantSessionNotFound { .. } => {
713            ClientError::not_found(message)
714        }
715        // A `SequenceConflict` is this codebase's single-writer invariant
716        // violation — a double-writer bug, not a caller mistake and not an
717        // idempotency conflict — and `Backend`/`Serialization` are
718        // infrastructure faults. All three are the engine's to answer for, and
719        // `from_wire_error` puts all three in the generic bucket for a caller
720        // arriving over the wire.
721        aion_store::StoreError::SequenceConflict { .. }
722        | aion_store::StoreError::Backend(_)
723        | aion_store::StoreError::Serialization(_) => ClientError::server(message),
724        // A zero limit or a cursor replayed under another query is the
725        // caller's to correct; nothing about it clears by retrying.
726        aion_store::StoreError::InvalidQuery(_) => ClientError::invalid_argument(message),
727    }
728}
729
730/// The class a live-query dispatch failure carries.
731///
732/// Mirrors `aion-server`'s `query_wire` composed with
733/// [`ClientError::from_wire_error`]. Every arm is a distinct remedy: name a
734/// query the workflow declares, wait or retry, address a run that is not
735/// answering, look up a workflow that does not exist, or fix the handler.
736fn query_error_class(error: &aion::QueryError, source: &aion::EngineError) -> ClientError {
737    match error {
738        aion::QueryError::UnknownQuery(_) => ClientError::unknown_query(source.to_string()),
739        aion::QueryError::Timeout => ClientError::query_timeout(source.to_string()),
740        // A run that cannot answer and a reply channel that closed are the same
741        // fact to a caller: the workflow is not there to answer right now.
742        aion::QueryError::NotRunning(_) | aion::QueryError::ReplyDropped => {
743            ClientError::not_running(source.to_string())
744        }
745        aion::QueryError::Unknown(_) => ClientError::not_found(source.to_string()),
746        // The handler ran and reported an application-level failure — the
747        // workflow author's to fix, and distinct from the engine failing to
748        // deliver the query at all.
749        aion::QueryError::HandlerFailed { .. } => ClientError::query_failed(source.to_string()),
750        // The caller sent arguments the engine cannot carry to a handler: the
751        // caller's request to fix, never the workflow's or the server's.
752        aion::QueryError::InvalidArguments { .. } => {
753            ClientError::invalid_argument(source.to_string())
754        }
755        aion::QueryError::Engine(_) => ClientError::server(source.to_string()),
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use std::num::NonZeroUsize;
762    use std::time::Duration;
763
764    use aion::EventStreamLagged;
765    use aion_core::{Event, EventEnvelope, Payload, RunId, WorkflowId};
766    use chrono::Utc;
767    use futures::{StreamExt, stream};
768
769    use super::{close_after_terminal, map_lag, splice_resume};
770    use crate::error::ClientError;
771
772    fn workflow_id() -> WorkflowId {
773        WorkflowId::new(uuid::Uuid::from_u128(1))
774    }
775
776    fn envelope(seq: u64) -> EventEnvelope {
777        EventEnvelope {
778            seq,
779            recorded_at: Utc::now(),
780            workflow_id: workflow_id(),
781        }
782    }
783
784    fn signal(seq: u64) -> Result<Event, aion_core::PayloadError> {
785        Ok(Event::SignalReceived {
786            envelope: envelope(seq),
787            name: format!("signal-{seq}"),
788            payload: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
789        })
790    }
791
792    fn completed(seq: u64) -> Result<Event, aion_core::PayloadError> {
793        Ok(Event::WorkflowCompleted {
794            envelope: envelope(seq),
795            result: Payload::from_json(&serde_json::json!({ "seq": seq }))?,
796        })
797    }
798
799    fn history(seqs: std::ops::RangeInclusive<u64>) -> Result<Vec<Event>, aion_core::PayloadError> {
800        seqs.map(signal).collect()
801    }
802
803    fn live(
804        items: Vec<Result<Event, EventStreamLagged>>,
805    ) -> futures::stream::BoxStream<'static, Result<Event, EventStreamLagged>> {
806        stream::iter(items).boxed()
807    }
808
809    async fn delivered_seqs(
810        events: futures::stream::BoxStream<'static, Result<Event, ClientError>>,
811    ) -> Result<Vec<u64>, ClientError> {
812        events
813            .map(|item| item.map(|event| event.seq()))
814            .collect::<Vec<_>>()
815            .await
816            .into_iter()
817            .collect()
818    }
819
820    #[tokio::test]
821    async fn cursor_zero_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>> {
822        let error = splice_resume(live(Vec::new()), history(1..=3)?, 0).err();
823
824        let Some(ClientError::InvalidArgument { detail }) = error else {
825            return Err(format!("cursor 0 must be InvalidArgument, got {error:?}").into());
826        };
827        assert!(detail.message.contains(">= 1"), "detail: {detail}");
828        Ok(())
829    }
830
831    #[tokio::test]
832    async fn cursor_ahead_of_history_is_invalid_argument() -> Result<(), Box<dyn std::error::Error>>
833    {
834        let error = splice_resume(live(Vec::new()), history(1..=5)?, 7).err();
835
836        let Some(ClientError::InvalidArgument { detail }) = error else {
837            return Err(format!("cursor head+2 must be InvalidArgument, got {error:?}").into());
838        };
839        assert!(
840            detail.message.contains("ahead of recorded history"),
841            "{detail}"
842        );
843
844        let empty = splice_resume(live(Vec::new()), Vec::new(), 2).err();
845        assert!(
846            matches!(empty, Some(ClientError::InvalidArgument { .. })),
847            "cursor 2 over empty history must be rejected, got {empty:?}"
848        );
849        Ok(())
850    }
851
852    #[tokio::test]
853    async fn overlap_between_snapshot_and_live_is_deduplicated_contiguous_unique()
854    -> Result<(), Box<dyn std::error::Error>> {
855        // Snapshot holds 1..=5; the live broadcast re-emits 4 and 5 (arrived
856        // between attach and snapshot) before the genuinely new 6.
857        let events = splice_resume(
858            live(vec![Ok(signal(4)?), Ok(signal(5)?), Ok(signal(6)?)]),
859            history(1..=5)?,
860            1,
861        )?;
862
863        assert_eq!(delivered_seqs(events).await?, vec![1, 2, 3, 4, 5, 6]);
864        Ok(())
865    }
866
867    #[tokio::test]
868    async fn mid_history_cursor_replays_suffix_only() -> Result<(), Box<dyn std::error::Error>> {
869        let events = splice_resume(live(vec![Ok(signal(6)?)]), history(1..=5)?, 3)?;
870
871        assert_eq!(delivered_seqs(events).await?, vec![3, 4, 5, 6]);
872        Ok(())
873    }
874
875    #[tokio::test]
876    async fn cursor_at_head_plus_one_yields_empty_replay_and_live_tail_only()
877    -> Result<(), Box<dyn std::error::Error>> {
878        let events = splice_resume(
879            live(vec![Ok(signal(6)?), Ok(signal(7)?)]),
880            history(1..=5)?,
881            6,
882        )?;
883
884        assert_eq!(delivered_seqs(events).await?, vec![6, 7]);
885        Ok(())
886    }
887
888    #[tokio::test]
889    async fn lag_mid_splice_surfaces_unavailable_after_the_replay()
890    -> Result<(), Box<dyn std::error::Error>> {
891        let events = splice_resume(
892            live(vec![Err(EventStreamLagged { skipped: 3 })]),
893            history(1..=2)?,
894            1,
895        )?;
896        let collected: Vec<_> = events.collect().await;
897
898        assert_eq!(collected.len(), 3, "two replay events then the lag item");
899        assert!(collected[0].is_ok() && collected[1].is_ok());
900        assert!(
901            matches!(
902                collected[2].as_ref().err(),
903                Some(ClientError::Unavailable { .. })
904            ),
905            "lag must surface as retryable Unavailable, never a silent gap, got {:?}",
906            collected[2]
907        );
908        Ok(())
909    }
910
911    #[tokio::test]
912    async fn per_workflow_stream_closes_after_terminal_event()
913    -> Result<(), Box<dyn std::error::Error>> {
914        // Terminal at seq 3 mid-replay: deliver 1..=3 and close without
915        // draining the live tail (continue-as-new/terminal run boundary).
916        let mut history = history(1..=2)?;
917        history.push(completed(3)?);
918        history.push(signal(4)?);
919        let events = splice_resume(live(vec![Ok(signal(5)?)]), history, 1)?;
920
921        assert_eq!(
922            delivered_seqs(close_after_terminal(events)).await?,
923            vec![1, 2, 3],
924            "the stream must close after the terminal event"
925        );
926        Ok(())
927    }
928
929    #[tokio::test]
930    async fn live_lag_maps_to_unavailable() -> Result<(), Box<dyn std::error::Error>> {
931        let events = map_lag(live(vec![
932            Ok(signal(1)?),
933            Err(EventStreamLagged { skipped: 9 }),
934        ]));
935        let collected: Vec<_> = events.collect().await;
936
937        assert_eq!(collected.len(), 2);
938        assert!(
939            matches!(
940                collected[1].as_ref().err(),
941                Some(ClientError::Unavailable { .. })
942            ),
943            "got {:?}",
944            collected[1]
945        );
946        Ok(())
947    }
948
949    /// End-to-end through a real engine: the embedded resume splice delivers
950    /// recorded history and live appends gap-free and duplicate-free, built
951    /// on `Engine::subscribe` + `engine.store()` (the pin-note seams).
952    #[tokio::test]
953    async fn embedded_resume_splices_recorded_history_with_live_appends()
954    -> Result<(), Box<dyn std::error::Error>> {
955        use crate::stream::SubscribeTarget;
956        use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
957
958        let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
959        let engine = std::sync::Arc::new(
960            aion::EngineBuilder::new()
961                .stop_drain_timeout(std::time::Duration::from_secs(5))
962                .store(aion_store::InMemoryStore::default())
963                .in_memory_visibility()
964                .event_streaming(capacity)
965                .build()
966                .await?,
967        );
968        let workflow_id = WorkflowId::new_v4();
969        let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
970        recorder
971            .record_workflow_started(
972                Utc::now(),
973                aion::durability::WorkflowStartRecord {
974                    workflow_type: String::from("checkout"),
975                    input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
976                    run_id: RunId::new(uuid::Uuid::from_u128(7)),
977                    parent_run_id: None,
978                    parent_workflow_id: None,
979                    package_version: aion_core::PackageVersion::new("a".repeat(64)),
980                },
981            )
982            .await?;
983        for seq in 2..=3 {
984            recorder
985                .record_signal_received(
986                    Utc::now(),
987                    format!("signal-{seq}"),
988                    Payload::from_json(&serde_json::json!({ "seq": seq }))?,
989                )
990                .await?;
991        }
992
993        // Resume from seq 2: replay [2, 3] from the snapshot, then splice the
994        // live append (4) with no gaps and no duplicates.
995        let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
996        let request = SubscribeTarget::Workflow {
997            workflow_id: workflow_id.clone(),
998        }
999        .request("default");
1000        let attempt = transport.subscribe(request, Some(2)).await?;
1001        let mut events = attempt.events;
1002
1003        let mut delivered = Vec::new();
1004        for _ in 0..2 {
1005            let item = tokio::time::timeout(Duration::from_secs(2), events.next())
1006                .await
1007                .map_err(|_| "timed out waiting for a replay event")?
1008                .ok_or("stream ended before the replay completed")?;
1009            delivered.push(item?.seq());
1010        }
1011        recorder
1012            .record_workflow_completed(
1013                Utc::now(),
1014                Payload::from_json(&serde_json::json!({ "done": true }))?,
1015            )
1016            .await?;
1017        let item = tokio::time::timeout(Duration::from_secs(2), events.next())
1018            .await
1019            .map_err(|_| "timed out waiting for the live spliced event")?
1020            .ok_or("stream ended before the live event arrived")?;
1021        delivered.push(item?.seq());
1022        assert_eq!(delivered, vec![2, 3, 4]);
1023
1024        // Seq 4 is terminal: the per-workflow stream must now close.
1025        let end = tokio::time::timeout(Duration::from_secs(2), events.next())
1026            .await
1027            .map_err(|_| "timed out waiting for the post-terminal close")?;
1028        assert!(
1029            end.is_none(),
1030            "per-workflow stream must close after the terminal event, got {end:?}"
1031        );
1032
1033        // A cursor beyond head + 1 is rejected against the same engine.
1034        let ahead = transport
1035            .subscribe(
1036                SubscribeTarget::Workflow { workflow_id }.request("default"),
1037                Some(9),
1038            )
1039            .await
1040            .err();
1041        assert!(
1042            matches!(ahead, Some(ClientError::InvalidArgument { .. })),
1043            "cursor ahead of history must be InvalidArgument, got {ahead:?}"
1044        );
1045
1046        engine.shutdown()?;
1047        Ok(())
1048    }
1049
1050    /// 🔴 THE THREE CALL SITES THAT READ THE STORE DIRECTLY CARRY A ROUTABLE
1051    /// REFUSAL THROUGH — THEY DO NOT FLATTEN IT INTO "ENGINE BUG".
1052    ///
1053    /// [`map_engine_error`] is only reached by the eight operations that go
1054    /// through an engine API. Three do not: `resolve_run_id` reads the run
1055    /// chain, and `describe_workflow` and the resuming half of `subscribe` read
1056    /// history, all straight off `engine.store()`. Each of those refusals
1057    /// arrives as a bare [`aion_store::StoreError`] with no `EngineError` around
1058    /// it, and each was mapped by hand to `ClientError::server`.
1059    ///
1060    /// `not_owner` is the one store class that names a ROUTING failure — try a
1061    /// different owner, never this one. It is a distinction the CALLER acts on,
1062    /// not one this crate retries for it: `stream.rs`'s `is_retryable` matches
1063    /// only `ClientError::Unavailable`. Flattened to `server` the caller is told
1064    /// its request is unanswerable and gives up on work a re-route would have
1065    /// served, and no test could see the difference:
1066    /// [`aion_store::InMemoryStore`] owns every shard and so can never produce
1067    /// `NotOwner` at all. [`FencedHistoryStore`] is the instrument that can.
1068    ///
1069    /// The unarmed pass is the control. Without it an armed `not_owner` would be
1070    /// consistent with a fixture that never worked — the wrong workflow id, an
1071    /// engine that never built — and the test would be measuring its own setup.
1072    #[tokio::test]
1073    async fn a_directly_read_store_refusal_keeps_its_routing_class()
1074    -> Result<(), Box<dyn std::error::Error>> {
1075        use aion_store::testing::FencedHistoryStore;
1076
1077        use crate::stream::SubscribeTarget;
1078        use crate::transport::{EmbeddedWorkflowTransport, WorkflowTransport};
1079
1080        let capacity = NonZeroUsize::new(16).ok_or("capacity must be non-zero")?;
1081        let store = std::sync::Arc::new(FencedHistoryStore::new());
1082        let engine = std::sync::Arc::new(
1083            aion::EngineBuilder::new()
1084                .stop_drain_timeout(std::time::Duration::from_secs(5))
1085                .store_arc(
1086                    std::sync::Arc::clone(&store) as std::sync::Arc<dyn aion_store::EventStore>
1087                )
1088                .in_memory_visibility()
1089                .event_streaming(capacity)
1090                .build()
1091                .await?,
1092        );
1093        let workflow_id = WorkflowId::new_v4();
1094        let mut recorder = aion::durability::Recorder::new(workflow_id.clone(), engine.store());
1095        recorder
1096            .record_workflow_started(
1097                Utc::now(),
1098                aion::durability::WorkflowStartRecord {
1099                    workflow_type: String::from("checkout"),
1100                    input: Payload::from_json(&serde_json::json!({ "cart": [] }))?,
1101                    run_id: RunId::new(uuid::Uuid::from_u128(11)),
1102                    parent_run_id: None,
1103                    parent_workflow_id: None,
1104                    package_version: aion_core::PackageVersion::new("b".repeat(64)),
1105                },
1106            )
1107            .await?;
1108
1109        let transport = EmbeddedWorkflowTransport::new(std::sync::Arc::clone(&engine));
1110        let describe = |include_history: bool| aion_proto::ProtoDescribeWorkflowRequest {
1111            namespace: String::from("default"),
1112            workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1113            run_id: None,
1114            include_history,
1115        };
1116
1117        // CONTROL, one per operation, so an armed `not_owner` below is
1118        // attributable to the fence rather than to a fixture that never reached
1119        // the read at all.
1120        //
1121        // 🔴 An earlier revision of this comment claimed "with the fence
1122        // disarmed every one of these succeeds" and ran a control for
1123        // `describe_workflow` only. That claim was not merely unproven, it was
1124        // impossible: this fixture records `WorkflowStarted` and nothing else,
1125        // so the run is NON-TERMINAL and `reopen_workflow` answers the AD-012
1126        // `invalid_state` this file names at `:486-487`. `reopen`'s control is
1127        // therefore the sharper one available — disarmed it must fail with some
1128        // class OTHER than `not_owner`, which still separates "the fence did
1129        // it" from "this call always fails".
1130        transport.describe_workflow(describe(true)).await?;
1131        transport
1132            .subscribe(
1133                SubscribeTarget::Workflow {
1134                    workflow_id: workflow_id.clone(),
1135                }
1136                .request("default"),
1137                Some(1),
1138            )
1139            .await?;
1140        let reopen_request = || aion_proto::ProtoReopenRequest {
1141            namespace: String::from("default"),
1142            workflow_id: Some(aion_proto::ProtoWorkflowId::from(workflow_id.clone())),
1143            run_id: None,
1144        };
1145        let reopen_control = transport.reopen(reopen_request()).await;
1146        assert_ne!(
1147            reopen_control.as_ref().err().map(ClientError::class),
1148            Some("not_owner"),
1149            "the reopen control answered `not_owner` with the fence DISARMED, so the armed \
1150             assertion below would prove nothing: {reopen_control:?}"
1151        );
1152
1153        store.arm_fence();
1154
1155        let described = transport.describe_workflow(describe(false)).await;
1156        assert_eq!(
1157            described.as_ref().err().map(ClientError::class),
1158            Some("not_owner"),
1159            "describe_workflow flattened a shard-ownership refusal into an unroutable class: \
1160             {described:?}"
1161        );
1162
1163        let subscribed = transport
1164            .subscribe(
1165                SubscribeTarget::Workflow {
1166                    workflow_id: workflow_id.clone(),
1167                }
1168                .request("default"),
1169                Some(1),
1170            )
1171            .await;
1172        assert_eq!(
1173            subscribed.as_ref().err().map(ClientError::class),
1174            Some("not_owner"),
1175            "the resuming half of subscribe flattened a shard-ownership refusal into an \
1176             unroutable class"
1177        );
1178
1179        // `resolve_run_id` reads the RUN CHAIN rather than history, and it is
1180        // reached only when the caller omits the run id — which is why
1181        // `reopen_request` carries `run_id: None`.
1182        let reopened = transport.reopen(reopen_request()).await;
1183        assert_eq!(
1184            reopened.as_ref().err().map(ClientError::class),
1185            Some("not_owner"),
1186            "resolve_run_id flattened a shard-ownership refusal into an unroutable class: \
1187             {reopened:?}"
1188        );
1189
1190        // Teardown, and the fence comes down FIRST. Every other engine-backed
1191        // test in this file shuts its engine down; this one left the engine to
1192        // `Drop`, which gates the epoch but cannot await, so teardown work that
1193        // must finish was left racing the test's exit. Disarming before the
1194        // shutdown means the shutdown is measured against an honest store — a
1195        // shutdown error arriving through `?` here would then be a real fault
1196        // and not the fixture fencing its own teardown's history access.
1197        store.disarm_fence();
1198        engine.shutdown()?;
1199        Ok(())
1200    }
1201
1202    /// Every arm this map NAMES must reach its own class, and the wildcard must
1203    /// still catch what it is for.
1204    ///
1205    /// 🔴 Structured as a table with a NEGATIVE control (`Runtime`), because the
1206    /// assertion that matters is not "these map somewhere" but "these map
1207    /// somewhere OTHER than the generic bucket". Without the control, deleting
1208    /// every named arm and returning `ClientError::server` for everything would
1209    /// still fail — but a test that only listed the named variants could not
1210    /// tell a correct map from one that had accidentally started naming
1211    /// everything.
1212    ///
1213    /// 🔴 EACH ERROR IS BUILT IN THE SHAPE PRODUCTION PRODUCES IT, NOT THE
1214    /// SHAPE THAT MAKES THE ARM LOOK COVERED.
1215    ///
1216    /// An earlier revision of this table listed a bare
1217    /// `Store(StoreError::NotOwner)` and passed — and the arm it exercised
1218    /// never fired on the shape a caller actually provokes. A fenced APPEND
1219    /// goes through the `Recorder`, which returns `DurabilityError`, so that
1220    /// path's caller-reachable shape is `Durability(Store(NotOwner))` — and
1221    /// against THAT shape the same table failed with `left: "backend"`.
1222    ///
1223    /// The bare shape is reachable too, and an earlier revision of THIS comment
1224    /// wrongly said it was not. `EngineError` carries `Store(#[from]
1225    /// StoreError)`, so every `?` on a store call inside an engine API that
1226    /// returns `EngineError` produces it: `list_workflows`
1227    /// (`api_workflow_ops.rs`, both `store.query` and `store.read_history`),
1228    /// `cancel` (`lifecycle/terminate.rs`) and `signal`
1229    /// (`engine/delegated.rs`) are three of the eight engine operations this
1230    /// transport calls, and all three are store READS rather than appends —
1231    /// which is precisely why no `Recorder` is involved and no `Durability`
1232    /// wrapper appears. Both shapes are listed below because both happen.
1233    ///
1234    /// ⚠️ WHAT THIS TEST DOES NOT DO. The classes are the same ones
1235    /// `aion-server`'s `error.rs` assigns, but this test cannot check that: the
1236    /// dependency runs server → client, so the server's table is not visible
1237    /// from here and these expectations are a TRANSCRIPTION of it. A rule
1238    /// written in two places with nothing forcing agreement has already
1239    /// drifted or will. What holds them together today is that a change to
1240    /// either side must be made deliberately on both; the durable fix is a
1241    /// shared classifier, which needs a crate both can depend on and is not in
1242    /// this change's scope. Naming it here rather than letting the doc comment
1243    /// imply a guarantee the test does not provide.
1244    #[test]
1245    fn every_named_engine_error_reaches_its_own_class() {
1246        use super::map_engine_error;
1247
1248        for (error, expected) in engine_error_class_table() {
1249            assert_eq!(
1250                map_engine_error(&error).class(),
1251                expected,
1252                "wrong class for {error}"
1253            );
1254        }
1255    }
1256
1257    /// F3: the CLASS is not the whole answer — `ShuttingDown` must also carry
1258    /// its discriminator, because the operator-facing hint keyed off a bare
1259    /// `not_running` tells them the run ended and points at `aion list`, which
1260    /// is false twice (the run is fine; that command fails the same way).
1261    ///
1262    /// The class-table test above is invariant to this: it asserts
1263    /// `"not_running"` and would stay green with the discriminator dropped.
1264    /// That is exactly why this assertion is separate.
1265    ///
1266    /// Killing mutation: revert the arm to `ClientError::not_running(...)`.
1267    /// `error_type` becomes `None` and the first assertion fails.
1268    #[test]
1269    fn shutting_down_carries_its_discriminator_not_only_its_class()
1270    -> Result<(), Box<dyn std::error::Error>> {
1271        let mapped = super::map_engine_error(&aion::EngineError::ShuttingDown);
1272        let ClientError::NotRunning { detail } = &mapped else {
1273            return Err(format!("ShuttingDown must keep the not_running CLASS: {mapped}").into());
1274        };
1275        assert_eq!(
1276            detail.error_type.as_deref(),
1277            Some("ShuttingDown"),
1278            "the wire surface builds `not_running_with_type(\"ShuttingDown\", …)`; an embedded \
1279             caller that loses the discriminator cannot be told anything true about why"
1280        );
1281        // CONTROL: a different error in the same class must NOT claim this
1282        // discriminator, or the assertion above would pass for anything.
1283        let other = super::map_engine_error(&aion::EngineError::Runtime {
1284            reason: "beamr scheduler refused".to_owned(),
1285        });
1286        assert_ne!(
1287            other.class(),
1288            "not_running",
1289            "control: the negative case must not share the class under test"
1290        );
1291        Ok(())
1292    }
1293
1294    /// The table itself, lifted out of the test body so the case list can grow
1295    /// with the taxonomy without the assertion loop growing at all, and split
1296    /// by family so each half stays readable.
1297    fn engine_error_class_table() -> Vec<(aion::EngineError, &'static str)> {
1298        let mut cases = admission_and_run_state_cases();
1299        cases.extend(store_and_query_cases());
1300        cases
1301    }
1302
1303    /// Refusals about the request, the run's state, or the engine's own
1304    /// availability — plus the negative control.
1305    fn admission_and_run_state_cases() -> Vec<(aion::EngineError, &'static str)> {
1306        use aion_core::{RunId, WorkflowId};
1307
1308        let version = aion::ContentHash::from_bytes([7u8; 32]);
1309        vec![
1310            // The two oldest named arms, and until this revision the only two
1311            // with no row: the table asserted every arm reached its own class
1312            // while silently omitting them, so either could have been deleted
1313            // and every assertion here would still have passed.
1314            (
1315                aion::EngineError::WorkflowNotFound {
1316                    workflow_type: "orders".to_owned(),
1317                },
1318                "not_found",
1319            ),
1320            (
1321                aion::EngineError::InvalidState {
1322                    reason: "workflow w run r is Running, not terminal".to_owned(),
1323                },
1324                "invalid_state",
1325            ),
1326            (
1327                aion::EngineError::StartInputRefused {
1328                    workflow_type: "orders".to_owned(),
1329                    version: version.clone(),
1330                    reason: "field `total` is missing".to_owned(),
1331                },
1332                "invalid_input",
1333            ),
1334            (
1335                aion::EngineError::SignalRefused {
1336                    workflow_id: WorkflowId::new_v4(),
1337                    run_id: RunId::new_v4(),
1338                    signal_name: "approve".to_owned(),
1339                    version,
1340                    reason: "undeclared signal".to_owned(),
1341                },
1342                "invalid_input",
1343            ),
1344            (
1345                aion::EngineError::TerminalWriterUnavailable {
1346                    workflow_id: "w".to_owned(),
1347                    run_id: "r".to_owned(),
1348                    holder: "another reservation".to_owned(),
1349                },
1350                "invalid_state",
1351            ),
1352            (
1353                aion::EngineError::TerminalWriterHeld {
1354                    workflow_id: "w".to_owned(),
1355                    run_id: "r".to_owned(),
1356                },
1357                "invalid_state",
1358            ),
1359            (
1360                aion::EngineError::RunIsRecoverable {
1361                    workflow_id: "w".to_owned(),
1362                    run_id: "r".to_owned(),
1363                    version: "abc".to_owned(),
1364                },
1365                "invalid_state",
1366            ),
1367            (
1368                aion::EngineError::NoResidencyVerdict {
1369                    workflow_id: "w".to_owned(),
1370                    run_id: "r".to_owned(),
1371                },
1372                "invalid_state",
1373            ),
1374            (
1375                aion::EngineError::ContractIdentity {
1376                    workflow_type: "orders".to_owned(),
1377                    source: aion::ContractIdentityError::RedeployRequired {
1378                        stored_version: "orders$deadbeef".to_owned(),
1379                    },
1380                },
1381                "invalid_state",
1382            ),
1383            (
1384                aion::EngineError::NoQueueDeclaration {
1385                    workflow_type: "orders".to_owned(),
1386                    version: aion::ContentHash::from_bytes([5u8; 32]),
1387                    activities: "charge_card,send_receipt".to_owned(),
1388                },
1389                "invalid_state",
1390            ),
1391            (aion::EngineError::ShuttingDown, "not_running"),
1392            // The control: a variant this map does NOT name must still land in
1393            // the generic bucket. If it stops doing so, the wildcard has been
1394            // replaced by something that names everything, and the assertions
1395            // above would have passed for the wrong reason.
1396            (
1397                aion::EngineError::Runtime {
1398                    reason: "beamr scheduler refused".to_owned(),
1399                },
1400                "backend",
1401            ),
1402        ]
1403    }
1404
1405    /// The two nested families, each of which reaches this map wrapped in an
1406    /// outer `EngineError` variant rather than as itself.
1407    fn store_and_query_cases() -> Vec<(aion::EngineError, &'static str)> {
1408        use aion_core::WorkflowId;
1409
1410        vec![
1411            (
1412                aion::EngineError::Store(aion_store::StoreError::NotOwner { shard: 3 }),
1413                "not_owner",
1414            ),
1415            // 🔴 The shape a caller actually gets: the fence is raised inside a
1416            // recorded append, so it arrives wrapped in `Durability`.
1417            (
1418                aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1419                    aion_store::StoreError::NotOwner { shard: 3 },
1420                )),
1421                "not_owner",
1422            ),
1423            (
1424                aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1425                    aion_store::StoreError::NotFound {
1426                        workflow_id: WorkflowId::new_v4(),
1427                    },
1428                )),
1429                "not_found",
1430            ),
1431            // The store family's own control: a store fault that is genuinely
1432            // the engine's problem must NOT acquire a caller-facing class just
1433            // because it arrived through the same helper.
1434            (
1435                aion::EngineError::Durability(aion::durability::DurabilityError::Store(
1436                    aion_store::StoreError::SequenceConflict {
1437                        expected: 4,
1438                        found: 7,
1439                    },
1440                )),
1441                "backend",
1442            ),
1443            (
1444                aion::EngineError::Query(aion::QueryError::UnknownQuery("balance".to_owned())),
1445                "unknown_query",
1446            ),
1447            (
1448                aion::EngineError::Query(aion::QueryError::Timeout),
1449                "query_timeout",
1450            ),
1451            (
1452                aion::EngineError::Query(aion::QueryError::NotRunning(WorkflowId::new_v4())),
1453                "not_running",
1454            ),
1455            (
1456                aion::EngineError::Query(aion::QueryError::ReplyDropped),
1457                "not_running",
1458            ),
1459            (
1460                aion::EngineError::Query(aion::QueryError::Unknown(WorkflowId::new_v4())),
1461                "not_found",
1462            ),
1463            (
1464                aion::EngineError::Query(aion::QueryError::HandlerFailed {
1465                    message: "handler panicked".to_owned(),
1466                }),
1467                "query_failed",
1468            ),
1469            // The caller's own arguments were malformed: the request is the
1470            // thing to fix, so this is `invalid_argument` — not the handler's
1471            // failure and not the engine's.
1472            (
1473                aion::EngineError::Query(aion::QueryError::InvalidArguments {
1474                    reason: "arguments payload is not a well-formed JSON document".to_owned(),
1475                }),
1476                "invalid_input",
1477            ),
1478            // The query family's own control, and the eighth of eight arms —
1479            // an earlier revision of this table listed six and left this one
1480            // unmeasured, so a change re-classifying an engine-seam failure as
1481            // a caller-facing one survived green. A seam that could not deliver
1482            // the query is the ENGINE's failure, not the caller's, and must
1483            // land in the generic bucket rather than acquire a remedy the
1484            // caller cannot act on.
1485            (
1486                aion::EngineError::Query(aion::QueryError::Engine(
1487                    aion::engine_seam::EngineSeamError::Delivery {
1488                        reason: "mailbox send failed".to_owned(),
1489                    },
1490                )),
1491                "backend",
1492            ),
1493        ]
1494    }
1495}