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