Skip to main content

af_workflow/
drafts.rs

1//! Durable, immutable workflow proposals with an explicit owner decision.
2use af_context::{InstanceId, WorkflowDraftId};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7/// Optional activation committed with publication when a proposal is confirmed.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(deny_unknown_fields)]
10pub struct WorkflowActivation {
11    /// Stable instance identity displayed in the proposal.
12    pub instance_id: InstanceId,
13    /// Lifecycle pinned by the created instance.
14    pub lifecycle: crate::LifecyclePolicy,
15    /// Instance configuration validated against the published Spec schema.
16    pub config: Value,
17    /// Trigger bindings to publish atomically; at most 32.
18    #[serde(default)]
19    pub bindings: Vec<crate::TriggerBinding>,
20}
21/// A proposal never changes after it is stored. Editing requires a new draft ID.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct WorkflowProposal {
25    /// Initial publication name.
26    pub name: String,
27    /// Exact revision to publish.
28    pub revision: crate::WorkflowRevision,
29    /// Exact execution profile to publish.
30    pub profile: crate::ExecutionProfileRevision,
31    /// Omit to publish without starting work.
32    pub activation: Option<WorkflowActivation>,
33}
34impl WorkflowProposal {
35    /// Validate structure and bound the atomic confirmation transaction.
36    pub fn validate(&self) -> Result<(), String> {
37        if self.name.trim().is_empty() || self.name.chars().count() > 256 {
38            return Err("workflow name requires 1..256 characters".into());
39        }
40        self.revision
41            .validate()
42            .map_err(|error| error.to_string())?;
43        self.profile.validate().map_err(|error| error.to_string())?;
44        if let Some(activation) = &self.activation {
45            activation
46                .lifecycle
47                .validate()
48                .map_err(|error| error.to_string())?;
49            if !activation.config.is_object() {
50                return Err("workflow instance config must be an object".into());
51            }
52            // ponytail: cap one confirmation at 32 bindings; raise only after measuring lock time.
53            if activation.bindings.len() > 32 {
54                return Err("workflow draft exceeds 32 trigger bindings".into());
55            }
56            let mut ids = std::collections::BTreeSet::new();
57            for binding in &activation.bindings {
58                binding.validate().map_err(|error| error.to_string())?;
59                if binding.instance_id != activation.instance_id || !ids.insert(&binding.id) {
60                    return Err(
61                        "draft bindings must be unique and target its proposed instance".into(),
62                    );
63                }
64                if let Some(branch) = &binding.branch_id {
65                    if !self
66                        .revision
67                        .spec
68                        .branches
69                        .iter()
70                        .any(|candidate| candidate.branch_id == branch.as_str())
71                    {
72                        return Err("draft binding names an unknown branch".into());
73                    }
74                } else if self.revision.spec.branches.len() != 1 {
75                    return Err("multi-branch draft bindings require branch_id".into());
76                }
77            }
78        }
79        if serde_json::to_vec(self)
80            .map_err(|error| error.to_string())?
81            .len()
82            > 1_048_576
83        {
84            return Err("workflow draft exceeds 1 MiB".into());
85        }
86        Ok(())
87    }
88}
89/// One irreversible decision on an immutable proposal.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum WorkflowDraftStatus {
93    /// Stored preview, with no published revision or active instance.
94    Proposed,
95    /// Publication and optional activation committed with the decision.
96    Applied,
97    /// Owner rejected the proposal.
98    Rejected,
99}
100impl WorkflowDraftStatus {
101    /// Stable storage and wire spelling.
102    pub fn as_str(self) -> &'static str {
103        match self {
104            Self::Proposed => "proposed",
105            Self::Applied => "applied",
106            Self::Rejected => "rejected",
107        }
108    }
109    /// Repeat the same decision or reject an attempted reversal.
110    pub fn decide(self, accept: bool) -> Result<Self, String> {
111        let target = if accept {
112            Self::Applied
113        } else {
114            Self::Rejected
115        };
116        if self != Self::Proposed && self != target {
117            return Err("workflow draft already decided".into());
118        }
119        Ok(target)
120    }
121}
122/// Owner-scoped durable preview and its decision.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct WorkflowDraft {
125    /// Stable UUID for proposal idempotency.
126    pub draft_id: WorkflowDraftId,
127    /// Immutable preview.
128    pub proposal: WorkflowProposal,
129    /// Current decision.
130    pub status: WorkflowDraftStatus,
131    /// Time the preview was created.
132    pub created_at: DateTime<Utc>,
133    /// Time the irreversible decision committed.
134    pub decided_at: Option<DateTime<Utc>>,
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    fn proposal() -> WorkflowProposal {
141        serde_json::from_value(serde_json::json!({
142            "name":"Preview", "revision": {
143                "definition_id":"definition", "revision":1, "content_digest":"revision",
144                "kernel_abi_version":"1", "dependency_set_digest":"deps",
145                "spec":{"spec_id":"spec", "version":"1", "branches":[{
146                    "branch_id":"root", "nodes":[{"id":"in", "type":"ingress.event", "config":{}}], "edges":[]
147                }]}
148            },
149            "profile":{"id":"profile", "revision":1,"content_digest":"profile", "mode":"paper", "durability_grade":"standard",
150                "trigger_provider":"events", "data_provider":"reference", "clock_model":"database", "action_provider":"actions"},
151            "activation":{"instance_id":"instance", "lifecycle":crate::LifecyclePolicy::run_once(), "config":{},"bindings":[{
152                "id":"binding","revision":1,"source":"source","event_type":"event","instance_id":"instance",
153                "branch_id":"root","predicate":{},"ordering":"commutative","gap_wait_ms":30000,"gap_limit":10
154            }]}
155        })).unwrap()
156    }
157    #[test]
158    fn proposals_validate_bounds_and_exact_binding_targets() {
159        let valid = proposal();
160        assert!(valid.validate().is_ok());
161        let mut changed = valid.clone();
162        changed.activation = None;
163        assert!(changed.validate().is_ok());
164        for name in [" ".into(), "字".repeat(257)] {
165            changed.name = name;
166            assert!(changed.validate().is_err());
167        }
168        let mut changed = valid.clone();
169        changed.revision.content_digest.clear();
170        assert!(changed.validate().is_err());
171        let mut changed = valid.clone();
172        changed.profile.action_provider.clear();
173        assert!(changed.validate().is_err());
174        let mut changed = valid.clone();
175        changed.activation.as_mut().unwrap().config = Value::Null;
176        assert!(changed.validate().is_err());
177        let mut changed = valid.clone();
178        let binding = &mut changed.activation.as_mut().unwrap().bindings[0];
179        binding.branch_id = None;
180        assert!(changed.validate().is_ok());
181        changed
182            .revision
183            .spec
184            .branches
185            .push(changed.revision.spec.branches[0].clone());
186        changed.revision.spec.branches[1].branch_id = "second".into();
187        assert!(changed.validate().is_err());
188        let mut changed = valid.clone();
189        changed.activation.as_mut().unwrap().bindings[0].branch_id =
190            Some("missing".parse().unwrap());
191        assert!(changed.validate().is_err());
192        let mut changed = valid.clone();
193        changed.activation.as_mut().unwrap().bindings[0].instance_id = "other".parse().unwrap();
194        assert!(changed.validate().is_err());
195        let mut changed = valid.clone();
196        changed.activation.as_mut().unwrap().bindings =
197            vec![valid.activation.as_ref().unwrap().bindings[0].clone(); 2];
198        assert!(changed.validate().is_err());
199        changed.activation.as_mut().unwrap().bindings =
200            vec![valid.activation.as_ref().unwrap().bindings[0].clone(); 33];
201        assert!(changed.validate().is_err());
202        let mut changed = valid.clone();
203        changed.activation.as_mut().unwrap().bindings[0]
204            .source
205            .clear();
206        assert!(changed.validate().is_err());
207        let mut changed = valid.clone();
208        changed.activation.as_mut().unwrap().config =
209            serde_json::json!({"oversized":"x".repeat(1_048_576)});
210        assert!(changed.validate().is_err());
211        let mut untrusted = serde_json::to_value(valid).unwrap();
212        untrusted["subject_id"] = "other".into();
213        assert!(serde_json::from_value::<WorkflowProposal>(untrusted).is_err());
214    }
215    #[test]
216    fn decision_is_irreversible_and_wire_status_is_stable() {
217        use WorkflowDraftStatus::*;
218        for (state, spelling) in [
219            (Proposed, "proposed"),
220            (Applied, "applied"),
221            (Rejected, "rejected"),
222        ] {
223            assert_eq!(state.as_str(), spelling);
224            assert_eq!(serde_json::to_value(state).unwrap(), spelling);
225        }
226        assert_eq!(Proposed.decide(true).unwrap(), Applied);
227        assert_eq!(Proposed.decide(false).unwrap(), Rejected);
228        assert_eq!(Applied.decide(true).unwrap(), Applied);
229        assert_eq!(Rejected.decide(false).unwrap(), Rejected);
230        assert!(Applied.decide(false).is_err());
231        assert!(Rejected.decide(true).is_err());
232    }
233}