Skip to main content

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}