Skip to main content

dpm_engine/
gates.rs

1use crate::EngineError;
2use chrono::{DateTime, Utc};
3use dpm_model::{
4    ActorId, BasisState, Dependency, DependencyId, DependencyKind, DependencyPolicy, Endpoint,
5    EventTime, Plan, Release, StartBasis, StartRelease, Timeline, WorkItem, WorkItemId, WorkStatus,
6};
7use serde::{Deserialize, Serialize};
8
9mod provisional;
10mod transition;
11pub use transition::{Transition, describe_applicability};
12
13/// Machine-readable reason preventing a lifecycle transition at the evaluated time.
14#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
15#[serde(tag = "type", rename_all = "snake_case")]
16pub enum UnmetGate {
17    /// Containers and milestones are projections, not claimable work.
18    Aggregate,
19    /// Proposed work needs explicit approval of its execution contract.
20    ContractNotRatified,
21    /// The current lifecycle state does not permit this transition.
22    Lifecycle {
23        /// Current authoritative lifecycle.
24        status: WorkStatus,
25    },
26    /// A worker recorded a blocker.
27    Blocker {
28        /// Concrete blocker text.
29        reason: String,
30    },
31    /// An unwaived constraint governing this transition is not yet released.
32    Dependency {
33        /// Stable identity of the unwaived constraint.
34        dependency: DependencyId,
35        /// Whether a human or service may waive the constraint.
36        policy: DependencyPolicy,
37        /// Stable identity of the prerequisite.
38        predecessor: WorkItemId,
39        /// Human-readable prerequisite key.
40        key: String,
41        /// Project containing the prerequisite.
42        project: String,
43        /// Assets required by the prerequisite; these are context, not satisfied gates.
44        asset_keys: Vec<String>,
45        /// Relation; FS and SS govern the successor's start, FF and SF its finish.
46        relation: DependencyKind,
47        /// Positive lag must elapse after the event; negative lag shapes the schedule only.
48        lag_hours: f64,
49        /// Predecessor event the relation waits for: start, or finish (verification).
50        requires: Endpoint,
51        /// Why the constraint is not released: missing event, elapsing lag or unknown event time.
52        release: Release,
53        /// Authoritative state of the prerequisite.
54        status: WorkStatus,
55        /// Worker responsible for the prerequisite, if reserved.
56        owner: Option<ActorId>,
57        /// Predecessor result the edge accepts for the successor's start.
58        #[serde(default, skip_serializing_if = "StartBasis::is_verified")]
59        start_basis: StartBasis,
60        /// Whether this evaluation accepts a pending submission; false for verification.
61        #[serde(default, skip_serializing_if = "std::ops::Not::not")]
62        accepts_submission: bool,
63        /// Pending predecessor attempt whose positive lag is still elapsing.
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        attempt: Option<u32>,
66    },
67    /// The execution relies on a predecessor attempt that was rejected; finishing waits for an
68    /// explicit independent revalidation against a newer attempt.
69    BasisInvalidated {
70        /// Provisional edge carrying the basis.
71        dependency: DependencyId,
72        /// Stable identity of the predecessor.
73        predecessor: WorkItemId,
74        /// Human-readable predecessor key.
75        key: String,
76        /// Ordinal of the rejected attempt the execution relies on.
77        attempt: u32,
78        /// Rejection of that attempt.
79        state: BasisState,
80        /// Predecessor's pending or verified attempt a revalidation may name, if any.
81        current_attempt: Option<u32>,
82    },
83    /// An open direct or inherited decision gates execution.
84    Decision {
85        /// Human-readable gate key.
86        key: String,
87    },
88    /// The choice that selects this conditional work, or a containing package, was made after the
89    /// evaluated time, so the work was not yet in the plan then.
90    Choice {
91        /// Human-readable key of the selecting decision.
92        key: String,
93        /// When its standing outcome was made.
94        chosen_at: DateTime<Utc>,
95    },
96    /// The work is outside the active graph: not selected, undecided, awaiting an upstream choice,
97    /// stranded behind excluded work, or an empty join.
98    Applicability {
99        /// Derived applicability explaining the exclusion.
100        applicability: dpm_model::Applicability,
101    },
102}
103
104/// A provisional edge released on a predecessor's pending attempt; a start records it as basis.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct ProvisionalRelease {
107    /// Provisional edge.
108    pub dependency: DependencyId,
109    /// Predecessor that made the attempt.
110    pub predecessor: WorkItemId,
111    /// Human-readable predecessor key.
112    pub key: String,
113    /// Ordinal of the pending attempt.
114    pub attempt: u32,
115}
116
117/// Shared eligibility of one lifecycle transition for queries, commands and terminal projections.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct GateReport {
120    /// Transition this report evaluates; `claim` is the readiness used by `next`.
121    #[serde(default)]
122    pub transition: Transition,
123    /// Whether the transition is permitted at the evaluated time.
124    pub ready: bool,
125    /// Every unmet condition; an empty list means ready.
126    pub unmet: Vec<UnmetGate>,
127    /// Start-gate edges released only by a pending predecessor attempt, not a verified finish.
128    #[serde(default, skip_serializing_if = "Vec::is_empty")]
129    pub provisional: Vec<ProvisionalRelease>,
130}
131
132/// Explain whether a transition is permitted at an adapter-supplied time, without changing state.
133pub fn gate_report(
134    plan: &Plan,
135    work: WorkItemId,
136    transition: Transition,
137    now: DateTime<Utc>,
138) -> Result<GateReport, EngineError> {
139    plan.validate()?;
140    let item = plan
141        .work_items
142        .get(&work)
143        .ok_or(EngineError::MissingWorkItem(work))?;
144    Ok(evaluate(plan, item, transition, &Timeline::at(plan, now)))
145}
146
147/// The one gate evaluation used by every query, command and view.
148pub(crate) fn evaluate(
149    plan: &Plan,
150    work: &WorkItem,
151    transition: Transition,
152    timeline: &Timeline,
153) -> GateReport {
154    let mut unmet = Vec::new();
155    if !work.is_executable() {
156        unmet.push(UnmetGate::Aggregate);
157    }
158    match work.execution.status {
159        status if status == transition.lifecycle() => {}
160        WorkStatus::Proposed => unmet.push(UnmetGate::ContractNotRatified),
161        status => unmet.push(UnmetGate::Lifecycle { status }),
162    }
163    if let Some(reason) = &work.execution.block_reason {
164        unmet.push(UnmetGate::Blocker {
165            reason: reason.clone(),
166        });
167    }
168    let applicability = timeline.applicability(work.id);
169    if !applicability.is_applicable() {
170        unmet.push(UnmetGate::Applicability {
171            applicability: applicability.clone(),
172        });
173    }
174    unmet.extend(choice_gates(plan, work, timeline));
175    let mut provisional = Vec::new();
176    for dep in timeline
177        .incoming(plan, work.id)
178        .filter(|dep| transition.governs(dep.kind))
179    {
180        let Some(item) = plan.work_items.get(&dep.predecessor) else {
181            continue;
182        };
183        let edge = if transition.starts() {
184            timeline.start_edge(plan, dep)
185        } else {
186            StartRelease {
187                release: timeline.edge(plan, dep),
188                attempt: None,
189                provisional: false,
190            }
191        };
192        match (edge.release.released_at(), edge.attempt) {
193            (Some(_), Some(attempt)) if edge.provisional => provisional.push(ProvisionalRelease {
194                dependency: dep.id,
195                predecessor: item.id,
196                key: item.key.to_string(),
197                attempt,
198            }),
199            (None, attempt) => unmet.push(dependency_gate(
200                plan,
201                dep,
202                item,
203                transition,
204                edge.release,
205                attempt,
206            )),
207            (Some(_), _) => {}
208        }
209    }
210    if transition.checks_basis() {
211        unmet.extend(provisional::basis_gates(plan, work));
212    }
213    if transition.checks_decisions() {
214        unmet.extend(
215            timeline
216                .decisions(plan, work.id)
217                .into_iter()
218                .filter(|(_, release)| release.released_at().is_none())
219                .map(|(decision, _)| UnmetGate::Decision {
220                    key: decision.key.to_string(),
221                }),
222        );
223    }
224    GateReport {
225        transition,
226        ready: unmet.is_empty(),
227        unmet,
228        provisional,
229    }
230}
231
232/// Selecting choices made after the evaluated time: a backfilled event cannot precede them.
233fn choice_gates(plan: &Plan, work: &WorkItem, timeline: &Timeline) -> Vec<UnmetGate> {
234    timeline
235        .selecting_choices(plan, work.id)
236        .into_iter()
237        .filter_map(|(decision, at)| match at {
238            EventTime::Recorded(chosen_at) if chosen_at > timeline.now() => {
239                Some(UnmetGate::Choice {
240                    key: decision.key.to_string(),
241                    chosen_at,
242                })
243            }
244            _ => None,
245        })
246        .collect()
247}
248
249fn dependency_gate(
250    plan: &Plan,
251    dep: &Dependency,
252    item: &WorkItem,
253    transition: Transition,
254    release: Release,
255    attempt: Option<u32>,
256) -> UnmetGate {
257    UnmetGate::Dependency {
258        dependency: dep.id,
259        policy: dep.policy,
260        asset_keys: item
261            .contract
262            .assets
263            .iter()
264            .filter_map(|r| plan.assets.get(&r.asset))
265            .map(|r| r.key.to_string())
266            .collect(),
267        predecessor: item.id,
268        key: item.key.to_string(),
269        project: plan
270            .projects
271            .get(&item.project)
272            .map(|p| p.key.to_string())
273            .unwrap_or_default(),
274        relation: dep.kind,
275        lag_hours: dep.lag_hours,
276        requires: dep.kind.predecessor_endpoint(),
277        release,
278        status: item.execution.status,
279        owner: item.execution.owner.clone(),
280        start_basis: dep.start_basis,
281        accepts_submission: transition.starts() && dep.start_basis == StartBasis::Provisional,
282        attempt,
283    }
284}
285
286impl GateReport {
287    /// Human-readable rendering of the same structured conditions returned to agents.
288    pub fn reasons(&self) -> Vec<String> {
289        self.unmet.iter().map(describe).collect()
290    }
291}
292
293pub(crate) fn summary(unmet: &[UnmetGate]) -> String {
294    unmet.iter().map(describe).collect::<Vec<_>>().join("; ")
295}
296
297/// Human-readable rendering of one unmet condition.
298pub(crate) fn describe(gate: &UnmetGate) -> String {
299    match gate {
300        UnmetGate::Aggregate => {
301            "aggregate work completes through its prerequisites or children".into()
302        }
303        UnmetGate::ContractNotRatified => "contract not ratified".into(),
304        UnmetGate::Lifecycle { status } => format!("work lifecycle state is {status:?}"),
305        UnmetGate::Blocker { reason } => format!("work is blocked: {reason}"),
306        UnmetGate::Dependency {
307            key,
308            relation,
309            lag_hours,
310            policy,
311            requires,
312            release,
313            start_basis,
314            accepts_submission,
315            attempt,
316            ..
317        } => {
318            let edge = format!("{} {lag_hours:+}h, {policy:?}", relation.abbreviation());
319            let mut text = if *accepts_submission {
320                provisional::describe_start(key, *release, *attempt, &edge, *policy)
321            } else {
322                transition::describe_release(key, *requires, *release, &edge, *policy)
323            };
324            if *start_basis == StartBasis::Provisional && !*accepts_submission {
325                text.push_str("; a provisional edge releases only the start, never finishing on an unverified result");
326            }
327            if *lag_hours < 0.0 {
328                text.push_str(&format!(
329                    "; the {lag_hours}h lead shapes the schedule only and never releases work before the event"
330                ));
331            }
332            text
333        }
334        UnmetGate::BasisInvalidated {
335            key,
336            attempt,
337            state,
338            current_attempt,
339            ..
340        } => provisional::describe_invalidated(key, *attempt, state, *current_attempt),
341        UnmetGate::Decision { key } => format!("work awaits decision: {key}"),
342        UnmetGate::Choice { key, chosen_at } => format!(
343            "work applies only through {key}, which selected it at {chosen_at}, after the evaluated time"
344        ),
345        UnmetGate::Applicability { applicability } => {
346            transition::describe_applicability(applicability)
347        }
348    }
349}
350
351#[cfg(test)]
352mod tests;