Skip to main content

aion_server/api/handlers/
workflows.rs

1//! Start/signal/query/cancel workflow operation handlers.
2
3use aion_proto::{
4    ProtoCancelRequest, ProtoCancelResponse, ProtoPauseRequest, ProtoPauseResponse,
5    ProtoQueryRequest, ProtoQueryResponse, ProtoReopenRequest, ProtoReopenResponse,
6    ProtoResumeRequest, ProtoResumeResponse, ProtoSignalRequest, ProtoSignalResponse,
7    ProtoStartWorkflowRequest, ProtoStartWorkflowResponse, WireError, proto_query_response,
8};
9use tracing::{Instrument, info_span};
10
11use super::error::{
12    cancel_terminal_error, log_server_error, map_start_error, map_workflow_operation_error,
13    signal_terminal_error,
14};
15use super::payload::{required_payload, required_workflow_id};
16use super::runs::{resolve_run_id, terminal_status};
17use crate::{
18    CallerIdentity, NamespaceGuard, NamespaceMinter, NamespaceOperation, ServerError,
19    WorkflowTarget,
20};
21
22/// Handles a decoded start-workflow request.
23///
24/// The authorized namespace is recorded durably as the `aion.namespace` search
25/// attribute in the same atomic append as the workflow's start event, so
26/// ownership survives server restarts and is never tracked only in memory.
27///
28/// # Errors
29///
30/// Returns a stable [`WireError`] when the payload is missing or malformed, namespace scoping fails,
31/// or the engine start call fails.
32pub async fn start(
33    guard: &NamespaceGuard,
34    caller: &CallerIdentity,
35    request: ProtoStartWorkflowRequest,
36) -> Result<ProtoStartWorkflowResponse, WireError> {
37    start_with_placement(guard, caller, request, None, None).await
38}
39
40/// Start a workflow, optionally with a `placement` id chosen by the routing edge
41/// so the new execution lands on a locally-owned shard (R-1 unsteered-start
42/// remint). `placement = None` is the default path: the engine mints the id, so
43/// the single-node / non-clustered behaviour is unchanged.
44///
45/// `minter` is the minted-on-use safety net (Control-Plane Phase 1, S6): when
46/// `Some`, the resolved-and-authorized namespace is durably minted (open) or
47/// gated (closed) BEFORE the engine start, so a client that starts a workflow
48/// before any worker registers still gets a durable namespace record. It is the
49/// SAME [`NamespaceMinter`] policy the worker-registration seam (S5) applies, so
50/// the two transports and the two mint choke-points can never diverge. `None`
51/// disables the mint entirely (every unit test of the bare handler), leaving the
52/// start path byte-identical.
53///
54/// The mint runs AFTER namespace authorization (`guard.scope`), so it is
55/// auth-scoped by construction — it can only record a namespace the caller is
56/// already permitted to start in. It does NOT change the immutable NSTQ
57/// `aion.namespace` binding ([`start_search_attributes`]) or the start response
58/// shape; the mint is purely additive.
59///
60/// # Errors
61///
62/// Identical to [`start`], plus a durable-store failure (a retryable `NotOwner`
63/// fence surfaces as such) or a `closed`-policy namespace-denied error from the
64/// minter, all mapped to a stable [`WireError`].
65pub async fn start_with_placement(
66    guard: &NamespaceGuard,
67    caller: &CallerIdentity,
68    request: ProtoStartWorkflowRequest,
69    placement: Option<aion_core::WorkflowId>,
70    minter: Option<&NamespaceMinter>,
71) -> Result<ProtoStartWorkflowResponse, WireError> {
72    let scoped = guard
73        .scope(caller, &NamespaceOperation::start(&request))
74        .await
75        .map_err(|error| error.to_wire_error())?;
76    let namespace = scoped.namespace().to_owned();
77    // MINT-ON-START safety net (Phase 1 S6). Runs strictly AFTER the namespace
78    // authorization above, so it can only ever mint a namespace the caller is
79    // already authorized to start in — auth-scoped by construction. A `closed`
80    // policy rejects an unknown namespace with the same namespace-denied error;
81    // a quorum `NotOwner` fence propagates as the retryable wire code, never a
82    // silent success. Shares the EXACT S5 policy via `NamespaceMinter`.
83    if let Some(minter) = minter {
84        minter
85            .mint_or_gate(
86                std::slice::from_ref(&namespace),
87                aion_store::NamespaceOrigin::StartMint,
88            )
89            .await
90            .map_err(|error| error.to_wire_error())?;
91    }
92    let input = required_payload(request.input.clone())?;
93    // An empty task_queue means "not selected": fall back to the namespace's
94    // default queue rather than recording an empty selection.
95    let task_queue = request
96        .task_queue
97        .as_deref()
98        .map(str::trim)
99        .filter(|queue| !queue.is_empty());
100    let span = info_span!(
101        "engine_operation",
102        operation = "start",
103        namespace = %namespace,
104        workflow_id = tracing::field::Empty,
105        workflow_type = %request.workflow_type,
106    );
107    let search_attributes = start_search_attributes(&namespace, task_queue);
108    let handle = async {
109        scoped
110            .engine()
111            .map_err(|error| log_server_error("start", Some(&namespace), None, &error))?
112            .start_workflow_with_id(
113                &request.workflow_type,
114                input,
115                search_attributes,
116                namespace.clone(),
117                placement,
118                // Steered-start shard derivation already happened at the edge
119                // (which holds the concrete cluster store); the engine receives
120                // the derived placement id, so no routing key is threaded here.
121                None,
122            )
123            .await
124            .map_err(|error| map_start_error(error, &request.workflow_type))
125    }
126    .instrument(span.clone())
127    .await?;
128    span.record("workflow_id", tracing::field::display(handle.workflow_id()));
129
130    Ok(ProtoStartWorkflowResponse {
131        workflow_id: Some(handle.workflow_id().clone().into()),
132        run_id: Some(handle.run_id().clone().into()),
133    })
134}
135
136/// Search attribute map stamping the authorized namespace — and, when the start
137/// selected one, the default task queue — onto an execution.
138///
139/// Both are recorded in the same atomic append as `WorkflowStarted`, so the
140/// `(namespace, task_queue)` targeting selection survives restarts/failover and
141/// is never tracked only in memory. `task_queue` is omitted when the start did
142/// not select one (the workflow falls back to the namespace's default queue).
143fn start_search_attributes(
144    namespace: &str,
145    task_queue: Option<&str>,
146) -> std::collections::HashMap<String, aion_core::SearchAttributeValue> {
147    let mut attributes = std::collections::HashMap::from([(
148        crate::namespace::NAMESPACE_ATTRIBUTE.to_owned(),
149        aion_core::SearchAttributeValue::String(namespace.to_owned()),
150    )]);
151    if let Some(task_queue) = task_queue {
152        attributes.insert(
153            crate::namespace::TASK_QUEUE_ATTRIBUTE.to_owned(),
154            aion_core::SearchAttributeValue::String(task_queue.to_owned()),
155        );
156    }
157    attributes
158}
159
160/// Handles a decoded signal request.
161///
162/// # Errors
163///
164/// Returns a stable [`WireError`] when IDs or payloads are missing or malformed, namespace scoping
165/// fails, or the engine signal call fails.
166pub async fn signal(
167    guard: &NamespaceGuard,
168    caller: &CallerIdentity,
169    request: ProtoSignalRequest,
170) -> Result<ProtoSignalResponse, WireError> {
171    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
172    let target = WorkflowTarget::workflow(&workflow_id);
173    let scoped = guard
174        .scope(caller, &NamespaceOperation::signal(&request, target))
175        .await
176        .map_err(|error| error.to_wire_error())?;
177    let namespace = scoped.namespace().to_owned();
178    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
179    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
180    let payload = required_payload(request.payload.clone())?;
181    if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
182        return Err(signal_terminal_error(&workflow_id, status));
183    }
184
185    let signal_name = request.signal_name.clone();
186    let span = info_span!(
187        "engine_operation",
188        operation = "signal",
189        namespace = %namespace,
190        workflow_id = %workflow_id,
191        signal_name = %signal_name,
192    );
193
194    async {
195        engine
196            .signal(&workflow_id, &run_id, signal_name, payload)
197            .await
198            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
199    }
200    .instrument(span)
201    .await?;
202
203    Ok(ProtoSignalResponse {})
204}
205
206/// Handles a decoded query request.
207///
208/// # Errors
209///
210/// Returns a stable [`WireError`] when IDs are missing or malformed, namespace scoping fails, or the
211/// engine query call fails.
212pub async fn query(
213    guard: &NamespaceGuard,
214    caller: &CallerIdentity,
215    request: ProtoQueryRequest,
216) -> Result<ProtoQueryResponse, WireError> {
217    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
218    let target = WorkflowTarget::workflow(&workflow_id);
219    let scoped = guard
220        .scope(caller, &NamespaceOperation::query(&request, target))
221        .await
222        .map_err(|error| error.to_wire_error())?;
223    let namespace = scoped.namespace().to_owned();
224    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
225    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
226    let query_name = request.query_name.clone();
227    let span = info_span!(
228        "engine_operation",
229        operation = "query",
230        namespace = %namespace,
231        workflow_id = %workflow_id,
232        query_name = %query_name,
233    );
234
235    let outcome = async { engine.query(&workflow_id, &run_id, query_name).await }
236        .instrument(span)
237        .await;
238
239    match outcome {
240        Ok(result) => Ok(ProtoQueryResponse {
241            outcome: Some(proto_query_response::Outcome::Result(result.into())),
242        }),
243        // Query-semantic failures (unknown query, timeout, not running,
244        // handler failure, reply dropped) are the operation's documented
245        // outcome and ride the QueryResponse.error oneof, which every SDK
246        // query op parses. Namespace, not-found, and backend failures stay
247        // transport-level errors, exactly as for every other operation.
248        Err(error @ aion::EngineError::Query(_)) => Ok(ProtoQueryResponse {
249            outcome: Some(proto_query_response::Outcome::Error(
250                ServerError::from(error).to_wire_error().into(),
251            )),
252        }),
253        Err(error) => Err(map_workflow_operation_error(error, &workflow_id)),
254    }
255}
256
257/// Handles a decoded cancel request.
258///
259/// # Errors
260///
261/// Returns a stable [`WireError`] when IDs are missing or malformed, namespace scoping fails, or the
262/// engine cancel call fails.
263pub async fn cancel(
264    guard: &NamespaceGuard,
265    caller: &CallerIdentity,
266    request: ProtoCancelRequest,
267) -> Result<ProtoCancelResponse, WireError> {
268    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
269    let target = WorkflowTarget::workflow(&workflow_id);
270    let scoped = guard
271        .scope(caller, &NamespaceOperation::cancel(&request, target))
272        .await
273        .map_err(|error| error.to_wire_error())?;
274    let namespace = scoped.namespace().to_owned();
275    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
276    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
277    if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
278        return Err(cancel_terminal_error(&workflow_id, status));
279    }
280
281    let span = info_span!(
282        "engine_operation",
283        operation = "cancel",
284        namespace = %namespace,
285        workflow_id = %workflow_id,
286    );
287
288    async {
289        engine
290            .cancel(&workflow_id, &run_id, request.reason)
291            .await
292            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
293    }
294    .instrument(span)
295    .await?;
296
297    Ok(ProtoCancelResponse {})
298}
299
300/// Handles a decoded reopen request.
301///
302/// Resolves the run (latest when omitted) and calls
303/// [`aion::Engine::reopen_workflow`], returning the reopened run id and its
304/// projected Running status. UNLIKE [`cancel`] this does NOT pre-check terminal
305/// status: the terminal-reopenable precondition is the engine's (AD-012) and the
306/// handler only surfaces its typed [`aion::EngineError::InvalidState`] error.
307///
308/// # Errors
309///
310/// Returns a stable [`WireError`] when IDs are missing or malformed, namespace
311/// scoping fails, or the engine reopen call fails — `invalid_state` for a
312/// non-reopenable-terminal run, `not_found` for an absent workflow.
313pub async fn reopen(
314    guard: &NamespaceGuard,
315    caller: &CallerIdentity,
316    request: ProtoReopenRequest,
317) -> Result<ProtoReopenResponse, WireError> {
318    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
319    let target = WorkflowTarget::workflow(&workflow_id);
320    let scoped = guard
321        .scope(caller, &NamespaceOperation::reopen(&request, target))
322        .await
323        .map_err(|error| error.to_wire_error())?;
324    let namespace = scoped.namespace().to_owned();
325    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
326    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
327
328    let span = info_span!(
329        "engine_operation",
330        operation = "reopen",
331        namespace = %namespace,
332        workflow_id = %workflow_id,
333    );
334
335    let handle = async {
336        engine
337            .reopen_workflow(&workflow_id, &run_id)
338            .await
339            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
340    }
341    .instrument(span)
342    .await?;
343
344    Ok(ProtoReopenResponse {
345        run_id: Some(handle.run_id().clone().into()),
346        status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
347    })
348}
349
350/// Handles a decoded pause request (#204).
351///
352/// Resolves the run (latest when omitted) and calls
353/// [`aion::Engine::pause_workflow`], returning the run id and its projected
354/// `Paused` status. The Running precondition is the engine's; the handler surfaces
355/// its typed [`aion::EngineError::InvalidState`] error verbatim.
356///
357/// # Errors
358///
359/// Returns a stable [`WireError`] when IDs are missing/malformed, namespace
360/// scoping fails, or the engine pause call fails — `invalid_state` when the run is
361/// not Running, `not_found` for an absent workflow.
362pub async fn pause(
363    guard: &NamespaceGuard,
364    caller: &CallerIdentity,
365    request: ProtoPauseRequest,
366) -> Result<ProtoPauseResponse, WireError> {
367    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
368    let target = WorkflowTarget::workflow(&workflow_id);
369    let scoped = guard
370        .scope(
371            caller,
372            &NamespaceOperation::pause_workflow(&request, target),
373        )
374        .await
375        .map_err(|error| error.to_wire_error())?;
376    let namespace = scoped.namespace().to_owned();
377    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
378    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
379    let reason = if request.reason.is_empty() {
380        None
381    } else {
382        Some(request.reason.clone())
383    };
384
385    let span = info_span!(
386        "engine_operation",
387        operation = "pause",
388        namespace = %namespace,
389        workflow_id = %workflow_id,
390    );
391
392    let handle = async {
393        engine
394            .pause_workflow(&workflow_id, &run_id, reason, None)
395            .await
396            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
397    }
398    .instrument(span)
399    .await?;
400
401    Ok(ProtoPauseResponse {
402        run_id: Some(handle.run_id().clone().into()),
403        // Pause projects Paused regardless of the resident handle's cached status
404        // (which stays Running under the dispatch-hold model).
405        status: aion_proto::ProtoWorkflowStatus::Paused as i32,
406    })
407}
408
409/// Handles a decoded resume request (#204).
410///
411/// Resolves the run (latest when omitted) and calls
412/// [`aion::Engine::resume_paused_workflow`], returning the run id and its
413/// projected `Running` status.
414///
415/// # Errors
416///
417/// Returns a stable [`WireError`] when IDs are missing/malformed, namespace
418/// scoping fails, or the engine resume call fails — `invalid_state` when the run
419/// is not Paused, `not_found` for an absent workflow.
420pub async fn resume(
421    guard: &NamespaceGuard,
422    caller: &CallerIdentity,
423    request: ProtoResumeRequest,
424) -> Result<ProtoResumeResponse, WireError> {
425    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
426    let target = WorkflowTarget::workflow(&workflow_id);
427    let scoped = guard
428        .scope(
429            caller,
430            &NamespaceOperation::resume_workflow(&request, target),
431        )
432        .await
433        .map_err(|error| error.to_wire_error())?;
434    let namespace = scoped.namespace().to_owned();
435    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
436    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
437
438    let span = info_span!(
439        "engine_operation",
440        operation = "resume",
441        namespace = %namespace,
442        workflow_id = %workflow_id,
443    );
444
445    let handle = async {
446        engine
447            .resume_paused_workflow(&workflow_id, &run_id, None)
448            .await
449            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
450    }
451    .instrument(span)
452    .await?;
453
454    Ok(ProtoResumeResponse {
455        run_id: Some(handle.run_id().clone().into()),
456        status: aion_proto::ProtoWorkflowStatus::Running as i32,
457    })
458}
459
460#[cfg(test)]
461mod tests {
462    use aion_proto::{WireError, WireErrorCode};
463
464    use super::super::test_support::{
465        NAMESPACE, append_completed, append_failed, append_started, append_timed_out,
466        assert_workflow_not_found, cancel_request, context, denied_guard, proto_payload,
467        query_request, reopen_request, run_id, signal_request, workflow_id,
468    };
469    use super::*;
470
471    #[tokio::test]
472    async fn start_handler_scopes_then_invokes_engine_start()
473    -> Result<(), Box<dyn std::error::Error>> {
474        let context = context().await?;
475        let request = ProtoStartWorkflowRequest {
476            namespace: NAMESPACE.to_owned(),
477            workflow_type: "missing-workflow".to_owned(),
478            input: Some(proto_payload()?),
479            routing_key: None,
480            task_queue: None,
481        };
482
483        let error = start(&context.guard, &context.caller, request).await;
484
485        let error = error
486            .err()
487            .ok_or_else(|| WireError::backend("expected error"))?;
488        assert_eq!(error.code, WireErrorCode::NotFound);
489        assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
490        assert_eq!(
491            error.message,
492            "workflow type missing-workflow is not registered"
493        );
494        Ok(())
495    }
496
497    #[test]
498    fn start_records_namespace_only_when_no_task_queue_selected() {
499        use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
500
501        let attributes = start_search_attributes("tenant-a", None);
502        assert_eq!(
503            attributes.get(NAMESPACE_ATTRIBUTE),
504            Some(&aion_core::SearchAttributeValue::String(
505                "tenant-a".to_owned()
506            ))
507        );
508        // No selection => no task_queue attribute is recorded, so the workflow
509        // falls back to the namespace's default queue.
510        assert!(!attributes.contains_key(TASK_QUEUE_ATTRIBUTE));
511    }
512
513    #[test]
514    fn start_records_selected_task_queue_durably_like_namespace() {
515        use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
516
517        let attributes = start_search_attributes("tenant-a", Some("gpu"));
518        assert_eq!(
519            attributes.get(NAMESPACE_ATTRIBUTE),
520            Some(&aion_core::SearchAttributeValue::String(
521                "tenant-a".to_owned()
522            ))
523        );
524        // The selected task_queue rides the SAME search-attribute map as the
525        // namespace, so it lands in the same atomic WorkflowStarted append and
526        // survives replay/failover exactly as the namespace does.
527        assert_eq!(
528            attributes.get(TASK_QUEUE_ATTRIBUTE),
529            Some(&aion_core::SearchAttributeValue::String("gpu".to_owned()))
530        );
531    }
532
533    #[tokio::test]
534    async fn signal_handler_scopes_then_invokes_engine_signal()
535    -> Result<(), Box<dyn std::error::Error>> {
536        let context = context().await?;
537        context.ownership.record(workflow_id(), NAMESPACE)?;
538
539        let error = signal(&context.guard, &context.caller, signal_request()?).await;
540
541        let error = error
542            .err()
543            .ok_or_else(|| WireError::backend("expected error"))?;
544        assert_eq!(error.code, WireErrorCode::NotFound);
545        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
546        assert_eq!(
547            error.message,
548            format!("workflow {} not found", workflow_id())
549        );
550        Ok(())
551    }
552
553    #[tokio::test]
554    async fn query_handler_scopes_then_invokes_engine_query()
555    -> Result<(), Box<dyn std::error::Error>> {
556        let context = context().await?;
557        context.ownership.record(workflow_id(), NAMESPACE)?;
558
559        let error = query(&context.guard, &context.caller, query_request()).await;
560
561        let error = error
562            .err()
563            .ok_or_else(|| WireError::backend("expected error"))?;
564        assert_eq!(error.code, WireErrorCode::NotFound);
565        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
566        assert_eq!(
567            error.message,
568            format!("workflow {} not found", workflow_id())
569        );
570        Ok(())
571    }
572
573    #[tokio::test]
574    async fn query_handler_returns_not_running_outcome_for_terminal_workflow()
575    -> Result<(), Box<dyn std::error::Error>> {
576        let context = context().await?;
577        context.ownership.record(workflow_id(), NAMESPACE)?;
578        append_completed(context.store.as_ref()).await?;
579        // Resolve the latest run from the chain: the completed history was
580        // recorded for the started run, not the fixed test run id.
581        let mut request = query_request();
582        request.run_id = None;
583
584        let response = query(&context.guard, &context.caller, request).await?;
585
586        // A terminal workflow is a query-semantic outcome: the transport call
587        // succeeds and the typed error rides the QueryResponse.error oneof.
588        let Some(proto_query_response::Outcome::Error(error)) = response.outcome else {
589            return Err("expected a QueryResponse.error outcome".into());
590        };
591        let error = WireError::try_from(error)?;
592        assert_eq!(error.code, WireErrorCode::NotRunning);
593        assert_eq!(error.error_type.as_deref(), Some("QueryNotRunning"));
594        Ok(())
595    }
596
597    #[tokio::test]
598    async fn query_handler_keeps_non_resident_non_terminal_workflow_as_transport_not_found()
599    -> Result<(), Box<dyn std::error::Error>> {
600        // A recorded but non-resident, non-terminal workflow misses the live
601        // registry and has no terminal history, so Engine::query reports
602        // WorkflowNotFound — a transport-level error, never an outcome.error.
603        let context = context().await?;
604        context.ownership.record(workflow_id(), NAMESPACE)?;
605        append_started(context.store.as_ref()).await?;
606        let mut request = query_request();
607        request.run_id = None;
608
609        let error = query(&context.guard, &context.caller, request).await;
610
611        let error = error
612            .err()
613            .ok_or_else(|| WireError::backend("expected error"))?;
614        assert_eq!(error.code, WireErrorCode::NotFound);
615        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
616        Ok(())
617    }
618
619    #[tokio::test]
620    async fn cancel_handler_scopes_then_invokes_engine_cancel()
621    -> Result<(), Box<dyn std::error::Error>> {
622        let context = context().await?;
623        context.ownership.record(workflow_id(), NAMESPACE)?;
624
625        let error = cancel(&context.guard, &context.caller, cancel_request()).await;
626
627        let error = error
628            .err()
629            .ok_or_else(|| WireError::backend("expected error"))?;
630        assert_eq!(error.code, WireErrorCode::NotFound);
631        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
632        assert_eq!(
633            error.message,
634            format!("workflow {} not found", workflow_id())
635        );
636        Ok(())
637    }
638
639    #[tokio::test]
640    async fn reopen_handler_maps_missing_workflow_to_not_found()
641    -> Result<(), Box<dyn std::error::Error>> {
642        let context = context().await?;
643        context.ownership.record(workflow_id(), NAMESPACE)?;
644
645        let error = reopen(&context.guard, &context.caller, reopen_request()).await;
646
647        let error = error
648            .err()
649            .ok_or_else(|| WireError::backend("expected error"))?;
650        assert_eq!(error.code, WireErrorCode::NotFound);
651        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
652        Ok(())
653    }
654
655    #[tokio::test]
656    async fn reopen_handler_rejects_completed_workflow_as_invalid_state()
657    -> Result<(), Box<dyn std::error::Error>> {
658        let context = context().await?;
659        context.ownership.record(workflow_id(), NAMESPACE)?;
660        append_completed(context.store.as_ref()).await?;
661        let mut request = reopen_request();
662        request.run_id = None;
663
664        let error = reopen(&context.guard, &context.caller, request).await;
665
666        let error = error
667            .err()
668            .ok_or_else(|| WireError::backend("expected error"))?;
669        assert_eq!(error.code, WireErrorCode::InvalidState);
670        assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
671        Ok(())
672    }
673
674    #[tokio::test]
675    async fn reopen_handler_rejects_timed_out_workflow_as_invalid_state()
676    -> Result<(), Box<dyn std::error::Error>> {
677        let context = context().await?;
678        context.ownership.record(workflow_id(), NAMESPACE)?;
679        append_timed_out(context.store.as_ref()).await?;
680        let mut request = reopen_request();
681        request.run_id = None;
682
683        let error = reopen(&context.guard, &context.caller, request).await;
684
685        let error = error
686            .err()
687            .ok_or_else(|| WireError::backend("expected error"))?;
688        // TimedOut is a non-reopenable terminal (only Failed and Cancelled reopen).
689        assert_eq!(error.code, WireErrorCode::InvalidState);
690        assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
691        Ok(())
692    }
693
694    #[tokio::test]
695    async fn reopen_handler_maps_omitted_run_missing_workflow_to_not_found()
696    -> Result<(), Box<dyn std::error::Error>> {
697        let context = context().await?;
698        context.ownership.record(workflow_id(), NAMESPACE)?;
699        let mut request = reopen_request();
700        request.run_id = None;
701
702        let error = reopen(&context.guard, &context.caller, request).await;
703
704        assert_workflow_not_found(error)?;
705        Ok(())
706    }
707
708    /// A caller WITHOUT a grant for the target namespace is denied reopen with
709    /// the namespace-denied wire code — mirroring the signal denial test.
710    #[tokio::test]
711    async fn denied_reopen_is_namespace_denied_before_engine_check()
712    -> Result<(), Box<dyn std::error::Error>> {
713        let (guard, caller) = denied_guard();
714        let request = ProtoReopenRequest {
715            namespace: NAMESPACE.to_owned(),
716            workflow_id: Some(workflow_id().into()),
717            run_id: Some(run_id().into()),
718        };
719
720        let error = reopen(&guard, &caller, request).await;
721
722        assert_eq!(
723            error.err().map(|error| error.code),
724            Some(WireErrorCode::NamespaceDenied)
725        );
726        Ok(())
727    }
728
729    #[tokio::test]
730    async fn signal_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
731        let context = context().await?;
732        context.ownership.record(workflow_id(), NAMESPACE)?;
733        append_completed(context.store.as_ref()).await?;
734
735        let error = signal(&context.guard, &context.caller, signal_request()?).await;
736
737        let error = error
738            .err()
739            .ok_or_else(|| WireError::backend("expected error"))?;
740        assert_eq!(error.code, WireErrorCode::NotRunning);
741        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
742        assert_eq!(
743            error.message,
744            format!(
745                "workflow {} has already reached terminal state Completed",
746                workflow_id()
747            )
748        );
749        Ok(())
750    }
751
752    #[tokio::test]
753    async fn signal_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
754        let context = context().await?;
755        context.ownership.record(workflow_id(), NAMESPACE)?;
756        append_failed(context.store.as_ref()).await?;
757
758        let error = signal(&context.guard, &context.caller, signal_request()?).await;
759
760        let error = error
761            .err()
762            .ok_or_else(|| WireError::backend("expected error"))?;
763        assert_eq!(error.code, WireErrorCode::NotRunning);
764        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
765        assert_eq!(
766            error.message,
767            format!(
768                "workflow {} has already reached terminal state Failed",
769                workflow_id()
770            )
771        );
772        Ok(())
773    }
774
775    #[tokio::test]
776    async fn cancel_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
777        let context = context().await?;
778        context.ownership.record(workflow_id(), NAMESPACE)?;
779        append_completed(context.store.as_ref()).await?;
780
781        let error = cancel(&context.guard, &context.caller, cancel_request()).await;
782
783        let error = error
784            .err()
785            .ok_or_else(|| WireError::backend("expected error"))?;
786        assert_eq!(error.code, WireErrorCode::NotRunning);
787        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
788        assert_eq!(
789            error.message,
790            format!(
791                "workflow {} has already completed with status Completed",
792                workflow_id()
793            )
794        );
795        assert!(!error.message.contains("process 0 is not live"));
796        Ok(())
797    }
798
799    #[tokio::test]
800    async fn cancel_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
801        let context = context().await?;
802        context.ownership.record(workflow_id(), NAMESPACE)?;
803        append_failed(context.store.as_ref()).await?;
804
805        let error = cancel(&context.guard, &context.caller, cancel_request()).await;
806
807        let error = error
808            .err()
809            .ok_or_else(|| WireError::backend("expected error"))?;
810        assert_eq!(error.code, WireErrorCode::NotRunning);
811        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
812        assert_eq!(
813            error.message,
814            format!(
815                "workflow {} has already completed with status Failed",
816                workflow_id()
817            )
818        );
819        assert!(!error.message.contains("process 0 is not live"));
820        Ok(())
821    }
822
823    #[tokio::test]
824    async fn signal_handler_maps_omitted_run_missing_workflow_to_not_found()
825    -> Result<(), Box<dyn std::error::Error>> {
826        let context = context().await?;
827        context.ownership.record(workflow_id(), NAMESPACE)?;
828        let mut request = signal_request()?;
829        request.run_id = None;
830
831        let error = signal(&context.guard, &context.caller, request).await;
832
833        assert_workflow_not_found(error)?;
834        Ok(())
835    }
836
837    #[tokio::test]
838    async fn query_handler_maps_omitted_run_missing_workflow_to_not_found()
839    -> Result<(), Box<dyn std::error::Error>> {
840        let context = context().await?;
841        context.ownership.record(workflow_id(), NAMESPACE)?;
842        let mut request = query_request();
843        request.run_id = None;
844
845        let error = query(&context.guard, &context.caller, request).await;
846
847        assert_workflow_not_found(error)?;
848        Ok(())
849    }
850
851    #[tokio::test]
852    async fn cancel_handler_maps_omitted_run_missing_workflow_to_not_found()
853    -> Result<(), Box<dyn std::error::Error>> {
854        let context = context().await?;
855        context.ownership.record(workflow_id(), NAMESPACE)?;
856        let mut request = cancel_request();
857        request.run_id = None;
858
859        let error = cancel(&context.guard, &context.caller, request).await;
860
861        assert_workflow_not_found(error)?;
862        Ok(())
863    }
864
865    #[tokio::test]
866    async fn denied_start_does_not_decode_missing_payload_before_namespace_check()
867    -> Result<(), Box<dyn std::error::Error>> {
868        let (guard, caller) = denied_guard();
869        let request = ProtoStartWorkflowRequest {
870            namespace: NAMESPACE.to_owned(),
871            workflow_type: "fixture".to_owned(),
872            input: None,
873            routing_key: None,
874            task_queue: None,
875        };
876
877        let error = start(&guard, &caller, request).await;
878
879        assert_eq!(
880            error.err().map(|error| error.code),
881            Some(WireErrorCode::NamespaceDenied)
882        );
883        Ok(())
884    }
885
886    #[tokio::test]
887    async fn denied_signal_does_not_decode_missing_payload_before_namespace_check()
888    -> Result<(), Box<dyn std::error::Error>> {
889        let (guard, caller) = denied_guard();
890        let request = ProtoSignalRequest {
891            namespace: NAMESPACE.to_owned(),
892            workflow_id: Some(workflow_id().into()),
893            run_id: Some(run_id().into()),
894            signal_name: "poke".to_owned(),
895            payload: None,
896        };
897
898        let error = signal(&guard, &caller, request).await;
899
900        assert_eq!(
901            error.err().map(|error| error.code),
902            Some(WireErrorCode::NamespaceDenied)
903        );
904        Ok(())
905    }
906
907    // ---- Minted-on-use START safety net (Control-Plane Phase 1, S6) --------
908
909    use std::sync::Arc;
910
911    use aion_store::{NamespaceOrigin, NamespaceStore};
912
913    use crate::config::AutoCreate;
914
915    fn namespace_store() -> Arc<dyn NamespaceStore> {
916        Arc::new(aion_store::InMemoryStore::default())
917    }
918
919    fn minter(store: &Arc<dyn NamespaceStore>, policy: AutoCreate) -> NamespaceMinter {
920        NamespaceMinter::new(Arc::clone(store), policy)
921    }
922
923    fn fresh_start_request() -> Result<ProtoStartWorkflowRequest, aion_core::PayloadError> {
924        Ok(ProtoStartWorkflowRequest {
925            namespace: NAMESPACE.to_owned(),
926            workflow_type: "missing-workflow".to_owned(),
927            input: Some(proto_payload()?),
928            routing_key: None,
929            task_queue: None,
930        })
931    }
932
933    /// A start into a never-before-seen namespace (no worker registered) mints a
934    /// durable record under the open policy, even though the start itself fails
935    /// at the engine (no such workflow type) — the mint runs strictly after
936    /// authorization and before the engine call. A second start is idempotent:
937    /// no duplicate row.
938    #[tokio::test]
939    async fn open_start_mints_durable_record_and_is_idempotent()
940    -> Result<(), Box<dyn std::error::Error>> {
941        let context = context().await?;
942        let store = namespace_store();
943        let minter = minter(&store, AutoCreate::Open);
944
945        // No worker ever registered, so the namespace has no row yet.
946        assert!(store.get_namespace(NAMESPACE).await?.is_none());
947
948        // The start fails at the engine (unknown workflow type) but the mint
949        // already ran: a durable record exists afterwards.
950        let first = start_with_placement(
951            &context.guard,
952            &context.caller,
953            fresh_start_request()?,
954            None,
955            Some(&minter),
956        )
957        .await;
958        assert!(
959            first.is_err(),
960            "the fixture start has no registered workflow type"
961        );
962        let record = store
963            .get_namespace(NAMESPACE)
964            .await?
965            .ok_or("expected a durable record minted by the start")?;
966        assert_eq!(record.name, NAMESPACE);
967        assert_eq!(record.origin, NamespaceOrigin::StartMint);
968
969        // A second start is idempotent: still exactly one row, no duplicate.
970        let _second = start_with_placement(
971            &context.guard,
972            &context.caller,
973            fresh_start_request()?,
974            None,
975            Some(&minter),
976        )
977        .await;
978        let all = store.list_namespaces().await?;
979        assert_eq!(
980            all.iter().filter(|r| r.name == NAMESPACE).count(),
981            1,
982            "a second start must not create a duplicate namespace row"
983        );
984        Ok(())
985    }
986
987    /// Under the closed policy a start into an unknown namespace is rejected with
988    /// the same namespace-denied error the worker-registration seam uses, and the
989    /// namespace is not created.
990    #[tokio::test]
991    async fn closed_start_rejects_unknown_namespace_and_does_not_create_it()
992    -> Result<(), Box<dyn std::error::Error>> {
993        let context = context().await?;
994        let store = namespace_store();
995        let minter = minter(&store, AutoCreate::Closed);
996
997        let denied = start_with_placement(
998            &context.guard,
999            &context.caller,
1000            fresh_start_request()?,
1001            None,
1002            Some(&minter),
1003        )
1004        .await;
1005
1006        let error = denied
1007            .err()
1008            .ok_or_else(|| WireError::backend("expected a namespace-denied error"))?;
1009        assert_eq!(error.code, WireErrorCode::NamespaceDenied);
1010        assert!(
1011            store.get_namespace(NAMESPACE).await?.is_none(),
1012            "closed policy must NOT create the namespace it rejected"
1013        );
1014        Ok(())
1015    }
1016
1017    /// Under the closed policy a start into a namespace that already has a
1018    /// durable record (the `POST /namespaces` escape hatch's effect) is admitted
1019    /// — it proceeds to the engine exactly as the open path does.
1020    #[tokio::test]
1021    async fn closed_start_admits_a_known_namespace() -> Result<(), Box<dyn std::error::Error>> {
1022        let context = context().await?;
1023        let store = namespace_store();
1024        store
1025            .register_namespace(NAMESPACE, NamespaceOrigin::Explicit)
1026            .await?;
1027        let minter = minter(&store, AutoCreate::Closed);
1028
1029        // The known namespace passes the gate, so the start reaches the engine
1030        // and fails only on the unknown workflow type — never on the namespace.
1031        let error = start_with_placement(
1032            &context.guard,
1033            &context.caller,
1034            fresh_start_request()?,
1035            None,
1036            Some(&minter),
1037        )
1038        .await
1039        .err()
1040        .ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
1041        assert_eq!(
1042            error.code,
1043            WireErrorCode::NotFound,
1044            "a known namespace must pass the gate and fail only at the engine"
1045        );
1046        assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
1047        Ok(())
1048    }
1049
1050    /// With no minter installed the start path is byte-identical to before S6:
1051    /// the namespace is never touched and the start reaches the engine as usual.
1052    #[tokio::test]
1053    async fn no_minter_leaves_start_untouched() -> Result<(), Box<dyn std::error::Error>> {
1054        let context = context().await?;
1055        let error = start_with_placement(
1056            &context.guard,
1057            &context.caller,
1058            fresh_start_request()?,
1059            None,
1060            None,
1061        )
1062        .await
1063        .err()
1064        .ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
1065        assert_eq!(error.code, WireErrorCode::NotFound);
1066        assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
1067        Ok(())
1068    }
1069}