polyc-query-credential 2026.10.0

Credential verification and query scope derivation, shared by the control plane's forensics authorization funnel and the standalone Query plane, with no DataFusion, Arrow, or engine dependency.
//! The catalog-scope selector a verified credential resolves to.
//!
//! [`QueryScope`] says which partitions a session's catalog may register —
//! `polyc-query`'s `QueryEngine::build` reads it to decide that; building and
//! holding the actual `SessionContext` is that crate's own job, not this
//! module's. This type carries no seal of its own: it is inert data. The
//! authorization boundary is [`crate::principal::Scoping`], which has no
//! public constructor — a caller that names a bare `QueryScope` still cannot
//! reach a runnable session with it, because nothing in `polyc-query`'s public
//! surface accepts one directly.

/// Which persona-memory partitions a [`QueryScope::Conversations`] session may
/// additionally name.
///
/// Minted only by [`crate::credential::CredentialAuthority::scoping_for`],
/// from a verified principal and — for a conversation grant — the
/// participation authority.
/// `owner` and `participants` are never merged into one list: an
/// owner-audience table (`persona-memory/v1`'s `memory_facts` and its
/// siblings) admits only `owner`'s partition, and a query that depends on one
/// refuses outright unless `participants` is empty (`02-DESIGN.md` §2.5).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct MemorySources {
    /// The partition-owning persona this scope's subject speaks for, when it
    /// speaks for one. `None` for a turn-subject grant, which has no owner
    /// identity (`GrantSubject::owner_persona_id`).
    pub owner: Option<String>,
    /// Co-participant personas this scope may read the *portable*-audience
    /// tables of, each independently verified — through
    /// `PersonaSource::active_persona` and `participations(active) ∋ X` for
    /// a conversation grant's conversation `X` — before this value exists.
    pub participants: Vec<String>,
}

impl MemorySources {
    /// Returns every partition this scope admits, owner and participants
    /// alike, canonicalized (sorted, de-duplicated).
    #[must_use]
    pub fn partitions(&self) -> Vec<String> {
        let mut ids: Vec<String> = self
            .owner
            .iter()
            .cloned()
            .chain(self.participants.iter().cloned())
            .collect();
        ids.sort();
        ids.dedup();
        ids
    }
}

/// Which partitions a query session's catalog may register.
///
/// Derived only from an already-verified [`crate::principal::Principal`] —
/// never a caller-supplied value — by
/// [`crate::credential::CredentialAuthority::scoping_for`]. Carrying a bare
/// `QueryScope` proves nothing on its own; see this module's own doc.
/// `Debug` reports the shape and the conversation count. A conversation id
/// names a real person's conversation, so the ids never reach a log line
/// through this type.
#[derive(Clone, PartialEq, Eq)]
pub enum QueryScope {
    /// A maintainer/operator session — fleet-wide, gated on
    /// `Permissions.admin`.
    Fleet,
    /// An end-user or conversation-scoped session, bounded to the given
    /// `conv-{id}` partitions — sourced from State participation records,
    /// never a client-supplied predicate — and the
    /// `persona-memory/v1` partitions this same session may additionally
    /// name.
    ///
    /// `memory` rides alongside `conversations` on the SAME variant, rather
    /// than a separate one, because `CredentialAuthority::scoping_for`
    /// mints one scope per query request, before the request's SQL is
    /// compiled and its table dependencies are known. A session that turns
    /// out to depend on `persona-memory/v1` tables uses `memory`; a session
    /// that does not carries an empty [`MemorySources::default`] and behaves
    /// exactly as it did before this field existed.
    Conversations {
        /// The `conv-{id}` partitions this session may register.
        conversations: Vec<String>,
        /// The `persona-{id}-mem` partitions this session may register.
        memory: MemorySources,
    },
    /// The Fleet realm, bounded to one conversation's `conv-{id}` partition
    /// (POLY-464).
    ///
    /// Only the admin composite trace's participant-address statement runs
    /// under this scope. It reads a Fleet-only table, so it needs the Fleet
    /// realm. A Fleet-wide scope would pin every conversation's generation
    /// and would fail when any one of them is absent. This scope pins the
    /// one conversation alone. It reads the `conversation-trace/v1` family
    /// and no other family, so it reads no persona memory.
    FleetConversation {
        /// The conversation id, without the `conv-` prefix.
        conversation: String,
    },
}

impl std::fmt::Debug for QueryScope {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Fleet => formatter.write_str("Fleet"),
            Self::Conversations {
                conversations,
                memory,
            } => formatter
                .debug_struct("Conversations")
                .field("count", &conversations.len())
                .field("memory_has_owner", &memory.owner.is_some())
                .field("memory_participant_count", &memory.participants.len())
                .finish(),
            Self::FleetConversation { .. } => formatter
                .debug_struct("FleetConversation")
                .field("count", &1_usize)
                .finish(),
        }
    }
}