shepherd-core 6.6.0

The harness-agnostic shepherd engine: domain types, configuration schema, and run state. Knows nothing about any CLI, harness, or process.
/*
    Appellation: vocabulary <module>
    Created At: 2026.08.28:00:00:00
    Contrib: @FL03
*/
//! The closed vocabularies `run.json` carries, and the wrapper that keeps
//! each of them readable when a newer host writes a value this build does not
//! know.
//!
//! `run.json` carries three closed vocabularies -- the run status, the run
//! kind, and each lane's state. Every one of them is closed *for this binary*
//! and open for a future one: [`crate::run::RunState`] deliberately lets a
//! schema-ahead document load so a read-only command can still report it,
//! while [`crate::run::RunState::store`] refuses to write a value this binary
//! does not understand. `crates/cli/tests/run_cli.rs`'s
//! `invalid_ids_missing_runs_and_schema_ahead_documents_have_stable_exit_codes_without_writes`
//! pins that with a literal `"paused-by-future-host"` status at
//! `schema_version: 2`, and `crates/cli/tests/run_store.rs`'s
//! `schema_ahead_state_is_readable_for_resume_but_never_overwritten` pins the
//! store side.
//!
//! Those fields used to be `String`, which bought the forward compatibility at
//! the price of every consumer comparing string literals -- `state.status ==
//! "executing"` in thirty-odd places, with the vocabulary itself restated as a
//! `matches!` list or a `[&str; N]` in six of them. A typo in any one is a
//! silent `false`, not a compile error, and six copies had already drifted:
//! two carried `"integrating"`, which is not a run status.
//!
//! [`Vocabulary`] buys both. `#[serde(untagged)]` tries `Known` first and falls
//! back to `Unknown`, so the wire format is unchanged -- a bare string, byte
//! for byte -- while the Rust type forces a consumer through [`Vocabulary::is`]
//! or [`Vocabulary::known`] and makes the unrecognized case impossible to
//! overlook. The vocabulary is then stated exactly once, in the enum's own
//! variants, and `store`'s validation is `is_known()` rather than a literal
//! list that has to be kept in step by hand.

#[cfg(feature = "alloc")]
use alloc::string::String;

/// One field of a closed vocabulary `T`, plus the value a schema-ahead
/// document may legitimately carry.
///
/// Serializes and deserializes as a bare `T` -- the wrapper is invisible on
/// the wire. See the module docs for why the `Unknown` arm exists.
#[derive(
    Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
)]
#[serde(untagged)]
pub enum Vocabulary<T> {
    /// A value this binary understands.
    Known(T),
    /// A value it does not. Preserved verbatim so a load/store cycle under a
    /// newer schema version does not rewrite another host's field.
    Unknown(String),
}

impl<T> Vocabulary<T> {
    /// Whether this binary understands the value.
    ///
    /// This is the whole of the write-side vocabulary check: a mutator refuses
    /// what it cannot reason about, and the set it accepts is `T`'s variants
    /// rather than a literal list maintained alongside them.
    #[must_use]
    pub const fn is_known(&self) -> bool {
        matches!(self, Self::Known(_))
    }

    /// The recognized value by reference, for a `T` that is not `Copy`.
    #[must_use]
    pub const fn as_known(&self) -> Option<&T> {
        match self {
            Self::Known(value) => Some(value),
            Self::Unknown(_) => None,
        }
    }

    /// The raw text of a value this binary does not recognize.
    ///
    /// Reporting paths want the original spelling; `run show` prints what the
    /// document actually says rather than a placeholder.
    #[must_use]
    pub fn unrecognized(&self) -> Option<&str> {
        match self {
            Self::Unknown(value) => Some(value),
            Self::Known(_) => None,
        }
    }
}

impl<T: Copy> Vocabulary<T> {
    /// The recognized value, or `None` for one this binary does not know.
    ///
    /// The `Option` is the point: a consumer cannot compare against a variant
    /// without first deciding what an unrecognized value means for it.
    #[must_use]
    pub const fn known(&self) -> Option<T> {
        match self {
            Self::Known(value) => Some(*value),
            Self::Unknown(_) => None,
        }
    }
}

impl<T: Copy + PartialEq> Vocabulary<T> {
    /// Whether this field holds exactly `value`.
    ///
    /// An unrecognized value is never equal to a known one, which is the same
    /// answer the old `state.status == "executing"` gave -- now without the
    /// literal.
    #[must_use]
    pub fn is(&self, value: T) -> bool {
        self.known() == Some(value)
    }
}

impl<T: AsRef<str>> Vocabulary<T> {
    /// The field's text, exactly as it serializes.
    #[must_use]
    pub fn as_str(&self) -> &str {
        match self {
            Self::Known(value) => value.as_ref(),
            Self::Unknown(value) => value.as_str(),
        }
    }
}

impl<T: serde::de::DeserializeOwned> Vocabulary<T> {
    /// Classify a raw string against the vocabulary.
    ///
    /// This is the non-serde entry point -- a value crossing the WIT boundary
    /// arrives as a `String` and has to be classified exactly the way the same
    /// value in a `run.json` would be.
    ///
    /// It goes through `T`'s `Deserialize`, not its `FromStr`, and that is the
    /// whole point. The two are NOT the same function here: `FromStr` comes
    /// from `strum(ascii_case_insensitive, ..)`, which is deliberately lenient
    /// because an operator types it (`--to Executing` should work). Serde is
    /// strict, because a stored document is machine-written and `"Planted"` in
    /// one is a foreign spelling, not a sloppy one. Routing `parse` through
    /// `FromStr` made a WIT caller's `"Planted"` authoritative while the
    /// identical bytes on disk were unrecognized -- for the field the dispatch
    /// authorization table keys on.
    /// `vocabulary_parse_agrees_with_deserialize` holds the two together.
    pub fn parse(value: impl Into<String>) -> Self {
        use serde::de::IntoDeserializer as _;
        let value = value.into();
        let deserializer: serde::de::value::StrDeserializer<'_, serde::de::value::Error> =
            value.as_str().into_deserializer();
        match T::deserialize(deserializer) {
            Ok(known) => Self::Known(known),
            Err(_) => Self::Unknown(value),
        }
    }
}

impl<T: Default> Default for Vocabulary<T> {
    fn default() -> Self {
        Self::Known(T::default())
    }
}

impl<T> From<T> for Vocabulary<T> {
    fn from(value: T) -> Self {
        Self::Known(value)
    }
}

impl<T: AsRef<str>> core::fmt::Display for Vocabulary<T> {
    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        formatter.write_str(self.as_str())
    }
}

/// The closed run lifecycle vocabulary.
///
/// This is the single statement of what statuses exist. It replaced
/// `RUN_STATUSES: [&str; 5]` in the CLI (a string array checked at runtime with
/// a hand-rolled `join(", ")` error message -- clap emits `[possible values:
/// ...]` from the type for free), then the five-literal `matches!` in
/// `run_store.rs`'s vocabulary check, and finally the `match run_status { .. }`
/// authorization table in `dispatch::pending`, which had drifted far enough to
/// key an arm on `"integrating"` -- a status no `run.json` has ever been
/// allowed to hold.
///
/// [`Vocabulary<RunStatus>`] is what `run.json` actually carries; see that
/// type for why the unrecognized case has to remain expressible.
#[derive(
    Clone,
    Copy,
    Debug,
    Default,
    Eq,
    Hash,
    Ord,
    PartialEq,
    PartialOrd,
    serde::Deserialize,
    serde::Serialize,
    strum::AsRefStr,
    strum::Display,
    strum::EnumCount,
    strum::EnumIs,
    strum::EnumString,
    strum::IntoStaticStr,
    strum::VariantArray,
    strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum RunStatus {
    /// A seed exists. The root is `planting`.
    #[default]
    Planted,
    /// A verified plan exists.
    Planned,
    /// Lanes are running.
    Executing,
    /// Lanes are done; integration is under way.
    Closing,
    /// The run is finished.
    Closed,
}

impl RunStatus {
    /// Whether reaching this status is a GUARDED transition rather than a
    /// field assignment.
    ///
    /// `planned` and `executing` re-verify checkpoints under the run lock;
    /// nothing may set them by writing the field. The other three are ordinary
    /// lifecycle marks. Conflating the two in one `set` subcommand is why a
    /// conformance case called `set-ok` once expected exit 0 for a transition
    /// the engine refuses.
    #[must_use]
    pub const fn is_guarded_transition(self) -> bool {
        matches!(self, Self::Planned | Self::Executing)
    }

    /// Whether a pending child dispatch may exist while a run holds this
    /// status -- that is, whether any caller/target/work-kind triple is
    /// authorized here at all.
    ///
    /// This is the statuses [`crate::dispatch::validate_pending_edge`] has a
    /// non-empty arm for, and nothing else. It used to be a second `matches!`
    /// list inside `PendingDispatch::validate` that disagreed with the first
    /// in both directions: it admitted `planned`, which authorizes no edge,
    /// and `integrating`, which is not a run status.
    /// Whether the run is still open -- anything but the terminal status.
    ///
    /// Gates the surfaces that stay available for the whole life of a run
    /// (child tool availability, broker parenthood) as distinct from
    /// [`Self::admits_dispatch`], which is the narrower question of whether a
    /// pending dispatch may be created. Both were the same five-literal
    /// `matches!` list, copied to four call sites and carrying `"integrating"`.
    #[must_use]
    pub const fn is_open(self) -> bool {
        !matches!(self, Self::Closed)
    }

    #[must_use]
    pub const fn admits_dispatch(self) -> bool {
        matches!(self, Self::Planted | Self::Executing | Self::Closing)
    }
}

/// What arc a run is.
///
/// Was the string literals `"sprint"` and `"patch-arc"`, checked by a
/// `--kind` validator whose error message was the only place the pair was
/// written down. `EnumString` derives the parse from the variants, so the
/// vocabulary and the parser cannot drift.
#[derive(
    Clone,
    Copy,
    Debug,
    Default,
    Eq,
    Hash,
    Ord,
    PartialEq,
    PartialOrd,
    serde::Deserialize,
    serde::Serialize,
    strum::AsRefStr,
    strum::Display,
    strum::EnumCount,
    strum::EnumIs,
    strum::EnumString,
    strum::IntoStaticStr,
    strum::VariantArray,
    strum::VariantNames,
)]
#[serde(rename_all = "kebab-case")]
#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
pub enum RunKind {
    /// A planned sprint: a seed, a plan, and file-disjoint lanes.
    #[default]
    Sprint,
    /// A patch arc: one focused correction outside the sprint cadence.
    PatchArc,
}

/// One lane's lifecycle state.
///
/// Was a bare `String` whose four legal values were restated as a `matches!`
/// list in `run_store.rs` and again in `cmd/execution.rs`. The variants are
/// now the single statement of that vocabulary.
#[derive(
    Clone,
    Copy,
    Debug,
    Default,
    Eq,
    Hash,
    Ord,
    PartialEq,
    PartialOrd,
    serde::Deserialize,
    serde::Serialize,
    strum::AsRefStr,
    strum::Display,
    strum::EnumCount,
    strum::EnumIs,
    strum::EnumString,
    strum::IntoStaticStr,
    strum::VariantArray,
    strum::VariantNames,
)]
#[serde(rename_all = "kebab-case")]
#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
pub enum LaneStatus {
    /// Registered, not yet claimed.
    #[default]
    Pending,
    /// A conductor holds it.
    InProgress,
    /// Its acceptance landed.
    Complete,
    /// It failed and needs an operator.
    Error,
}