nomograph-workflow 0.3.0

Shared workflow layer for nomograph claim-substrate tools: Store adapter, phase state machine, v1 legacy detection, cross-tool helpers.
Documentation
//! Workflow phase state machine and enforcement.
//!
//! The ORIENT → PLAN → AGREE → EXECUTE ↔ REFLECT → REPLAN → REPORT
//! state machine is a workflow concern shared across tools. Synthesist
//! owns the CLI for setting/showing phase. Future tools (seer) that
//! need to respect phase gates call [`check_phase`] directly rather
//! than reimplementing the rules.
//!
//! Per D14, phase is per-session: a [`Phase`] claim carries
//! `{session_id, name}`. Setting a phase appends a new claim that
//! supersedes the prior Phase claim for the same `session_id`.
//! Migrated v1 data lives under `session_id = "global-migrated"`.

use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::store::Store;

/// Workflow phase.
///
/// Transitions:
/// ```text
///   orient → plan
///   plan   → agree
///   agree  → execute
///   execute → reflect | report
///   reflect → execute | replan | report
///   replan  → agree
///   report  → (terminal)
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
    Orient,
    Plan,
    Agree,
    Execute,
    Reflect,
    Replan,
    Report,
}

impl Phase {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Orient => "orient",
            Self::Plan => "plan",
            Self::Agree => "agree",
            Self::Execute => "execute",
            Self::Reflect => "reflect",
            Self::Replan => "replan",
            Self::Report => "report",
        }
    }

    // Intentional: `Option<Self>` is the ergonomic return type for callers
    // (plain `.ok_or_else(...)` use); `FromStr` would force `Result` noise.
    #[allow(clippy::should_implement_trait)]
    pub fn from_str(s: &str) -> Option<Self> {
        match s {
            "orient" => Some(Self::Orient),
            "plan" => Some(Self::Plan),
            "agree" => Some(Self::Agree),
            "execute" => Some(Self::Execute),
            "reflect" => Some(Self::Reflect),
            "replan" => Some(Self::Replan),
            "report" => Some(Self::Report),
            _ => None,
        }
    }

    /// Phases reachable in a single `phase set` transition from here.
    pub fn valid_transitions(&self) -> &'static [Phase] {
        match self {
            Self::Orient => &[Self::Plan],
            Self::Plan => &[Self::Agree],
            Self::Agree => &[Self::Execute],
            Self::Execute => &[Self::Reflect, Self::Report],
            Self::Reflect => &[Self::Execute, Self::Replan, Self::Report],
            Self::Replan => &[Self::Agree],
            Self::Report => &[],
        }
    }

    /// Whether transitioning from this phase to `target` is allowed
    /// without `--force`.
    pub fn can_transition_to(&self, target: Phase) -> bool {
        self.valid_transitions().contains(&target)
    }
}

impl std::fmt::Display for Phase {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Per-phase command gate.
///
/// A callers passes the top-level command verb (e.g. `"task"`) and the
/// sub-command (`"add"`, `"claim"`), and this function returns an
/// error if the current phase forbids that operation. Commands not
/// listed here are allowed in every non-terminal phase.
///
/// Rules (unchanged from v1):
/// - ORIENT: no writes
/// - PLAN: no task claim/done/block (only adds)
/// - AGREE: no operations (wait for human approval)
/// - EXECUTE: no task add/cancel, no spec add (do the agreed plan)
/// - REFLECT: no task claim (reflect, don't execute)
/// - REPLAN: no task claim (plan changes, not execution)
/// - REPORT: no writes
pub fn check_phase(
    store: &Store,
    session: Option<&str>,
    top_cmd: &str,
    sub_cmd: &str,
    force: bool,
) -> Result<()> {
    if force {
        return Ok(());
    }

    // Phase is per-session in v2. Callers must pass an explicit
    // session id; there is no estate-wide phase to fall back to.
    // The session-required gate at the CLI layer should normally
    // catch this before check_phase runs, so reaching here without
    // a session indicates a programmer error in the dispatcher.
    let session_id = session
        .filter(|s| !s.is_empty())
        .ok_or_else(|| anyhow::anyhow!(
            "phase enforcement requires a session id; phase is per-session in v2"
        ))?;

    let phase = current_phase_name(store, session_id)?
        .unwrap_or_else(|| Phase::Orient.as_str().to_string());

    let violation = match phase.as_str() {
        "orient" => Some("no writes allowed in ORIENT phase"),
        "plan" => {
            if top_cmd == "task" && matches!(sub_cmd, "claim" | "done" | "block") {
                Some("cannot claim/complete tasks in PLAN phase")
            } else {
                None
            }
        }
        "agree" => Some("no operations in AGREE phase -- present plan and wait for human approval"),
        "execute" => {
            if top_cmd == "task" && matches!(sub_cmd, "add" | "cancel") {
                Some("cannot add/cancel tasks in EXECUTE phase -- transition to REPLAN")
            } else if top_cmd == "spec" && sub_cmd == "add" {
                Some("cannot add specs in EXECUTE phase -- transition to REPLAN")
            } else {
                None
            }
        }
        "reflect" => {
            if top_cmd == "task" && sub_cmd == "claim" {
                Some("cannot claim tasks in REFLECT phase")
            } else {
                None
            }
        }
        "replan" => {
            if top_cmd == "task" && sub_cmd == "claim" {
                Some("cannot claim tasks in REPLAN phase")
            } else {
                None
            }
        }
        "report" => Some("no writes allowed in REPORT phase"),
        _ => None,
    };

    if let Some(msg) = violation {
        bail!("phase violation ({phase}): {msg}");
    }
    Ok(())
}

/// Return the name of the current Phase claim for `session_id`, or
/// `None` if no Phase claim exists (caller defaults to `orient`).
pub fn current_phase_name(store: &Store, session_id: &str) -> Result<Option<String>> {
    let rows = store.query(
        "SELECT json_extract(props, '$.name') AS name \
         FROM claims \
         WHERE claim_type = 'phase' \
           AND json_extract(props, '$.session_id') = ?1 \
         ORDER BY asserted_at DESC LIMIT 1",
        &[&session_id],
    )?;
    Ok(rows
        .into_iter()
        .next()
        .and_then(|r| r.get("name").cloned())
        .and_then(|v| v.as_str().map(String::from)))
}

/// Return `(claim_id, phase_name)` for the most recent Phase claim in
/// `session_id` — used by supersession-chain writes that need the
/// previous claim's id.
pub fn current_phase_claim(store: &Store, session_id: &str) -> Result<Option<(String, String)>> {
    let rows = store.query(
        "SELECT id, props FROM claims \
         WHERE claim_type = 'phase' \
           AND json_extract(props, '$.session_id') = ?1 \
         ORDER BY asserted_at DESC LIMIT 1",
        &[&session_id],
    )?;
    let row = match rows.into_iter().next() {
        Some(r) => r,
        None => return Ok(None),
    };
    let claim_id = row
        .get("id")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("phase row missing id"))?
        .to_string();
    let props_str = row
        .get("props")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("phase row missing props"))?;
    let props: Value = serde_json::from_str(props_str)?;
    let name = props
        .get("name")
        .and_then(|v| v.as_str())
        .ok_or_else(|| anyhow::anyhow!("phase claim missing name"))?
        .to_string();
    Ok(Some((claim_id, name)))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn phase_transitions_cover_the_state_machine() {
        assert!(Phase::Orient.can_transition_to(Phase::Plan));
        assert!(!Phase::Orient.can_transition_to(Phase::Execute));
        assert!(Phase::Plan.can_transition_to(Phase::Agree));
        assert!(Phase::Agree.can_transition_to(Phase::Execute));
        assert!(Phase::Execute.can_transition_to(Phase::Reflect));
        assert!(Phase::Execute.can_transition_to(Phase::Report));
        assert!(!Phase::Execute.can_transition_to(Phase::Plan));
        assert!(Phase::Reflect.can_transition_to(Phase::Execute));
        assert!(Phase::Reflect.can_transition_to(Phase::Replan));
        assert!(Phase::Replan.can_transition_to(Phase::Agree));
        assert!(!Phase::Report.can_transition_to(Phase::Plan));
    }

    #[test]
    fn phase_from_str_roundtrips() {
        for name in [
            "orient", "plan", "agree", "execute", "reflect", "replan", "report",
        ] {
            assert_eq!(Phase::from_str(name).unwrap().as_str(), name);
        }
        assert!(Phase::from_str("bogus").is_none());
    }
}