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::{optional_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    // An absent arguments field means the caller supplied none; the handler
228    // still receives one well-formed document, the canonical JSON `null`.
229    let arguments = optional_payload(request.arguments.clone())?;
230    let span = info_span!(
231        "engine_operation",
232        operation = "query",
233        namespace = %namespace,
234        workflow_id = %workflow_id,
235        query_name = %query_name,
236    );
237
238    let outcome = async {
239        engine
240            .query(&workflow_id, &run_id, query_name, arguments)
241            .await
242    }
243    .instrument(span)
244    .await;
245
246    match outcome {
247        Ok(result) => Ok(ProtoQueryResponse {
248            outcome: Some(proto_query_response::Outcome::Result(result.into())),
249        }),
250        // Query-semantic failures (unknown query, timeout, not running,
251        // handler failure, reply dropped) are the operation's documented
252        // outcome and ride the QueryResponse.error oneof, which every SDK
253        // query op parses. Namespace, not-found, and backend failures stay
254        // transport-level errors, exactly as for every other operation.
255        Err(error @ aion::EngineError::Query(_)) => Ok(ProtoQueryResponse {
256            outcome: Some(proto_query_response::Outcome::Error(
257                ServerError::from(error).to_wire_error().into(),
258            )),
259        }),
260        Err(error) => Err(map_workflow_operation_error(error, &workflow_id)),
261    }
262}
263
264/// Handles a decoded cancel request.
265///
266/// # Errors
267///
268/// Returns a stable [`WireError`] when IDs are missing or malformed, namespace scoping fails, or the
269/// engine cancel call fails.
270pub async fn cancel(
271    guard: &NamespaceGuard,
272    caller: &CallerIdentity,
273    request: ProtoCancelRequest,
274) -> Result<ProtoCancelResponse, WireError> {
275    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
276    let target = WorkflowTarget::workflow(&workflow_id);
277    let scoped = guard
278        .scope(caller, &NamespaceOperation::cancel(&request, target))
279        .await
280        .map_err(|error| error.to_wire_error())?;
281    let namespace = scoped.namespace().to_owned();
282    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
283    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
284    if let Some(status) = terminal_status(engine.as_ref(), &workflow_id).await? {
285        return Err(cancel_terminal_error(&workflow_id, status));
286    }
287
288    let span = info_span!(
289        "engine_operation",
290        operation = "cancel",
291        namespace = %namespace,
292        workflow_id = %workflow_id,
293    );
294
295    async {
296        engine
297            .cancel(&workflow_id, &run_id, request.reason)
298            .await
299            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
300    }
301    .instrument(span)
302    .await?;
303
304    Ok(ProtoCancelResponse {})
305}
306
307/// Handles a decoded reopen request.
308///
309/// Resolves the run (latest when omitted) and calls
310/// [`aion::Engine::reopen_workflow`], returning the reopened run id and its
311/// projected Running status. UNLIKE [`cancel`] this does NOT pre-check terminal
312/// status: the terminal-reopenable precondition is the engine's (AD-012) and the
313/// handler only surfaces its typed [`aion::EngineError::InvalidState`] error.
314///
315/// # Errors
316///
317/// Returns a stable [`WireError`] when IDs are missing or malformed, namespace
318/// scoping fails, or the engine reopen call fails — `invalid_state` for a
319/// non-reopenable-terminal run, `not_found` for an absent workflow.
320pub async fn reopen(
321    guard: &NamespaceGuard,
322    caller: &CallerIdentity,
323    request: ProtoReopenRequest,
324) -> Result<ProtoReopenResponse, WireError> {
325    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
326    let target = WorkflowTarget::workflow(&workflow_id);
327    let scoped = guard
328        .scope(caller, &NamespaceOperation::reopen(&request, target))
329        .await
330        .map_err(|error| error.to_wire_error())?;
331    let namespace = scoped.namespace().to_owned();
332    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
333    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
334
335    let span = info_span!(
336        "engine_operation",
337        operation = "reopen",
338        namespace = %namespace,
339        workflow_id = %workflow_id,
340    );
341
342    let handle = async {
343        engine
344            .reopen_workflow(&workflow_id, &run_id)
345            .await
346            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
347    }
348    .instrument(span)
349    .await?;
350
351    Ok(ProtoReopenResponse {
352        run_id: Some(handle.run_id().clone().into()),
353        status: aion_proto::ProtoWorkflowStatus::from(handle.cached_status()) as i32,
354    })
355}
356
357/// Handles a decoded pause request (#204).
358///
359/// Resolves the run (latest when omitted) and calls
360/// [`aion::Engine::pause_workflow`], returning the run id and its projected
361/// `Paused` status. The Running precondition is the engine's; the handler surfaces
362/// its typed [`aion::EngineError::InvalidState`] error verbatim.
363///
364/// # Errors
365///
366/// Returns a stable [`WireError`] when IDs are missing/malformed, namespace
367/// scoping fails, or the engine pause call fails — `invalid_state` when the run is
368/// not Running, `not_found` for an absent workflow.
369pub async fn pause(
370    guard: &NamespaceGuard,
371    caller: &CallerIdentity,
372    request: ProtoPauseRequest,
373) -> Result<ProtoPauseResponse, WireError> {
374    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
375    let target = WorkflowTarget::workflow(&workflow_id);
376    let scoped = guard
377        .scope(
378            caller,
379            &NamespaceOperation::pause_workflow(&request, target),
380        )
381        .await
382        .map_err(|error| error.to_wire_error())?;
383    let namespace = scoped.namespace().to_owned();
384    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
385    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
386    let reason = if request.reason.is_empty() {
387        None
388    } else {
389        Some(request.reason.clone())
390    };
391
392    let span = info_span!(
393        "engine_operation",
394        operation = "pause",
395        namespace = %namespace,
396        workflow_id = %workflow_id,
397    );
398
399    let handle = async {
400        engine
401            .pause_workflow(&workflow_id, &run_id, reason, None)
402            .await
403            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
404    }
405    .instrument(span)
406    .await?;
407
408    Ok(ProtoPauseResponse {
409        run_id: Some(handle.run_id().clone().into()),
410        // Pause projects Paused regardless of the resident handle's cached status
411        // (which stays Running under the dispatch-hold model).
412        status: aion_proto::ProtoWorkflowStatus::Paused as i32,
413    })
414}
415
416/// Handles a decoded resume request (#204).
417///
418/// Resolves the run (latest when omitted) and calls
419/// [`aion::Engine::resume_paused_workflow`], returning the run id and its
420/// projected `Running` status.
421///
422/// # Errors
423///
424/// Returns a stable [`WireError`] when IDs are missing/malformed, namespace
425/// scoping fails, or the engine resume call fails — `invalid_state` when the run
426/// is not Paused, `not_found` for an absent workflow.
427pub async fn resume(
428    guard: &NamespaceGuard,
429    caller: &CallerIdentity,
430    request: ProtoResumeRequest,
431) -> Result<ProtoResumeResponse, WireError> {
432    let workflow_id = required_workflow_id(request.workflow_id.clone())?;
433    let target = WorkflowTarget::workflow(&workflow_id);
434    let scoped = guard
435        .scope(
436            caller,
437            &NamespaceOperation::resume_workflow(&request, target),
438        )
439        .await
440        .map_err(|error| error.to_wire_error())?;
441    let namespace = scoped.namespace().to_owned();
442    let engine = scoped.engine().map_err(|error| error.to_wire_error())?;
443    let run_id = resolve_run_id(engine.as_ref(), &workflow_id, request.run_id.clone()).await?;
444
445    let span = info_span!(
446        "engine_operation",
447        operation = "resume",
448        namespace = %namespace,
449        workflow_id = %workflow_id,
450    );
451
452    let handle = async {
453        engine
454            .resume_paused_workflow(&workflow_id, &run_id, None)
455            .await
456            .map_err(|error| map_workflow_operation_error(error, &workflow_id))
457    }
458    .instrument(span)
459    .await?;
460
461    Ok(ProtoResumeResponse {
462        run_id: Some(handle.run_id().clone().into()),
463        status: aion_proto::ProtoWorkflowStatus::Running as i32,
464    })
465}
466
467#[cfg(test)]
468mod tests {
469    use aion_proto::{WireError, WireErrorCode};
470
471    use super::super::test_support::{
472        NAMESPACE, append_completed, append_failed, append_started, append_timed_out,
473        assert_workflow_not_found, cancel_request, context, denied_guard, proto_payload,
474        query_request, reopen_request, run_id, signal_request, workflow_id,
475    };
476    use super::*;
477
478    #[tokio::test]
479    async fn start_handler_scopes_then_invokes_engine_start()
480    -> Result<(), Box<dyn std::error::Error>> {
481        let context = context().await?;
482        let request = ProtoStartWorkflowRequest {
483            namespace: NAMESPACE.to_owned(),
484            workflow_type: "missing-workflow".to_owned(),
485            input: Some(proto_payload()?),
486            routing_key: None,
487            task_queue: None,
488        };
489
490        let error = start(&context.guard, &context.caller, request).await;
491
492        let error = error
493            .err()
494            .ok_or_else(|| WireError::backend("expected error"))?;
495        assert_eq!(error.code, WireErrorCode::NotFound);
496        assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
497        assert_eq!(
498            error.message,
499            "workflow type missing-workflow is not registered"
500        );
501        Ok(())
502    }
503
504    #[test]
505    fn start_records_namespace_only_when_no_task_queue_selected() {
506        use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
507
508        let attributes = start_search_attributes("tenant-a", None);
509        assert_eq!(
510            attributes.get(NAMESPACE_ATTRIBUTE),
511            Some(&aion_core::SearchAttributeValue::String(
512                "tenant-a".to_owned()
513            ))
514        );
515        // No selection => no task_queue attribute is recorded, so the workflow
516        // falls back to the namespace's default queue.
517        assert!(!attributes.contains_key(TASK_QUEUE_ATTRIBUTE));
518    }
519
520    #[test]
521    fn start_records_selected_task_queue_durably_like_namespace() {
522        use crate::namespace::{NAMESPACE_ATTRIBUTE, TASK_QUEUE_ATTRIBUTE};
523
524        let attributes = start_search_attributes("tenant-a", Some("gpu"));
525        assert_eq!(
526            attributes.get(NAMESPACE_ATTRIBUTE),
527            Some(&aion_core::SearchAttributeValue::String(
528                "tenant-a".to_owned()
529            ))
530        );
531        // The selected task_queue rides the SAME search-attribute map as the
532        // namespace, so it lands in the same atomic WorkflowStarted append and
533        // survives replay/failover exactly as the namespace does.
534        assert_eq!(
535            attributes.get(TASK_QUEUE_ATTRIBUTE),
536            Some(&aion_core::SearchAttributeValue::String("gpu".to_owned()))
537        );
538    }
539
540    #[tokio::test]
541    async fn signal_handler_scopes_then_invokes_engine_signal()
542    -> Result<(), Box<dyn std::error::Error>> {
543        let context = context().await?;
544        context.ownership.record(workflow_id(), NAMESPACE)?;
545
546        let error = signal(&context.guard, &context.caller, signal_request()?).await;
547
548        let error = error
549            .err()
550            .ok_or_else(|| WireError::backend("expected error"))?;
551        assert_eq!(error.code, WireErrorCode::NotFound);
552        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
553        assert_eq!(
554            error.message,
555            format!("workflow {} not found", workflow_id())
556        );
557        Ok(())
558    }
559
560    #[tokio::test]
561    async fn query_handler_scopes_then_invokes_engine_query()
562    -> Result<(), Box<dyn std::error::Error>> {
563        let context = context().await?;
564        context.ownership.record(workflow_id(), NAMESPACE)?;
565
566        let error = query(&context.guard, &context.caller, query_request()).await;
567
568        let error = error
569            .err()
570            .ok_or_else(|| WireError::backend("expected error"))?;
571        assert_eq!(error.code, WireErrorCode::NotFound);
572        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
573        assert_eq!(
574            error.message,
575            format!("workflow {} not found", workflow_id())
576        );
577        Ok(())
578    }
579
580    #[tokio::test]
581    async fn query_handler_returns_not_running_outcome_for_terminal_workflow()
582    -> Result<(), Box<dyn std::error::Error>> {
583        let context = context().await?;
584        context.ownership.record(workflow_id(), NAMESPACE)?;
585        append_completed(context.store.as_ref()).await?;
586        // Resolve the latest run from the chain: the completed history was
587        // recorded for the started run, not the fixed test run id.
588        let mut request = query_request();
589        request.run_id = None;
590
591        let response = query(&context.guard, &context.caller, request).await?;
592
593        // A terminal workflow is a query-semantic outcome: the transport call
594        // succeeds and the typed error rides the QueryResponse.error oneof.
595        let Some(proto_query_response::Outcome::Error(error)) = response.outcome else {
596            return Err("expected a QueryResponse.error outcome".into());
597        };
598        let error = WireError::try_from(error)?;
599        assert_eq!(error.code, WireErrorCode::NotRunning);
600        assert_eq!(error.error_type.as_deref(), Some("QueryNotRunning"));
601        Ok(())
602    }
603
604    #[tokio::test]
605    async fn query_handler_keeps_non_resident_non_terminal_workflow_as_transport_not_found()
606    -> Result<(), Box<dyn std::error::Error>> {
607        // A recorded but non-resident, non-terminal workflow misses the live
608        // registry and has no terminal history, so Engine::query reports
609        // WorkflowNotFound — a transport-level error, never an outcome.error.
610        let context = context().await?;
611        context.ownership.record(workflow_id(), NAMESPACE)?;
612        append_started(context.store.as_ref()).await?;
613        let mut request = query_request();
614        request.run_id = None;
615
616        let error = query(&context.guard, &context.caller, request).await;
617
618        let error = error
619            .err()
620            .ok_or_else(|| WireError::backend("expected error"))?;
621        assert_eq!(error.code, WireErrorCode::NotFound);
622        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
623        Ok(())
624    }
625
626    #[tokio::test]
627    async fn cancel_handler_scopes_then_invokes_engine_cancel()
628    -> Result<(), Box<dyn std::error::Error>> {
629        let context = context().await?;
630        context.ownership.record(workflow_id(), NAMESPACE)?;
631
632        let error = cancel(&context.guard, &context.caller, cancel_request()).await;
633
634        let error = error
635            .err()
636            .ok_or_else(|| WireError::backend("expected error"))?;
637        assert_eq!(error.code, WireErrorCode::NotFound);
638        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
639        assert_eq!(
640            error.message,
641            format!("workflow {} not found", workflow_id())
642        );
643        Ok(())
644    }
645
646    #[tokio::test]
647    async fn reopen_handler_maps_missing_workflow_to_not_found()
648    -> Result<(), Box<dyn std::error::Error>> {
649        let context = context().await?;
650        context.ownership.record(workflow_id(), NAMESPACE)?;
651
652        let error = reopen(&context.guard, &context.caller, reopen_request()).await;
653
654        let error = error
655            .err()
656            .ok_or_else(|| WireError::backend("expected error"))?;
657        assert_eq!(error.code, WireErrorCode::NotFound);
658        assert_eq!(error.error_type.as_deref(), Some("WorkflowNotFound"));
659        Ok(())
660    }
661
662    #[tokio::test]
663    async fn reopen_handler_rejects_completed_workflow_as_invalid_state()
664    -> Result<(), Box<dyn std::error::Error>> {
665        let context = context().await?;
666        context.ownership.record(workflow_id(), NAMESPACE)?;
667        append_completed(context.store.as_ref()).await?;
668        let mut request = reopen_request();
669        request.run_id = None;
670
671        let error = reopen(&context.guard, &context.caller, request).await;
672
673        let error = error
674            .err()
675            .ok_or_else(|| WireError::backend("expected error"))?;
676        assert_eq!(error.code, WireErrorCode::InvalidState);
677        assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
678        Ok(())
679    }
680
681    #[tokio::test]
682    async fn reopen_handler_rejects_timed_out_workflow_as_invalid_state()
683    -> Result<(), Box<dyn std::error::Error>> {
684        let context = context().await?;
685        context.ownership.record(workflow_id(), NAMESPACE)?;
686        append_timed_out(context.store.as_ref()).await?;
687        let mut request = reopen_request();
688        request.run_id = None;
689
690        let error = reopen(&context.guard, &context.caller, request).await;
691
692        let error = error
693            .err()
694            .ok_or_else(|| WireError::backend("expected error"))?;
695        // TimedOut is a non-reopenable terminal (only Failed and Cancelled reopen).
696        assert_eq!(error.code, WireErrorCode::InvalidState);
697        assert_eq!(error.error_type.as_deref(), Some("InvalidState"));
698        Ok(())
699    }
700
701    #[tokio::test]
702    async fn reopen_handler_maps_omitted_run_missing_workflow_to_not_found()
703    -> Result<(), Box<dyn std::error::Error>> {
704        let context = context().await?;
705        context.ownership.record(workflow_id(), NAMESPACE)?;
706        let mut request = reopen_request();
707        request.run_id = None;
708
709        let error = reopen(&context.guard, &context.caller, request).await;
710
711        assert_workflow_not_found(error)?;
712        Ok(())
713    }
714
715    /// A caller WITHOUT a grant for the target namespace is denied reopen with
716    /// the namespace-denied wire code — mirroring the signal denial test.
717    #[tokio::test]
718    async fn denied_reopen_is_namespace_denied_before_engine_check()
719    -> Result<(), Box<dyn std::error::Error>> {
720        let (guard, caller) = denied_guard();
721        let request = ProtoReopenRequest {
722            namespace: NAMESPACE.to_owned(),
723            workflow_id: Some(workflow_id().into()),
724            run_id: Some(run_id().into()),
725        };
726
727        let error = reopen(&guard, &caller, request).await;
728
729        assert_eq!(
730            error.err().map(|error| error.code),
731            Some(WireErrorCode::NamespaceDenied)
732        );
733        Ok(())
734    }
735
736    #[tokio::test]
737    async fn signal_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
738        let context = context().await?;
739        context.ownership.record(workflow_id(), NAMESPACE)?;
740        append_completed(context.store.as_ref()).await?;
741
742        let error = signal(&context.guard, &context.caller, signal_request()?).await;
743
744        let error = error
745            .err()
746            .ok_or_else(|| WireError::backend("expected error"))?;
747        assert_eq!(error.code, WireErrorCode::NotRunning);
748        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
749        assert_eq!(
750            error.message,
751            format!(
752                "workflow {} has already reached terminal state Completed",
753                workflow_id()
754            )
755        );
756        Ok(())
757    }
758
759    #[tokio::test]
760    async fn signal_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
761        let context = context().await?;
762        context.ownership.record(workflow_id(), NAMESPACE)?;
763        append_failed(context.store.as_ref()).await?;
764
765        let error = signal(&context.guard, &context.caller, signal_request()?).await;
766
767        let error = error
768            .err()
769            .ok_or_else(|| WireError::backend("expected error"))?;
770        assert_eq!(error.code, WireErrorCode::NotRunning);
771        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
772        assert_eq!(
773            error.message,
774            format!(
775                "workflow {} has already reached terminal state Failed",
776                workflow_id()
777            )
778        );
779        Ok(())
780    }
781
782    #[tokio::test]
783    async fn cancel_handler_rejects_completed_workflow() -> Result<(), Box<dyn std::error::Error>> {
784        let context = context().await?;
785        context.ownership.record(workflow_id(), NAMESPACE)?;
786        append_completed(context.store.as_ref()).await?;
787
788        let error = cancel(&context.guard, &context.caller, cancel_request()).await;
789
790        let error = error
791            .err()
792            .ok_or_else(|| WireError::backend("expected error"))?;
793        assert_eq!(error.code, WireErrorCode::NotRunning);
794        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
795        assert_eq!(
796            error.message,
797            format!(
798                "workflow {} has already completed with status Completed",
799                workflow_id()
800            )
801        );
802        assert!(!error.message.contains("process 0 is not live"));
803        Ok(())
804    }
805
806    #[tokio::test]
807    async fn cancel_handler_rejects_failed_workflow() -> Result<(), Box<dyn std::error::Error>> {
808        let context = context().await?;
809        context.ownership.record(workflow_id(), NAMESPACE)?;
810        append_failed(context.store.as_ref()).await?;
811
812        let error = cancel(&context.guard, &context.caller, cancel_request()).await;
813
814        let error = error
815            .err()
816            .ok_or_else(|| WireError::backend("expected error"))?;
817        assert_eq!(error.code, WireErrorCode::NotRunning);
818        assert_eq!(error.error_type.as_deref(), Some("WorkflowTerminal"));
819        assert_eq!(
820            error.message,
821            format!(
822                "workflow {} has already completed with status Failed",
823                workflow_id()
824            )
825        );
826        assert!(!error.message.contains("process 0 is not live"));
827        Ok(())
828    }
829
830    #[tokio::test]
831    async fn signal_handler_maps_omitted_run_missing_workflow_to_not_found()
832    -> Result<(), Box<dyn std::error::Error>> {
833        let context = context().await?;
834        context.ownership.record(workflow_id(), NAMESPACE)?;
835        let mut request = signal_request()?;
836        request.run_id = None;
837
838        let error = signal(&context.guard, &context.caller, request).await;
839
840        assert_workflow_not_found(error)?;
841        Ok(())
842    }
843
844    #[tokio::test]
845    async fn query_handler_maps_omitted_run_missing_workflow_to_not_found()
846    -> Result<(), Box<dyn std::error::Error>> {
847        let context = context().await?;
848        context.ownership.record(workflow_id(), NAMESPACE)?;
849        let mut request = query_request();
850        request.run_id = None;
851
852        let error = query(&context.guard, &context.caller, request).await;
853
854        assert_workflow_not_found(error)?;
855        Ok(())
856    }
857
858    #[tokio::test]
859    async fn cancel_handler_maps_omitted_run_missing_workflow_to_not_found()
860    -> Result<(), Box<dyn std::error::Error>> {
861        let context = context().await?;
862        context.ownership.record(workflow_id(), NAMESPACE)?;
863        let mut request = cancel_request();
864        request.run_id = None;
865
866        let error = cancel(&context.guard, &context.caller, request).await;
867
868        assert_workflow_not_found(error)?;
869        Ok(())
870    }
871
872    #[tokio::test]
873    async fn denied_start_does_not_decode_missing_payload_before_namespace_check()
874    -> Result<(), Box<dyn std::error::Error>> {
875        let (guard, caller) = denied_guard();
876        let request = ProtoStartWorkflowRequest {
877            namespace: NAMESPACE.to_owned(),
878            workflow_type: "fixture".to_owned(),
879            input: None,
880            routing_key: None,
881            task_queue: None,
882        };
883
884        let error = start(&guard, &caller, request).await;
885
886        assert_eq!(
887            error.err().map(|error| error.code),
888            Some(WireErrorCode::NamespaceDenied)
889        );
890        Ok(())
891    }
892
893    #[tokio::test]
894    async fn denied_signal_does_not_decode_missing_payload_before_namespace_check()
895    -> Result<(), Box<dyn std::error::Error>> {
896        let (guard, caller) = denied_guard();
897        let request = ProtoSignalRequest {
898            namespace: NAMESPACE.to_owned(),
899            workflow_id: Some(workflow_id().into()),
900            run_id: Some(run_id().into()),
901            signal_name: "poke".to_owned(),
902            payload: None,
903        };
904
905        let error = signal(&guard, &caller, request).await;
906
907        assert_eq!(
908            error.err().map(|error| error.code),
909            Some(WireErrorCode::NamespaceDenied)
910        );
911        Ok(())
912    }
913
914    // ---- Minted-on-use START safety net (Control-Plane Phase 1, S6) --------
915
916    use std::sync::Arc;
917
918    use aion_store::{NamespaceOrigin, NamespaceStore};
919
920    use crate::config::AutoCreate;
921
922    fn namespace_store() -> Arc<dyn NamespaceStore> {
923        Arc::new(aion_store::InMemoryStore::default())
924    }
925
926    fn minter(store: &Arc<dyn NamespaceStore>, policy: AutoCreate) -> NamespaceMinter {
927        NamespaceMinter::new(Arc::clone(store), policy)
928    }
929
930    fn fresh_start_request() -> Result<ProtoStartWorkflowRequest, aion_core::PayloadError> {
931        Ok(ProtoStartWorkflowRequest {
932            namespace: NAMESPACE.to_owned(),
933            workflow_type: "missing-workflow".to_owned(),
934            input: Some(proto_payload()?),
935            routing_key: None,
936            task_queue: None,
937        })
938    }
939
940    /// A start into a never-before-seen namespace (no worker registered) mints a
941    /// durable record under the open policy, even though the start itself fails
942    /// at the engine (no such workflow type) — the mint runs strictly after
943    /// authorization and before the engine call. A second start is idempotent:
944    /// no duplicate row.
945    #[tokio::test]
946    async fn open_start_mints_durable_record_and_is_idempotent()
947    -> Result<(), Box<dyn std::error::Error>> {
948        let context = context().await?;
949        let store = namespace_store();
950        let minter = minter(&store, AutoCreate::Open);
951
952        // No worker ever registered, so the namespace has no row yet.
953        assert!(store.get_namespace(NAMESPACE).await?.is_none());
954
955        // The start fails at the engine (unknown workflow type) but the mint
956        // already ran: a durable record exists afterwards.
957        let first = start_with_placement(
958            &context.guard,
959            &context.caller,
960            fresh_start_request()?,
961            None,
962            Some(&minter),
963        )
964        .await;
965        assert!(
966            first.is_err(),
967            "the fixture start has no registered workflow type"
968        );
969        let record = store
970            .get_namespace(NAMESPACE)
971            .await?
972            .ok_or("expected a durable record minted by the start")?;
973        assert_eq!(record.name, NAMESPACE);
974        assert_eq!(record.origin, NamespaceOrigin::StartMint);
975
976        // A second start is idempotent: still exactly one row, no duplicate.
977        let _second = start_with_placement(
978            &context.guard,
979            &context.caller,
980            fresh_start_request()?,
981            None,
982            Some(&minter),
983        )
984        .await;
985        let all = store.list_namespaces().await?;
986        assert_eq!(
987            all.iter().filter(|r| r.name == NAMESPACE).count(),
988            1,
989            "a second start must not create a duplicate namespace row"
990        );
991        Ok(())
992    }
993
994    /// Under the closed policy a start into an unknown namespace is rejected with
995    /// the same namespace-denied error the worker-registration seam uses, and the
996    /// namespace is not created.
997    #[tokio::test]
998    async fn closed_start_rejects_unknown_namespace_and_does_not_create_it()
999    -> Result<(), Box<dyn std::error::Error>> {
1000        let context = context().await?;
1001        let store = namespace_store();
1002        let minter = minter(&store, AutoCreate::Closed);
1003
1004        let denied = start_with_placement(
1005            &context.guard,
1006            &context.caller,
1007            fresh_start_request()?,
1008            None,
1009            Some(&minter),
1010        )
1011        .await;
1012
1013        let error = denied
1014            .err()
1015            .ok_or_else(|| WireError::backend("expected a namespace-denied error"))?;
1016        assert_eq!(error.code, WireErrorCode::NamespaceDenied);
1017        assert!(
1018            store.get_namespace(NAMESPACE).await?.is_none(),
1019            "closed policy must NOT create the namespace it rejected"
1020        );
1021        Ok(())
1022    }
1023
1024    /// Under the closed policy a start into a namespace that already has a
1025    /// durable record (the `POST /namespaces` escape hatch's effect) is admitted
1026    /// — it proceeds to the engine exactly as the open path does.
1027    #[tokio::test]
1028    async fn closed_start_admits_a_known_namespace() -> Result<(), Box<dyn std::error::Error>> {
1029        let context = context().await?;
1030        let store = namespace_store();
1031        store
1032            .register_namespace(NAMESPACE, NamespaceOrigin::Explicit)
1033            .await?;
1034        let minter = minter(&store, AutoCreate::Closed);
1035
1036        // The known namespace passes the gate, so the start reaches the engine
1037        // and fails only on the unknown workflow type — never on the namespace.
1038        let error = start_with_placement(
1039            &context.guard,
1040            &context.caller,
1041            fresh_start_request()?,
1042            None,
1043            Some(&minter),
1044        )
1045        .await
1046        .err()
1047        .ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
1048        assert_eq!(
1049            error.code,
1050            WireErrorCode::NotFound,
1051            "a known namespace must pass the gate and fail only at the engine"
1052        );
1053        assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
1054        Ok(())
1055    }
1056
1057    /// With no minter installed the start path is byte-identical to before S6:
1058    /// the namespace is never touched and the start reaches the engine as usual.
1059    #[tokio::test]
1060    async fn no_minter_leaves_start_untouched() -> Result<(), Box<dyn std::error::Error>> {
1061        let context = context().await?;
1062        let error = start_with_placement(
1063            &context.guard,
1064            &context.caller,
1065            fresh_start_request()?,
1066            None,
1067            None,
1068        )
1069        .await
1070        .err()
1071        .ok_or_else(|| WireError::backend("expected the fixture workflow-type miss"))?;
1072        assert_eq!(error.code, WireErrorCode::NotFound);
1073        assert_eq!(error.error_type.as_deref(), Some("WorkflowTypeNotFound"));
1074        Ok(())
1075    }
1076}