Skip to main content

nomograph_workflow/
phase.rs

1//! Workflow phase state machine and enforcement.
2//!
3//! The ORIENT → PLAN → AGREE → EXECUTE ↔ REFLECT → REPLAN → REPORT
4//! state machine is a workflow concern shared across tools. Synthesist
5//! owns the CLI for setting/showing phase. Future tools (seer) that
6//! need to respect phase gates call [`check_phase`] directly rather
7//! than reimplementing the rules.
8//!
9//! Per D14, phase is per-session: a [`Phase`] claim carries
10//! `{session_id, name}`. Setting a phase appends a new claim that
11//! supersedes the prior Phase claim for the same `session_id`.
12//! Migrated v1 data lives under `session_id = "global-migrated"`.
13
14use anyhow::{Result, bail};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::store::Store;
19
20/// Workflow phase.
21///
22/// Transitions:
23/// ```text
24///   orient → plan
25///   plan   → agree
26///   agree  → execute
27///   execute → reflect | report
28///   reflect → execute | replan | report
29///   replan  → agree
30///   report  → (terminal)
31/// ```
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum Phase {
35    Orient,
36    Plan,
37    Agree,
38    Execute,
39    Reflect,
40    Replan,
41    Report,
42}
43
44impl Phase {
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            Self::Orient => "orient",
48            Self::Plan => "plan",
49            Self::Agree => "agree",
50            Self::Execute => "execute",
51            Self::Reflect => "reflect",
52            Self::Replan => "replan",
53            Self::Report => "report",
54        }
55    }
56
57    // Intentional: `Option<Self>` is the ergonomic return type for callers
58    // (plain `.ok_or_else(...)` use); `FromStr` would force `Result` noise.
59    #[allow(clippy::should_implement_trait)]
60    pub fn from_str(s: &str) -> Option<Self> {
61        match s {
62            "orient" => Some(Self::Orient),
63            "plan" => Some(Self::Plan),
64            "agree" => Some(Self::Agree),
65            "execute" => Some(Self::Execute),
66            "reflect" => Some(Self::Reflect),
67            "replan" => Some(Self::Replan),
68            "report" => Some(Self::Report),
69            _ => None,
70        }
71    }
72
73    /// Phases reachable in a single `phase set` transition from here.
74    pub fn valid_transitions(&self) -> &'static [Phase] {
75        match self {
76            Self::Orient => &[Self::Plan],
77            Self::Plan => &[Self::Agree],
78            Self::Agree => &[Self::Execute],
79            Self::Execute => &[Self::Reflect, Self::Report],
80            Self::Reflect => &[Self::Execute, Self::Replan, Self::Report],
81            Self::Replan => &[Self::Agree],
82            Self::Report => &[],
83        }
84    }
85
86    /// Whether transitioning from this phase to `target` is allowed
87    /// without `--force`.
88    pub fn can_transition_to(&self, target: Phase) -> bool {
89        self.valid_transitions().contains(&target)
90    }
91}
92
93impl std::fmt::Display for Phase {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        f.write_str(self.as_str())
96    }
97}
98
99/// Per-phase command gate.
100///
101/// A callers passes the top-level command verb (e.g. `"task"`) and the
102/// sub-command (`"add"`, `"claim"`), and this function returns an
103/// error if the current phase forbids that operation. Commands not
104/// listed here are allowed in every non-terminal phase.
105///
106/// Rules (unchanged from v1):
107/// - ORIENT: no writes
108/// - PLAN: no task claim/done/block (only adds)
109/// - AGREE: no operations (wait for human approval)
110/// - EXECUTE: no task add/cancel, no spec add (do the agreed plan)
111/// - REFLECT: no task claim (reflect, don't execute)
112/// - REPLAN: no task claim (plan changes, not execution)
113/// - REPORT: no writes
114pub fn check_phase(
115    store: &Store,
116    session: Option<&str>,
117    top_cmd: &str,
118    sub_cmd: &str,
119    force: bool,
120) -> Result<()> {
121    if force {
122        return Ok(());
123    }
124
125    // Phase is per-session in v2. Callers must pass an explicit
126    // session id; there is no estate-wide phase to fall back to.
127    // The session-required gate at the CLI layer should normally
128    // catch this before check_phase runs, so reaching here without
129    // a session indicates a programmer error in the dispatcher.
130    let session_id = session
131        .filter(|s| !s.is_empty())
132        .ok_or_else(|| anyhow::anyhow!(
133            "phase enforcement requires a session id; phase is per-session in v2"
134        ))?;
135
136    let phase = current_phase_name(store, session_id)?
137        .unwrap_or_else(|| Phase::Orient.as_str().to_string());
138
139    let violation = match phase.as_str() {
140        "orient" => Some("no writes allowed in ORIENT phase"),
141        "plan" => {
142            if top_cmd == "task" && matches!(sub_cmd, "claim" | "done" | "block") {
143                Some("cannot claim/complete tasks in PLAN phase")
144            } else {
145                None
146            }
147        }
148        "agree" => Some("no operations in AGREE phase -- present plan and wait for human approval"),
149        "execute" => {
150            if top_cmd == "task" && matches!(sub_cmd, "add" | "cancel") {
151                Some("cannot add/cancel tasks in EXECUTE phase -- transition to REPLAN")
152            } else if top_cmd == "spec" && sub_cmd == "add" {
153                Some("cannot add specs in EXECUTE phase -- transition to REPLAN")
154            } else {
155                None
156            }
157        }
158        "reflect" => {
159            if top_cmd == "task" && sub_cmd == "claim" {
160                Some("cannot claim tasks in REFLECT phase")
161            } else {
162                None
163            }
164        }
165        "replan" => {
166            if top_cmd == "task" && sub_cmd == "claim" {
167                Some("cannot claim tasks in REPLAN phase")
168            } else {
169                None
170            }
171        }
172        "report" => Some("no writes allowed in REPORT phase"),
173        _ => None,
174    };
175
176    if let Some(msg) = violation {
177        bail!("phase violation ({phase}): {msg}");
178    }
179    Ok(())
180}
181
182/// Return the name of the current Phase claim for `session_id`, or
183/// `None` if no Phase claim exists (caller defaults to `orient`).
184pub fn current_phase_name(store: &Store, session_id: &str) -> Result<Option<String>> {
185    let rows = store.query(
186        "SELECT json_extract(props, '$.name') AS name \
187         FROM claims \
188         WHERE claim_type = 'phase' \
189           AND json_extract(props, '$.session_id') = ?1 \
190         ORDER BY asserted_at DESC LIMIT 1",
191        &[&session_id],
192    )?;
193    Ok(rows
194        .into_iter()
195        .next()
196        .and_then(|r| r.get("name").cloned())
197        .and_then(|v| v.as_str().map(String::from)))
198}
199
200/// Return `(claim_id, phase_name)` for the most recent Phase claim in
201/// `session_id` — used by supersession-chain writes that need the
202/// previous claim's id.
203pub fn current_phase_claim(store: &Store, session_id: &str) -> Result<Option<(String, String)>> {
204    let rows = store.query(
205        "SELECT id, props FROM claims \
206         WHERE claim_type = 'phase' \
207           AND json_extract(props, '$.session_id') = ?1 \
208         ORDER BY asserted_at DESC LIMIT 1",
209        &[&session_id],
210    )?;
211    let row = match rows.into_iter().next() {
212        Some(r) => r,
213        None => return Ok(None),
214    };
215    let claim_id = row
216        .get("id")
217        .and_then(|v| v.as_str())
218        .ok_or_else(|| anyhow::anyhow!("phase row missing id"))?
219        .to_string();
220    let props_str = row
221        .get("props")
222        .and_then(|v| v.as_str())
223        .ok_or_else(|| anyhow::anyhow!("phase row missing props"))?;
224    let props: Value = serde_json::from_str(props_str)?;
225    let name = props
226        .get("name")
227        .and_then(|v| v.as_str())
228        .ok_or_else(|| anyhow::anyhow!("phase claim missing name"))?
229        .to_string();
230    Ok(Some((claim_id, name)))
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn phase_transitions_cover_the_state_machine() {
239        assert!(Phase::Orient.can_transition_to(Phase::Plan));
240        assert!(!Phase::Orient.can_transition_to(Phase::Execute));
241        assert!(Phase::Plan.can_transition_to(Phase::Agree));
242        assert!(Phase::Agree.can_transition_to(Phase::Execute));
243        assert!(Phase::Execute.can_transition_to(Phase::Reflect));
244        assert!(Phase::Execute.can_transition_to(Phase::Report));
245        assert!(!Phase::Execute.can_transition_to(Phase::Plan));
246        assert!(Phase::Reflect.can_transition_to(Phase::Execute));
247        assert!(Phase::Reflect.can_transition_to(Phase::Replan));
248        assert!(Phase::Replan.can_transition_to(Phase::Agree));
249        assert!(!Phase::Report.can_transition_to(Phase::Plan));
250    }
251
252    #[test]
253    fn phase_from_str_roundtrips() {
254        for name in [
255            "orient", "plan", "agree", "execute", "reflect", "replan", "report",
256        ] {
257            assert_eq!(Phase::from_str(name).unwrap().as_str(), name);
258        }
259        assert!(Phase::from_str("bogus").is_none());
260    }
261}