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}