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