car-engine 0.15.0

Core runtime engine for Common Agent Runtime
Documentation
//! `RuntimeScope` — per-execution caller identity surface for the
//! multi-tenant work tracked in Parslee-ai/car#187.
//!
//! ## What this is
//!
//! A small value the dispatcher attaches to each `Runtime::execute_*`
//! invocation so the runtime knows **on whose behalf** an
//! `ActionProposal` is being executed. The fields mirror — and are
//! sourced from — the proposal-context surfaces #187 phase 1 and
//! phase 2 already populate:
//!
//! - `caller_id` — typically `proposal.context["a2a_caller_verified"].subject`
//!   when an `AuthValidator` returns an `Identity`, else
//!   `proposal.context["a2a_caller"].caller_id` (cooperative-peer hint).
//! - `tenant_id` — verified-tenant claim if present, else the
//!   cooperative `a2a_caller.tenant_id` hint, else `None`.
//! - `claims` — bag of verified token claims the dispatcher chose to
//!   forward (subject is already on `caller_id`; this carries the
//!   surrounding metadata like `iss`, `aud`, `org_id`, etc.).
//!
//! ## What this PR enforces vs. what's deferred
//!
//! This first PR of #187 phase 3 is **foundation only**:
//!
//! - ✅ `Runtime::execute_scoped` / `execute_scoped_with_cancel` accept
//!   a `&RuntimeScope` and record it on the per-execution event log
//!   (each `ActionInvoked` event carries `caller_id` / `tenant_id`
//!   so downstream audit / log analysis sees who issued what).
//! - ✅ The car-a2a dispatcher derives a scope from the verified
//!   `Identity` (when present) and the cooperative `a2a_caller`
//!   metadata, and calls the scoped entry point. Default-deny
//!   activates when a non-`NoAuth` validator is configured but no
//!   `Identity` surfaces (e.g. token validated but subject missing).
//!
//! - ❌ **Memgine queries are NOT yet scoped.** Facts, skills, and
//!   working-set retrieval still hit the global graph regardless of
//!   `scope.tenant_id`. Tracked as the next PR in #187 phase 3.
//! - ❌ **State keys are NOT yet namespaced.** `state.get` /
//!   `state.set` still live in a flat KV space. Tracked separately;
//!   needs careful migration design for existing persisted state.
//! - ❌ **FFI parity is partial.** WS dispatches that flow through
//!   the car-a2a bridge get scope automatically; the NAPI / PyO3
//!   `execute_proposal` standalone functions don't accept a scope
//!   parameter yet. Adding that is one of the issue's acceptance
//!   criteria and lands in a follow-up.
//!
//! Tools and policies that want per-tenant behaviour today can still
//! read `proposal.context["a2a_caller_verified"]` directly — the
//! phase 1/2 surfaces remain. `RuntimeScope` is the structured
//! handle the runtime itself uses; tool handlers should keep reading
//! the proposal context.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Per-execution identity surface (Parslee-ai/car#187 phase 3).
///
/// Constructed by the dispatcher from caller-identity surfaces on
/// the inbound `ActionProposal`; threaded through `Runtime::execute_*`
/// so downstream layers (memgine, state, audit log) can route on
/// `caller_id` / `tenant_id`. See the module docstring for what's
/// enforced in this PR vs. deferred.
///
/// Designed to be cheap to clone — small fixed fields plus a
/// `BTreeMap` of arbitrary claims. The dispatcher typically builds
/// one per inbound message, so allocations are bounded.
///
/// `Default` returns the unscoped (all-`None`) value. The runtime
/// treats this as "no identity" — same legacy behaviour as the
/// pre-#187 path. The non-scoped `Runtime::execute_with_cancel`
/// entry point internally passes `&RuntimeScope::default()` so
/// existing in-process callers see no behaviour change.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeScope {
    /// Verified subject when the dispatcher's `AuthValidator`
    /// returned an `Identity`, else the cooperative
    /// `a2a_caller.caller_id` hint, else `None`. Empty string
    /// (`Some("")`) is never produced — the dispatcher normalizes
    /// to `None` before building the scope.
    pub caller_id: Option<String>,

    /// Tenant scoping key. Either the verified-claim tenant id, the
    /// cooperative-peer hint from `Message.metadata`, or `None`. The
    /// downstream memgine / state filters key on this — distinct
    /// from `caller_id` because one tenant can have many callers
    /// (humans + their agents) and the isolation boundary is the
    /// tenant, not the individual caller.
    pub tenant_id: Option<String>,

    /// Bag of verified token claims forwarded by the dispatcher.
    /// `subject` itself is already on `caller_id`; this carries the
    /// surrounding metadata (`iss`, `aud`, `org_id`, `roles`, etc.)
    /// in a structured form so audit logs don't have to re-parse
    /// the inbound message.
    ///
    /// Stored as a `BTreeMap` for stable iteration order in event
    /// logs and deterministic serialization. The dispatcher decides
    /// which claims to forward; this type makes no guarantees about
    /// which keys are present.
    pub claims: BTreeMap<String, Value>,
}

impl RuntimeScope {
    /// Build a scope from `caller_id` + `tenant_id` only, with no
    /// extra claims. Used by tests and by callers that don't yet
    /// surface a full claim set.
    pub fn new(caller_id: Option<String>, tenant_id: Option<String>) -> Self {
        Self {
            caller_id,
            tenant_id,
            claims: BTreeMap::new(),
        }
    }

    /// True when the scope carries no identity at all — equivalent
    /// to the `Default` shape. The car-a2a dispatcher's default-deny
    /// check uses this: if auth is on but the scope is unscoped,
    /// reject before dispatch.
    pub fn is_unscoped(&self) -> bool {
        self.caller_id.is_none() && self.tenant_id.is_none() && self.claims.is_empty()
    }
}