Skip to main content

car_engine/
scope.rs

1//! `RuntimeScope` — per-execution caller identity surface for the
2//! multi-tenant work tracked in Parslee-ai/car#187.
3//!
4//! ## What this is
5//!
6//! A small value the dispatcher attaches to each `Runtime::execute_*`
7//! invocation so the runtime knows **on whose behalf** an
8//! `ActionProposal` is being executed. The fields mirror — and are
9//! sourced from — the proposal-context surfaces #187 phase 1 and
10//! phase 2 already populate:
11//!
12//! - `caller_id` — typically `proposal.context["a2a_caller_verified"].subject`
13//!   when an `AuthValidator` returns an `Identity`, else
14//!   `proposal.context["a2a_caller"].caller_id` (cooperative-peer hint).
15//! - `tenant_id` — verified-tenant claim if present, else the
16//!   cooperative `a2a_caller.tenant_id` hint, else `None`.
17//! - `claims` — bag of verified token claims the dispatcher chose to
18//!   forward (subject is already on `caller_id`; this carries the
19//!   surrounding metadata like `iss`, `aud`, `org_id`, etc.).
20//!
21//! ## What this PR enforces vs. what's deferred
22//!
23//! This first PR of #187 phase 3 is **foundation only**:
24//!
25//! - ✅ `Runtime::execute_scoped` / `execute_scoped_with_cancel` accept
26//!   a `&RuntimeScope` and record it on the per-execution event log
27//!   (each `ActionInvoked` event carries `caller_id` / `tenant_id`
28//!   so downstream audit / log analysis sees who issued what).
29//! - ✅ The car-a2a dispatcher derives a scope from the verified
30//!   `Identity` (when present) and the cooperative `a2a_caller`
31//!   metadata, and calls the scoped entry point. Default-deny
32//!   activates when a non-`NoAuth` validator is configured but no
33//!   `Identity` surfaces (e.g. token validated but subject missing).
34//!
35//! - ❌ **Memgine queries are NOT yet scoped.** Facts, skills, and
36//!   working-set retrieval still hit the global graph regardless of
37//!   `scope.tenant_id`. Tracked as the next PR in #187 phase 3.
38//! - ❌ **State keys are NOT yet namespaced.** `state.get` /
39//!   `state.set` still live in a flat KV space. Tracked separately;
40//!   needs careful migration design for existing persisted state.
41//! - ❌ **FFI parity is partial.** WS dispatches that flow through
42//!   the car-a2a bridge get scope automatically; the NAPI / PyO3
43//!   `execute_proposal` standalone functions don't accept a scope
44//!   parameter yet. Adding that is one of the issue's acceptance
45//!   criteria and lands in a follow-up.
46//!
47//! Tools and policies that want per-tenant behaviour today can still
48//! read `proposal.context["a2a_caller_verified"]` directly — the
49//! phase 1/2 surfaces remain. `RuntimeScope` is the structured
50//! handle the runtime itself uses; tool handlers should keep reading
51//! the proposal context.
52
53use std::collections::BTreeMap;
54
55use serde::{Deserialize, Serialize};
56use serde_json::Value;
57
58/// Per-execution identity surface (Parslee-ai/car#187 phase 3).
59///
60/// Constructed by the dispatcher from caller-identity surfaces on
61/// the inbound `ActionProposal`; threaded through `Runtime::execute_*`
62/// so downstream layers (memgine, state, audit log) can route on
63/// `caller_id` / `tenant_id`. See the module docstring for what's
64/// enforced in this PR vs. deferred.
65///
66/// Designed to be cheap to clone — small fixed fields plus a
67/// `BTreeMap` of arbitrary claims. The dispatcher typically builds
68/// one per inbound message, so allocations are bounded.
69///
70/// `Default` returns the unscoped (all-`None`) value. The runtime
71/// treats this as "no identity" — same legacy behaviour as the
72/// pre-#187 path. The non-scoped `Runtime::execute_with_cancel`
73/// entry point internally passes `&RuntimeScope::default()` so
74/// existing in-process callers see no behaviour change.
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct RuntimeScope {
78    /// Verified subject when the dispatcher's `AuthValidator`
79    /// returned an `Identity`, else the cooperative
80    /// `a2a_caller.caller_id` hint, else `None`. Empty string
81    /// (`Some("")`) is never produced — the dispatcher normalizes
82    /// to `None` before building the scope.
83    pub caller_id: Option<String>,
84
85    /// Tenant scoping key. Either the verified-claim tenant id, the
86    /// cooperative-peer hint from `Message.metadata`, or `None`. The
87    /// downstream memgine / state filters key on this — distinct
88    /// from `caller_id` because one tenant can have many callers
89    /// (humans + their agents) and the isolation boundary is the
90    /// tenant, not the individual caller.
91    pub tenant_id: Option<String>,
92
93    /// Bag of verified token claims forwarded by the dispatcher.
94    /// `subject` itself is already on `caller_id`; this carries the
95    /// surrounding metadata (`iss`, `aud`, `org_id`, `roles`, etc.)
96    /// in a structured form so audit logs don't have to re-parse
97    /// the inbound message.
98    ///
99    /// Stored as a `BTreeMap` for stable iteration order in event
100    /// logs and deterministic serialization. The dispatcher decides
101    /// which claims to forward; this type makes no guarantees about
102    /// which keys are present.
103    pub claims: BTreeMap<String, Value>,
104}
105
106impl RuntimeScope {
107    /// Build a scope from `caller_id` + `tenant_id` only, with no
108    /// extra claims. Used by tests and by callers that don't yet
109    /// surface a full claim set.
110    pub fn new(caller_id: Option<String>, tenant_id: Option<String>) -> Self {
111        Self {
112            caller_id,
113            tenant_id,
114            claims: BTreeMap::new(),
115        }
116    }
117
118    /// True when the scope carries no identity at all — equivalent
119    /// to the `Default` shape. The car-a2a dispatcher's default-deny
120    /// check uses this: if auth is on but the scope is unscoped,
121    /// reject before dispatch.
122    pub fn is_unscoped(&self) -> bool {
123        self.caller_id.is_none() && self.tenant_id.is_none() && self.claims.is_empty()
124    }
125}