polyc-turn-runner 2026.9.0

polychrome turn-runner: run one agent turn from a wire request against an injected provider + tool executor.
//! Typed model-broker authority vocabulary.
//!
//! The execution fence names a Control-to-Execution session. It does not
//! authorize a provider dial. D6 binds each model request to a separate,
//! stable model-attempt identity. The broker verifies this vocabulary before
//! it dials a provider and asks State to reserve spend.

use polyc_state::{
    command::CommandScope,
    digest::ContentDigest,
    id::{AttemptId, WorkId},
    model_attempt::{ModelConversationId, TenantId},
};

use crate::execution::ExecutionIdentity;

/// The model role a caller is authorized to perform.
///
/// The broker authorizes a provider target per purpose, so this type is
/// ordered: an approved target names the exact purpose set it serves.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ModelPurpose {
    /// Produces a user-visible answering turn.
    Answering,
    /// Produces a turn-owned summary.
    Summarization,
    /// Classifies one independently claimed input.
    Classification,
}

/// The four cost ceilings that bound one model call.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ModelCostCeilings {
    tenant: u64,
    conversation: u64,
    turn: u64,
    call: u64,
}

impl ModelCostCeilings {
    /// Builds non-zero ceilings for each required accounting scope.
    ///
    /// # Errors
    ///
    /// Returns [`ModelError::MissingBound`] when any supplied ceiling is zero.
    /// A zero ceiling is an absent bound, and an absent bound makes the
    /// capability unbounded.
    pub fn new(tenant: u64, conversation: u64, turn: u64, call: u64) -> Result<Self, ModelError> {
        if [tenant, conversation, turn, call].contains(&0) {
            return Err(ModelError::MissingBound);
        }
        Ok(Self {
            tenant,
            conversation,
            turn,
            call,
        })
    }

    /// Returns the lowest remaining ceiling that a capability may carry.
    #[must_use]
    pub fn effective(self) -> u64 {
        self.tenant
            .min(self.conversation)
            .min(self.turn)
            .min(self.call)
    }

    /// Returns the tenant ceiling bound into the capability.
    #[must_use]
    pub const fn tenant(self) -> u64 {
        self.tenant
    }

    /// Returns the conversation ceiling bound into the capability.
    #[must_use]
    pub const fn conversation(self) -> u64 {
        self.conversation
    }

    /// Returns the turn ceiling bound into the capability.
    #[must_use]
    pub const fn turn(self) -> u64 {
        self.turn
    }

    /// Returns the call ceiling bound into the capability.
    #[must_use]
    pub const fn call(self) -> u64 {
        self.call
    }
}

/// The request fields a capability binds before a provider dial.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelRequestBinding {
    /// The tenant that owns the request.
    pub tenant: TenantId,
    /// The conversation that owns the request.
    pub conversation: ModelConversationId,
    /// The active execution session.
    pub execution: ExecutionIdentity,
    /// The durable State claim attempt.
    pub claim_attempt: AttemptId,
    /// The exact State claims scope that must still hold this attempt.
    pub claim_scope: CommandScope,
    /// The claimed work item addressed inside [`Self::claim_scope`].
    pub claim_work: WorkId,
    /// The stable model attempt. It is not the conversation fence.
    pub model_attempt: AttemptId,
    /// The typed role this request serves.
    pub purpose: ModelPurpose,
    /// The configured provider target name, not a URL.
    pub provider_target: String,
    /// The configured model name.
    pub model: String,
    /// The canonical request digest.
    pub request_digest: ContentDigest,
    /// The maximum request bytes.
    pub max_request_bytes: u64,
    /// The maximum response bytes.
    pub max_response_bytes: u64,
    /// The maximum output tokens.
    pub max_output_tokens: u64,
    /// The absolute end-to-end deadline, in nanoseconds since the Unix epoch.
    ///
    /// Wall-clock, not monotonic: it crosses a process boundary, and every
    /// producer and consumer builds it from `SystemTime::UNIX_EPOCH`. A
    /// monotonic value would be meaningless to the broker, whose clock has a
    /// different origin.
    pub deadline_nanos: u64,
    /// The broker audience.
    pub audience: String,
}

impl ModelRequestBinding {
    /// The claim epoch this binding was minted under.
    ///
    /// Control mints the execution identity from the claim it holds, so the
    /// two numbers coincide today. They are separate concepts, and a reader
    /// checking claim freshness means this one. Naming it here keeps that
    /// check from silently reading a wire fence if they ever diverge.
    #[must_use]
    pub const fn claim_fence(&self) -> polyc_state::command::FencingToken {
        self.execution.fence()
    }
}

/// A bounded capability presented to the model broker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelCapability {
    binding: ModelRequestBinding,
    ceilings: ModelCostCeilings,
}

impl ModelCapability {
    /// Builds a capability after rejecting missing identities and bounds.
    /// # Errors
    ///
    /// Returns [`ModelError`] when the binding is incomplete or a ceiling is
    /// not finite.
    pub fn new(
        binding: ModelRequestBinding,
        ceilings: ModelCostCeilings,
    ) -> Result<Self, ModelError> {
        validate_binding(&binding)?;
        Ok(Self { binding, ceilings })
    }

    /// Returns the bound request fields.
    #[must_use]
    pub const fn binding(&self) -> &ModelRequestBinding {
        &self.binding
    }

    /// Returns the effective cost ceiling for this call.
    #[must_use]
    pub fn effective_cost_ceiling(&self) -> u64 {
        self.ceilings.effective()
    }

    /// Returns all scoped cost ceilings.
    #[must_use]
    pub const fn ceilings(&self) -> ModelCostCeilings {
        self.ceilings
    }

    /// Refuses a request whose bound fields differ before any provider dial.
    /// # Errors
    ///
    /// Returns [`ModelError::BindingMismatch`] when any capability-bound field
    /// differs from the request presented for the call.
    pub fn verify(&self, request: &ModelRequestBinding) -> Result<(), ModelError> {
        validate_binding(request)?;
        if self.binding != *request {
            return Err(ModelError::BindingMismatch);
        }
        Ok(())
    }
}

/// A model capability could not authorize a provider dial.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ModelError {
    /// A caller omitted a required identity or bound.
    #[error("the model capability omits a required identity or bound")]
    MissingBound,
    /// A caller changed a field the capability binds.
    #[error("the model request does not match its bounded capability")]
    BindingMismatch,
}

fn validate_binding(binding: &ModelRequestBinding) -> Result<(), ModelError> {
    if binding.tenant.is_empty()
        || binding.conversation.is_empty()
        || binding.claim_attempt.is_empty()
        || binding.claim_work.as_str().is_empty()
        || binding.claim_scope.aggregate().as_str().is_empty()
        || binding.claim_scope.partition().as_str().is_empty()
        || binding.claim_scope.namespace().as_str().is_empty()
        || binding.model_attempt.is_empty()
        || binding.provider_target.is_empty()
        || binding.model.is_empty()
        || binding.audience.is_empty()
        || binding.max_request_bytes == 0
        || binding.max_response_bytes == 0
        || binding.max_output_tokens == 0
        || binding.deadline_nanos == 0
    {
        return Err(ModelError::MissingBound);
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use polyc_state::{
        command::{CommandScope, FencingToken},
        digest::ContentDigest,
        id::{AggregateId, AttemptId, NamespaceId, PartitionId, WorkId},
        model_attempt::{ModelConversationId, TenantId},
    };

    use super::{
        ModelCapability, ModelCostCeilings, ModelError, ModelPurpose, ModelRequestBinding,
    };
    use crate::execution::{ExecutionId, ExecutionIdentity};

    fn binding() -> ModelRequestBinding {
        ModelRequestBinding {
            tenant: TenantId::new("tenant-a"),
            conversation: ModelConversationId::new("conversation-a"),
            execution: ExecutionIdentity::new(
                ExecutionId::new("execution-a"),
                AttemptId::new("transport-a"),
                FencingToken::new(7),
            )
            .expect("valid execution identity"),
            claim_attempt: AttemptId::new("claim-a"),
            claim_scope: CommandScope::new(
                AggregateId::new("work-a"),
                PartitionId::new("turns:conversation-a"),
                NamespaceId::new("polychrome"),
            ),
            claim_work: WorkId::new("work-a"),
            model_attempt: AttemptId::new("model-a"),
            purpose: ModelPurpose::Answering,
            provider_target: "approved-provider".to_owned(),
            model: "approved-model".to_owned(),
            request_digest: ContentDigest::from_bytes([7; ContentDigest::LEN]),
            max_request_bytes: 1024,
            max_response_bytes: 2048,
            max_output_tokens: 256,
            deadline_nanos: 9,
            audience: "model-broker".to_owned(),
        }
    }

    #[test]
    fn capability_rejects_each_changed_bound_field() {
        let original = binding();
        let capability = ModelCapability::new(
            original.clone(),
            ModelCostCeilings::new(100, 90, 80, 70).expect("non-zero ceilings"),
        )
        .expect("complete binding");

        let mut changed = original.clone();
        changed.tenant = TenantId::new("tenant-b");
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.conversation = ModelConversationId::new("conversation-b");
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.execution = ExecutionIdentity::new(
            ExecutionId::new("execution-b"),
            AttemptId::new("transport-a"),
            FencingToken::new(7),
        )
        .expect("valid execution identity");
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.claim_attempt = AttemptId::new("claim-b");
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.model_attempt = AttemptId::new("model-b");
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.purpose = ModelPurpose::Classification;
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.provider_target = "other-provider".to_owned();
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.model = "other-model".to_owned();
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.request_digest = ContentDigest::from_bytes([8; ContentDigest::LEN]);
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.max_request_bytes = 1025;
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.max_response_bytes = 2049;
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.max_output_tokens = 257;
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original.clone();
        changed.deadline_nanos = 10;
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
        changed = original;
        changed.audience = "other-broker".to_owned();
        assert_eq!(
            capability.verify(&changed),
            Err(ModelError::BindingMismatch)
        );
    }

    #[test]
    fn effective_ceiling_is_the_lowest_required_scope() {
        let ceilings = ModelCostCeilings::new(100, 90, 80, 70).expect("non-zero ceilings");
        assert_eq!(ceilings.effective(), 70);
        assert_eq!(
            ModelCostCeilings::new(0, 90, 80, 70),
            Err(ModelError::MissingBound)
        );
    }
}