Skip to main content

spec_driven_docs/plan/
readiness.rs

1//! Whether a plan may be applied, and what stands in the way.
2//!
3//! Readiness is derived, never asserted. Each precondition carries a
4//! requirement, each is evaluated against what was observed, and the plan's
5//! readiness is the worst of them. A gap is honest and is not permission: a
6//! precondition nobody could evaluate blocks where ownership says it must,
7//! and never quietly passes.
8
9use serde::{Deserialize, Serialize};
10
11/// How much one precondition weighs.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum Requirement {
15    /// Worth reporting and never worth stopping for.
16    Advisory,
17    /// The operator decides, and the plan waits until they have.
18    DecisionRequired,
19    /// The apply is unsafe until this holds.
20    Required,
21}
22
23/// What the observation found.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(tag = "state", rename_all = "kebab-case")]
26pub enum Evaluation {
27    /// It holds.
28    Satisfied,
29    /// Nobody could tell, and why.
30    NotObserved {
31        /// What stopped the observation.
32        reason: String,
33    },
34    /// It does not hold, and why.
35    Unsatisfied {
36        /// What was found instead.
37        reason: String,
38    },
39}
40
41impl Evaluation {
42    /// Whether this evaluation lets an apply proceed.
43    #[must_use]
44    pub const fn is_satisfied(&self) -> bool {
45        matches!(self, Self::Satisfied)
46    }
47}
48
49/// Whether a plan may be applied.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum Readiness {
53    /// Every precondition holds. Apply may proceed.
54    Ready,
55    /// The operator has a decision to make first.
56    NeedsDecision,
57    /// Something must change in the target or the request first.
58    Blocked,
59}
60
61/// One thing that must hold before an apply.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Precondition {
64    /// A stable identifier, so a caller can route on it.
65    pub id: String,
66    /// One sentence a person reads.
67    pub statement: String,
68    /// How much it weighs.
69    pub requirement: Requirement,
70    /// What was found.
71    pub evaluation: Evaluation,
72    /// The decision that resolves it, where one does.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub resolved_by: Option<String>,
75    /// What the plan cited to evaluate it.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub evidence_refs: Vec<String>,
78}
79
80impl Precondition {
81    /// What this one precondition alone permits.
82    #[must_use]
83    pub const fn verdict(&self) -> Readiness {
84        if self.evaluation.is_satisfied() {
85            return Readiness::Ready;
86        }
87        match self.requirement {
88            Requirement::Advisory => Readiness::Ready,
89            Requirement::DecisionRequired => Readiness::NeedsDecision,
90            Requirement::Required => Readiness::Blocked,
91        }
92    }
93}
94
95/// The worst verdict among the preconditions.
96///
97/// Worst rather than first, so the order the planner happens to append in
98/// cannot decide whether a plan may run.
99#[must_use]
100pub fn readiness(preconditions: &[Precondition]) -> Readiness {
101    preconditions
102        .iter()
103        .map(Precondition::verdict)
104        .max()
105        .unwrap_or(Readiness::Ready)
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn precondition(requirement: Requirement, evaluation: Evaluation) -> Precondition {
113        Precondition {
114            id: "one".to_string(),
115            statement: "one thing holds".to_string(),
116            requirement,
117            evaluation,
118            resolved_by: None,
119            evidence_refs: Vec::new(),
120        }
121    }
122
123    fn unsatisfied() -> Evaluation {
124        Evaluation::Unsatisfied {
125            reason: "it does not".to_string(),
126        }
127    }
128
129    fn not_observed() -> Evaluation {
130        Evaluation::NotObserved {
131            reason: "nobody could tell".to_string(),
132        }
133    }
134
135    #[test]
136    fn readiness_is_the_worst_precondition() {
137        use Requirement::{Advisory, DecisionRequired, Required};
138
139        let cases: &[(Requirement, Evaluation, Readiness)] = &[
140            (Advisory, Evaluation::Satisfied, Readiness::Ready),
141            (Advisory, unsatisfied(), Readiness::Ready),
142            (Advisory, not_observed(), Readiness::Ready),
143            (DecisionRequired, Evaluation::Satisfied, Readiness::Ready),
144            (DecisionRequired, unsatisfied(), Readiness::NeedsDecision),
145            (DecisionRequired, not_observed(), Readiness::NeedsDecision),
146            (Required, Evaluation::Satisfied, Readiness::Ready),
147            (Required, unsatisfied(), Readiness::Blocked),
148            (Required, not_observed(), Readiness::Blocked),
149        ];
150        for (requirement, evaluation, expected) in cases {
151            let held = precondition(*requirement, evaluation.clone());
152            assert_eq!(held.verdict(), *expected, "{requirement:?} {evaluation:?}");
153        }
154    }
155
156    #[test]
157    fn one_blocked_precondition_blocks_the_plan_whatever_its_position() {
158        let ready = precondition(Requirement::Advisory, Evaluation::Satisfied);
159        let waiting = precondition(Requirement::DecisionRequired, unsatisfied());
160        let blocked = precondition(Requirement::Required, unsatisfied());
161        assert_eq!(readiness(&[]), Readiness::Ready);
162        assert_eq!(readiness(std::slice::from_ref(&ready)), Readiness::Ready);
163        assert_eq!(
164            readiness(&[ready.clone(), waiting.clone()]),
165            Readiness::NeedsDecision
166        );
167        assert_eq!(
168            readiness(&[blocked.clone(), ready.clone(), waiting.clone()]),
169            Readiness::Blocked
170        );
171        assert_eq!(readiness(&[waiting, ready, blocked]), Readiness::Blocked);
172    }
173
174    #[test]
175    fn a_gap_is_honest_and_is_not_permission() {
176        // A required precondition nobody could evaluate blocks. Reporting
177        // it as satisfied would turn an unread target into a green light.
178        let held = precondition(Requirement::Required, not_observed());
179        assert_eq!(held.verdict(), Readiness::Blocked);
180    }
181}