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.
//! Native identity validation independent of per-tool call identifiers.

#[cfg(feature = "alloc")]
use alloc::string::{String, ToString};

use crate::Harness;
use crate::vocabulary::{RunStatus, Vocabulary};

use super::{
    AgentId, AgentType, CapabilityReadiness, DispatchRecord, DispatchState, LaneId, ProjectId,
    Role, RunId, SessionId,
};

pub const ROOT_SESSION_SCHEMA: &str = "shepherd.root-session/1";

/// The phase a root session is in.
///
/// It is `planting`, not `planning`. Planning is the ENGINEER's mode -- see
/// `plan::validate`, which maps `"engineer" => "planning"` -- and giving the
/// root's phase that name made the root look like it authored plans, which is
/// the role boundary this project spends the most effort defending. The status
/// pairing says it plainly: a root is `planting` while the run is `planted`.
///
/// A unit enum, so `FromStr`, `Display`, and the variant list are derived
/// rather than written. `FromStr` is also what lets clap parse this directly,
/// so the CLI needs no second type and no normalization step.
///
/// `planning` remains ACCEPTED ON READ. A persisted binding is durable state an
/// operator did not choose, so a rename must not invalidate a session that is
/// mid-sprint; strum's `serialize` and serde's `alias` take the old spelling
/// while `to_string`/`rename` guarantee only the new one is ever written.
#[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,
)]
// Both are required and they drive different things: `strum` governs
// Display/FromStr, `serde` governs the wire. Setting only the strum one
// serialized `Execution` with a capital E and broke the frozen dispatch-request
// bytes -- caught by the byte-exact test, which is what it is for.
#[serde(rename_all = "snake_case")]
#[strum(ascii_case_insensitive, serialize_all = "snake_case")]
pub enum RootMode {
    /// The root authors the seed. Pairs with run status `planted` or `planned`.
    #[default]
    #[serde(rename = "planting", alias = "planning")]
    #[strum(to_string = "planting", serialize = "planning")]
    Planting,
    /// The root drives lanes. Pairs with run status `executing`.
    Execution,
}

impl RootMode {
    /// Whether a run in this status admits this mode.
    #[must_use]
    pub fn admits_run_status(self, status: &Vocabulary<RunStatus>) -> bool {
        match (self, status.known()) {
            (Self::Planting, Some(RunStatus::Planted | RunStatus::Planned)) => true,
            (Self::Execution, Some(RunStatus::Executing)) => true,
            // A status this build does not recognize admits no root mode. The
            // old `status == "executing"` gave the same answer for an unknown
            // value; the difference is that this one cannot be reached by a
            // misspelling.
            _ => false,
        }
    }
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct RootSessionBinding {
    pub schema: String,
    pub project_id: ProjectId,
    pub run: RunId,
    pub harness: Harness,
    pub session_id: SessionId,
    pub role: Role,
    pub mode: RootMode,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project_filesystem_id: Option<String>,
    pub bound_at: i64,
    pub expires_at: i64,
}

impl RootSessionBinding {
    /// Validate the persisted, host-independent shape of a root-session binding.
    pub fn validate(&self) -> core::result::Result<(), IdentityError> {
        if self.schema != ROOT_SESSION_SCHEMA {
            return Err(IdentityError::InvalidRootBinding(self.schema.clone()));
        }
        if !matches!(self.role, Role::Shepherd | Role::Planter) {
            return Err(IdentityError::InvalidRootBinding(self.role.to_string()));
        }
        // No mode validation here any more. It was twelve lines asserting the
        // string was 1..=64 lowercase ASCII; `RootMode` makes every one of
        // those states unrepresentable, and an unknown spelling now fails at
        // deserialization instead of passing a shape check and failing later.
        if self.bound_at < 0
            || self.expires_at <= self.bound_at
            || self
                .project_filesystem_id
                .as_deref()
                .is_some_and(|identity| !valid_project_filesystem_id(identity))
        {
            return Err(IdentityError::InvalidRootBinding(
                "binding time range is invalid".into(),
            ));
        }
        Ok(())
    }
}

fn valid_project_filesystem_id(identity: &str) -> bool {
    if identity.len() == 64
        && identity
            .bytes()
            .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
    {
        return true;
    }
    // Read compatibility for the pre-v6.5.7 Unix dev/inode form. New native
    // bindings use the cross-platform hashed workspace identity.
    let mut parts = identity.split(':');
    parts.next() == Some("unix")
        && parts.next().is_some_and(|part| {
            !part.is_empty()
                && part.len() <= 16
                && part.bytes().all(|byte| byte.is_ascii_hexdigit())
        })
        && parts.next().is_some_and(|part| {
            !part.is_empty()
                && part.len() <= 16
                && part.bytes().all(|byte| byte.is_ascii_hexdigit())
        })
        && parts.next().is_none()
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NativeIdentity {
    pub harness: Harness,
    pub project_id: ProjectId,
    pub run: RunId,
    pub lane: Option<LaneId>,
    pub session_id: SessionId,
    pub agent_id: Option<AgentId>,
    pub agent_type: Option<AgentType>,
    pub role: Option<Role>,
    /// Audit-only correlation for one Pre/Post tool pair. Identity resolution
    /// deliberately never reads this field.
    pub tool_call_id: Option<String>,
    pub now: i64,
    pub root_binding: Option<RootSessionBinding>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IdentityResolution {
    Root { role: Role, mode: RootMode },
    Agent { agent_id: AgentId, role: Role },
}

#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum IdentityError {
    #[error("native identity fields are incomplete")]
    IncompleteNativeIdentity,
    #[error("no explicit root session binding exists")]
    MissingRootBinding,
    #[error("dispatch record missing for native agent `{agent_id}`")]
    MissingRecord { agent_id: AgentId },
    #[error("dispatch agent id mismatch: native `{native}`, record `{record}`")]
    AgentIdMismatch { native: AgentId, record: AgentId },
    #[error("dispatch agent type mismatch: native `{native}`, record `{record}`")]
    AgentTypeMismatch {
        native: AgentType,
        record: AgentType,
    },
    #[error("dispatch role mismatch: native `{native}`, record `{record}`")]
    RoleMismatch { native: Role, record: Role },
    #[error("dispatch harness mismatch")]
    HarnessMismatch,
    #[error("dispatch project mismatch")]
    ProjectMismatch,
    #[error("wrong active run: native `{native}`, record `{record}`")]
    WrongRun { native: RunId, record: RunId },
    #[error("wrong lane: native `{native:?}`, record `{record:?}`")]
    WrongLane {
        native: Option<LaneId>,
        record: Option<LaneId>,
    },
    #[error("dispatch session mismatch")]
    SessionMismatch,
    #[error("dispatch record is stale since {expired_at}")]
    Stale { expired_at: i64 },
    #[error("dispatch record is terminal: {state}")]
    Terminal { state: DispatchState },
    #[error("dispatch capability contract blocks work")]
    CapabilityBlocked,
    #[error("root session binding is invalid: {0}")]
    InvalidRootBinding(String),
}

pub fn resolve_native_identity(
    record: Option<&DispatchRecord>,
    native: &NativeIdentity,
) -> core::result::Result<IdentityResolution, IdentityError> {
    let Some(agent_id) = &native.agent_id else {
        if native.agent_type.is_some() || native.role.is_some() {
            return Err(IdentityError::IncompleteNativeIdentity);
        }
        return resolve_root(native);
    };
    let record = record.ok_or_else(|| IdentityError::MissingRecord {
        agent_id: agent_id.clone(),
    })?;
    if agent_id != &record.agent_id {
        return Err(IdentityError::AgentIdMismatch {
            native: agent_id.clone(),
            record: record.agent_id.clone(),
        });
    }
    // A host declares agent_type when the agent starts and resends only
    // agent_id on each tool call afterwards. The record is the authority for
    // the type, so an absent one is read from there rather than treated as an
    // incomplete identity. A type that IS supplied still has to agree.
    if let Some(agent_type) = &native.agent_type
        && agent_type != &record.agent_type
    {
        return Err(IdentityError::AgentTypeMismatch {
            native: agent_type.clone(),
            record: record.agent_type.clone(),
        });
    }
    if let Some(role) = native.role
        && role != record.role
    {
        return Err(IdentityError::RoleMismatch {
            native: role,
            record: record.role,
        });
    }
    if native.harness != record.harness {
        return Err(IdentityError::HarnessMismatch);
    }
    if native.project_id != record.project_id {
        return Err(IdentityError::ProjectMismatch);
    }
    if native.run != record.run {
        return Err(IdentityError::WrongRun {
            native: native.run.clone(),
            record: record.run.clone(),
        });
    }
    if native.lane.is_some() && native.lane != record.lane {
        return Err(IdentityError::WrongLane {
            native: native.lane.clone(),
            record: record.lane.clone(),
        });
    }
    if native.session_id != record.session_id {
        return Err(IdentityError::SessionMismatch);
    }
    if record.state != DispatchState::Active {
        return Err(IdentityError::Terminal {
            state: record.state,
        });
    }
    if native.now >= record.lease_expires_at {
        return Err(IdentityError::Stale {
            expired_at: record.lease_expires_at,
        });
    }
    if record.capabilities.readiness() == CapabilityReadiness::Blocked {
        return Err(IdentityError::CapabilityBlocked);
    }
    Ok(IdentityResolution::Agent {
        agent_id: agent_id.clone(),
        role: record.role,
    })
}

fn resolve_root(
    native: &NativeIdentity,
) -> core::result::Result<IdentityResolution, IdentityError> {
    let binding = native
        .root_binding
        .as_ref()
        .ok_or(IdentityError::MissingRootBinding)?;
    binding.validate()?;
    if binding.bound_at > native.now {
        return Err(IdentityError::InvalidRootBinding(
            "binding begins in the future".into(),
        ));
    }
    if binding.harness != native.harness {
        return Err(IdentityError::HarnessMismatch);
    }
    if binding.project_id != native.project_id {
        return Err(IdentityError::ProjectMismatch);
    }
    if binding.run != native.run {
        return Err(IdentityError::WrongRun {
            native: native.run.clone(),
            record: binding.run.clone(),
        });
    }
    if binding.session_id != native.session_id {
        return Err(IdentityError::SessionMismatch);
    }
    if native.now >= binding.expires_at {
        return Err(IdentityError::Stale {
            expired_at: binding.expires_at,
        });
    }
    Ok(IdentityResolution::Root {
        role: binding.role,
        mode: binding.mode,
    })
}