Summary
Rewrite the CPEX PluginManager as a Rust core library with C FFI, Python (PyO3), and Go (cgo) bindings. Rust enforces CMF invariants (monotonic labels, immutable SubjectExtension, scope narrowing) internally through the type system; inputs crossing language/runtime boundaries are validated at ingress before entering the core. This gives us a single plugin execution engine shared across all language consumers, with support for native Rust, WASM, and Python plugin hosts.
The Rust core replaces the current Python PluginManager while maintaining backward compatibility with existing Python plugins via a PyO3 bridge host.
Motivation
The current Python PluginManager works, but:
- Invariants are enforced by convention, not the type system. Monotonic label sets, immutable security extensions, and delegation scope narrowing are documented rules that plugins can violate at runtime. Rust enforces these internally through the type system; inputs from other languages are validated at ingress.
- Every consumer reimplements the runtime. If Kagenti (Go), ContextForge (Python), or an Envoy filter (Rust) wants CPEX plugins, they each need a full PluginManager implementation. A shared Rust core with language bindings solves this.
- WASM plugin sandboxing requires a native runtime. Python cannot efficiently host WASM plugins. A Rust core with wasmtime gives us sandboxed third-party plugins where the host grants no filesystem, network, or memory capabilities by default — only those explicitly configured by the runtime.
- Performance-sensitive paths (identity resolution, policy evaluation, session lookup) benefit from native execution, especially in high-throughput gateway deployments.
User Stories
User Story 1: Gateway Integrator (Python — ContextForge)
- As a: ContextForge gateway developer
- I want: to replace
from cpex import PluginManager with a Rust-backed PluginManager that exposes the same Python API
- So that: I get compile-time CMF invariants, WASM plugin support, and faster policy evaluation without changing my integration code
Acceptance Criteria
Scenario: Drop-in replacement for Python PluginManager
Given a ContextForge gateway using cpex.PluginManager
When I upgrade the cpex package to the Rust-backed version
Then existing Python plugin configurations load without changes
And existing Python plugins execute through the PyO3 bridge host
And the 5-phase execution order is preserved (sequential → transform → audit → concurrent → fire_and_forget)
Scenario: Monotonic security labels enforced at compile time
Given a plugin attempts to remove a label from SecurityExtension
When the Rust type system evaluates the operation
Then compilation fails — MonotonicSet has no remove() method
Scenario: WASM plugin loading
Given a plugin declared as kind: "wasm://path/to/plugin.wasm"
When the PluginManager initializes
Then the plugin loads in a wasmtime sandbox
And the WASM host grants no filesystem, network, or host memory access by default (only explicitly configured capabilities)
And the plugin implements the same Plugin trait as native plugins
User Story 2: Gateway Integrator (Go — Kagenti)
- As a: Kagenti developer working in Go
- I want: to use
cpex.NewManager() from a Go package backed by the same Rust core
- So that: Kagenti gets identical policy enforcement behavior as ContextForge without reimplementing the PluginManager in Go
Acceptance Criteria
Scenario: Go bindings via cgo + C FFI
Given the cpex-ffi crate is compiled to libcpex_ffi.so
And include/cpex.h is generated by cbindgen
When a Go application imports "github.com/contextforge/cpex/go"
Then cpex.NewManager() initializes the Rust PluginManager via cgo
And cpex.InvokeHook() dispatches through the same 5-phase executor
Scenario: Monorepo developer workflow
Given a developer has cloned the cpex monorepo
When they run "cargo build --release -p cpex-ffi && cd go && go test ./..."
Then the Go tests pass using relative paths to the compiled library
And no system-wide installation is required
User Story 3: Plugin Author (Rust)
- As a: a security team member writing a content-filter plugin
- I want: to write my plugin in Rust using the
cpex-sdk crate
- So that: my plugin compiles to a native shared library with zero runtime overhead
Acceptance Criteria
Scenario: Plugin author uses lean SDK crate
Given a Rust developer adds cpex-sdk as a dependency
When they implement the Plugin trait and compile
Then the resulting .so/.dylib can be loaded by cpex-hosts as a native plugin
And cpex-sdk does not pull in wasmtime, PyO3, or the full PluginManager
Scenario: Plugin author targets WASM
Given a Rust developer compiles with --target wasm32-wasip1
When they use only cpex-sdk types
Then the plugin compiles to .wasm and loads via the WASM host
Design
Crate Architecture
cpex/
├── Cargo.toml # Workspace root
├── pyproject.toml # maturin build for Python package
├── Makefile # Top-level: rust-ffi, go-test, python-build
│
├── crates/
│ ├── cpex-core/ # Pure Rust — no FFI deps
│ │ └── src/
│ │ ├── manager.rs # PluginManager + lifecycle
│ │ ├── executor.rs # 5-phase execution engine
│ │ ├── registry.rs # PluginInstanceRegistry + HookRegistry
│ │ ├── plugin.rs # Plugin trait, PluginRef, HookRef
│ │ ├── config.rs # Unified YAML config (serde)
│ │ ├── context.rs # GlobalContext, PluginContext
│ │ ├── policy.rs # PluginMode, OnError, HookPayloadPolicy
│ │ ├── error.rs # PluginError, Violation, Timeout
│ │ ├── hooks/
│ │ │ ├── types.rs # HookType enum, payload/result traits
│ │ │ └── builtin.rs # tool_pre_invoke, prompt_pre_fetch, etc.
│ │ ├── cmf/
│ │ │ ├── message.rs # Message, ContentPart, Role, Channel
│ │ │ ├── view.rs # MessageView (zero-copy projection)
│ │ │ └── extensions/
│ │ │ ├── security.rs # MonotonicSet, SubjectExt (immutable)
│ │ │ ├── delegation.rs # DelegationChain (append-only, scope-narrowing)
│ │ │ ├── request.rs
│ │ │ ├── agent.rs
│ │ │ ├── http.rs # Guarded (capability-gated)
│ │ │ ├── mcp.rs
│ │ │ └── filter.rs # Capability-gated extension filtering
│ │ └── session/
│ │ ├── state.rs # SessionState (monotonic merge)
│ │ ├── store.rs # SessionStore trait + in-memory impl
│ │ └── resolver.rs # Multi-tier session resolution
│ │
│ ├── cpex-hosts/ # Plugin host runtimes (feature-gated)
│ │ └── src/
│ │ ├── native.rs # dlopen .so/.dylib — libloading
│ │ ├── wasm.rs # wasmtime sandbox
│ │ └── python.rs # PyO3 bridge (calls INTO Python plugins)
│ │
│ ├── cpex-ffi/ # C ABI for Go/Swift/other consumers
│ │ ├── src/
│ │ │ ├── lib.rs # #[no_mangle] extern "C" functions
│ │ │ ├── handles.rs # Opaque handle types + Arc safety
│ │ │ ├── manager.rs # cpex_manager_new(), cpex_invoke_hook()
│ │ │ ├── payload.rs # Byte buffer exchange across boundary
│ │ │ └── error.rs # cpex_last_error(), error codes
│ │ └── cbindgen.toml # Generates include/cpex.h
│ │
│ ├── cpex-python/ # PyO3 bindings (Python calls INTO Rust)
│ │ └── src/
│ │ ├── lib.rs # #[pymodule]
│ │ ├── manager.rs # PyPluginManager
│ │ ├── config.rs # from_yaml()
│ │ ├── types.rs # PyPluginResult, PyExtensions
│ │ └── plugin.rs # PyPlugin trait adapter
│ │
│ └── cpex-sdk/ # Lean crate for plugin authors
│ └── src/
│ ├── plugin.rs # Plugin trait + derive macro stubs
│ ├── payload.rs # PluginPayload, PluginResult
│ ├── context.rs # PluginContext (read-only view)
│ └── extensions.rs # Extension types (re-exported from core)
│
├── include/
│ └── cpex.h # Generated by cbindgen
│
├── python/
│ └── cpex/ # Thin Python wrapper
│ ├── __init__.py # Imports from cpex._native
│ ├── _native.pyi # Type stubs
│ ├── compat.py # Backward-compat shims
│ └── testing.py # Test harness for plugin authors
│
├── go/ # module: github.com/contextforge/cpex/go
│ ├── go.mod # package name: cpex
│ ├── cpex.go # NewManager, InvokeHook, etc.
│ ├── config.go
│ ├── types.go
│ ├── errors.go
│ └── internal/
│ ├── ffi.go # cgo → cpex.h (pkg-config + relative fallback)
│ └── memory.go # Handle lifecycle, free() safety
│
└── tests/
├── rust/
├── python/
└── go/
Key Design Decisions
1. cpex-core is pure Rust — no FFI, no wasmtime, no PyO3
This is the most important structural decision. Core contains the PluginManager, executor, CMF types, session store, and config parser — with zero external runtime dependencies. This means:
- Core compiles to any Rust target including
wasm32-wasip1
- Core is testable without standing up Python or WASM runtimes
- Plugin hosts (
cpex-hosts) depend on core, not the other way around
2. cpex-hosts is separate and feature-gated
[features]
default = ["native", "wasm", "python"]
native = ["libloading"]
wasm = ["wasmtime"]
python = ["pyo3"]
Each host implements the Plugin trait by bridging to foreign plugin code. A Go-only deployment can compile with default-features = false, features = ["native", "wasm"] and skip PyO3 entirely.
3. cpex-python vs cpex-hosts::python — opposite directions
These both use PyO3 but serve different roles:
| Crate |
Direction |
Purpose |
cpex-python |
Python → Rust |
Python code calls the Rust PluginManager |
cpex-hosts::python |
Rust → Python |
Rust PluginManager calls existing Python plugins |
ContextForge needs both. Kagenti needs neither.
4. cpex-sdk is lean for plugin authors
Plugin authors depend only on cpex-sdk, which re-exports the Plugin trait and payload/result types from core. It does not pull in the PluginManager, hosts, or FFI. This is also the crate WASM plugins compile against.
5. Go bindings use coarse-grained C FFI with zero-copy where possible
The FFI boundary is coarse-grained: one round trip per hook invocation, no chatty per-field accessors. Data crosses the boundary with minimal copying:
| Direction |
What crosses |
Copy? |
| Go → Rust (payload in) |
Pointer + length to Go's existing bytes |
Zero-copy read; Rust deserializes into owned types |
| Rust → Go (result envelope) |
#[repr(C)] struct with scalar fields |
Zero-copy (shared C layout) |
| Rust → Go (modified payload) |
Pointer + length to Rust-owned bytes |
Zero-copy read; Go copies only if it needs to keep it |
| Cleanup |
cpex_result_free() |
Go tells Rust when to drop |
Serialization only happens when necessary: always on input (Go bytes → Rust types), and on output only if a plugin modified the payload. A deny with no modification requires zero serialization on the result path — Go reads the C struct directly.
// cpex.h
typedef struct cpex_manager cpex_manager_t;
// Result envelope — #[repr(C)], Go reads fields directly
typedef struct {
int continue_processing; // 0 = denied, 1 = allowed
const char* violation_code; // NULL if allowed
const char* violation_reason; // NULL if allowed
const uint8_t* modified_payload; // NULL if unmodified
size_t modified_payload_len; // 0 if unmodified
} cpex_result_t;
// ABI version for compatibility checking
uint32_t cpex_abi_version(void);
// Lifecycle
cpex_manager_t* cpex_manager_new(const char* config_path);
void cpex_manager_free(cpex_manager_t* mgr);
// Hook dispatch — zero-copy pointer+length in, C struct out
cpex_result_t* cpex_invoke_hook(cpex_manager_t* mgr,
const char* hook_type,
const uint8_t* payload_buf,
size_t payload_len);
void cpex_result_free(cpex_result_t* result);
// Error reporting
const char* cpex_last_error(void);
Go consumers interact via cpex.NewManager() / cpex.InvokeHook() — the cgo layer is internal.
Monorepo devs: cargo build -p cpex-ffi && cd go && go test — cgo finds the library via relative paths with pkg-config fallback for system installs.
5a. Wire format: MessagePack (default), JSON (debug)
Payloads are serialized at the FFI boundary using MessagePack by default (~10x faster than JSON, same data model, schema-less). JSON is available as a debug option for human-readable inspection.
| Format |
Serialize |
Deserialize |
Schema required? |
Use case |
| MessagePack (default) |
~20µs |
~20µs |
No |
Production — fast, compact binary |
| JSON (debug) |
~200µs |
~200µs |
No |
Development — human-readable |
MessagePack is a drop-in replacement for JSON at the serde layer — same data model (maps, arrays, strings, numbers), just binary-encoded. No schema files, no code generation, no type model changes.
Alternatives considered:
| Format |
Why not (for now) |
| Protocol Buffers |
Requires .proto schema files + codegen for internal FFI — overkill |
| FlatBuffers / Cap'n Proto |
Zero-copy reads but requires generated accessor types instead of plain structs — premature optimization |
| CBOR |
Same family as MessagePack, slightly less Rust/Go library support |
| Bincode |
Rust-specific, no stable Go library |
FlatBuffers remains an option if profiling shows serialization is a bottleneck on a hot path. The FFI surface is format-agnostic — swapping the wire format doesn't change the C API.
6. CMF invariants enforced in the Rust core
Rust's type system enforces invariants internally; inputs from other languages are validated at ingress before entering the core:
// Security labels: add-only (no remove, no clear)
pub struct MonotonicSet<T: Eq + Hash> { inner: HashSet<T> }
impl<T: Eq + Hash> MonotonicSet<T> {
pub fn insert(&mut self, value: T) { self.inner.insert(value); }
pub fn contains(&self, value: &T) -> bool { self.inner.contains(value) }
// No remove(). No clear(). No drain().
}
// Delegation chain: append-only, scopes must narrow
pub struct DelegationChain { hops: Vec<DelegationHop> }
impl DelegationChain {
pub fn push(&mut self, hop: DelegationHop) -> Result<(), ScopeEscalation> {
if let Some(prev) = self.hops.last() {
if !hop.scopes.is_subset(&prev.scopes) {
return Err(ScopeEscalation { .. });
}
}
self.hops.push(hop);
Ok(())
}
}
Execution Model (preserved from Python)
The 5-phase execution order is identical to the current Python implementation:
SEQUENTIAL → TRANSFORM → AUDIT → CONCURRENT → FIRE_AND_FORGET
| Phase |
Block? |
Modify? |
Execution |
Use Case |
| Sequential |
Yes |
Yes |
Serial, chained |
Policy enforcement + transformation |
| Transform |
No |
Yes |
Serial, chained |
Data shaping (PII redaction) |
| Audit |
No |
No |
Serial |
Observation, logging |
| Concurrent |
Yes |
No |
Parallel (fail-fast) |
Independent policy gates |
| Fire-and-forget |
No |
No |
Background (tokio tasks) |
Telemetry, async side effects |
Plugin Trait
#[async_trait]
pub trait Plugin: Send + Sync {
fn config(&self) -> &PluginConfig;
async fn initialize(&self) -> Result<(), PluginError>;
async fn execute(
&self,
hook_type: &str,
payload: &serde_json::Value,
context: &PluginContext,
) -> Result<PluginResult, PluginError>;
async fn shutdown(&self) -> Result<(), PluginError>;
}
Note: The manager wraps each plugin in a PluginRef that holds the authoritative config from the config loader — not from the plugin. The plugin's config() is available for the plugin's own reading during execute(), but the manager/executor never reads it. Trust flows one direction: config loader → manager → PluginRef → executor.
This single trait is implemented by:
- Native Rust plugins (directly)
cpex-hosts::wasm (bridges to WASM guest)
cpex-hosts::python (bridges to Python plugin classes)
cpex-hosts::native (bridges to dlopen'd shared libraries)
Additional Framework Features
The following features are supported by the Rust core:
Dynamic Hook Types
Hook types use a HookType(String) newtype for runtime extensibility. Built-in hook names are provided as &str constants in hook_names:: and cmf_hook_names:: modules for compile-time checking of known hooks, while hosts can register custom hooks at runtime:
// Built-in string constants (compile-time checked)
pub mod hook_names {
pub const TOOL_PRE_INVOKE: &str = "tool_pre_invoke";
pub const TOOL_POST_INVOKE: &str = "tool_post_invoke";
// ...
}
// Create a HookType from a built-in constant or custom string
let builtin = HookType::new(hook_names::TOOL_PRE_INVOKE);
let custom = HookType::new("generation_pre_call");
Payload-Agnostic Design
The framework operates on any type implementing PluginPayload, not just CMF messages. MessagePayload is a built-in payload type; domain-specific hooks use domain-specific payloads through the same pipeline, modes, and capability gating.
Function Hooks
In addition to the class-based Plugin trait, standalone functions can be registered directly as hook handlers. This is the simplest way to add a single hook without a full plugin struct.
PluginSet (Composable Groupings)
PluginSet groups related plugins into reusable, composable units. Sets are inert containers — they organize plugins for registration but do not execute anything themselves. Sets can nest other sets.
Scoped Registration
Plugins can be activated for a specific scope and automatically deregistered when the scope exits. In Rust this uses RAII (Drop); Python and Go bindings expose context managers / defer respectively.
PipelineResult
PipelineResult wraps the aggregate outcome of a full hook invocation with factory methods (allowed(), denied()). Callers treat it identically to PluginResult.
COW Validation & Hook Payload Policies
Modify plugins operate on copy-on-write payloads. After modification, the framework validates against extension mutability tiers (immutable, monotonic, guarded, mutable). Per-hook HookPayloadPolicy can further restrict which payload fields are writable.
Global Settings
plugin_settings:
plugin_timeout: 30
cow_validation: strict # strict | warn | disabled
short_circuit_on_deny: true
Unified Configuration
The Rust core parses the unified YAML config:
global:
identity:
provider: cedarling
session:
store: memory
ttl: 3600
policies:
pii:
policy: require(perm.pii_access)
hr:
policy: require(role.hr | role.auditor)
plugins:
- name: apl-policy
kind: builtin
hooks: [tool_pre_invoke, tool_post_invoke]
mode: sequential
capabilities: [read_security, append_labels]
- name: content-filter
kind: "wasm://plugins/content_filter.wasm"
hooks: [tool_pre_invoke]
mode: sequential
- name: legacy-audit
kind: "python://plugins.audit.AuditPlugin"
hooks: [tool_post_invoke]
mode: audit
routes:
- tool: get_compensation
meta:
tags: [pii, hr]
scope: hr-services
policy:
- "args.include_ssn & !perm.view_ssn": deny
result:
salary: "redact(!role.hr)"
ssn: "redact(!perm.view_ssn)"
Typed Hook System
HookType trait defines typed payload + result per hook. No serde_json::Value in the Rust core — native Rust plugins work with typed structs at zero cost.
- Mode drives scheduling — the hook type doesn't declare read-only vs mutating. The executor decides borrow vs clone based on the plugin's mode from
PluginRef.trusted_config.
- Extensions are a separate parameter — capability-filtered per plugin, modified independently from the payload (no COW needed for extension-only changes).
- CMF: one handler, multiple hook names — a plugin implements
CmfHookHandler once, registers for cmf.tool_pre_invoke, cmf.llm_input, etc. The MessageHookType field tells the handler which hook fired.
- Python compatibility preserved —
@hook decorators, Plugin base class, and block()/allow() helpers are unchanged. Rust-defined payloads surface in Python as PyO3-wrapped objects with typed attributes. Python can also define custom hooks at runtime via register_hook_type().
- Serialization only at language boundaries — native Rust: zero-cost. Python: PyO3 attribute conversion. Go/WASM: MessagePack (default), JSON (debug).
Implementation Phases
Phase 1 is deliberately narrow: minimal Rust execution kernel, one payload path (JSON values), and Go as the first binding target (there is active interest from Red Hat, and Python already has a complete implementation). We prove the execution semantics before expanding.
| Phase |
Deliverable |
Depends On |
Can Parallelize? |
| 1a |
cpex-core: Plugin trait, PluginManager, 5-phase executor, hook registry |
— |
— |
| 1b |
cpex-ffi + go/: C ABI + Go bindings — first consumer, proves FFI semantics |
Phase 1a |
— |
| 1c |
Conformance test corpus (YAML scenarios, runs against Python impl and Rust/Go) |
Phase 1a |
Yes (with 1b) |
| 2 |
cpex-core config: unified YAML config parsing |
Phase 1a |
— |
| 3 |
cpex-core extensions: CMF types, monotonic sets, capability-gated filtering, session store |
Phase 1a |
Yes (with 2) |
| 4 |
cpex-sdk: Lean plugin author crate |
Phase 1a |
Yes (with 2, 3) |
| 5 |
cpex-python: PyO3 bindings (Python calls Rust PluginManager) |
Phase 1a |
Yes (with 2, 3, 4) |
| 6 |
cpex-hosts::python: Run existing Python plugins from Rust |
Phase 5 |
— |
| 7 |
cpex-hosts::wasm: wasmtime sandbox host |
Phase 1a + 4 |
Yes (with 5, 6) |
| 8 |
cpex-hosts::native: dlopen for .so plugins |
Phase 1a |
Yes (with all) |
| 9 |
Integrate apl-core + cedarling as built-in plugins |
Phase 3 |
— |
Phases 2-8 are largely independent and can be worked in parallel once Phase 1 lands.
Backward Compatibility
Python backward compatibility has two layers:
| Layer |
Guarantee |
Scope |
| API compatibility |
from cpex import PluginManager, public interfaces, config YAML format |
Hard requirement (Phase 5+6) |
| Behavioral compatibility |
Documented lifecycle semantics: 5-phase ordering, capability gating, COW validation, error isolation |
Hard requirement |
Some undocumented behaviors may differ once execution moves into Rust — mutation timing, threading model, internal exception types. These are explicitly not guaranteed.
ABI Stability (cpex-ffi)
Documented as part of Phase 1b (FFI/Go bindings):
- ABI version function —
cpex_abi_version() returns a version integer; consumers check at init.
- Ownership rules — Every handle-returning function documents who owns the handle and which
_free function releases it.
- Semver for
cpex.h — Breaking changes to the C header require a major version bump.
- Stable vs unstable surfaces — Functions prefixed
cpex_experimental_ are not covered by stability guarantees.
Conformance Tests
Since the goal is shared semantics across languages, a common test corpus will be introduced in Phase 1c:
- YAML scenario files specifying: hook type, plugins (mode, priority, config), payload, expected outcome (allow/deny/modify).
- Each scenario runs against both the existing Python
PluginManager and the Rust core (via Go bindings initially, Python bindings later).
- Covers: hook ordering, allow/deny behavior, mutation rules, timeout semantics, error handling modes (fail/ignore/disable).
- Scenarios are the source of truth for behavioral compatibility — if a scenario passes in both runtimes, the behavior is guaranteed.
Alternatives Considered
1. Keep Python PluginManager, add Rust only for hot paths (APL, session)
- Pro: Less work. Already partially done with
apl-core PyO3.
- Con: Every new consumer (Go, Envoy) must reimplement the PluginManager. CMF invariants remain unenforced.
- Rejected because: The PluginManager is the value — policy enforcement, phase ordering, capability gating. Optimizing leaves only gives you speed; a shared core gives you correctness across languages.
2. Use gRPC/HTTP between language runtimes instead of FFI
- Pro: No cgo, no PyO3. Language-independent via network.
- Con: ~1-10ms per call (network + serialization). Unacceptable for pre/post-invoke hooks on every tool call in a gateway hot path.
- Rejected because: The plugin pipeline runs on every request. FFI overhead is ~50-200ns; gRPC is 1000-10000x more.
3. Single cpex-ffi crate that bundles everything
- Pro: One library to link.
- Con: Go consumers pull in PyO3 + wasmtime even if they don't need them. Compilation time explodes.
- Rejected because: Feature-gated
cpex-hosts + separate cpex-ffi keeps the dependency graph lean per consumer.
Additional Context
- Existing Rust work:
apl-core (APL evaluator + token service) already compiles via maturin with PyO3 bindings — same pattern cpex-python will use.
- ContextForge gateway: Primary Python consumer. Backward compatibility with existing Python plugins is a hard requirement (Phase 5).
- Kagenti: Primary Go consumer. Go bindings (Phase 7) enable shared policy enforcement across both gateways.
Summary
Rewrite the CPEX PluginManager as a Rust core library with C FFI, Python (PyO3), and Go (cgo) bindings. Rust enforces CMF invariants (monotonic labels, immutable SubjectExtension, scope narrowing) internally through the type system; inputs crossing language/runtime boundaries are validated at ingress before entering the core. This gives us a single plugin execution engine shared across all language consumers, with support for native Rust, WASM, and Python plugin hosts.
The Rust core replaces the current Python
PluginManagerwhile maintaining backward compatibility with existing Python plugins via a PyO3 bridge host.Motivation
The current Python PluginManager works, but:
User Stories
User Story 1: Gateway Integrator (Python — ContextForge)
from cpex import PluginManagerwith a Rust-backedPluginManagerthat exposes the same Python APIAcceptance Criteria
User Story 2: Gateway Integrator (Go — Kagenti)
cpex.NewManager()from a Go package backed by the same Rust coreAcceptance Criteria
User Story 3: Plugin Author (Rust)
cpex-sdkcrateAcceptance Criteria
Design
Crate Architecture
Key Design Decisions
1.
cpex-coreis pure Rust — no FFI, no wasmtime, no PyO3This is the most important structural decision. Core contains the PluginManager, executor, CMF types, session store, and config parser — with zero external runtime dependencies. This means:
wasm32-wasip1cpex-hosts) depend on core, not the other way around2.
cpex-hostsis separate and feature-gatedEach host implements the
Plugintrait by bridging to foreign plugin code. A Go-only deployment can compile withdefault-features = false, features = ["native", "wasm"]and skip PyO3 entirely.3.
cpex-pythonvscpex-hosts::python— opposite directionsThese both use PyO3 but serve different roles:
cpex-pythoncpex-hosts::pythonContextForge needs both. Kagenti needs neither.
4.
cpex-sdkis lean for plugin authorsPlugin authors depend only on
cpex-sdk, which re-exports thePlugintrait and payload/result types from core. It does not pull in the PluginManager, hosts, or FFI. This is also the crate WASM plugins compile against.5. Go bindings use coarse-grained C FFI with zero-copy where possible
The FFI boundary is coarse-grained: one round trip per hook invocation, no chatty per-field accessors. Data crosses the boundary with minimal copying:
#[repr(C)]struct with scalar fieldscpex_result_free()Serialization only happens when necessary: always on input (Go bytes → Rust types), and on output only if a plugin modified the payload. A deny with no modification requires zero serialization on the result path — Go reads the C struct directly.
Go consumers interact via
cpex.NewManager()/cpex.InvokeHook()— the cgo layer is internal.Monorepo devs:
cargo build -p cpex-ffi && cd go && go test— cgo finds the library via relative paths with pkg-config fallback for system installs.5a. Wire format: MessagePack (default), JSON (debug)
Payloads are serialized at the FFI boundary using MessagePack by default (~10x faster than JSON, same data model, schema-less). JSON is available as a debug option for human-readable inspection.
MessagePack is a drop-in replacement for JSON at the serde layer — same data model (maps, arrays, strings, numbers), just binary-encoded. No schema files, no code generation, no type model changes.
Alternatives considered:
.protoschema files + codegen for internal FFI — overkillFlatBuffers remains an option if profiling shows serialization is a bottleneck on a hot path. The FFI surface is format-agnostic — swapping the wire format doesn't change the C API.
6. CMF invariants enforced in the Rust core
Rust's type system enforces invariants internally; inputs from other languages are validated at ingress before entering the core:
Execution Model (preserved from Python)
The 5-phase execution order is identical to the current Python implementation:
Plugin Trait
Note: The manager wraps each plugin in a
PluginRefthat holds the authoritative config from the config loader — not from the plugin. The plugin'sconfig()is available for the plugin's own reading duringexecute(), but the manager/executor never reads it. Trust flows one direction: config loader → manager → PluginRef → executor.This single trait is implemented by:
cpex-hosts::wasm(bridges to WASM guest)cpex-hosts::python(bridges to Python plugin classes)cpex-hosts::native(bridges to dlopen'd shared libraries)Additional Framework Features
The following features are supported by the Rust core:
Dynamic Hook Types
Hook types use a
HookType(String)newtype for runtime extensibility. Built-in hook names are provided as&strconstants inhook_names::andcmf_hook_names::modules for compile-time checking of known hooks, while hosts can register custom hooks at runtime:Payload-Agnostic Design
The framework operates on any type implementing
PluginPayload, not just CMF messages.MessagePayloadis a built-in payload type; domain-specific hooks use domain-specific payloads through the same pipeline, modes, and capability gating.Function Hooks
In addition to the class-based
Plugintrait, standalone functions can be registered directly as hook handlers. This is the simplest way to add a single hook without a full plugin struct.PluginSet (Composable Groupings)
PluginSetgroups related plugins into reusable, composable units. Sets are inert containers — they organize plugins for registration but do not execute anything themselves. Sets can nest other sets.Scoped Registration
Plugins can be activated for a specific scope and automatically deregistered when the scope exits. In Rust this uses RAII (
Drop); Python and Go bindings expose context managers /deferrespectively.PipelineResult
PipelineResultwraps the aggregate outcome of a full hook invocation with factory methods (allowed(),denied()). Callers treat it identically toPluginResult.COW Validation & Hook Payload Policies
Modify plugins operate on copy-on-write payloads. After modification, the framework validates against extension mutability tiers (immutable, monotonic, guarded, mutable). Per-hook
HookPayloadPolicycan further restrict which payload fields are writable.Global Settings
Unified Configuration
The Rust core parses the unified YAML config:
Typed Hook System
HookTypetrait defines typed payload + result per hook. Noserde_json::Valuein the Rust core — native Rust plugins work with typed structs at zero cost.PluginRef.trusted_config.CmfHookHandleronce, registers forcmf.tool_pre_invoke,cmf.llm_input, etc. TheMessageHookTypefield tells the handler which hook fired.@hookdecorators,Pluginbase class, andblock()/allow()helpers are unchanged. Rust-defined payloads surface in Python as PyO3-wrapped objects with typed attributes. Python can also define custom hooks at runtime viaregister_hook_type().Implementation Phases
Phase 1 is deliberately narrow: minimal Rust execution kernel, one payload path (JSON values), and Go as the first binding target (there is active interest from Red Hat, and Python already has a complete implementation). We prove the execution semantics before expanding.
cpex-core: Plugin trait, PluginManager, 5-phase executor, hook registrycpex-ffi+go/: C ABI + Go bindings — first consumer, proves FFI semanticscpex-coreconfig: unified YAML config parsingcpex-coreextensions: CMF types, monotonic sets, capability-gated filtering, session storecpex-sdk: Lean plugin author cratecpex-python: PyO3 bindings (Python calls Rust PluginManager)cpex-hosts::python: Run existing Python plugins from Rustcpex-hosts::wasm: wasmtime sandbox hostcpex-hosts::native: dlopen for .so pluginsapl-core+ cedarling as built-in pluginsPhases 2-8 are largely independent and can be worked in parallel once Phase 1 lands.
Backward Compatibility
Python backward compatibility has two layers:
from cpex import PluginManager, public interfaces, config YAML formatSome undocumented behaviors may differ once execution moves into Rust — mutation timing, threading model, internal exception types. These are explicitly not guaranteed.
ABI Stability (cpex-ffi)
Documented as part of Phase 1b (FFI/Go bindings):
cpex_abi_version()returns a version integer; consumers check at init._freefunction releases it.cpex.h— Breaking changes to the C header require a major version bump.cpex_experimental_are not covered by stability guarantees.Conformance Tests
Since the goal is shared semantics across languages, a common test corpus will be introduced in Phase 1c:
PluginManagerand the Rust core (via Go bindings initially, Python bindings later).Alternatives Considered
1. Keep Python PluginManager, add Rust only for hot paths (APL, session)
apl-corePyO3.2. Use gRPC/HTTP between language runtimes instead of FFI
3. Single
cpex-fficrate that bundles everythingcpex-hosts+ separatecpex-ffikeeps the dependency graph lean per consumer.Additional Context
apl-core(APL evaluator + token service) already compiles via maturin with PyO3 bindings — same patterncpex-pythonwill use.