Skip to main content

af_workflow/
spec_driver.rs

1//! Bridge from a validated product `Spec` to the durable supervisor.
2//!
3//! Product specs get durable execution without a host-side state machine.
4//! Action steps emit data-only requests; this driver pins and persists them as
5//! `ActionIntent`s before the product provider can run.
6
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde_json::{json, Map, Value};
12
13use crate::{
14    ingress_plans, schedule_decision, ActionIntent, ActionState, CapabilityKind,
15    CapabilityManifest, CatchUpPolicy, EvaluationOutcome, Event, HostError, MemoryState,
16    NodeRegistry, ScheduleCadence, SchedulePolicy, Spec, Terminal, WorkDisposition, WorkItem,
17    WorkflowDriver, WorkflowHost, WorkflowTransitionCommand,
18};
19
20/// Seconds an event-driven instance sleeps between wakeups when no timer or
21/// trigger delivery is pending. Deliveries pull `next_run_at` forward, so this
22/// only bounds how long a stale row stays idle.
23const IDLE_WAIT_SECS: i64 = 86_400;
24
25/// Durable driver compiled from a product spec; see the module docs.
26pub struct SpecDriver {
27    spec_id: String,
28    host: WorkflowHost,
29    schedule: Option<SchedulePolicy>,
30    actions: BTreeMap<String, CapabilityManifest>,
31}
32
33impl SpecDriver {
34    /// Compile `spec`; rejects action nodes without an `Action` manifest and funds actions.
35    pub fn new(spec: &Spec, registry: &NodeRegistry) -> Result<Self, HostError> {
36        let host = WorkflowHost::from_spec(spec, registry)?;
37        let schedule = ingress_plans(spec, registry)
38            .into_iter()
39            .find(|plan| plan.branch_id == host.branch_id())
40            .map(|plan| {
41                let required_u64 = |name: &str| {
42                    plan.ingress_config[name]
43                        .as_u64()
44                        .ok_or_else(|| HostError::Schedule {
45                            spec_id: spec.spec_id.clone(),
46                            reason: format!("{} requires integer '{name}'", plan.ingress_type),
47                        })
48                };
49                let cadence = match plan.ingress_type.as_str() {
50                    "ingress.cron" => ScheduleCadence::Cron {
51                        expression: plan.ingress_config["expression"]
52                            .as_str()
53                            .ok_or_else(|| HostError::Schedule {
54                                spec_id: spec.spec_id.clone(),
55                                reason: "ingress.cron requires a string 'expression'".into(),
56                            })?
57                            .to_owned(),
58                    },
59                    "ingress.fixed_rate" => ScheduleCadence::FixedRate {
60                        milliseconds: required_u64("milliseconds")?,
61                    },
62                    "ingress.fixed_delay" => ScheduleCadence::FixedDelay {
63                        milliseconds: required_u64("milliseconds")?,
64                    },
65                    _ => return Ok(None),
66                };
67                let catch_up = match plan.ingress_config["catch_up"].as_str().unwrap_or("once") {
68                    "skip" => CatchUpPolicy::Skip,
69                    "once" => CatchUpPolicy::CatchUpOnce,
70                    "all" => CatchUpPolicy::CatchUpAll {
71                        limit: plan.ingress_config["catch_up_limit"]
72                            .as_u64()
73                            .and_then(|value| u32::try_from(value).ok())
74                            .ok_or_else(|| HostError::Schedule {
75                                spec_id: spec.spec_id.clone(),
76                                reason: "catch_up=all requires positive integer catch_up_limit"
77                                    .into(),
78                            })?,
79                    },
80                    value => {
81                        return Err(HostError::Schedule {
82                            spec_id: spec.spec_id.clone(),
83                            reason: format!("unknown catch_up policy '{value}'"),
84                        })
85                    }
86                };
87                let policy = SchedulePolicy {
88                    cadence,
89                    timezone: plan.ingress_config["timezone"]
90                        .as_str()
91                        .unwrap_or("UTC")
92                        .to_owned(),
93                    catch_up,
94                };
95                policy.validate().map_err(|error| HostError::Schedule {
96                    spec_id: spec.spec_id.clone(),
97                    reason: error.to_string(),
98                })?;
99                Ok::<_, HostError>(Some(policy))
100            })
101            .transpose()?
102            .flatten();
103        let actions = registry
104            .capability_manifests()
105            .filter(|manifest| manifest.kind == CapabilityKind::Action)
106            .map(|manifest| (manifest.id.clone(), manifest.clone()))
107            .collect();
108        Ok(Self {
109            spec_id: spec.spec_id.clone(),
110            host,
111            schedule,
112            actions,
113        })
114    }
115
116    fn action_intents(
117        &self,
118        item: &WorkItem,
119        requests: Vec<crate::PreparedAction>,
120    ) -> Result<Vec<ActionIntent>, String> {
121        requests
122            .into_iter()
123            .enumerate()
124            .map(|(index, request)| {
125                request.validate().map_err(|error| error.to_string())?;
126                let manifest = self
127                    .actions
128                    .get(request.capability_id.as_str())
129                    .ok_or_else(|| {
130                        format!(
131                            "action step emitted unknown capability '{}'",
132                            request.capability_id
133                        )
134                    })?;
135                let capability = item
136                    .capability_pins
137                    .iter()
138                    .find(|pin| {
139                        pin.id == manifest.id
140                            && pin.contract_version == manifest.contract_version
141                            && pin.content_digest == manifest.content_digest
142                    })
143                    .cloned()
144                    .ok_or_else(|| {
145                        format!(
146                            "workflow revision does not pin action capability '{}'",
147                            manifest.id
148                        )
149                    })?;
150                Ok(ActionIntent {
151                    id: format!("{}:{}:action:{index}", item.id, item.state_version),
152                    tenant_id: item.tenant_id.clone(),
153                    instance_id: item
154                        .id
155                        .parse()
156                        .map_err(|error: af_context::EmptyId| error.to_string())?,
157                    run_id: item.run_id.clone(),
158                    capability,
159                    idempotency_key: format!(
160                        "{}:{}:{}",
161                        item.id, item.state_version, request.idempotency_key
162                    ),
163                    state: ActionState::Prepared,
164                    input: request.input,
165                    effect: manifest.effect,
166                    retry_class: manifest.idempotency_mode,
167                    control_epochs: item.control_epochs,
168                    resource_scope_id: request.resource_scope_id,
169                    lease_epoch: item.lease_version,
170                    action_epoch: item.state_version,
171                    // Provider timeout is per attempt. A product may set a
172                    // separate whole-intent deadline explicitly.
173                    deadline: request.deadline,
174                    reservation: request.reservation,
175                    created_at: item.claimed_at,
176                })
177            })
178            .collect()
179    }
180
181    fn terminal_action(
182        &self,
183        item: &WorkItem,
184        next_state: &mut Map<String, Value>,
185    ) -> Result<Option<WorkflowTransitionCommand>, String> {
186        let Some(wakeup) = item
187            .wakeups
188            .iter()
189            .find(|wakeup| wakeup.payload["kind"] == "terminal_action")
190        else {
191            return Ok(None);
192        };
193        let observation: crate::ActionObservation =
194            serde_json::from_value(wakeup.payload["observation"].clone())
195                .map_err(|error| format!("terminal action fact: {error}"))?;
196        let Some(internal) = next_state
197            .get_mut("__workflow")
198            .and_then(Value::as_object_mut)
199        else {
200            return Err("terminal action has no durable pending-action state".into());
201        };
202        let pending = internal
203            .get_mut("pending_actions")
204            .and_then(Value::as_array_mut)
205            .ok_or_else(|| "terminal action has no pending action list".to_string())?;
206        let before = pending.len();
207        pending.retain(|id| id.as_str() != Some(&observation.action_intent_id));
208        if pending.len() == before {
209            return Err("terminal action does not belong to this workflow instance".into());
210        }
211        let disposition = if pending.is_empty() {
212            internal
213                .remove("resume_at")
214                .map(serde_json::from_value)
215                .transpose()
216                .map_err(|error| format!("stored workflow resume_at: {error}"))?
217                .map(|at| WorkDisposition::Reschedule { at })
218                .unwrap_or_else(|| {
219                    if internal
220                        .get("static_event_consumed")
221                        .and_then(Value::as_bool)
222                        .unwrap_or(false)
223                    {
224                        WorkDisposition::Complete
225                    } else {
226                        Self::idle_disposition()
227                    }
228                })
229        } else {
230            WorkDisposition::Continue {
231                delay_secs: IDLE_WAIT_SECS,
232            }
233        };
234        let succeeded = matches!(
235            observation.state.as_str(),
236            "completed" | "max_steps_reached" | "succeeded"
237        );
238        Ok(Some(command(
239            item,
240            Value::Object(next_state.clone()),
241            Vec::new(),
242            EvaluationOutcome {
243                triggered: true,
244                matched: true,
245                succeeded,
246                action_terminal: true,
247            },
248            disposition,
249            "workflow.action_observed",
250        )))
251    }
252
253    fn catch_up_count(state: &Map<String, Value>) -> u32 {
254        state
255            .get("__workflow")
256            .and_then(Value::as_object)
257            .and_then(|internal| internal.get("catch_up_count"))
258            .and_then(Value::as_u64)
259            .and_then(|value| u32::try_from(value).ok())
260            .unwrap_or(0)
261    }
262
263    fn set_internal(state: &mut Map<String, Value>, key: &str, value: Value) -> Result<(), String> {
264        let internal = state
265            .entry("__workflow")
266            .or_insert_with(|| json!({}))
267            .as_object_mut()
268            .ok_or_else(|| "workflow internal state must be an object".to_string())?;
269        internal.insert(key.into(), value);
270        Ok(())
271    }
272
273    fn schedule_decision(
274        &self,
275        item: &WorkItem,
276        state: &Map<String, Value>,
277    ) -> Result<Option<crate::ScheduleDecision>, String> {
278        if self.schedule.is_some() && !item.wakeups.is_empty() {
279            return Ok(Some(crate::ScheduleDecision {
280                tick_at: None,
281                next_at: item.scheduled_at,
282                catch_up_count: Self::catch_up_count(state),
283            }));
284        }
285        self.schedule
286            .as_ref()
287            .map(|schedule| {
288                schedule_decision(
289                    schedule,
290                    item.scheduled_at,
291                    item.claimed_at,
292                    Self::catch_up_count(state),
293                )
294            })
295            .transpose()
296    }
297
298    fn idle_disposition() -> WorkDisposition {
299        WorkDisposition::Continue {
300            delay_secs: IDLE_WAIT_SECS,
301        }
302    }
303}
304
305#[async_trait]
306impl<Context: Send + Sync> WorkflowDriver<Context> for SpecDriver {
307    fn name(&self) -> &'static str {
308        "spec"
309    }
310
311    fn spec_ids(&self) -> Vec<&str> {
312        vec![&self.spec_id]
313    }
314
315    fn validate_specs(&self) -> Result<(), String> {
316        Ok(())
317    }
318
319    async fn evaluate(
320        &self,
321        _: &Context,
322        item: &WorkItem,
323    ) -> Result<WorkflowTransitionCommand, String> {
324        let mut next_state = match &item.config {
325            Value::Object(map) => map.clone(),
326            Value::Null => Map::new(),
327            _ => return Err("spec instance config must be a JSON object".into()),
328        };
329        if item.cancel_requested {
330            return Ok(command(
331                item,
332                Value::Object(next_state),
333                Vec::new(),
334                EvaluationOutcome::default(),
335                WorkDisposition::Complete,
336                "workflow.cancelled",
337            ));
338        }
339        if let Some(command) = self.terminal_action(item, &mut next_state)? {
340            return Ok(command);
341        }
342        if next_state
343            .get("__workflow")
344            .and_then(Value::as_object)
345            .and_then(|internal| internal.get("pending_actions"))
346            .and_then(Value::as_array)
347            .is_some_and(|pending| !pending.is_empty())
348        {
349            return Ok(command(
350                item,
351                Value::Object(next_state),
352                Vec::new(),
353                EvaluationOutcome::default(),
354                Self::idle_disposition(),
355                "workflow.action_waiting",
356            ));
357        }
358        let schedule = self.schedule_decision(item, &next_state)?;
359        if let Some(decision) = schedule {
360            Self::set_internal(
361                &mut next_state,
362                "catch_up_count",
363                json!(decision.catch_up_count),
364            )?;
365            if decision.tick_at.is_none() && item.wakeups.is_empty() {
366                return Ok(command(
367                    item,
368                    Value::Object(next_state),
369                    Vec::new(),
370                    EvaluationOutcome::default(),
371                    WorkDisposition::Reschedule {
372                        at: decision.next_at,
373                    },
374                    "workflow.schedule_skipped",
375                ));
376            }
377        }
378        let state = Arc::new(MemoryState::from_snapshot(
379            next_state
380                .get("state")
381                .and_then(Value::as_object)
382                .cloned()
383                .unwrap_or_default(),
384        ));
385        let wakeup_payload = item.wakeups.first().map(|wakeup| wakeup.payload.clone());
386        let static_event = next_state.get("event").cloned().filter(|_| {
387            !next_state
388                .get("__workflow")
389                .and_then(Value::as_object)
390                .and_then(|internal| internal.get("static_event_consumed"))
391                .and_then(Value::as_bool)
392                .unwrap_or(false)
393        });
394        let consumed_static_event = wakeup_payload.is_none() && static_event.is_some();
395        let payload = wakeup_payload
396            .or(static_event)
397            .or_else(|| {
398                schedule
399                    .and_then(|decision| decision.tick_at)
400                    .map(|tick_at| json!({ "tick_at": tick_at }))
401            })
402            .ok_or_else(|| "event workflow requires a trigger delivery".to_string())?;
403        if consumed_static_event {
404            Self::set_internal(&mut next_state, "static_event_consumed", json!(true))?;
405        }
406        let context = self.host.context(state.clone());
407        let outcome = self
408            .host
409            .run_event(&context, Event::from_json(payload))
410            .await
411            .ok_or_else(|| "spec has no root branch".to_string())?;
412        let (terminal, exit_reason) = match &outcome.terminal {
413            Terminal::Completed => ("completed", Value::Null),
414            Terminal::Dropped { node_id, reason } => {
415                ("dropped", json!({ "node_id": node_id, "reason": reason }))
416            }
417        };
418        next_state.insert("state".into(), Value::Object(state.snapshot()));
419        next_state.insert(
420            "last_run".into(),
421            json!({
422                "terminal": terminal,
423                "exit": exit_reason,
424                "steps_run": outcome.steps_run,
425                "survivors": outcome.survivors.len(),
426                "at": item.claimed_at,
427            }),
428        );
429        let action_intents = self.action_intents(item, outcome.actions)?;
430        let disposition = match schedule {
431            Some(decision) if action_intents.is_empty() => WorkDisposition::Reschedule {
432                at: decision.next_at,
433            },
434            Some(decision) => {
435                Self::set_internal(&mut next_state, "resume_at", json!(decision.next_at))?;
436                Self::idle_disposition()
437            }
438            None if action_intents.is_empty() && consumed_static_event => WorkDisposition::Complete,
439            None => Self::idle_disposition(),
440        };
441        if !action_intents.is_empty() {
442            let internal = next_state
443                .entry("__workflow")
444                .or_insert_with(|| json!({}))
445                .as_object_mut()
446                .ok_or_else(|| "workflow internal state must be an object".to_string())?;
447            let pending = internal
448                .entry("pending_actions")
449                .or_insert_with(|| json!([]))
450                .as_array_mut()
451                .ok_or_else(|| "workflow pending actions must be an array".to_string())?;
452            pending.extend(action_intents.iter().map(|intent| json!(intent.id)));
453        }
454        let evaluation = EvaluationOutcome {
455            triggered: true,
456            matched: outcome.matched,
457            succeeded: outcome.succeeded && action_intents.is_empty(),
458            action_terminal: false,
459        };
460        Ok(command(
461            item,
462            Value::Object(next_state),
463            action_intents,
464            evaluation,
465            disposition,
466            "workflow.spec_evaluated",
467        ))
468    }
469}
470
471fn command(
472    item: &WorkItem,
473    next_state: Value,
474    action_intents: Vec<ActionIntent>,
475    outcome: EvaluationOutcome,
476    disposition: WorkDisposition,
477    event_type: &str,
478) -> WorkflowTransitionCommand {
479    WorkflowTransitionCommand {
480        delivery_key: format!("spec:{}:{}", item.id, item.state_version),
481        delivery_digest: format!(
482            "{}:{}:{}",
483            item.workflow_revision_digest, item.execution_profile_digest, item.state_version
484        ),
485        event_type: event_type.into(),
486        event_digest: format!("{event_type}:{}:{}", item.id, item.state_version),
487        event_payload: json!({ "spec_id": item.spec_id, "wakeups": item.wakeups.len() }),
488        next_state,
489        action_intents,
490        outcome,
491        disposition,
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::{ControlEpochs, DriverRegistry, PreparedAction, StepNode, StepResult, Wakeup};
499
500    struct PassNode;
501
502    #[async_trait::async_trait]
503    impl StepNode for PassNode {
504        async fn process(&self, event: &Event, _: &crate::WorkflowContext) -> StepResult {
505            StepResult::Pass(event.clone())
506        }
507    }
508
509    struct ActionNode;
510
511    #[async_trait::async_trait]
512    impl StepNode for ActionNode {
513        async fn process(&self, event: &Event, _: &crate::WorkflowContext) -> StepResult {
514            StepResult::Action {
515                event: event.clone(),
516                action: Box::new(PreparedAction::new(
517                    "execute.demo".parse().unwrap(),
518                    "request-1",
519                    json!({"value": 1}),
520                )),
521            }
522        }
523    }
524
525    fn item(spec_id: &str, config: Value) -> WorkItem {
526        WorkItem {
527            id: "instance".into(),
528            run_id: uuid::Uuid::new_v4().to_string().parse().unwrap(),
529            tenant_id: "tenant".parse().unwrap(),
530            subject_id: "subject".parse().unwrap(),
531            spec_id: spec_id.into(),
532            definition_id: spec_id.into(),
533            workflow_revision: 1,
534            workflow_revision_digest: "digest".into(),
535            execution_profile_id: "profile".into(),
536            execution_profile_revision: 1,
537            execution_profile_digest: "profile-digest".into(),
538            kernel_abi_version: "1".into(),
539            capability_pins: Vec::new(),
540            lifecycle: crate::LifecyclePolicy::run_once(),
541            scheduled_at: chrono::Utc::now(),
542            claimed_at: chrono::Utc::now(),
543            config,
544            state_version: 0,
545            control_epochs: ControlEpochs::default(),
546            cancel_requested: false,
547            lease_version: 1,
548            wakeups: Vec::new(),
549        }
550    }
551
552    fn spec(ingress: &str, ingress_config: Value) -> Spec {
553        Spec::from_json(
554            &json!({
555                "spec_id": "counter", "version": "1",
556                "branches": [{
557                    "branch_id": "__root__",
558                    "nodes": [
559                        {"id": "in", "type": ingress, "config": ingress_config},
560                        {"id": "count", "type": "transform.state_append",
561                         "config": {"key": "seen", "path": "value", "max_len": 3}}
562                    ],
563                    "edges": [{"source": "in", "target": "count"}]
564                }]
565            })
566            .to_string(),
567        )
568        .unwrap()
569    }
570
571    #[tokio::test]
572    async fn cron_spec_reschedules_and_persists_branch_state() {
573        let registry = NodeRegistry::with_builtins();
574        let driver = SpecDriver::new(
575            &spec("ingress.cron", json!({"expression": "0 0 * * * *"})),
576            &registry,
577        )
578        .unwrap();
579        let first = WorkflowDriver::<()>::evaluate(
580            &driver,
581            &(),
582            &item("counter", json!({"event": {"value": 1}})),
583        )
584        .await
585        .unwrap();
586        let WorkDisposition::Reschedule { at } = first.disposition else {
587            panic!("cron spec must reschedule");
588        };
589        assert_eq!(first.next_state["last_run"]["terminal"], "completed");
590        let mut next = item("counter", first.next_state);
591        next.scheduled_at = at;
592        next.claimed_at = at;
593        let second = WorkflowDriver::<()>::evaluate(&driver, &(), &next)
594            .await
595            .unwrap();
596        assert_eq!(
597            second.next_state["state"]["__root__.seen"],
598            json!([1]),
599            "the consumed bootstrap event must not be replayed on a cron tick"
600        );
601        assert_eq!(second.next_state["last_run"]["at"], json!(at));
602    }
603
604    #[tokio::test]
605    async fn event_spec_consumes_a_wakeup_then_waits() {
606        let registry = NodeRegistry::with_builtins();
607        let driver = SpecDriver::new(&spec("ingress.event", json!({})), &registry).unwrap();
608        assert_eq!(
609            WorkflowDriver::<()>::evaluate(&driver, &(), &item("counter", json!({})))
610                .await
611                .unwrap_err(),
612            "event workflow requires a trigger delivery"
613        );
614        let mut work = item("counter", json!({}));
615        work.wakeups.push(Wakeup {
616            id: "delivery".into(),
617            kind: "delivery".into(),
618            payload: json!({"value": 7}),
619        });
620        let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
621            .await
622            .unwrap();
623        assert_eq!(command.next_state["state"]["__root__.seen"], json!([7]));
624        assert!(matches!(
625            command.disposition,
626            WorkDisposition::Continue { .. }
627        ));
628        let mut registry = DriverRegistry::<()>::new();
629        registry.register(Arc::new(driver)).unwrap();
630        assert_eq!(registry.spec_ids(), ["counter"]);
631    }
632
633    #[tokio::test]
634    async fn effectful_steps_are_lifted_into_pinned_intents() {
635        let mut registry = NodeRegistry::with_builtins();
636        registry.register_step("guard.auth", |_| Ok(Box::new(PassNode)));
637        registry.register_side_effect_guard("guard.auth");
638        registry.register_step("execute.demo", |_| Ok(Box::new(ActionNode)));
639        let manifest = CapabilityManifest::action(
640            "execute.demo",
641            "1",
642            "demo-digest",
643            crate::Effect::ExternalWrite,
644            crate::IdempotencyMode::Native,
645            true,
646        );
647        registry.register_capability(manifest.clone()).unwrap();
648        let effect = Spec::from_json(
649            &json!({
650                "spec_id": "effect", "version": "1",
651                "branches": [{
652                    "branch_id": "__root__",
653                        "nodes": [
654                        {"id": "in", "type": "ingress.event", "config": {}},
655                        {"id": "auth", "type": "guard.auth", "config": {}},
656                        {"id": "do", "type": "execute.demo", "config": {}}
657                    ],
658                    "edges": [
659                        {"source": "in", "target": "auth"},
660                        {"source": "auth", "target": "do"}
661                    ]
662                }]
663            })
664            .to_string(),
665        )
666        .unwrap();
667        let driver = SpecDriver::new(&effect, &registry).unwrap();
668        let mut work = item("effect", json!({"event": {"value": 1}}));
669        work.capability_pins.push(crate::CapabilityPin {
670            id: manifest.id,
671            contract_version: manifest.contract_version,
672            content_digest: manifest.content_digest,
673        });
674        let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
675            .await
676            .unwrap();
677        assert_eq!(command.action_intents.len(), 1);
678        assert_eq!(command.action_intents[0].state, ActionState::Prepared);
679        assert_eq!(command.action_intents[0].capability.id, "execute.demo");
680        assert_eq!(command.action_intents[0].deadline, None);
681
682        let mut waiting = item("effect", command.next_state.clone());
683        waiting.capability_pins = work.capability_pins.clone();
684        let waiting_command = WorkflowDriver::<()>::evaluate(&driver, &(), &waiting)
685            .await
686            .unwrap();
687        assert!(waiting_command.action_intents.is_empty());
688        assert_eq!(waiting_command.event_type, "workflow.action_waiting");
689
690        waiting.wakeups.push(Wakeup {
691            id: "terminal".into(),
692            kind: "timer".into(),
693            payload: json!({
694                "kind": "terminal_action",
695                "observation": {
696                    "id": "observation",
697                    "action_intent_id": command.action_intents[0].id,
698                    "provider_version": "1",
699                    "observed_at": chrono::Utc::now(),
700                    "state": "rejected",
701                    "resource_ref": null,
702                    "raw_receipt_digest": "receipt",
703                    "terminal": true,
704                    "retry_authorized": false
705                }
706            }),
707        });
708        let terminal = WorkflowDriver::<()>::evaluate(&driver, &(), &waiting)
709            .await
710            .unwrap();
711        assert!(terminal.action_intents.is_empty());
712        assert!(matches!(terminal.disposition, WorkDisposition::Complete));
713        assert!(matches!(
714            SpecDriver::new(&spec("ingress.cron", json!({})), &registry),
715            Err(HostError::Schedule { .. })
716        ));
717    }
718
719    #[tokio::test]
720    async fn delivery_does_not_advance_the_cron_cursor() {
721        let registry = NodeRegistry::with_builtins();
722        let driver = SpecDriver::new(
723            &spec("ingress.cron", json!({"expression": "0 0 * * * *"})),
724            &registry,
725        )
726        .unwrap();
727        let mut work = item("counter", json!({}));
728        work.scheduled_at = work.claimed_at + chrono::Duration::hours(1);
729        work.wakeups.push(Wakeup {
730            id: "delivery".into(),
731            kind: "delivery".into(),
732            payload: json!({"value": 7}),
733        });
734        let command = WorkflowDriver::<()>::evaluate(&driver, &(), &work)
735            .await
736            .unwrap();
737        assert_eq!(command.next_state["state"]["__root__.seen"], json!([7]));
738        assert!(matches!(
739            command.disposition,
740            WorkDisposition::Reschedule { at } if at == work.scheduled_at
741        ));
742    }
743}