polyc-controller 2026.8.3

Conversation CRD + kube reconciler for the polychrome control plane.
//! Compiles a `routine_create` tool call's arguments into an explicit
//! [`RoutineSpec`] (`#1497`).
//!
//! An admin never names a run-as principal — a routine always runs as its
//! owner, by rule (#1802) — but they may name a sharing intent: whether
//! other members of this instance can view and duplicate the routine they're
//! creating. They describe the routine in plain language, and the model
//! turns that into the fields a spec actually varies on: the
//! [`RoutineSchedule`] (which variant — `cron` vs `once` — the model derives
//! from whether the request names a repeating cadence or a single instant),
//! the prompt text, and an optional `scope` (`"public"`/`"private"`) when the
//! admin said something about sharing it. This module is the single seam
//! that turns those model-supplied fields into a complete, admission-checked
//! [`RoutineSpec`]: `scope` defaults to [`RoutineScope::Private`] when the
//! admin named no sharing intent, and `suspend` is always absent (a routine
//! is never created pre-paused). Provenance is supplied by the caller — this
//! module never invents it — so the SAME compiled spec (minus provenance) can
//! be validated before a human ever sees the confirmation card, and again,
//! byte-for-byte, at the moment it is actually written.
//!
//! [`compile_spec`] calls [`crate::routine_reconcile::validate_spec`] before
//! returning `Ok` — the same validator the API server's admission would run —
//! so a request that admission would reject never reaches the confirmation
//! card at all (INV-RL2/RL3). It does not special-case any model vendor: the
//! entire input is the structural fields a tool call carries.

use serde::Deserialize;

use crate::routine::{RoutinePayload, RoutineProvenance, RoutineSchedule};
use crate::routine_reconcile::validate_spec;
use crate::{RoutineScope, RoutineSpec};

/// The per-routine tool-approval grant mode.
///
/// A `routine_create`/`routine_duplicate` call may name how much of the
/// routine's future unattended tool use the owner authorizes up front, at
/// creation time.
///
/// This is a DIFFERENT axis from the deployment-global
/// `polyc_tools::approval::ApprovalMode` (the `POLYCHROME_APPROVAL_MODE`
/// env var, a test-rig-only deployment setting). `ApprovalMode` governs
/// whether the deployment gates tool calls at all; `GrantMode` governs what
/// one routine's owner has pre-authorized within that gate. Never convert
/// between the two types — they answer unrelated questions.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GrantMode {
    /// The owner approves each gated tool call the first time the routine
    /// fires it, individually. The default.
    #[default]
    Individual,
    /// The owner pre-authorizes every gated tool below the high risk tier.
    Auto,
    /// The owner pre-authorizes every gated tool, including high-risk ones.
    ApproveAllDangerous,
}

/// The `routine_create`/`routine_duplicate` argument name carrying the grant
/// mode choice.
pub const ARG_APPROVAL_MODE: &str = "approval_mode";

/// The `approval_mode` argument value naming [`GrantMode::Individual`].
pub const APPROVAL_MODE_INDIVIDUAL: &str = "individual";
/// The `approval_mode` argument value naming [`GrantMode::Auto`].
pub const APPROVAL_MODE_AUTO: &str = "auto";
/// The `approval_mode` argument value naming [`GrantMode::ApproveAllDangerous`].
pub const APPROVAL_MODE_APPROVE_ALL_DANGEROUS: &str = "approve-all-dangerous";

/// Parse a `routine_create`/`routine_duplicate` call's `approval_mode`
/// argument into a [`GrantMode`].
///
/// Absent (or `args_json` failing to parse as an object) defaults to
/// [`GrantMode::Individual`] — the same fail-open-to-the-safest-choice
/// posture [`compile_spec`]'s own `scope` default takes. A present
/// `approval_mode` naming anything other than the three recognized values is
/// refused with a purpose-built message, mirroring `reject_unknown_scope`.
///
/// # Errors
///
/// Returns a human-readable reason when `approval_mode` is present but names
/// an unrecognized value.
pub fn parse_grant_mode(args_json: &str) -> Result<GrantMode, String> {
    let Ok(serde_json::Value::Object(map)) = serde_json::from_str(args_json) else {
        return Ok(GrantMode::default());
    };
    let Some(value) = map.get(ARG_APPROVAL_MODE) else {
        return Ok(GrantMode::default());
    };
    let Some(mode) = value.as_str() else {
        return Err(
            "the approval mode has to be one of \"individual\", \"auto\", or \
             \"approve-all-dangerous\""
                .to_owned(),
        );
    };
    match mode {
        APPROVAL_MODE_INDIVIDUAL => Ok(GrantMode::Individual),
        APPROVAL_MODE_AUTO => Ok(GrantMode::Auto),
        APPROVAL_MODE_APPROVE_ALL_DANGEROUS => Ok(GrantMode::ApproveAllDangerous),
        other => Err(format!(
            "\"{other}\" isn't a routine approval mode — use \"individual\" to approve each \
             tool the routine wants the first time, \"auto\" to pre-approve its ordinary \
             tools, or \"approve-all-dangerous\" to also pre-approve its high-risk tools."
        )),
    }
}

/// The `routine_create` tool call's argument shape: a structured schedule,
/// the prompt text to run, and an optional sharing intent. Nothing else —
/// the admin names no run-as principal, and the compiler adds none beyond
/// the fixed defaults [`compile_spec`] documents.
#[derive(Debug, Deserialize)]
struct RoutineCreateArgs {
    /// The schedule the model derived: `cron` for a repeating cadence, `once`
    /// for a single instant.
    schedule: RoutineSchedule,
    /// The prompt text the routine runs at fire time.
    prompt: String,
    /// Whether other members of this instance may view this routine's
    /// definition and duplicate it. Absent (or omitted by the model) defaults
    /// to [`RoutineScope::Private`] — a routine is created private unless the
    /// admin said something about sharing it.
    #[serde(default)]
    scope: Option<RoutineScope>,
}

/// Compile a `routine_create` call's `args_json` into a complete,
/// admission-checked [`RoutineSpec`].
///
/// `provenance` is stamped onto the returned spec verbatim — this function
/// never derives or guesses it, so the same call compiles identically
/// whether it's being validated ahead of the confirmation card (a
/// placeholder provenance; the fields the validator never reads) or written
/// for real at approval time (the actual creator persona + conversation id).
///
/// Fixed defaults, never model-supplied: `suspend` is always absent (nothing
/// is ever created pre-paused); `description` is always absent. `scope`
/// defaults to [`RoutineScope::Private`] when `args_json` names none.
///
/// # Errors
///
/// Returns a human-readable reason when `args_json` doesn't parse against
/// the argument shape, or when the compiled spec fails [`validate_spec`] —
/// the exact validator API-server admission runs, so a spec this rejects
/// would also be rejected by a real `kubectl apply` of the same shape.
///
/// A `scope` naming anything other than `"public"` or `"private"` — including
/// one of the values this field used to accept — is refused here with a
/// purpose-built message rather than falling through to the generic parse
/// error `serde` would otherwise produce for an unknown enum variant.
pub fn compile_spec(args_json: &str, provenance: RoutineProvenance) -> Result<RoutineSpec, String> {
    reject_unknown_scope(args_json)?;
    let args: RoutineCreateArgs = serde_json::from_str(args_json)
        .map_err(|e| format!("couldn't read the schedule and prompt: {e}"))?;
    let spec = RoutineSpec {
        description: None,
        scope: args.scope.unwrap_or_default(),
        schedule: args.schedule,
        payload: RoutinePayload {
            prompt: args.prompt,
        },
        provenance,
        suspend: None,
    };
    validate_spec(&spec)?;
    Ok(spec)
}

/// Refuse a `scope` naming anything other than `"public"` or `"private"`
/// before the structured parse runs, so the admin gets a purpose-built
/// explanation of the two choices instead of a raw "unknown variant" error.
///
/// Reads `args_json` as loosely-typed JSON rather than the [`RoutineCreateArgs`]
/// shape, so a bad `scope` is caught even when other fields are also
/// malformed; a missing `scope`, a non-string `scope`, or unparsable JSON are
/// all left for [`RoutineCreateArgs`]'s own parse to report.
fn reject_unknown_scope(args_json: &str) -> Result<(), String> {
    let Ok(serde_json::Value::Object(map)) = serde_json::from_str(args_json) else {
        return Ok(());
    };
    let Some(serde_json::Value::String(scope)) = map.get("scope") else {
        return Ok(());
    };
    if scope == "public" || scope == "private" {
        return Ok(());
    }
    Err(format!(
        "\"{scope}\" isn't a routine sharing choice — use \"private\" so only the \
         person who created the routine can see and copy it, or \"public\" so \
         other members of this instance can see and copy its definition."
    ))
}

#[cfg(test)]
mod tests {
    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]

    use super::*;

    fn provenance() -> RoutineProvenance {
        RoutineProvenance {
            creator_persona: "persona-1".to_owned(),
            conversation_id: "conv-1".to_owned(),
        }
    }

    #[test]
    fn cron_args_compile_to_a_private_scoped_spec_by_default() {
        let args =
            r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup."}"#;
        let spec = compile_spec(args, provenance()).expect("valid cron spec compiles");
        assert_eq!(
            spec.scope,
            RoutineScope::Private,
            "create-by-chat defaults to private when the admin names no sharing intent"
        );
        assert_eq!(spec.suspend, None);
        assert_eq!(spec.payload.prompt, "Post the standup.");
        assert!(matches!(spec.schedule, RoutineSchedule::Cron { .. }));
        assert_eq!(spec.provenance, provenance());
    }

    /// #1802 acceptance criterion 4: create-by-chat can produce a public
    /// routine when the model names that sharing intent.
    #[test]
    fn cron_args_naming_public_scope_compile_to_a_public_spec() {
        let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","scope":"public"}"#;
        let spec = compile_spec(args, provenance()).expect("valid cron spec compiles");
        assert_eq!(spec.scope, RoutineScope::Public);
    }

    /// The same sharing intent, spelled `"private"` explicitly rather than
    /// omitted, compiles identically to the default.
    #[test]
    fn cron_args_naming_private_scope_compile_to_a_private_spec() {
        let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","scope":"private"}"#;
        let spec = compile_spec(args, provenance()).expect("valid cron spec compiles");
        assert_eq!(spec.scope, RoutineScope::Private);
    }

    #[test]
    fn once_args_compile_to_a_once_schedule() {
        let args =
            r#"{"schedule":{"once":{"at":"2026-08-01T15:00:00Z"}},"prompt":"Remind them once."}"#;
        let spec = compile_spec(args, provenance()).expect("valid once spec compiles");
        assert_eq!(
            spec.schedule,
            RoutineSchedule::Once {
                at: "2026-08-01T15:00:00Z".to_owned()
            }
        );
    }

    #[test]
    fn malformed_args_json_is_refused() {
        let err = compile_spec("not json", provenance()).expect_err("must refuse");
        assert!(err.contains("schedule and prompt"), "{err}");
    }

    /// #1802 acceptance criterion: the retired `scope` values are rejected
    /// with a purpose-built message naming both valid choices, not a raw
    /// serde "unknown variant" error.
    #[test]
    fn retired_scope_values_are_refused_with_a_clear_message() {
        for retired in ["instance", "persona", "shared"] {
            let args = format!(
                r#"{{"schedule":{{"cron":{{"expression":"0 9 * * 1-5"}}}},"prompt":"Post the standup.","scope":"{retired}"}}"#
            );
            let err = compile_spec(&args, provenance()).expect_err("retired scope must be refused");
            assert!(err.contains(retired), "{err}");
            assert!(err.contains("private"), "{err}");
            assert!(err.contains("public"), "{err}");
            assert!(!err.contains("unknown variant"), "{err}");
        }
    }

    /// Any other unrecognized `scope` value gets the same purpose-built
    /// refusal, not just the three retired names.
    #[test]
    fn an_arbitrary_unknown_scope_value_is_refused_with_a_clear_message() {
        let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","scope":"everyone"}"#;
        let err = compile_spec(args, provenance()).expect_err("unknown scope must be refused");
        assert!(err.contains("everyone"), "{err}");
        assert!(err.contains("private"), "{err}");
        assert!(err.contains("public"), "{err}");
    }

    #[test]
    fn absent_approval_mode_defaults_to_individual() {
        let args =
            r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup."}"#;
        assert_eq!(parse_grant_mode(args).unwrap(), GrantMode::Individual);
    }

    #[test]
    fn recognized_approval_mode_values_parse() {
        for (value, expected) in [
            (APPROVAL_MODE_INDIVIDUAL, GrantMode::Individual),
            (APPROVAL_MODE_AUTO, GrantMode::Auto),
            (
                APPROVAL_MODE_APPROVE_ALL_DANGEROUS,
                GrantMode::ApproveAllDangerous,
            ),
        ] {
            let args = format!(
                r#"{{"schedule":{{"cron":{{"expression":"0 9 * * 1-5"}}}},"prompt":"Post the standup.","approval_mode":"{value}"}}"#
            );
            assert_eq!(parse_grant_mode(&args).unwrap(), expected, "{value}");
        }
    }

    #[test]
    fn unrecognized_approval_mode_is_refused_with_a_clear_message() {
        let args = r#"{"schedule":{"cron":{"expression":"0 9 * * 1-5"}},"prompt":"Post the standup.","approval_mode":"yolo"}"#;
        let err = parse_grant_mode(args).expect_err("must refuse");
        assert!(err.contains("yolo"), "{err}");
        assert!(err.contains("individual"), "{err}");
        assert!(err.contains("auto"), "{err}");
        assert!(err.contains("approve-all-dangerous"), "{err}");
    }

    #[test]
    fn missing_prompt_is_refused() {
        let args = r#"{"schedule":{"cron":{"expression":"0 9 * * *"}}}"#;
        compile_spec(args, provenance()).expect_err("prompt is required");
    }

    /// The compiler calls the SAME validator admission runs, so a spec that
    /// would be rejected at `kubectl apply` is refused here too, before any
    /// card is shown (INV-RL2/RL3).
    #[test]
    fn a_spec_validate_spec_would_reject_is_refused_before_any_card() {
        let bad_cron = r#"{"schedule":{"cron":{"expression":"not a cron"}},"prompt":"hi"}"#;
        let err = compile_spec(bad_cron, provenance()).expect_err("bad cron must be refused");
        assert!(err.contains("cron expression"), "{err}");

        let empty_prompt = r#"{"schedule":{"cron":{"expression":"0 9 * * *"}},"prompt":"   "}"#;
        let err = compile_spec(empty_prompt, provenance()).expect_err("blank prompt refused");
        assert!(err.contains("payload.prompt"), "{err}");
    }
}