Skip to main content

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", &registry, &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", &registry, &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", &registry, &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", &registry, &overrides).is_none());
289    }
290}