apl_core/plugin_decl.rs
1// Location: ./crates/apl-core/src/plugin_decl.rs
2// Copyright 2026
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Plugin declarations — the parsed shape of the `plugins:` block in a
7// unified-config YAML document, plus the per-route override block and
8// the 2-layer resolver that merges them.
9//
10// Spec: `contextforge-plugins-framework-apl/docs/specs/unified-config-proposal.md`,
11// §"Plugin Declaration" (lines 173+)
12// §"Route-Level Plugin Config Overrides" (lines 360+)
13//
14// Layering, per spec:
15// - Global declaration (root `plugins:`) — full shape
16// - Route-level override (`routes.<rt>.plugins.<p>:`) — `config`,
17// `capabilities`, `on_error` only; hooks/kind/source NOT overridable
18// - `EffectivePlugin::resolve(name, registry, route)` merges them.
19//
20// v0 enforcement: hooks are read from the resolved view (which equals
21// the global view since hooks aren't overridable). Config + capability
22// overrides are parsed and stored so they survive in the IR for later
23// consumers, but not propagated to dispatch yet — capability gating
24// and per-call config-override plumbing are tracked separately in the
25// APL implementation memory.
26
27use std::collections::HashMap;
28
29use serde::{Deserialize, Serialize};
30
31/// One entry from the root `plugins:` block. The minimal shape apl-core
32/// needs to make routing + dispatch decisions; richer CPEX fields
33/// (`source`, `priority`, `mode`, transport blocks, `description`,
34/// `version`) are captured opaquely under `extra` so the round-trip
35/// preserves them without us modeling every variant for v0.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct PluginDeclaration {
38 /// Plugin name — referenced from routes by `plugin(name)` and used
39 /// as the key in [`PluginRegistry`].
40 pub name: String,
41
42 /// Implementation kind. Spec defines a closed set (`builtin`,
43 /// `native`, `wasm`, FQN, `external`, `isolated_venv`, PDP kinds)
44 /// but we parse as a free string so configs using future kinds
45 /// the runtime understands aren't rejected at the apl-core layer.
46 pub kind: String,
47
48 /// CPEX hook names this plugin implements. Invokers pick which
49 /// hook to dispatch based on this list; v0 uses the first entry,
50 /// future versions will choose by invocation context (policy vs
51 /// post_policy vs pipe-chain).
52 ///
53 /// Per spec §"Hook dispatch": NOT overridable per-route.
54 #[serde(default)]
55 pub hooks: Vec<String>,
56
57 /// Attribute-extension capabilities (`read_subject`, `read_labels`,
58 /// `append_labels`, `read_headers`, …). The runtime uses these for
59 /// extension filtering before dispatch. v0: parsed but not yet
60 /// enforced (capability gating is a separately tracked item).
61 #[serde(default)]
62 pub capabilities: Vec<String>,
63
64 /// Opaque per-plugin config. Passed to the plugin verbatim by the
65 /// CPEX runtime; apl-core doesn't interpret it.
66 #[serde(default)]
67 pub config: Option<serde_yaml::Value>,
68
69 /// `fail | ignore | disable`. Defaults to `fail` per spec when None.
70 #[serde(default)]
71 pub on_error: Option<String>,
72
73 /// Catch-all for `source`, `priority`, `mode`, transport blocks,
74 /// `description`, `version`, etc. Preserved so a future loader can
75 /// read them without re-parsing the YAML.
76 #[serde(flatten)]
77 pub extra: HashMap<String, serde_yaml::Value>,
78}
79
80/// Per-route override block — only the spec-overridable keys. Bare
81/// key-value pairs are NOT merged into `config` implicitly (spec line
82/// 399): "The override object always uses the same keys as a plugin
83/// declaration (`config:`, `capabilities:`, `on_error:`); bare
84/// key-value pairs are not merged into `config` implicitly."
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct PluginOverride {
87 #[serde(default)]
88 pub config: Option<serde_yaml::Value>,
89
90 #[serde(default)]
91 pub capabilities: Option<Vec<String>>,
92
93 #[serde(default)]
94 pub on_error: Option<String>,
95}
96
97/// Registry of plugin declarations, keyed by name. Built by the parser
98/// from the root `plugins:` block. Type alias — no methods — so callers
99/// can wrap it in `Arc<_>` or borrow it directly without ceremony.
100pub type PluginRegistry = HashMap<String, PluginDeclaration>;
101
102/// Plugin shape after layering route-level overrides on top of the
103/// global declaration. This is what invokers should consume — calling
104/// `EffectivePlugin::resolve` (rather than reading the global directly)
105/// ensures future override enforcement lands without re-walking the
106/// dispatch sites.
107#[derive(Debug, Clone)]
108pub struct EffectivePlugin<'a> {
109 pub name: &'a str,
110 pub kind: &'a str,
111 /// NOT overridable per spec — always from the global declaration.
112 pub hooks: &'a [String],
113 /// Capabilities: route override wins if present, else global.
114 /// Borrowed when no override applies; owned (cloned) when override
115 /// present. Use [`capabilities`] to read regardless.
116 pub capabilities: CapsView<'a>,
117 /// Config: route override wins if present, else global. Borrowed
118 /// directly; callers that need to own it call `.cloned()`.
119 pub config: Option<&'a serde_yaml::Value>,
120 /// on_error: route override wins if present, else global.
121 pub on_error: Option<&'a str>,
122}
123
124/// Internal helper that holds either a borrowed slice from the global
125/// declaration or an owned override vec; callers see a slice either way.
126#[derive(Debug, Clone)]
127pub enum CapsView<'a> {
128 /// Cheap path — no override; point at the global's slice.
129 Global(&'a [String]),
130 /// Override applied — caller-owned copy from the override block.
131 Override(&'a [String]),
132}
133
134impl<'a> CapsView<'a> {
135 pub fn as_slice(&self) -> &'a [String] {
136 match self {
137 Self::Global(s) | Self::Override(s) => s,
138 }
139 }
140}
141
142impl<'a> EffectivePlugin<'a> {
143 /// Merge a global declaration with a per-route override and return
144 /// the effective view. Returns `None` if `name` isn't in the
145 /// registry — caller decides whether that's an error.
146 ///
147 /// Spec §"Route-Level Plugin Config Overrides":
148 /// - Override `config` replaces the global `config` entirely.
149 /// - Override `capabilities` replaces global capabilities.
150 /// - Override `on_error` replaces global on_error.
151 /// - Everything else inherits unchanged from the global.
152 pub fn resolve(
153 name: &str,
154 registry: &'a PluginRegistry,
155 route_overrides: &'a HashMap<String, PluginOverride>,
156 ) -> Option<Self> {
157 let global = registry.get(name)?;
158 let ovr = route_overrides.get(name);
159
160 let capabilities = match ovr.and_then(|o| o.capabilities.as_deref()) {
161 Some(c) => CapsView::Override(c),
162 None => CapsView::Global(global.capabilities.as_slice()),
163 };
164 let config = ovr
165 .and_then(|o| o.config.as_ref())
166 .or(global.config.as_ref());
167 let on_error = ovr
168 .and_then(|o| o.on_error.as_deref())
169 .or(global.on_error.as_deref());
170
171 Some(Self {
172 name: global.name.as_str(),
173 kind: global.kind.as_str(),
174 hooks: global.hooks.as_slice(),
175 capabilities,
176 config,
177 on_error,
178 })
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn yaml(s: &str) -> serde_yaml::Value {
187 serde_yaml::from_str(s).unwrap()
188 }
189
190 fn registry_with(decl: PluginDeclaration) -> PluginRegistry {
191 let mut r = PluginRegistry::new();
192 r.insert(decl.name.clone(), decl);
193 r
194 }
195
196 #[test]
197 fn resolve_with_no_override_returns_global_values() {
198 let registry = registry_with(PluginDeclaration {
199 name: "rate_limiter".into(),
200 kind: "native".into(),
201 hooks: vec!["tool_pre_invoke".into()],
202 capabilities: vec!["read_subject".into()],
203 config: Some(yaml("max_requests: 100")),
204 on_error: Some("fail".into()),
205 extra: HashMap::new(),
206 });
207 let overrides = HashMap::new();
208
209 let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap();
210 assert_eq!(eff.name, "rate_limiter");
211 assert_eq!(eff.kind, "native");
212 assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
213 assert_eq!(eff.capabilities.as_slice(), &["read_subject".to_string()]);
214 assert_eq!(eff.on_error, Some("fail"));
215 assert!(matches!(eff.capabilities, CapsView::Global(_)));
216 }
217
218 #[test]
219 fn resolve_with_override_replaces_config_and_capabilities_and_on_error() {
220 let registry = registry_with(PluginDeclaration {
221 name: "rate_limiter".into(),
222 kind: "native".into(),
223 hooks: vec!["tool_pre_invoke".into()],
224 capabilities: vec!["read_subject".into()],
225 config: Some(yaml("max_requests: 100")),
226 on_error: Some("fail".into()),
227 extra: HashMap::new(),
228 });
229 let mut overrides = HashMap::new();
230 overrides.insert(
231 "rate_limiter".to_string(),
232 PluginOverride {
233 config: Some(yaml("max_requests: 10")),
234 capabilities: Some(vec!["read_subject".into(), "read_labels".into()]),
235 on_error: Some("ignore".into()),
236 },
237 );
238
239 let eff = EffectivePlugin::resolve("rate_limiter", ®istry, &overrides).unwrap();
240 // Hooks NOT overridable — still the global value.
241 assert_eq!(eff.hooks, &["tool_pre_invoke".to_string()]);
242 // Capabilities/config/on_error — overridden.
243 assert_eq!(
244 eff.capabilities.as_slice(),
245 &["read_subject".to_string(), "read_labels".to_string()]
246 );
247 assert!(matches!(eff.capabilities, CapsView::Override(_)));
248 assert_eq!(eff.on_error, Some("ignore"));
249 let cfg = eff.config.expect("config present");
250 assert_eq!(cfg["max_requests"], yaml("10"));
251 }
252
253 #[test]
254 fn resolve_with_partial_override_only_replaces_present_keys() {
255 // Per spec line 399: only keys present in the override replace
256 // inherited values. An override with just `on_error` inherits
257 // config + capabilities from the global.
258 let registry = registry_with(PluginDeclaration {
259 name: "audit".into(),
260 kind: "native".into(),
261 hooks: vec!["tool_post_invoke".into()],
262 capabilities: vec!["read_labels".into()],
263 config: Some(yaml("log_level: info")),
264 on_error: Some("ignore".into()),
265 extra: HashMap::new(),
266 });
267 let mut overrides = HashMap::new();
268 overrides.insert(
269 "audit".to_string(),
270 PluginOverride {
271 config: None,
272 capabilities: None,
273 on_error: Some("fail".into()),
274 },
275 );
276
277 let eff = EffectivePlugin::resolve("audit", ®istry, &overrides).unwrap();
278 assert_eq!(eff.on_error, Some("fail")); // overridden
279 assert_eq!(eff.capabilities.as_slice(), &["read_labels".to_string()]); // inherited
280 let cfg = eff.config.expect("config inherited");
281 assert_eq!(cfg["log_level"], yaml("info")); // inherited
282 }
283
284 #[test]
285 fn resolve_returns_none_for_unknown_plugin() {
286 let registry = PluginRegistry::new();
287 let overrides = HashMap::new();
288 assert!(EffectivePlugin::resolve("missing", ®istry, &overrides).is_none());
289 }
290}