cpex_core/context.rs
1// Location: ./crates/cpex-core/src/context.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Execution context types.
7//
8// Provides PluginContext — the per-plugin, per-invocation execution
9// context carrying transient state (counters, caches, intermediate
10// results). All data needed for policy evaluation comes from the
11// payload's extensions (filtered by capabilities), not from context.
12//
13// PluginContext has two state maps:
14// - local_state: private to this plugin, this invocation
15// - global_state: shared across plugins in a pipeline
16//
17// Identity, request metadata, tenant scope, etc. live in extensions
18// (MetaExtension, SecurityExtension), not in the context.
19//
20// Mirrors the spec's PluginContext in plugin-framework-spec-v2.md §8.1.
21
22use std::collections::HashMap;
23
24use serde::{Deserialize, Serialize};
25use serde_json::Value;
26use uuid::Uuid;
27
28// ---------------------------------------------------------------------------
29// Plugin Context
30// ---------------------------------------------------------------------------
31
32/// Per-plugin, per-invocation execution context.
33///
34/// Each plugin receives its own `PluginContext` with:
35///
36/// - `local_state` — private to this plugin, this invocation. Fresh
37/// each time. Used for per-plugin counters, caches, scratch data.
38/// - `global_state` — shared across all plugins in a pipeline. The
39/// executor merges changes back after serial phases so subsequent
40/// plugins see contributions from earlier ones.
41///
42/// All data needed for policy evaluation (identity, tenant, request
43/// metadata) comes from the payload's extensions, capability-gated
44/// per plugin. Context is purely for transient execution state.
45///
46/// ```text
47/// PluginContext
48/// ├── local_state: HashMap # Per-plugin, per-request. Private.
49/// └── global_state: HashMap # Shared across plugins. Use with care.
50/// ```
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct PluginContext {
53 /// Plugin-local state. Private to this plugin, this invocation.
54 #[serde(default)]
55 pub local_state: HashMap<String, Value>,
56
57 /// Shared state across all plugins in the pipeline.
58 /// The executor merges changes back after each serial-phase plugin.
59 #[serde(default)]
60 pub global_state: HashMap<String, Value>,
61}
62
63impl PluginContext {
64 /// Create a new empty plugin context.
65 pub fn new() -> Self {
66 Self {
67 local_state: HashMap::new(),
68 global_state: HashMap::new(),
69 }
70 }
71
72 /// Create a plugin context with pre-populated global state.
73 pub fn with_global_state(global_state: HashMap<String, Value>) -> Self {
74 Self {
75 local_state: HashMap::new(),
76 global_state,
77 }
78 }
79
80 /// Get a value from local state.
81 pub fn get_local(&self, key: &str) -> Option<&Value> {
82 self.local_state.get(key)
83 }
84
85 /// Set a value in local state.
86 pub fn set_local(&mut self, key: impl Into<String>, value: Value) {
87 self.local_state.insert(key.into(), value);
88 }
89
90 /// Get a value from global state.
91 pub fn get_global(&self, key: &str) -> Option<&Value> {
92 self.global_state.get(key)
93 }
94
95 /// Set a value in global state.
96 pub fn set_global(&mut self, key: impl Into<String>, value: Value) {
97 self.global_state.insert(key.into(), value);
98 }
99}
100
101impl Default for PluginContext {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107// ---------------------------------------------------------------------------
108// Plugin Context Table
109// ---------------------------------------------------------------------------
110
111/// Threaded execution state carried from one hook invocation to the next
112/// within a single request lifecycle (e.g., `pre_invoke` → `post_invoke`).
113///
114/// The table holds the canonical pipeline state in two parts:
115///
116/// - `global_state` — a single shared map across all plugins. The executor
117/// clones this into each plugin's `PluginContext.global_state` at the
118/// start of a run, then commits the plugin's possibly-modified copy back
119/// when the run completes (last-writer-wins for serial phases).
120/// - `local_states` — per-plugin private state, indexed by plugin ID.
121/// Persists across hook invocations so a plugin's `pre_invoke` can stash
122/// data its `post_invoke` will read.
123///
124/// Storing `global_state` once (rather than copying it inside every per-plugin
125/// `PluginContext`) makes the canonical state explicit and removes the
126/// non-deterministic "pick an arbitrary plugin's snapshot" pattern that was
127/// previously needed to recover it.
128///
129/// Returned by the executor in `PipelineResult` and passed back into the
130/// next hook call. On the first hook call pass `None` — the executor
131/// creates a fresh table.
132#[derive(Debug, Default, Clone, Serialize, Deserialize)]
133pub struct PluginContextTable {
134 /// Authoritative shared state across all plugins in the pipeline.
135 #[serde(default)]
136 pub global_state: HashMap<String, Value>,
137
138 /// Per-plugin local state, indexed by plugin ID (`Uuid`).
139 #[serde(default)]
140 pub local_states: HashMap<Uuid, HashMap<String, Value>>,
141}
142
143impl PluginContextTable {
144 /// Create an empty context table.
145 pub fn new() -> Self {
146 Self::default()
147 }
148
149 /// Build a `PluginContext` for the given plugin, *removing* its stored
150 /// local_state from the table and seeding it with a fresh clone of the
151 /// canonical global_state. Use in serial phases where the plugin will
152 /// commit its local_state changes back via [`store_context`].
153 ///
154 /// If the plugin has no stored local_state yet, its context starts
155 /// empty (first invocation in the request lifecycle).
156 pub fn take_context(&mut self, plugin_id: Uuid) -> PluginContext {
157 PluginContext {
158 local_state: self.local_states.remove(&plugin_id).unwrap_or_default(),
159 global_state: self.global_state.clone(),
160 }
161 }
162
163 /// Build a `PluginContext` for the given plugin without mutating the
164 /// table — the local_state is *cloned* and the global_state is cloned.
165 /// Use in read-only phases (audit, concurrent, fire-and-forget) where
166 /// per-plugin mutations should not influence subsequent plugins.
167 pub fn snapshot_context(&self, plugin_id: Uuid) -> PluginContext {
168 PluginContext {
169 local_state: self
170 .local_states
171 .get(&plugin_id)
172 .cloned()
173 .unwrap_or_default(),
174 global_state: self.global_state.clone(),
175 }
176 }
177
178 /// Commit a plugin's context back into the table after it ran. Replaces
179 /// the canonical global_state with the plugin's possibly-modified copy
180 /// (move, no clone) and stores the plugin's local_state for next time.
181 pub fn store_context(&mut self, plugin_id: Uuid, ctx: PluginContext) {
182 self.global_state = ctx.global_state;
183 self.local_states.insert(plugin_id, ctx.local_state);
184 }
185
186 /// Number of plugins with stored local_state in the table.
187 pub fn len(&self) -> usize {
188 self.local_states.len()
189 }
190
191 /// Whether the table holds no per-plugin local_state.
192 pub fn is_empty(&self) -> bool {
193 self.local_states.is_empty()
194 }
195}