car-server-core 0.30.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Channel-agnostic approval semantics (Unit 1 — the lifted core).
//!
//! `ApprovalCore` holds the approval semantics that EVERY channel shares,
//! lifted near-verbatim out of the #403 iMessage orchestrator. It contains the
//! semantics ONLY — nothing channel-specific:
//!
//! - [`ApprovalCore::is_eligible_pending`] — the eligibility predicate
//!   (`status == Pending && !action.starts_with(WS_METHOD_PREFIX) &&
//!   client_id.is_none()`), unchanged byte-for-byte (MC-7). Excludes the
//!   v2-deferred blocking-gate rows and session-owned rows the in-process
//!   resolver cannot own.
//! - [`ApprovalCore::eligible_pending`] — the eligible-pending query (snapshot
//!   the host's approvals, filter by the predicate).
//! - [`ApprovalCore::resolve`] — the resolve path. Calls through to the
//!   UNTOUCHED [`HostState::resolve_approval`] first-writer-wins idempotent
//!   guard. The system-raised principal is a **parameter** (`principal: &str`)
//!   passed by each adapter, NOT a hardcoded transport-specific literal — so
//!   the literal lives only in its adapter, never here.
//!
//! Deliberately CHANNEL-FREE (MC-3): this module names no per-channel transport
//! mechanism, no message-store handle, no per-channel correlation map, no
//! per-channel inbound parser, no read-cursor/high-water concept, and no
//! second-channel identifier. Every such concern stays in its own adapter.

use std::sync::Arc;

use car_proto::{HostApprovalRequest, HostApprovalStatus, ResolveHostApprovalRequest};

use crate::host::HostState;

/// The blocking high-risk-method gate's action prefix (`handler.rs:2183-2184`).
/// There is NO `system_level`/`kind` field on the persisted
/// [`HostApprovalRequest`] to key off, so the action prefix is the ONLY
/// discriminator between the fire-and-return rows a channel can resolve and the
/// blocking-gate rows it must never touch. Channel-agnostic — every adapter's
/// eligibility runs through the same predicate here.
pub const WS_METHOD_PREFIX: &str = "ws.method:";

/// The outcome of a [`ApprovalCore::resolve`] call, so the calling adapter can
/// decide whether to evict its code↔id mapping. Mirrors the #403 contract: a
/// real resolve is `Resolved`; a fan-out non-resolve comes back `Pending`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolveOutcome {
    /// The row moved to `Resolved` (this call won the first-writer-wins guard,
    /// or it was already resolved by a converging racer — either way the
    /// approval is now resolved). The adapter may evict its code mapping.
    Resolved,
    /// `HostState::resolve_approval` returned `Ok` but the row is still
    /// `Pending` (the cross-session fan-out branch — `host.rs:334-366`). The
    /// row did NOT move; the adapter must NOT evict its code or report success.
    /// Should be unreachable for an eligible (system-level) row, but handled as
    /// defense-in-depth.
    StillPending,
    /// `HostState::resolve_approval` returned an `Err` (e.g. unknown id, or an
    /// owner that is no longer connected). Nothing moved.
    Error,
}

/// Channel-agnostic approval semantics over a shared [`HostState`]. Cheap to
/// clone (just an `Arc`). One per adapter, or shared.
#[derive(Clone)]
pub struct ApprovalCore {
    host: Arc<HostState>,
}

impl ApprovalCore {
    /// Build a core over the shared host state.
    pub fn new(host: Arc<HostState>) -> Self {
        Self { host }
    }

    /// The shared host this core resolves against (adapters that still need
    /// direct host access — e.g. the iMessage code-eviction sweep — borrow it
    /// here rather than holding a second `Arc`).
    pub fn host(&self) -> &Arc<HostState> {
        &self.host
    }

    /// True iff `approval` is a pending, eligible row an in-process channel
    /// adapter can actually resolve. Three conditions, all required (MC-7 —
    /// unchanged from #403):
    ///
    /// 1. **`status == Pending`** — resolved rows stay in the map marked
    ///    `Resolved`.
    /// 2. **`!action.starts_with("ws.method:")`** — exclude the v2-deferred
    ///    blocking high-risk-method gate rows.
    /// 3. **`client_id.is_none()`** — SYSTEM-LEVEL rows only. A session-owned
    ///    row (`client_id: Some(...)`) hits the cross-session fan-out branch and
    ///    comes back still-Pending without resolving; the orchestrator is not a
    ///    WS session and cannot own such a row, so it must never prompt for one.
    pub fn is_eligible_pending(approval: &HostApprovalRequest) -> bool {
        approval.status == HostApprovalStatus::Pending
            && !approval.action.starts_with(WS_METHOD_PREFIX)
            && approval.client_id.is_none()
    }

    /// Snapshot the host's approvals and return only the eligible-pending ones.
    /// The shared eligible-pending query every adapter consumes (outbound prompt
    /// minting, disambiguation, code lookup).
    pub async fn eligible_pending(&self) -> Vec<HostApprovalRequest> {
        self.host
            .approvals()
            .await
            .into_iter()
            .filter(Self::is_eligible_pending)
            .collect()
    }

    /// Whether `approval_id` is currently an eligible-pending row. Used by an
    /// adapter to confirm a named code still maps to an actionable approval
    /// before resolving.
    pub async fn is_id_eligible_pending(&self, approval_id: &str) -> bool {
        self.host
            .approvals()
            .await
            .into_iter()
            .any(|a| a.id == approval_id && Self::is_eligible_pending(&a))
    }

    /// Resolve `approval_id` in-process via the UNTOUCHED
    /// [`HostState::resolve_approval`] first-writer-wins guard. The
    /// system-raised `principal` string is supplied by the calling adapter — it
    /// is NOT hardcoded here (each adapter passes its own transport principal;
    /// the literal lives in the adapter, never in this channel-free core).
    ///
    /// Returns a [`ResolveOutcome`] so the adapter knows whether the row
    /// actually moved (evict its mapping only on [`ResolveOutcome::Resolved`]).
    pub async fn resolve(
        &self,
        principal: &str,
        approval_id: &str,
        resolution: &str,
    ) -> ResolveOutcome {
        let req = ResolveHostApprovalRequest {
            approval_id: approval_id.to_string(),
            resolution: resolution.to_string(),
        };
        match self.host.resolve_approval(principal, req).await {
            Ok(returned) if returned.status == HostApprovalStatus::Resolved => {
                ResolveOutcome::Resolved
            }
            Ok(returned) => {
                tracing::warn!(
                    approval_id = %approval_id,
                    status = ?returned.status,
                    "in-process resolve returned a non-resolved row (fan-out); leaving pending"
                );
                ResolveOutcome::StillPending
            }
            Err(e) => {
                tracing::warn!(approval_id = %approval_id, error = %e, "in-process resolve failed");
                ResolveOutcome::Error
            }
        }
    }
}