polyc-controller 2026.9.0

Conversation CRD + kube reconciler for the polychrome control plane.
//! The effective connector grant — the single policy-decision point for
//! which tool connectors a conversation's turn may see.
//!
//! Membership used to be computed across three call sites in two crates,
//! with "empty" meaning something different at each layer. Every resolution
//! site now consumes [`EffectiveGrant`] instead of re-implementing set
//! logic, so cross-agent leakage is a unit test here rather than an
//! emergent property of distributed conventions (#582, #488).

use std::collections::HashSet;

/// Normalizes a connector handle to its trailing segment, so a namespaced
/// grammar handle (`toolservice:polychrome/scaffold`) and a bare resource
/// name (`scaffold`) refer to the same connector. Bare names pass through
/// unchanged — Kubernetes resource names contain neither `/` nor `:`.
fn normalize(handle: &str) -> &str {
    handle.rsplit(['/', ':']).next().unwrap_or(handle)
}

/// How a connector entered (or failed to enter) a conversation's grant — the
/// membership decision [`EffectiveGrant::admission`] returns, split by which
/// path admitted it.
///
/// The split is what the shortlist-retrieval machinery (#582) keys deferral
/// on: a [`Self::Broadcast`] connector is a catalog member the harness must
/// never auto-advertise (#488), only surface via the ranked shortlist or the
/// escape hatch, while a [`Self::Direct`] connector keeps today's
/// always-advertised behavior.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Admission {
    /// Allowlisted membership: the conversation named the connector
    /// explicitly.
    Direct,
    /// `defaultEnabled`-only membership: the operator broadcast the connector
    /// and nothing named it. It enters the deferred pool — never
    /// auto-advertised (#488).
    Broadcast,
    /// Outside the grant: not broadcast, not allowlisted, denied, or excluded
    /// by the bound agent's ceiling.
    Denied,
}

/// The effective connector grant for one conversation turn.
///
/// One pure function decides membership ([`Self::admission`], with
/// [`Self::admits`] as its boolean projection): a connector is
/// granted when the operator broadcasts it (`defaultEnabled`) or the
/// conversation's allowlist names it, minus the conversation's deny list,
/// intersected with the bound agent's ceiling.
///
/// The connector-layer empty-value convention holds on every grant:
///
/// - an empty allowlist adds nothing beyond the broadcast set;
/// - an empty deny list withdraws nothing;
/// - an empty ceiling means the agent declares no restriction.
///
/// Handles in all three lists are normalized to their trailing segment so
/// bare names and namespaced grammar handles match the same connector.
///
/// The tool dimension is always a no-op: every tool on an admitted connector
/// is exposed.
#[derive(Debug, Clone, Default)]
pub struct EffectiveGrant {
    allow: HashSet<String>,
    deny: HashSet<String>,
    ceiling: HashSet<String>,
}

impl EffectiveGrant {
    /// Builds the grant from the conversation's allowlist and deny list plus
    /// the bound agent's ceiling, normalizing every handle.
    #[must_use]
    pub fn new(allow: &[String], deny: &[String], ceiling: &[String]) -> Self {
        let collect = |handles: &[String]| {
            handles
                .iter()
                .map(|h| normalize(h).to_owned())
                .collect::<HashSet<String>>()
        };
        Self {
            allow: collect(allow),
            deny: collect(deny),
            ceiling: collect(ceiling),
        }
    }

    /// The grant of a conversation with no per-conversation configuration and
    /// no bound agent: broadcast connectors only. Used by the shared-Service
    /// resolution path, which has no `Conversation` resource to read.
    #[must_use]
    pub fn broadcast_only() -> Self {
        Self::default()
    }

    /// A grant that applies only an agent's ceiling: no broadcast membership,
    /// no allowlist, no deny. For call sites that restrict an
    /// already-membership-decided descriptor set to what the bound agent may
    /// see ([`Self::retains`]) — an empty `ceiling` restricts nothing, matching
    /// [`Self::new`]'s empty-value convention.
    #[must_use]
    pub fn ceiling_only(ceiling: &[String]) -> Self {
        Self::new(&[], &[], ceiling)
    }

    /// Whether `connector` (a `ToolService` resource name) enters the
    /// conversation's tool surface: broadcast (`default_enabled`) or
    /// allowlisted, then retained by [`Self::retains`]. The boolean
    /// projection of [`Self::admission`] — the one membership decision.
    #[must_use]
    pub fn admits(&self, connector: &str, default_enabled: bool) -> bool {
        !matches!(
            self.admission(connector, default_enabled),
            Admission::Denied
        )
    }

    /// How `connector` enters the conversation's tool surface, split by
    /// membership path: [`Admission::Direct`] when the allowlist names it
    /// (allow beats broadcast, so an explicitly named connector is never
    /// demoted to the deferred pool), [`Admission::Broadcast`] when only the
    /// operator's `default_enabled` broadcast admits it, and
    /// [`Admission::Denied`] otherwise. [`Self::retains`] guards both
    /// admitting paths — the deny list and the agent ceiling exclude a
    /// connector regardless of how it would have entered — so it is checked
    /// first, before the membership split.
    #[must_use]
    pub fn admission(&self, connector: &str, default_enabled: bool) -> Admission {
        let connector = normalize(connector);
        if !self.retains(connector) {
            return Admission::Denied;
        }
        if self.allow.contains(connector) {
            Admission::Direct
        } else if default_enabled {
            Admission::Broadcast
        } else {
            Admission::Denied
        }
    }

    /// Whether an already-resolved connector survives the deny list and the
    /// agent ceiling. The filter for descriptors whose membership was decided
    /// elsewhere (the deployment's static catalog); [`Self::admits`] composes
    /// this with the broadcast-or-allowlist membership test. An empty ceiling
    /// means "no restriction", matching [`Self::new`]'s convention.
    #[must_use]
    pub fn retains(&self, connector: &str) -> bool {
        let connector = normalize(connector);
        if self.deny.contains(connector) {
            return false;
        }
        self.ceiling.is_empty() || self.ceiling.contains(connector)
    }

    /// Whether `tool` on `connector` enters the turn's tool surface: the
    /// connector must be admitted ([`Self::admits`]) — the tool dimension
    /// itself is unrestricted, so this is exactly [`Self::admits`] over the
    /// whole admitted catalog.
    #[must_use]
    pub fn admits_tool(&self, connector: &str, tool: &str, default_enabled: bool) -> bool {
        let _ = tool;
        self.admits(connector, default_enabled)
    }

    /// The bare tool names admitted on `connector` — always empty: the tool
    /// dimension is a no-op ([`Self::admits_tool`] passes every tool), so
    /// there is no closed set to name. The wire's
    /// `ToolServiceDescriptor.allowed_tools` convention reads an empty list
    /// as "no restriction", matching this exactly. Takes `&self` (rather than
    /// being a free function) to keep the same call-site shape as
    /// [`Self::admits_tool`] for the seam a future closed/scoped grant would
    /// need.
    #[must_use]
    #[allow(clippy::unused_self, clippy::missing_const_for_fn)]
    pub fn admitted_tool_names(&self, connector: &str) -> Vec<String> {
        let _ = connector;
        Vec::new()
    }
}

#[cfg(test)]
mod tests;