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}