Problem
All plugin crates (apl-pii-scanner, apl-audit-logger, apl-identity-jwt,
apl-delegator-oauth, apl-pdp-*, apl-cedarling) live flat under crates/,
at the same level as the engine (cpex-core) and the policy layer
(apl-core/apl-cmf/apl-cpex). Two issues:
- No separation between "the engine" and "plugins that ship with it."
- Registration is hand-maintained in
cpex-ffi/src/apl.rs — one
register_factory(KIND, Box::new(Factory)) line per plugin — and every host
pays for every plugin's compiled size, since the staticlib bundles them all.
Only apl-cedarling is feature-gated today.
Proposal
1. Directory move (organizational only)
Split by extension axis — plugins and PDPs are different traits with different
registration paths, so the directory layout mirrors that seam:
builtins/
plugins/ PluginFactory → register_factory()
pii-scanner/ (was apl-pii-scanner)
audit-logger/
identity-jwt/
delegator-oauth/
pdps/ PdpFactory → AplOptions.pdp_factories
cedar-direct/ (was apl-pdp-cedar-direct)
cel/
cedarling/ spans BOTH axes (identity + PDP seams) — kept at root
crates/
cpex-core/ engine
cpex-builtins/ NEW — aggregator + registration macro
apl-core/ apl-cmf/ apl-cpex/ policy engine + bridge (NOT plugins)
Workspace members are path-based, so this is a pure rename — no compilation
impact. (Optional follow-up: drop the apl- prefix on plugin crates, since they
are CPEX plugins that use APL hooks, not APL itself.)
2. Packaging: each plugin is its own crate; one aggregator on top
-
Plugins stay individual crates — independent compilation units, isolated
dep trees (cedarling's heavy deps only pulled when enabled), individually
reusable.
-
cpex-builtins is the single crate a host depends on. Its [features]
map 1:1 to plugin crates, plus convenience bundles:
[features]
default = ["pii-scanner", "audit-logger", "identity-jwt", "delegator-oauth"]
full = ["default", "cedarling"]
pii-scanner = ["dep:cpex-plugin-pii-scanner"]
audit-logger = ["dep:cpex-plugin-audit-logger"]
identity-jwt = ["dep:cpex-plugin-identity-jwt"]
delegator-oauth = ["dep:cpex-plugin-delegator-oauth"]
cedarling = ["dep:cpex-plugin-cedarling"] # ~200 transitive deps
One package to depend on, a feature set to pick what compiles in — without
fusing plugins into a single crate.
Consumer side
A downstream Rust program selects exactly what it wants in its own
Cargo.toml. Take the defaults:
[dependencies]
cpex-builtins = "0.2" # default = pii-scanner, audit-logger, identity-jwt, delegator-oauth
Or opt out of defaults and pick a minimal subset:
[dependencies]
cpex-builtins = { version = "0.2", default-features = false, features = ["pii-scanner"] }
Or take everything, including the heavy cedarling tree:
[dependencies]
cpex-builtins = { version = "0.2", features = ["full"] }
The host then calls cpex_builtins::register_builtins(&mgr) (and
builtin_pdps()) — the body is already #[cfg]-gated to exactly the features
selected above, so only the chosen plugins are compiled in and registered. No
code change between configurations, just the feature list.
3. Feature-gated registration (NOT inventory/linkme)
cpex-builtins exposes register_builtins(), generated by a declarative macro
that expands to #[cfg(feature = ...)]-gated, explicit register_factory
calls keyed off each crate's existing KIND const:
register_builtins! {
feature "pii-scanner" => cpex_plugin_pii_scanner::PiiScannerFactory,
feature "audit-logger" => cpex_plugin_audit_logger::AuditLoggerFactory,
feature "identity-jwt" => cpex_plugin_identity_jwt::JwtIdentityFactory,
feature "delegator-oauth" => cpex_plugin_delegator_oauth::OAuthDelegatorFactory,
}
→ expands to:
pub fn register_builtins(mgr: &PluginManager) {
#[cfg(feature = "pii-scanner")]
mgr.register_factory(
cpex_plugin_pii_scanner::KIND,
Box::new(cpex_plugin_pii_scanner::PiiScannerFactory),
);
// ... one cfg-gated block per builtin
}
cpex_apl_install then collapses to a single
cpex_builtins::register_builtins(&mgr) call.
Why not inventory/linkme: they register via #[used] link-sections
discovered at startup. In a staticlib linked into a Go/C host, the linker
garbage-collects sections nothing references — auto-registered plugins silently
disappear. The current explicit design in apl.rs exists precisely to avoid
this. The macro keeps registration explicit (symbols survive) while making
inclusion feature-driven.
PDP factories are a separate axis (they flow through
apl_cpex::AplOptions.pdp_factories, not register_factory), so they get a
parallel register_builtin_pdps() rather than being mixed in.
Extending on top of builtins
Builtins are a convenient starting set, not a closed list.
register_builtins() is purely additive (each call is a HashMap insert in
PluginFactoryRegistry), so a host keeps registering its own factories on top:
// plugins
cpex_builtins::register_builtins(&mgr); // builtins
mgr.register_factory("acme/custom-guard", // host's own
Box::new(AcmeGuardFactory));
// pdps — builtin_pdps() returns an owned Vec the host can extend
let mut opts = apl_cpex::AplOptions::in_process();
opts.pdp_factories = cpex_builtins::builtin_pdps(); // builtins
opts.pdp_factories.push(Arc::new(AcmePdpFactory::new())); // host's own
apl_cpex::register_apl(&mgr, opts);
Collision handling. Registration is last-writer-wins on the kind string —
re-registering a builtin's kind overrides it. This is intentional (lets a host
swap a builtin's impl), but silent override is a footgun. register_factory
should log a warning when it overwrites an existing kind so accidental
shadowing is visible:
if self.factories.insert(kind.clone(), factory).is_some() {
tracing::warn!(kind = %kind, "plugin factory overrides an existing registration");
}
(This path is Rust-host only. The FFI entry point bundles a fixed set for Go/C
hosts; custom registration over FFI is the deferred out-of-tree-plugin work.)
Rollout (incremental)
- Create
cpex-builtins with the register_builtins! macro; have cpex-ffi
delegate to it. No directory move yet — proves the mechanism in isolation,
starting with one plugin (pii-scanner) as a proof-of-concept.
- Feature-gate the remaining builtins through
cpex-builtins.
- Move plugin crates into
plugins/builtins/ (pure rename).
- (Optional) drop the
apl- prefix.
Out of scope
- Dynamic/WASM/out-of-tree plugin loading (this is about built-in, compiled-in
plugins).
- Renaming the APL policy crates.
Problem
All plugin crates (
apl-pii-scanner,apl-audit-logger,apl-identity-jwt,apl-delegator-oauth,apl-pdp-*,apl-cedarling) live flat undercrates/,at the same level as the engine (
cpex-core) and the policy layer(
apl-core/apl-cmf/apl-cpex). Two issues:cpex-ffi/src/apl.rs— oneregister_factory(KIND, Box::new(Factory))line per plugin — and every hostpays for every plugin's compiled size, since the staticlib bundles them all.
Only
apl-cedarlingis feature-gated today.Proposal
1. Directory move (organizational only)
Split by extension axis — plugins and PDPs are different traits with different
registration paths, so the directory layout mirrors that seam:
Workspace members are path-based, so this is a pure rename — no compilation
impact. (Optional follow-up: drop the
apl-prefix on plugin crates, since theyare CPEX plugins that use APL hooks, not APL itself.)
2. Packaging: each plugin is its own crate; one aggregator on top
Plugins stay individual crates — independent compilation units, isolated
dep trees (cedarling's heavy deps only pulled when enabled), individually
reusable.
cpex-builtinsis the single crate a host depends on. Its[features]map 1:1 to plugin crates, plus convenience bundles:
One package to depend on, a feature set to pick what compiles in — without
fusing plugins into a single crate.
Consumer side
A downstream Rust program selects exactly what it wants in its own
Cargo.toml. Take the defaults:Or opt out of defaults and pick a minimal subset:
Or take everything, including the heavy cedarling tree:
The host then calls
cpex_builtins::register_builtins(&mgr)(andbuiltin_pdps()) — the body is already#[cfg]-gated to exactly the featuresselected above, so only the chosen plugins are compiled in and registered. No
code change between configurations, just the feature list.
3. Feature-gated registration (NOT inventory/linkme)
cpex-builtinsexposesregister_builtins(), generated by a declarative macrothat expands to
#[cfg(feature = ...)]-gated, explicitregister_factorycalls keyed off each crate's existing
KINDconst:→ expands to:
cpex_apl_installthen collapses to a singlecpex_builtins::register_builtins(&mgr)call.Why not
inventory/linkme: they register via#[used]link-sectionsdiscovered at startup. In a staticlib linked into a Go/C host, the linker
garbage-collects sections nothing references — auto-registered plugins silently
disappear. The current explicit design in
apl.rsexists precisely to avoidthis. The macro keeps registration explicit (symbols survive) while making
inclusion feature-driven.
PDP factories are a separate axis (they flow through
apl_cpex::AplOptions.pdp_factories, notregister_factory), so they get aparallel
register_builtin_pdps()rather than being mixed in.Extending on top of builtins
Builtins are a convenient starting set, not a closed list.
register_builtins()is purely additive (each call is aHashMapinsert inPluginFactoryRegistry), so a host keeps registering its own factories on top:Collision handling. Registration is last-writer-wins on the
kindstring —re-registering a builtin's
kindoverrides it. This is intentional (lets a hostswap a builtin's impl), but silent override is a footgun.
register_factoryshould log a warning when it overwrites an existing
kindso accidentalshadowing is visible:
(This path is Rust-host only. The FFI entry point bundles a fixed set for Go/C
hosts; custom registration over FFI is the deferred out-of-tree-plugin work.)
Rollout (incremental)
cpex-builtinswith theregister_builtins!macro; havecpex-ffidelegate to it. No directory move yet — proves the mechanism in isolation,
starting with one plugin (pii-scanner) as a proof-of-concept.
cpex-builtins.plugins/builtins/(pure rename).apl-prefix.Out of scope
plugins).