Skip to main content

af_workflow/
contract.rs

1//! Stable contracts for durable workflow execution.
2//!
3//! These types describe immutable definitions, accepted source facts and
4//! external effects. Products provide capabilities; the workflow kernel owns
5//! lifecycle, fencing and persistence semantics.
6
7use af_context::{InstanceId, RunId, SubjectId, TenantId};
8use std::collections::{BTreeMap, BTreeSet};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14/// Named workflow whose revisions are immutable.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct WorkflowDefinition {
17    /// Stable identifier of this record.
18    pub id: String,
19    /// Display name.
20    pub name: String,
21}
22
23/// One durable instance pinned to a revision and execution profile for its lifetime.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct WorkflowInstance {
26    /// Stable identifier of this record.
27    pub id: String,
28    /// Tenant that owns this record.
29    pub tenant_id: TenantId,
30    /// Subject (user or service principal) acting on or owning this record.
31    pub subject_id: SubjectId,
32    /// Workflow definition this record belongs to.
33    pub definition_id: String,
34    /// Monotonic revision number.
35    pub revision: u64,
36    /// Execution profile the instance pins.
37    pub execution_profile_id: String,
38    /// Pinned execution profile revision.
39    pub execution_profile_revision: u64,
40    /// Lifecycle policy applied on database time.
41    pub lifecycle: LifecyclePolicy,
42    /// Current lifecycle status.
43    pub status: String,
44    /// CAS version of the authoritative state.
45    pub state_version: i64,
46    /// Sequence of the last authoritative event.
47    pub event_sequence: i64,
48    /// Instance control epoch; pause/resume bump it and stale workers fail.
49    pub control_epoch: i64,
50}
51
52/// Subscription of an instance to a source event type, with a JSON-containment predicate.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct TriggerBinding {
55    /// Stable identifier of this record.
56    pub id: String,
57    /// Monotonic revision number.
58    pub revision: u64,
59    /// Source the binding subscribes to.
60    pub source: String,
61    /// Stable machine-readable event type.
62    pub event_type: String,
63    /// Workflow instance this record refers to.
64    pub instance_id: InstanceId,
65    /// Target branch; omitted legacy requests resolve only for a single-branch spec.
66    #[serde(default)]
67    pub branch_id: Option<af_context::BranchId>,
68    /// JSON object the event payload must contain (`@>`) to create a delivery.
69    pub predicate: Value,
70    /// How out-of-order source sequences are handled.
71    pub ordering: OrderingPolicy,
72    /// Earliest time the binding or instance is active.
73    pub starts_at: Option<DateTime<Utc>>,
74    /// When the record stops being valid.
75    pub expires_at: Option<DateTime<Utc>>,
76    /// How long a missing source sequence may block before it is reported as a gap.
77    #[serde(default = "default_gap_wait_ms")]
78    pub gap_wait_ms: u64,
79    /// Maximum buffered out-of-order deliveries before the source is blocked.
80    #[serde(default = "default_gap_limit")]
81    pub gap_limit: u32,
82}
83
84impl TriggerBinding {
85    /// Reject blank identifiers, out-of-range gap settings and inverted windows.
86    pub fn validate(&self) -> Result<(), ContractError> {
87        for (name, value) in [
88            ("trigger binding id", self.id.as_str()),
89            ("trigger source", self.source.as_str()),
90            ("trigger event_type", self.event_type.as_str()),
91            ("trigger instance_id", self.instance_id.as_str()),
92        ] {
93            required(name, value)?;
94        }
95        if self.gap_wait_ms == 0 || self.gap_wait_ms > 3_600_000 {
96            return Err(ContractError::Invalid(
97                "trigger gap_wait_ms must be between 1 and 3600000".into(),
98            ));
99        }
100        if self.gap_limit == 0 || self.gap_limit > 10_000 {
101            return Err(ContractError::Invalid(
102                "trigger gap_limit must be between 1 and 10000".into(),
103            ));
104        }
105        if !self.predicate.is_object() {
106            return Err(ContractError::Invalid(
107                "trigger predicate must be a JSON object".into(),
108            ));
109        }
110        if matches!((self.starts_at, self.expires_at), (Some(start), Some(end)) if end <= start) {
111            return Err(ContractError::Invalid(
112                "trigger expires_at must be after starts_at".into(),
113            ));
114        }
115        Ok(())
116    }
117}
118
119const fn default_gap_wait_ms() -> u64 {
120    30_000
121}
122
123const fn default_gap_limit() -> u32 {
124    100
125}
126
127/// Authoritative, append-only fact recorded by a committed transition.
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct WorkflowEvent {
130    /// Workflow instance this record refers to.
131    pub instance_id: InstanceId,
132    /// Position in the instance's event log.
133    pub sequence: i64,
134    /// Stable machine-readable event type.
135    pub event_type: String,
136    /// Structured payload.
137    pub payload: Value,
138    /// Content hash that makes the referenced artifact immutable.
139    pub content_digest: String,
140    /// When the event happened.
141    pub occurred_at: DateTime<Utc>,
142}
143
144/// Where an execution profile is allowed to act.
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum ExecutionMode {
148    /// In-memory dry run; no external effects.
149    Simulation,
150    /// Historical replay.
151    Backtest,
152    /// Live data, simulated effects.
153    Paper,
154    /// Real external effects.
155    Live,
156}
157
158/// Durability the deployment must provide for an execution profile.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "snake_case")]
161pub enum DurabilityGrade {
162    /// Up to five minutes RPO for ordinary work.
163    Standard,
164    /// Committed intents synchronously preserved before dispatch; required for funds actions.
165    FundsGrade,
166}
167
168/// Immutable execution profile: mode, durability grade and provider bindings an instance pins.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct ExecutionProfileRevision {
171    /// Stable identifier of this record.
172    pub id: String,
173    /// Monotonic revision number.
174    pub revision: u64,
175    /// Content hash that makes the referenced artifact immutable.
176    pub content_digest: String,
177    /// Execution mode this record was produced under.
178    pub mode: ExecutionMode,
179    /// Durability the deployment guarantees for this profile.
180    pub durability_grade: DurabilityGrade,
181    /// Provider that feeds triggers.
182    pub trigger_provider: String,
183    /// Provider that feeds market or reference data.
184    pub data_provider: String,
185    /// Clock source; `database` is the only kernel-supported value.
186    pub clock_model: String,
187    /// Provider that dispatches actions.
188    pub action_provider: String,
189    /// Model bindings by role.
190    #[serde(default)]
191    pub models: BTreeMap<String, Value>,
192    /// Environment settings the product interprets.
193    #[serde(default)]
194    pub environment: Value,
195    /// Product policy bundle applied to permissions and guards.
196    #[serde(default)]
197    pub policy_bundle: Value,
198    /// Named external connections by role.
199    #[serde(default)]
200    pub connection_bindings: BTreeMap<String, String>,
201}
202
203impl ExecutionProfileRevision {
204    /// Reject blank identifiers and provider names.
205    pub fn validate(&self) -> Result<(), ContractError> {
206        for (name, value) in [
207            ("execution profile id", self.id.as_str()),
208            (
209                "execution profile content_digest",
210                self.content_digest.as_str(),
211            ),
212            ("trigger_provider", self.trigger_provider.as_str()),
213            ("data_provider", self.data_provider.as_str()),
214            ("clock_model", self.clock_model.as_str()),
215            ("action_provider", self.action_provider.as_str()),
216        ] {
217            required(name, value)?;
218        }
219        Ok(())
220    }
221}
222
223/// Exact capability identity: id, contract version and content digest.
224#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
225pub struct CapabilityPin {
226    /// Stable identifier of this record.
227    pub id: String,
228    /// Contract version of the capability.
229    pub contract_version: String,
230    /// Content hash that makes the referenced artifact immutable.
231    pub content_digest: String,
232}
233
234/// Immutable, digest-locked revision of a workflow spec with its pinned capabilities.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct WorkflowRevision {
237    /// Workflow definition this record belongs to.
238    pub definition_id: String,
239    /// Monotonic revision number.
240    pub revision: u64,
241    /// Content hash that makes the referenced artifact immutable.
242    pub content_digest: String,
243    /// Kernel ABI the revision was validated against.
244    pub kernel_abi_version: String,
245    /// Digest over the pinned capabilities.
246    pub dependency_set_digest: String,
247    /// Versions of generic expressions used by the spec.
248    #[serde(default)]
249    pub expression_versions: BTreeMap<String, String>,
250    /// Capabilities the spec may dispatch.
251    #[serde(default)]
252    pub capabilities: Vec<CapabilityPin>,
253    /// Where the spec came from (template, draft command, author).
254    #[serde(default)]
255    pub template_provenance: Value,
256    /// The validated spec.
257    pub spec: crate::Spec,
258}
259
260impl WorkflowRevision {
261    /// Check this contract's invariants; returns the first violation.
262    pub fn validate(&self) -> Result<(), ContractError> {
263        required("definition_id", &self.definition_id)?;
264        required("content_digest", &self.content_digest)?;
265        required("kernel_abi_version", &self.kernel_abi_version)?;
266        required("dependency_set_digest", &self.dependency_set_digest)?;
267        self.spec
268            .validate_structure()
269            .map_err(|error| ContractError::Invalid(error.to_string()))?;
270        let unique = self
271            .capabilities
272            .iter()
273            .map(|pin| &pin.id)
274            .collect::<BTreeSet<_>>();
275        if unique.len() != self.capabilities.len() {
276            return Err(ContractError::Invalid(
277                "capability pins must be unique by id within one revision".into(),
278            ));
279        }
280        for pin in &self.capabilities {
281            required("capability id", &pin.id)?;
282            required("capability contract_version", &pin.contract_version)?;
283            required("capability content_digest", &pin.content_digest)?;
284        }
285        Ok(())
286    }
287}
288
289/// Role a capability plays in a graph.
290#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
291#[serde(rename_all = "snake_case")]
292pub enum CapabilityKind {
293    /// Produces events.
294    Trigger,
295    /// Pure or read-only transform.
296    Expression,
297    /// Authorization, freshness, reservation or policy check dominating an action.
298    Guard,
299    /// External effect dispatched through an intent.
300    Action,
301    /// Terminal consumer.
302    Sink,
303}
304
305/// Side-effect class of a capability.
306#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
307#[serde(rename_all = "snake_case")]
308pub enum Effect {
309    /// No I/O.
310    Pure,
311    /// Reads external state.
312    Read,
313    /// Writes kernel-owned state only.
314    InternalWrite,
315    /// Writes external state; requires authorization dominance.
316    ExternalWrite,
317    /// Moves value; requires authorization, freshness, reservation and funds-grade durability.
318    Funds,
319}
320
321/// Whether repeating a dispatch is safe.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "snake_case")]
324pub enum IdempotencyMode {
325    /// Not idempotent; never retried automatically.
326    None,
327    /// The provider deduplicates by idempotency key; safe to retry.
328    Native,
329    /// Must reconcile the previous attempt before dispatching again.
330    ReconcileBeforeRetry,
331    /// Retry only through an explicit operator action.
332    NeverAutomaticRetry,
333}
334
335/// Deployment state of a capability provider.
336#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
337#[serde(rename_all = "snake_case")]
338pub enum CapabilityLifecycle {
339    /// Registered but not yet serving.
340    Installed,
341    /// Serving new and existing work.
342    Active,
343    /// Serving pinned work only; new pins rejected.
344    Deprecated,
345    /// Not serving; pinned work waits.
346    Disabled,
347    /// Temporarily unreachable.
348    Unavailable,
349    /// Blocks every action immediately.
350    EmergencyRevoked,
351}
352
353/// Automatic retry policy of a capability.
354#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355pub struct RetryPolicy {
356    /// Total dispatch attempts allowed.
357    pub max_attempts: u32,
358    /// Per-attempt deadline.
359    pub timeout_ms: u64,
360    /// Backoff after the first failure.
361    pub initial_backoff_ms: u64,
362    /// Cap on exponential backoff.
363    pub max_backoff_ms: u64,
364}
365
366impl RetryPolicy {
367    /// Exponential backoff for the 1-based `attempt`, capped at `max_backoff_ms`
368    /// with up to 25% deterministic jitter derived from `jitter_seed`.
369    pub fn backoff_ms(&self, attempt: u32, jitter_seed: u64) -> u64 {
370        let factor = 1_u64
371            .checked_shl(attempt.saturating_sub(1).min(20))
372            .unwrap_or(u64::MAX);
373        let base = self
374            .initial_backoff_ms
375            .saturating_mul(factor)
376            .min(self.max_backoff_ms);
377        let jitter_ceiling = (base / 4).max(1);
378        base.saturating_add(jitter_seed % jitter_ceiling)
379            .min(self.max_backoff_ms)
380    }
381}
382
383/// Immutable contract of a trigger, expression, guard, action or sink.
384#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
385pub struct CapabilityManifest {
386    /// Stable identifier of this record.
387    pub id: String,
388    /// Contract version of the capability.
389    pub contract_version: String,
390    /// Content hash that makes the referenced artifact immutable.
391    pub content_digest: String,
392    /// Discriminator naming the variant of this record.
393    pub kind: CapabilityKind,
394    /// JSON Schema for node configuration exposed to authoring clients.
395    #[serde(default = "object_schema")]
396    pub config_schema: Value,
397    /// JSON Schema for provider input.
398    pub input_schema: Value,
399    /// JSON Schema for provider output.
400    pub output_schema: Value,
401    /// Side-effect class of the action.
402    pub effect: Effect,
403    /// Whether equal inputs always produce equal outputs.
404    pub deterministic: bool,
405    /// Retry safety class.
406    pub idempotency_mode: IdempotencyMode,
407    /// Retry policy.
408    pub retry: RetryPolicy,
409    /// Permissions granted or required.
410    #[serde(default)]
411    pub permissions: BTreeSet<String>,
412    /// Guards that must dominate the action.
413    #[serde(default)]
414    pub required_guards: BTreeSet<GuardKind>,
415    /// Exact guard capabilities that must each dominate, including guards of the same role.
416    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
417    pub required_guard_pins: BTreeSet<CapabilityPin>,
418    /// Which inputs may be LLM-tainted.
419    #[serde(default)]
420    pub taint_rules: Value,
421    /// Declared cost for budgeting.
422    #[serde(default)]
423    pub resource_cost: Value,
424    /// Usable in simulation.
425    pub supports_simulation: bool,
426    /// Usable in backtest replay.
427    pub supports_replay: bool,
428    /// Usable in paper mode.
429    pub supports_paper: bool,
430    /// Usable live.
431    pub supports_live: bool,
432    /// Clock guarantees the provider needs.
433    #[serde(default)]
434    pub clock_requirements: Value,
435    /// Data freshness the provider needs.
436    #[serde(default)]
437    pub data_requirements: Value,
438    /// Whether the provider can observe a dispatched action's outcome.
439    pub supports_reconciliation: bool,
440    /// Deployment state.
441    pub lifecycle: CapabilityLifecycle,
442}
443
444fn object_schema() -> Value {
445    serde_json::json!({"type":"object"})
446}
447
448impl CapabilityManifest {
449    /// Manifest for an action capability with conservative defaults (one attempt, 30 s timeout).
450    pub fn action(
451        id: impl Into<String>,
452        contract_version: impl Into<String>,
453        content_digest: impl Into<String>,
454        effect: Effect,
455        idempotency_mode: IdempotencyMode,
456        supports_reconciliation: bool,
457    ) -> Self {
458        let required_guards = match effect {
459            Effect::Funds => BTreeSet::from([
460                GuardKind::Authorization,
461                GuardKind::Freshness,
462                GuardKind::Reservation,
463            ]),
464            Effect::ExternalWrite => BTreeSet::from([GuardKind::Authorization]),
465            _ => BTreeSet::new(),
466        };
467        Self {
468            id: id.into(),
469            contract_version: contract_version.into(),
470            content_digest: content_digest.into(),
471            kind: CapabilityKind::Action,
472            config_schema: object_schema(),
473            input_schema: serde_json::json!({"type": "object"}),
474            output_schema: serde_json::json!({"type": "object"}),
475            effect,
476            deterministic: false,
477            idempotency_mode,
478            retry: RetryPolicy {
479                max_attempts: 1,
480                timeout_ms: 30_000,
481                initial_backoff_ms: 100,
482                max_backoff_ms: 5_000,
483            },
484            permissions: BTreeSet::new(),
485            required_guards,
486            required_guard_pins: BTreeSet::new(),
487            taint_rules: Value::Null,
488            resource_cost: Value::Null,
489            supports_simulation: true,
490            supports_replay: false,
491            supports_paper: true,
492            supports_live: true,
493            clock_requirements: Value::Null,
494            data_requirements: Value::Null,
495            supports_reconciliation,
496            lifecycle: CapabilityLifecycle::Active,
497        }
498    }
499
500    /// Check this contract's invariants; returns the first violation.
501    pub fn validate(&self) -> Result<(), ContractError> {
502        required("capability id", &self.id)?;
503        required("contract_version", &self.contract_version)?;
504        required("content_digest", &self.content_digest)?;
505        for pin in &self.required_guard_pins {
506            required("required guard id", &pin.id)?;
507            required("required guard contract_version", &pin.contract_version)?;
508            required("required guard content_digest", &pin.content_digest)?;
509        }
510        if !self.config_schema.is_object()
511            || jsonschema::validator_for(&self.config_schema).is_err()
512        {
513            return Err(ContractError::Invalid(
514                "capability config_schema must be a valid JSON Schema object".into(),
515            ));
516        }
517        if self.retry.max_attempts == 0
518            || self.retry.timeout_ms == 0
519            || self.retry.initial_backoff_ms == 0
520            || self.retry.max_backoff_ms < self.retry.initial_backoff_ms
521        {
522            return Err(ContractError::Invalid(
523                "retry attempts, timeout and backoff bounds are invalid".into(),
524            ));
525        }
526        if self.effect == Effect::Funds {
527            if self.kind != CapabilityKind::Action {
528                return Err(ContractError::Invalid(
529                    "funds effect is only valid for action capabilities".into(),
530                ));
531            }
532            if !self.supports_reconciliation && self.idempotency_mode != IdempotencyMode::Native {
533                return Err(ContractError::Invalid(
534                    "funds actions require native idempotency or reconciliation".into(),
535                ));
536            }
537            for guard in [
538                GuardKind::Authorization,
539                GuardKind::Freshness,
540                GuardKind::Reservation,
541            ] {
542                if !self.required_guards.contains(&guard) {
543                    return Err(ContractError::Invalid(format!(
544                        "funds action must require {guard:?} guard"
545                    )));
546                }
547            }
548        }
549        Ok(())
550    }
551
552    /// Whether new intents may pin this provider (`installed` or `active`).
553    pub fn can_start_new_work(&self) -> bool {
554        matches!(
555            self.lifecycle,
556            CapabilityLifecycle::Installed
557                | CapabilityLifecycle::Active
558                | CapabilityLifecycle::Deprecated
559        )
560    }
561}
562
563/// Role a guard plays on an action's dominating path.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
565#[serde(rename_all = "snake_case")]
566pub enum GuardKind {
567    /// Caller may perform the effect.
568    Authorization,
569    /// Inputs are recent enough.
570    Freshness,
571    /// Resources are reserved and fenced.
572    Reservation,
573    /// Product policy allows it.
574    Policy,
575}
576
577/// How a binding treats source sequence order.
578#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(rename_all = "snake_case")]
580pub enum OrderingPolicy {
581    /// Reject a gap; the next accepted sequence must be previous + 1.
582    StrictSequence,
583    /// Order does not matter.
584    Commutative,
585    /// Only the latest event matters.
586    LatestStateReconcile,
587    /// Reject anything older than the last accepted sequence.
588    RejectUnordered,
589}
590
591/// Immutable source event as delivered by a trigger adapter.
592#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
593pub struct TriggerEnvelope {
594    /// Source-unique event id.
595    pub event_id: String,
596    /// Stable machine-readable event type.
597    pub event_type: String,
598    /// Trigger adapter that delivered the event.
599    pub source: String,
600    /// Projection schema version.
601    pub schema_version: String,
602    /// Tenant that owns this record.
603    pub tenant_id: TenantId,
604    /// Subject (user or service principal) acting on or owning this record.
605    pub subject_id: SubjectId,
606    /// Aggregate the event belongs to (for ordering).
607    pub aggregate_id: String,
608    /// Per-aggregate sequence from the source.
609    pub source_sequence: Option<i64>,
610    /// Version of the observed object, if any.
611    pub observed_version: Option<String>,
612    /// When the event happened.
613    pub occurred_at: DateTime<Utc>,
614    /// When the receipt arrived.
615    pub received_at: DateTime<Utc>,
616    /// Source watermark up to which events are complete.
617    pub watermark: Option<DateTime<Utc>>,
618    /// Key that correlates related events.
619    pub correlation_key: String,
620    /// Key that deduplicates redeliveries.
621    pub dedup_key: String,
622    /// Source cursor after this event.
623    pub cursor: Option<String>,
624    /// Structured payload.
625    pub payload: Value,
626    /// Distributed tracing context.
627    #[serde(default)]
628    pub trace_context: Value,
629}
630
631impl TriggerEnvelope {
632    /// Check this contract's invariants; returns the first violation.
633    pub fn validate(&self) -> Result<(), ContractError> {
634        for (name, value) in [
635            ("event_id", self.event_id.as_str()),
636            ("event_type", self.event_type.as_str()),
637            ("source", self.source.as_str()),
638            ("schema_version", self.schema_version.as_str()),
639            ("tenant_id", self.tenant_id.as_str()),
640            ("aggregate_id", self.aggregate_id.as_str()),
641            ("correlation_key", self.correlation_key.as_str()),
642            ("dedup_key", self.dedup_key.as_str()),
643        ] {
644            required(name, value)?;
645        }
646        if self
647            .cursor
648            .as_ref()
649            .is_some_and(|cursor| cursor.is_empty() || cursor.len() > 4096)
650        {
651            return Err(ContractError::Invalid(
652                "source cursor requires 1..4096 bytes".into(),
653            ));
654        }
655        if self.occurred_at > self.received_at + chrono::Duration::minutes(5) {
656            return Err(ContractError::Invalid(
657                "occurred_at is implausibly ahead of received_at".into(),
658            ));
659        }
660        Ok(())
661    }
662}
663
664/// Cursor to resume an admitted source adapter after restart.
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
666pub struct SourceAdapterState {
667    /// Exact source.
668    pub source: af_context::WorkflowSourceId,
669    /// Opaque provider cursor.
670    pub cursor: String,
671    /// Event whose transaction last advanced the cursor; absent for an initial seed.
672    pub source_event_id: Option<String>,
673}
674/// Receipt returned only after source fact, binding snapshot, deliveries and cursor commit.
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
676pub struct SourceIngestionReceipt {
677    /// Durable event identity safe for adapter ACK.
678    pub event_id: String,
679    /// True for an identical committed retry.
680    pub duplicate: bool,
681    /// Newly inserted deliveries; duplicate retries report zero.
682    pub deliveries: u64,
683    /// Current committed resume cursor, including one advanced after this event.
684    pub committed_cursor: Option<String>,
685}
686
687/// When an instance completes on its own.
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
689#[serde(rename_all = "snake_case")]
690pub enum CompletionPolicy {
691    /// Only an operator stops it.
692    ExplicitStop,
693    /// After the first consumed trigger or timer.
694    FirstTrigger,
695    /// After the first evaluation that matched.
696    FirstMatch,
697    /// After the first action reaches a terminal state.
698    FirstActionTerminal,
699    /// After the first successful evaluation.
700    FirstSuccess,
701    /// After this many matched evaluations.
702    AfterMatchedEvaluations(u64),
703    /// After this many successful evaluations.
704    AfterSuccessfulRuns(u64),
705}
706
707/// What happens when a lifecycle deadline passes.
708#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "snake_case")]
710pub enum ExpiryPolicy {
711    /// Stop new work; let dispatched actions finish.
712    Drain,
713    /// Stop and request cancellation.
714    Cancel,
715}
716
717/// How a schedule fires.
718#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719#[serde(rename_all = "snake_case")]
720pub enum ScheduleCadence {
721    /// Cron expression evaluated in the schedule's timezone.
722    Cron {
723        /// Six- or seven-field cron expression.
724        expression: String,
725    },
726    /// Fixed period measured from the scheduled time.
727    FixedRate {
728        /// Period in milliseconds.
729        milliseconds: u64,
730    },
731    /// Fixed delay measured from the previous completion.
732    FixedDelay {
733        /// Delay in milliseconds.
734        milliseconds: u64,
735    },
736}
737
738/// How missed schedule windows are handled.
739#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
740#[serde(rename_all = "snake_case")]
741pub enum CatchUpPolicy {
742    /// Run only the most recent due occurrence.
743    Skip,
744    /// Run once, listing the missed windows it covers.
745    CatchUpOnce,
746    /// Run every missed occurrence oldest first, at most `limit` per transition.
747    CatchUpAll {
748        /// Maximum occurrences per transition.
749        limit: u32,
750    },
751}
752
753/// Cadence, timezone and catch-up behavior of a schedule.
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755pub struct SchedulePolicy {
756    /// Cron, fixed-rate or fixed-delay.
757    pub cadence: ScheduleCadence,
758    /// IANA timezone for cron evaluation and DST.
759    pub timezone: String,
760    /// Missed-window behavior.
761    pub catch_up: CatchUpPolicy,
762}
763
764impl SchedulePolicy {
765    /// Check this contract's invariants; returns the first violation.
766    pub fn validate(&self) -> Result<(), ContractError> {
767        self.timezone.parse::<chrono_tz::Tz>().map_err(|_| {
768            ContractError::Invalid(format!("unknown IANA timezone '{}'", self.timezone))
769        })?;
770        match &self.cadence {
771            ScheduleCadence::Cron { expression } => {
772                expression.parse::<cron::Schedule>().map_err(|error| {
773                    ContractError::Invalid(format!("invalid cron '{expression}': {error}"))
774                })?;
775            }
776            ScheduleCadence::FixedRate { milliseconds }
777            | ScheduleCadence::FixedDelay { milliseconds }
778                if *milliseconds == 0 =>
779            {
780                return Err(ContractError::Invalid(
781                    "schedule interval must be positive".into(),
782                ));
783            }
784            _ => {}
785        }
786        if matches!(self.catch_up, CatchUpPolicy::CatchUpAll { limit: 0 }) {
787            return Err(ContractError::Invalid(
788                "catch_up_all limit must be positive".into(),
789            ));
790        }
791        Ok(())
792    }
793}
794
795/// Start, completion, timeout and expiry rules evaluated on database time.
796#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
797pub struct LifecyclePolicy {
798    /// Earliest time the binding or instance is active.
799    pub starts_at: Option<DateTime<Utc>>,
800    /// When the record stops being valid.
801    pub expires_at: Option<DateTime<Utc>>,
802    /// When the instance completes.
803    pub completion: CompletionPolicy,
804    /// Terminalize after this long without a consumed event.
805    pub event_idle_timeout_ms: Option<u64>,
806    /// Terminalize after this long without a committed transition.
807    pub progress_timeout_ms: Option<u64>,
808    /// Drain or cancel on expiry.
809    pub on_expiry: ExpiryPolicy,
810    /// Hard stop for draining.
811    pub drain_deadline: Option<DateTime<Utc>>,
812}
813
814impl LifecyclePolicy {
815    /// One-shot lifecycle: completes on the first success, drains on expiry.
816    pub fn run_once() -> Self {
817        Self {
818            starts_at: None,
819            expires_at: None,
820            completion: CompletionPolicy::FirstSuccess,
821            event_idle_timeout_ms: None,
822            progress_timeout_ms: None,
823            on_expiry: ExpiryPolicy::Drain,
824            drain_deadline: None,
825        }
826    }
827
828    /// Check this contract's invariants; returns the first violation.
829    pub fn validate(&self) -> Result<(), ContractError> {
830        if self
831            .starts_at
832            .zip(self.expires_at)
833            .is_some_and(|(a, b)| a >= b)
834        {
835            return Err(ContractError::Invalid(
836                "lifecycle starts_at must be before expires_at".into(),
837            ));
838        }
839        if self.drain_deadline.is_some() && self.on_expiry != ExpiryPolicy::Drain {
840            return Err(ContractError::Invalid(
841                "drain_deadline requires on_expiry=drain".into(),
842            ));
843        }
844        if self
845            .expires_at
846            .zip(self.drain_deadline)
847            .is_some_and(|(expires, drain)| drain <= expires)
848        {
849            return Err(ContractError::Invalid(
850                "lifecycle drain_deadline must be after expires_at".into(),
851            ));
852        }
853        if self.event_idle_timeout_ms == Some(0) || self.progress_timeout_ms == Some(0) {
854            return Err(ContractError::Invalid(
855                "lifecycle timeouts must be positive".into(),
856            ));
857        }
858        if matches!(
859            self.completion,
860            CompletionPolicy::AfterMatchedEvaluations(0) | CompletionPolicy::AfterSuccessfulRuns(0)
861        ) {
862            return Err(ContractError::Invalid(
863                "lifecycle completion count must be positive".into(),
864            ));
865        }
866        Ok(())
867    }
868}
869
870/// How a step ended.
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "snake_case")]
873pub enum StepOutcomeKind {
874    /// Terminal success.
875    Succeeded,
876    /// Filtered out.
877    Skipped,
878    /// Parked on a wake condition.
879    Waiting,
880    /// Errored.
881    Failed,
882    /// Cancelled.
883    Cancelled,
884}
885
886/// Recorded outcome of one step.
887#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
888pub struct StepOutcome {
889    /// Discriminator naming the variant of this record.
890    pub kind: StepOutcomeKind,
891    /// Stable code for material outcomes.
892    pub reason_code: Option<String>,
893    /// What resumes a waiting step.
894    #[serde(default)]
895    pub wake_condition: Value,
896}
897
898/// Lifecycle of an action intent; `dispatch_committed` is the point of no return.
899#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(rename_all = "snake_case")]
901pub enum ActionState {
902    /// Created by a transition; not yet authorized.
903    Prepared,
904    /// Waiting for a human confirmation.
905    AwaitingConfirmation,
906    /// Guards passed; may be dispatched.
907    Authorized,
908    /// Effect committed; cancellation can no longer claim it was not performed.
909    DispatchCommitted,
910    /// Provider reports progress.
911    Executing,
912    /// Terminal success.
913    Succeeded,
914    /// Terminal rejection.
915    Rejected,
916    /// Failed; waits for its backoff, then dispatches again.
917    Retryable,
918    /// Outcome unknown; waits for reconciliation.
919    Unknown,
920    /// Outcome established by reconciliation.
921    Reconciled,
922}
923
924impl ActionState {
925    /// Whether the state machine allows `self -> next`.
926    pub fn can_transition_to(self, next: Self) -> bool {
927        use ActionState::*;
928        matches!(
929            (self, next),
930            (Prepared, AwaitingConfirmation | Authorized | Rejected)
931                | (AwaitingConfirmation, Authorized | Rejected)
932                | (Authorized, DispatchCommitted | Rejected)
933                | (
934                    DispatchCommitted,
935                    Executing | Succeeded | Rejected | Retryable | Unknown
936                )
937                | (Executing, Succeeded | Rejected | Retryable | Unknown)
938                | (Retryable, DispatchCommitted | Reconciled)
939                | (Unknown, Reconciled)
940                | (Succeeded | Rejected, Reconciled)
941        )
942    }
943
944    /// Whether the effect may already have happened.
945    pub fn dispatch_committed(self) -> bool {
946        matches!(
947            self,
948            Self::DispatchCommitted
949                | Self::Executing
950                | Self::Succeeded
951                | Self::Rejected
952                | Self::Retryable
953                | Self::Unknown
954                | Self::Reconciled
955        )
956    }
957}
958
959/// Control epochs an intent was created under; any bump makes it stale.
960#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
961pub struct ControlEpochs {
962    /// Tenant control epoch.
963    pub tenant: i64,
964    /// Resource control epoch.
965    pub resource: i64,
966    /// Instance control epoch.
967    pub instance: i64,
968}
969
970/// Scope of a control command.
971#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
972#[serde(rename_all = "snake_case")]
973pub enum ControlScope {
974    /// Whole tenant.
975    Tenant,
976    /// One external resource.
977    Resource,
978    /// One instance.
979    Instance,
980}
981
982/// Operating mode set by a control command.
983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
984#[serde(rename_all = "snake_case")]
985pub enum ControlMode {
986    /// Normal operation.
987    Running,
988    /// No new evaluations or dispatches.
989    Paused,
990    /// Terminal stop.
991    Stopped,
992    /// Immediate stop that also revokes providers.
993    EmergencyStopped,
994}
995
996/// Operator command that bumps a control epoch.
997#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
998pub struct ControlCommand {
999    /// Scope kind.
1000    pub scope: ControlScope,
1001    /// Scope identity.
1002    pub scope_id: String,
1003    /// Execution mode this record was produced under.
1004    pub mode: ControlMode,
1005    /// Operator issuing the command.
1006    pub operator_subject_id: SubjectId,
1007    /// Human-readable reason.
1008    pub reason: String,
1009    /// When an override lapses.
1010    pub override_expires_at: Option<DateTime<Utc>>,
1011}
1012
1013impl ControlCommand {
1014    /// Check this contract's invariants; returns the first violation.
1015    pub fn validate(&self) -> Result<(), ContractError> {
1016        required("control scope_id", &self.scope_id)?;
1017        required("control operator_subject_id", &self.operator_subject_id)?;
1018        required("control reason", &self.reason)?;
1019        Ok(())
1020    }
1021}
1022
1023/// Reference to a product-owned fenced reservation.
1024#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1025pub struct ResourceReservationRef {
1026    /// Reservation identity.
1027    pub reservation_id: String,
1028    /// Fence the reservation was taken under.
1029    pub fencing_token: i64,
1030}
1031
1032/// Durable intent to perform one external effect, created inside a fenced transition.
1033#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1034pub struct ActionIntent {
1035    /// Stable identifier of this record.
1036    pub id: String,
1037    /// Tenant that owns this record.
1038    pub tenant_id: TenantId,
1039    /// Workflow instance this record refers to.
1040    pub instance_id: InstanceId,
1041    /// Run this record belongs to.
1042    pub run_id: RunId,
1043    /// Pinned capability (id, contract version, content digest).
1044    pub capability: CapabilityPin,
1045    /// Caller-supplied key that makes repeated submissions return the first result.
1046    pub idempotency_key: String,
1047    /// Current lifecycle state.
1048    pub state: ActionState,
1049    /// Structured input handed to the provider.
1050    pub input: Value,
1051    /// Side-effect class of the action.
1052    pub effect: Effect,
1053    /// Idempotency class that decides whether automatic retry is safe.
1054    pub retry_class: IdempotencyMode,
1055    /// Control epochs at creation.
1056    pub control_epochs: ControlEpochs,
1057    /// Resource the effect targets; empty when unscoped.
1058    pub resource_scope_id: String,
1059    /// Instance lease version at creation.
1060    pub lease_epoch: i64,
1061    /// Bumped on every claim; fences claim owners.
1062    pub action_epoch: i64,
1063    /// Latest time by which the work must finish.
1064    pub deadline: Option<DateTime<Utc>>,
1065    /// Fenced reservation for funds actions.
1066    pub reservation: Option<ResourceReservationRef>,
1067    /// When the record was created (database time).
1068    pub created_at: DateTime<Utc>,
1069}
1070
1071impl ActionIntent {
1072    /// Check this contract's invariants; returns the first violation.
1073    pub fn validate(&self) -> Result<(), ContractError> {
1074        required("action id", &self.id)?;
1075        required("action idempotency_key", &self.idempotency_key)?;
1076        if self.effect == Effect::Funds && self.reservation.is_none() {
1077            return Err(ContractError::Invalid(
1078                "funds action requires a resource reservation".into(),
1079            ));
1080        }
1081        if self.effect == Effect::Funds && self.resource_scope_id.trim().is_empty() {
1082            return Err(ContractError::Invalid(
1083                "funds action requires a resource scope".into(),
1084            ));
1085        }
1086        if self.effect == Effect::Funds && self.retry_class == IdempotencyMode::None {
1087            return Err(ContractError::Invalid(
1088                "funds action requires an explicit retry class".into(),
1089            ));
1090        }
1091        if self
1092            .deadline
1093            .is_some_and(|deadline| deadline <= self.created_at)
1094        {
1095            return Err(ContractError::Invalid(
1096                "action deadline must be after creation".into(),
1097            ));
1098        }
1099        Ok(())
1100    }
1101
1102    /// Validate an intent at the only creation boundary. Later states are
1103    /// reached exclusively through the fenced store transition.
1104    pub fn validate_prepared(&self) -> Result<(), ContractError> {
1105        self.validate()?;
1106        if self.state != ActionState::Prepared {
1107            return Err(ContractError::Invalid(
1108                "new action intent must start in prepared state".into(),
1109            ));
1110        }
1111        Ok(())
1112    }
1113}
1114
1115/// Provider's answer to a dispatch.
1116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1117pub struct ActionReceipt {
1118    /// Stable identifier of this record.
1119    pub id: String,
1120    /// Intent this refers to.
1121    pub action_intent_id: String,
1122    /// Provider version that produced it.
1123    pub provider_version: String,
1124    /// When the receipt arrived.
1125    pub received_at: DateTime<Utc>,
1126    /// State the provider reports.
1127    pub outcome: ActionState,
1128    /// Structured payload.
1129    pub payload: Value,
1130    /// Digest of the raw provider response; deduplicates receipts.
1131    pub raw_receipt_digest: String,
1132}
1133
1134/// Observed state of a dispatched action.
1135#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1136pub struct ActionObservation {
1137    /// Stable identifier of this record.
1138    pub id: String,
1139    /// Intent this refers to.
1140    pub action_intent_id: String,
1141    /// Provider version that produced it.
1142    pub provider_version: String,
1143    /// When the state was observed.
1144    pub observed_at: DateTime<Utc>,
1145    /// Current lifecycle state.
1146    pub state: String,
1147    /// External resource created or affected.
1148    pub resource_ref: Option<Value>,
1149    /// Digest of the raw provider response; deduplicates receipts.
1150    pub raw_receipt_digest: String,
1151    /// Whether this observation ends the action.
1152    pub terminal: bool,
1153    /// A reconciliation provider sets this only after proving that repeating
1154    /// the same idempotent action is safe.
1155    #[serde(default)]
1156    pub retry_authorized: bool,
1157}
1158
1159/// A missing source-sequence range that blocks ordered delivery for an aggregate.
1160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1161pub struct SourceGap {
1162    /// Tenant that owns this record.
1163    pub tenant_id: TenantId,
1164    /// Source the gap belongs to.
1165    pub source: String,
1166    /// Aggregate whose sequence has the hole.
1167    pub aggregate_id: String,
1168    /// First missing sequence (inclusive).
1169    pub missing_from: i64,
1170    /// Last missing sequence (inclusive).
1171    pub missing_to: i64,
1172    /// When waiting for the gap expires and delivery blocks.
1173    pub deadline: DateTime<Utc>,
1174    /// Gap status (`waiting`, `blocked`, `unblocked`).
1175    pub status: String,
1176}
1177
1178/// Scope of a resource reservation.
1179#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1180#[serde(rename_all = "snake_case")]
1181pub enum ReservationScope {
1182    /// Whole tenant.
1183    Tenant,
1184    /// One external resource.
1185    Resource,
1186}
1187
1188/// Lifecycle of a reservation.
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1190#[serde(rename_all = "snake_case")]
1191pub enum ReservationState {
1192    /// Held.
1193    Reserved,
1194    /// Used by a committed dispatch.
1195    Consumed,
1196    /// Given back.
1197    Released,
1198    /// Lapsed unused.
1199    Expired,
1200}
1201
1202/// Mirror of a product-owned fenced reservation.
1203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1204pub struct ResourceReservation {
1205    /// Stable identifier of this record.
1206    pub id: String,
1207    /// Tenant that owns this record.
1208    pub tenant_id: TenantId,
1209    /// Scope kind.
1210    pub scope: ReservationScope,
1211    /// Scope identity.
1212    pub scope_id: String,
1213    /// Kind of resource reserved.
1214    pub resource_kind: String,
1215    /// Reserved amount as a decimal string.
1216    pub amount: String,
1217    /// Product policy version that granted it.
1218    pub policy_version: String,
1219    /// When the record stops being valid.
1220    pub expires_at: DateTime<Utc>,
1221    /// Fence the reservation was taken under.
1222    pub fencing_token: i64,
1223    /// Provider-side reservation id.
1224    pub provider_reservation_id: Option<String>,
1225    /// Current lifecycle state.
1226    pub state: ReservationState,
1227}
1228
1229/// A value with provenance and freshness metadata.
1230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1231pub struct ObservedValue<T> {
1232    /// The value carried by this record.
1233    pub value: T,
1234    /// Where the value was observed.
1235    pub source: String,
1236    /// When the state was observed.
1237    pub observed_at: DateTime<Utc>,
1238    /// When the receipt arrived.
1239    pub received_at: DateTime<Utc>,
1240    /// Version of the source.
1241    pub source_version: String,
1242    /// Quality label from the source.
1243    pub quality: String,
1244    /// Digest of the value.
1245    pub digest: String,
1246}
1247
1248impl<T> ObservedValue<T> {
1249    /// Whether the value is fresh enough for `max_age` at `now`.
1250    pub fn is_accepted(
1251        &self,
1252        now: DateTime<Utc>,
1253        max_age: chrono::Duration,
1254        accepted_quality: &BTreeSet<String>,
1255    ) -> bool {
1256        self.observed_at <= now
1257            && now - self.observed_at <= max_age
1258            && accepted_quality.contains(&self.quality)
1259    }
1260}
1261
1262/// Everything a decision depended on, for replay and audit.
1263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1264pub struct DecisionSnapshot {
1265    /// Digests of the inputs.
1266    pub input_digests: Vec<String>,
1267    /// Config version.
1268    pub instance_config_version: String,
1269    /// Product policy version that granted it.
1270    pub policy_version: String,
1271    /// Capabilities in force.
1272    pub capability_versions: Vec<CapabilityPin>,
1273    /// Execution profile revision in force.
1274    pub execution_profile_revision_id: String,
1275    /// Context values used.
1276    #[serde(default)]
1277    pub context_snapshot: Value,
1278    /// Policy values used.
1279    #[serde(default)]
1280    pub policy_snapshot: Value,
1281    /// Which Agent artifacts influenced the decision.
1282    #[serde(default)]
1283    pub agent_provenance: Value,
1284}
1285
1286/// Who supplied a parameter value.
1287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1288#[serde(rename_all = "snake_case")]
1289pub enum ParameterProvenance {
1290    /// Explicitly supplied by the user.
1291    UserSupplied,
1292    /// Product default.
1293    ProductDefault,
1294    /// Inferred by an Agent.
1295    AgentInferred,
1296    /// Computed from other parameters.
1297    Derived,
1298}
1299
1300/// A parameter with its provenance.
1301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1302pub struct ParameterValue {
1303    /// The value carried by this record.
1304    pub value: Value,
1305    /// Who supplied it.
1306    pub provenance: ParameterProvenance,
1307}
1308
1309/// Declared parameter and its live-mode requirements.
1310#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1311pub struct ParameterSpec {
1312    /// Display name.
1313    pub name: String,
1314    /// Must be present.
1315    pub required: bool,
1316    /// Must be user-supplied in live mode.
1317    pub required_explicit_for_live: bool,
1318}
1319
1320/// Check supplied parameters against their specs for `mode`.
1321pub fn validate_parameters(
1322    mode: ExecutionMode,
1323    specs: &[ParameterSpec],
1324    values: &BTreeMap<String, ParameterValue>,
1325) -> Result<(), MissingRequirements> {
1326    let missing = specs
1327        .iter()
1328        .filter(|spec| match values.get(&spec.name) {
1329            None => {
1330                spec.required || (mode == ExecutionMode::Live && spec.required_explicit_for_live)
1331            }
1332            Some(value) => {
1333                mode == ExecutionMode::Live
1334                    && spec.required_explicit_for_live
1335                    && value.provenance != ParameterProvenance::UserSupplied
1336            }
1337        })
1338        .map(|spec| spec.name.clone())
1339        .collect::<Vec<_>>();
1340    if missing.is_empty() {
1341        Ok(())
1342    } else {
1343        Err(MissingRequirements {
1344            parameters: missing,
1345        })
1346    }
1347}
1348
1349/// Parameters that block a live run.
1350#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
1351#[error("missing explicit workflow requirements: {parameters:?}")]
1352pub struct MissingRequirements {
1353    /// Missing or non-explicit parameter names.
1354    pub parameters: Vec<String>,
1355}
1356
1357/// Budget reserved atomically before a root operation starts children.
1358#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1359pub struct RootOperationBudget {
1360    /// Root operation identity.
1361    pub root_operation_id: String,
1362    /// Maximum nesting depth.
1363    pub max_depth: u32,
1364    /// Maximum descendants.
1365    pub descendant_limit: u32,
1366    /// Maximum runs.
1367    pub run_limit: u32,
1368    /// Token budget.
1369    pub token_budget: u64,
1370    /// Cost budget in micro-units.
1371    pub cost_budget_micros: u64,
1372    /// Maximum actions.
1373    pub action_budget: u32,
1374    /// Latest time by which the work must finish.
1375    pub deadline: DateTime<Utc>,
1376    /// Permissions children may not exceed.
1377    #[serde(default)]
1378    pub permission_ceiling: BTreeSet<String>,
1379}
1380
1381/// Persisted Agent decision reused after recovery instead of re-asking the model.
1382#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1383pub struct DecisionArtifact {
1384    /// Stable identifier of this record.
1385    pub id: String,
1386    /// Root operation identity.
1387    pub root_operation_id: String,
1388    /// Content hash that makes the referenced artifact immutable.
1389    pub content_digest: String,
1390    /// Model identifier as registered in the model registry.
1391    pub model: String,
1392    /// Digest of the prompt.
1393    pub prompt_digest: String,
1394    /// Model output.
1395    pub output: Value,
1396    /// When the record was created (database time).
1397    pub created_at: DateTime<Utc>,
1398}
1399
1400/// Operator-facing diagnostic attached to an instance.
1401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1402pub struct DiagnosticRecord {
1403    /// Stable diagnostic code.
1404    pub code: String,
1405    /// Human-readable message.
1406    pub message: String,
1407    /// When it was recorded.
1408    pub at: DateTime<Utc>,
1409    /// Structured detail.
1410    #[serde(default)]
1411    pub detail: Value,
1412}
1413
1414/// Rebuildable read model of an instance; never authorizes or schedules work.
1415#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1416pub struct ExecutionProjection {
1417    /// Projection schema version.
1418    pub schema_version: String,
1419    /// Authoritative event sequence the projection was built from.
1420    pub source_sequence: i64,
1421    /// Steps in progress.
1422    pub current_steps: Vec<String>,
1423    /// Steps completed.
1424    pub completed_steps: Vec<String>,
1425    /// What the instance waits for.
1426    pub waiting_on: Option<Value>,
1427    /// Most recent decision.
1428    pub last_decision: Option<Value>,
1429    /// Steps that may run next.
1430    pub next_possible_steps: Vec<String>,
1431    /// Next scheduled evaluation.
1432    pub next_trigger_at: Option<DateTime<Utc>>,
1433    /// Actions prepared but not dispatched.
1434    pub planned_actions: Vec<String>,
1435    /// Most recent diagnostic.
1436    pub latest_diagnostic: Option<DiagnosticRecord>,
1437    /// Product-defined progress.
1438    pub progress: Value,
1439    /// When the record stops being valid.
1440    pub expires_at: Option<DateTime<Utc>>,
1441}
1442
1443/// A contract value violates its invariants.
1444#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1445pub enum ContractError {
1446    /// Invalid workflow contract.
1447    #[error("invalid workflow contract: {0}")]
1448    Invalid(String),
1449}
1450
1451fn required(name: &str, value: &str) -> Result<(), ContractError> {
1452    if value.trim().is_empty() {
1453        Err(ContractError::Invalid(format!("{name} is required")))
1454    } else {
1455        Ok(())
1456    }
1457}
1458
1459#[cfg(test)]
1460mod tests {
1461    use super::*;
1462
1463    fn empty_spec() -> crate::Spec {
1464        crate::Spec {
1465            spec_id: "test".into(),
1466            version: "1".into(),
1467            description: String::new(),
1468            aliases: vec![],
1469            instance_config_schema: None,
1470            display: BTreeMap::new(),
1471            branches: vec![],
1472        }
1473    }
1474
1475    fn funds_manifest() -> CapabilityManifest {
1476        CapabilityManifest::action(
1477            "action.example",
1478            "1",
1479            "sha256:x",
1480            Effect::Funds,
1481            IdempotencyMode::ReconcileBeforeRetry,
1482            true,
1483        )
1484    }
1485
1486    #[test]
1487    fn funds_capability_requires_reconciliation_and_all_guards() {
1488        assert!(funds_manifest().validate().is_ok());
1489        let mut invalid = funds_manifest();
1490        invalid.required_guards.remove(&GuardKind::Freshness);
1491        assert!(invalid.validate().is_err());
1492        invalid.required_guards.insert(GuardKind::Freshness);
1493        invalid.supports_reconciliation = false;
1494        assert!(invalid.validate().is_err());
1495    }
1496
1497    #[test]
1498    fn action_state_never_skips_dispatch_commit() {
1499        assert!(ActionState::Authorized.can_transition_to(ActionState::DispatchCommitted));
1500        assert!(!ActionState::Authorized.can_transition_to(ActionState::Succeeded));
1501        assert!(ActionState::Unknown.can_transition_to(ActionState::Reconciled));
1502    }
1503
1504    #[test]
1505    fn live_parameters_cannot_be_silently_inferred() {
1506        let specs = [ParameterSpec {
1507            name: "account".into(),
1508            required: true,
1509            required_explicit_for_live: true,
1510        }];
1511        let inferred = BTreeMap::from([(
1512            "account".into(),
1513            ParameterValue {
1514                value: Value::String("a".into()),
1515                provenance: ParameterProvenance::AgentInferred,
1516            },
1517        )]);
1518        assert_eq!(
1519            validate_parameters(ExecutionMode::Live, &specs, &inferred)
1520                .unwrap_err()
1521                .parameters,
1522            ["account"]
1523        );
1524        assert!(validate_parameters(ExecutionMode::Paper, &specs, &inferred).is_ok());
1525    }
1526
1527    #[test]
1528    fn freshness_is_fail_closed() {
1529        let now = Utc::now();
1530        let observed = ObservedValue {
1531            value: 1,
1532            source: "source".into(),
1533            observed_at: now - chrono::Duration::seconds(2),
1534            received_at: now,
1535            source_version: "1".into(),
1536            quality: "good".into(),
1537            digest: "d".into(),
1538        };
1539        assert!(observed.is_accepted(
1540            now,
1541            chrono::Duration::seconds(3),
1542            &BTreeSet::from(["good".into()])
1543        ));
1544        assert!(!observed.is_accepted(
1545            now,
1546            chrono::Duration::seconds(1),
1547            &BTreeSet::from(["good".into()])
1548        ));
1549    }
1550
1551    #[test]
1552    fn revision_and_manifest_contracts_fail_closed() {
1553        let mut revision = WorkflowRevision {
1554            definition_id: "definition".into(),
1555            revision: 1,
1556            content_digest: "digest".into(),
1557            kernel_abi_version: "1".into(),
1558            dependency_set_digest: "dependencies".into(),
1559            expression_versions: BTreeMap::new(),
1560            capabilities: vec![CapabilityPin {
1561                id: "action.example".into(),
1562                contract_version: "1".into(),
1563                content_digest: "capability-digest".into(),
1564            }],
1565            template_provenance: Value::Null,
1566            spec: empty_spec(),
1567        };
1568        assert!(revision.validate().is_ok());
1569        revision.capabilities.push(CapabilityPin {
1570            id: "action.example".into(),
1571            contract_version: "2".into(),
1572            content_digest: "capability-digest-v2".into(),
1573        });
1574        assert!(revision.validate().is_err());
1575        revision.capabilities.pop();
1576        revision.kernel_abi_version.clear();
1577        assert!(revision.validate().is_err());
1578
1579        let external = CapabilityManifest::action(
1580            "action.notify",
1581            "1",
1582            "digest",
1583            Effect::ExternalWrite,
1584            IdempotencyMode::NeverAutomaticRetry,
1585            false,
1586        );
1587        assert_eq!(
1588            external.required_guards,
1589            BTreeSet::from([GuardKind::Authorization])
1590        );
1591        assert!(external.validate().is_ok());
1592        assert_eq!(external.config_schema, serde_json::json!({"type":"object"}));
1593        let mut invalid_schema = external.clone();
1594        invalid_schema.config_schema = serde_json::json!({"type":7});
1595        assert!(invalid_schema.validate().is_err());
1596        let mut invalid = external;
1597        invalid.retry.max_attempts = 0;
1598        assert!(invalid.validate().is_err());
1599
1600        let mut wrong_kind = funds_manifest();
1601        wrong_kind.kind = CapabilityKind::Expression;
1602        assert!(wrong_kind.validate().is_err());
1603        wrong_kind.kind = CapabilityKind::Action;
1604        wrong_kind.idempotency_mode = IdempotencyMode::Native;
1605        wrong_kind.supports_reconciliation = false;
1606        assert!(wrong_kind.validate().is_ok());
1607        wrong_kind.lifecycle = CapabilityLifecycle::Disabled;
1608        assert!(!wrong_kind.can_start_new_work());
1609    }
1610
1611    #[test]
1612    fn trigger_schedule_lifecycle_and_control_validate_boundaries() {
1613        let now = Utc::now();
1614        let mut trigger = TriggerEnvelope {
1615            event_id: "event".into(),
1616            event_type: "example".into(),
1617            source: "source".into(),
1618            schema_version: "1".into(),
1619            tenant_id: "tenant".parse().unwrap(),
1620            subject_id: "subject".parse().unwrap(),
1621            aggregate_id: "aggregate".into(),
1622            source_sequence: Some(1),
1623            observed_version: None,
1624            occurred_at: now,
1625            received_at: now,
1626            watermark: None,
1627            correlation_key: "key".into(),
1628            dedup_key: "dedup".into(),
1629            cursor: None,
1630            payload: Value::Null,
1631            trace_context: Value::Null,
1632        };
1633        assert!(trigger.validate().is_ok());
1634        trigger.occurred_at = now + chrono::Duration::minutes(6);
1635        assert!(trigger.validate().is_err());
1636
1637        let valid_schedule = SchedulePolicy {
1638            cadence: ScheduleCadence::Cron {
1639                expression: "0 0 * * * *".into(),
1640            },
1641            timezone: "Asia/Shanghai".into(),
1642            catch_up: CatchUpPolicy::CatchUpOnce,
1643        };
1644        assert!(valid_schedule.validate().is_ok());
1645        for invalid in [
1646            SchedulePolicy {
1647                timezone: "Nowhere/Invalid".into(),
1648                ..valid_schedule.clone()
1649            },
1650            SchedulePolicy {
1651                cadence: ScheduleCadence::FixedRate { milliseconds: 0 },
1652                ..valid_schedule.clone()
1653            },
1654            SchedulePolicy {
1655                catch_up: CatchUpPolicy::CatchUpAll { limit: 0 },
1656                ..valid_schedule
1657            },
1658        ] {
1659            assert!(invalid.validate().is_err());
1660        }
1661
1662        let mut lifecycle = LifecyclePolicy::run_once();
1663        assert!(lifecycle.validate().is_ok());
1664        lifecycle.starts_at = Some(now);
1665        lifecycle.expires_at = Some(now);
1666        assert!(lifecycle.validate().is_err());
1667        lifecycle.starts_at = None;
1668        lifecycle.expires_at = None;
1669        lifecycle.on_expiry = ExpiryPolicy::Cancel;
1670        lifecycle.drain_deadline = Some(now);
1671        assert!(lifecycle.validate().is_err());
1672
1673        let mut command = ControlCommand {
1674            scope: ControlScope::Instance,
1675            scope_id: "instance".into(),
1676            mode: ControlMode::Paused,
1677            operator_subject_id: "operator".parse().unwrap(),
1678            reason: "maintenance".into(),
1679            override_expires_at: None,
1680        };
1681        assert!(command.validate().is_ok());
1682        command.reason.clear();
1683        assert!(command.validate().is_err());
1684    }
1685
1686    #[test]
1687    fn funds_intent_requires_reservation_scope_and_retry_class() {
1688        let now = Utc::now();
1689        let mut intent = ActionIntent {
1690            id: "intent".into(),
1691            tenant_id: "tenant".parse().unwrap(),
1692            instance_id: "instance".parse().unwrap(),
1693            run_id: "run".parse().unwrap(),
1694            capability: CapabilityPin {
1695                id: "action.example".into(),
1696                contract_version: "1".into(),
1697                content_digest: "digest".into(),
1698            },
1699            idempotency_key: "idempotency".into(),
1700            state: ActionState::Prepared,
1701            input: Value::Null,
1702            effect: Effect::Funds,
1703            retry_class: IdempotencyMode::ReconcileBeforeRetry,
1704            control_epochs: ControlEpochs::default(),
1705            resource_scope_id: "resource".into(),
1706            lease_epoch: 1,
1707            action_epoch: 1,
1708            deadline: None,
1709            reservation: Some(ResourceReservationRef {
1710                reservation_id: "reservation".into(),
1711                fencing_token: 1,
1712            }),
1713            created_at: now,
1714        };
1715        assert!(intent.validate().is_ok());
1716        assert!(intent.validate_prepared().is_ok());
1717        intent.reservation = None;
1718        assert!(intent.validate().is_err());
1719        intent.reservation = Some(ResourceReservationRef {
1720            reservation_id: "reservation".into(),
1721            fencing_token: 1,
1722        });
1723        intent.resource_scope_id.clear();
1724        assert!(intent.validate().is_err());
1725        intent.resource_scope_id = "resource".into();
1726        intent.retry_class = IdempotencyMode::None;
1727        assert!(intent.validate().is_err());
1728        intent.retry_class = IdempotencyMode::ReconcileBeforeRetry;
1729        intent.state = ActionState::Authorized;
1730        assert!(intent.validate_prepared().is_err());
1731        assert!(!ActionState::Prepared.dispatch_committed());
1732        assert!(ActionState::Unknown.dispatch_committed());
1733    }
1734
1735    fn trigger_binding() -> TriggerBinding {
1736        TriggerBinding {
1737            id: "binding".into(),
1738            revision: 1,
1739            source: "source".into(),
1740            event_type: "event".into(),
1741            instance_id: "instance".parse().unwrap(),
1742            branch_id: None,
1743            predicate: serde_json::json!({}),
1744            ordering: OrderingPolicy::Commutative,
1745            starts_at: None,
1746            expires_at: None,
1747            gap_wait_ms: default_gap_wait_ms(),
1748            gap_limit: default_gap_limit(),
1749        }
1750    }
1751
1752    #[test]
1753    fn trigger_binding_rejects_blank_ids_bad_gaps_and_inverted_windows() {
1754        assert!(trigger_binding().validate().is_ok());
1755        let blank = TriggerBinding {
1756            source: "  ".into(),
1757            ..trigger_binding()
1758        };
1759        assert!(blank.validate().is_err());
1760        for gap_wait_ms in [0, 3_600_001] {
1761            let binding = TriggerBinding {
1762                gap_wait_ms,
1763                ..trigger_binding()
1764            };
1765            assert!(binding.validate().is_err(), "gap_wait_ms {gap_wait_ms}");
1766        }
1767        for gap_limit in [0, 10_001] {
1768            let binding = TriggerBinding {
1769                gap_limit,
1770                ..trigger_binding()
1771            };
1772            assert!(binding.validate().is_err(), "gap_limit {gap_limit}");
1773        }
1774        let array_predicate = TriggerBinding {
1775            predicate: serde_json::json!([1]),
1776            ..trigger_binding()
1777        };
1778        assert!(array_predicate.validate().is_err());
1779        let now = Utc::now();
1780        let inverted = TriggerBinding {
1781            starts_at: Some(now),
1782            expires_at: Some(now),
1783            ..trigger_binding()
1784        };
1785        assert!(inverted.validate().is_err());
1786        let ordered = TriggerBinding {
1787            starts_at: Some(now),
1788            expires_at: Some(now + chrono::Duration::seconds(1)),
1789            ..trigger_binding()
1790        };
1791        assert!(ordered.validate().is_ok());
1792        let defaults: TriggerBinding = serde_json::from_value(serde_json::json!({
1793            "id": "binding", "revision": 1, "source": "s", "event_type": "e",
1794            "instance_id": "i", "predicate": {}, "ordering": "commutative",
1795            "starts_at": null, "expires_at": null
1796        }))
1797        .unwrap();
1798        assert_eq!(defaults.gap_wait_ms, 30_000);
1799        assert_eq!(defaults.gap_limit, 100);
1800    }
1801
1802    #[test]
1803    fn execution_profile_revision_requires_every_provider_name() {
1804        let profile = ExecutionProfileRevision {
1805            id: "profile".into(),
1806            revision: 1,
1807            content_digest: "sha256:profile".into(),
1808            mode: ExecutionMode::Paper,
1809            durability_grade: DurabilityGrade::Standard,
1810            trigger_provider: "triggers".into(),
1811            data_provider: "data".into(),
1812            clock_model: "database".into(),
1813            action_provider: "actions".into(),
1814            models: BTreeMap::new(),
1815            environment: Value::Null,
1816            policy_bundle: Value::Null,
1817            connection_bindings: BTreeMap::new(),
1818        };
1819        assert!(profile.validate().is_ok());
1820        let blank_provider = ExecutionProfileRevision {
1821            action_provider: String::new(),
1822            ..profile.clone()
1823        };
1824        assert!(blank_provider.validate().is_err());
1825        let blank_digest = ExecutionProfileRevision {
1826            content_digest: " ".into(),
1827            ..profile
1828        };
1829        assert!(blank_digest.validate().is_err());
1830    }
1831
1832    #[test]
1833    fn retry_backoff_grows_exponentially_with_bounded_jitter_and_cap() {
1834        let policy = RetryPolicy {
1835            max_attempts: 5,
1836            timeout_ms: 1_000,
1837            initial_backoff_ms: 100,
1838            max_backoff_ms: 1_000,
1839        };
1840        assert_eq!(policy.backoff_ms(1, 0), 100);
1841        assert_eq!(policy.backoff_ms(2, 0), 200);
1842        assert_eq!(policy.backoff_ms(3, 0), 400);
1843        // Jitter is deterministic and never exceeds 25% of the base.
1844        assert_eq!(policy.backoff_ms(1, 24), 124);
1845        assert_eq!(policy.backoff_ms(1, 25), 100);
1846        // Jitter is taken modulo the ceiling (800 / 4 = 200 here).
1847        assert_eq!(policy.backoff_ms(4, 249), 849);
1848        // The cap holds for large attempts and for base + jitter.
1849        assert_eq!(policy.backoff_ms(5, 0), 1_000);
1850        assert_eq!(policy.backoff_ms(5, 249), 1_000);
1851        assert_eq!(policy.backoff_ms(40, u64::MAX), 1_000);
1852        // Attempt 0 behaves like the first attempt instead of underflowing.
1853        assert_eq!(policy.backoff_ms(0, 0), 100);
1854    }
1855
1856    #[test]
1857    fn lifecycle_policy_rejects_inconsistent_windows_and_zero_counts() {
1858        let now = Utc::now();
1859        let later = now + chrono::Duration::hours(1);
1860        assert!(LifecyclePolicy::run_once().validate().is_ok());
1861        let inverted = LifecyclePolicy {
1862            starts_at: Some(later),
1863            expires_at: Some(now),
1864            ..LifecyclePolicy::run_once()
1865        };
1866        assert!(inverted.validate().is_err());
1867        let drain_without_policy = LifecyclePolicy {
1868            on_expiry: ExpiryPolicy::Cancel,
1869            drain_deadline: Some(later),
1870            ..LifecyclePolicy::run_once()
1871        };
1872        assert!(drain_without_policy.validate().is_err());
1873        let drain_before_expiry = LifecyclePolicy {
1874            expires_at: Some(later),
1875            drain_deadline: Some(now),
1876            ..LifecyclePolicy::run_once()
1877        };
1878        assert!(drain_before_expiry.validate().is_err());
1879        let zero_timeout = LifecyclePolicy {
1880            event_idle_timeout_ms: Some(0),
1881            ..LifecyclePolicy::run_once()
1882        };
1883        assert!(zero_timeout.validate().is_err());
1884        for completion in [
1885            CompletionPolicy::AfterMatchedEvaluations(0),
1886            CompletionPolicy::AfterSuccessfulRuns(0),
1887        ] {
1888            let zero_count = LifecyclePolicy {
1889                completion,
1890                ..LifecyclePolicy::run_once()
1891            };
1892            assert!(zero_count.validate().is_err());
1893        }
1894        let bounded = LifecyclePolicy {
1895            starts_at: Some(now),
1896            expires_at: Some(later),
1897            completion: CompletionPolicy::AfterSuccessfulRuns(2),
1898            event_idle_timeout_ms: Some(1_000),
1899            progress_timeout_ms: Some(1_000),
1900            on_expiry: ExpiryPolicy::Drain,
1901            drain_deadline: Some(later + chrono::Duration::minutes(1)),
1902        };
1903        assert!(bounded.validate().is_ok());
1904    }
1905}