feat: add restrict effect#114
Conversation
…tions. Signed-off-by: Teryl Taylor <terylt@ibm.com>
araujof
left a comment
There was a problem hiding this comment.
Overall, nice work! The core design looks good: restrict behaves as a monotonic, accumulating policy effect, and the composition logic looks correct.
The main issue I'd fix before merging is that deny_models currently fails open. Unlike the allow_* fields, which preserve unresolved references as an empty allow-list (correctly denying everything), deny_models uses .unwrap_or_default(), so an unresolved reference becomes an empty vector. If that's the only field in the rule, the resulting constraint is treated as empty and discarded entirely, allowing unconstrained routing. That contradicts the fail-closed behavior described in the docs. I'd either make deny_models literal-only in v1 (like max_cost_tier and custom) or change it to Option<Vec<_>> so unresolved references remain distinguishable from an intentionally empty deny-list.
More generally, I noticed the same pattern in a few places: missing or malformed attributes tend to result in "unconstrained" rather than "deny." Besides the deny_models case, a when-gated restrict rule is simply skipped if the referenced attribute is missing, and attribute shape mismatches can disappear during bag flattening, preventing comparison rules from ever matching. Individually these aren't severe, but together they weaken the fail-closed story. It would be worth defining a consistent policy for missing attributes, potentially including load-time shape validation and documenting (or linting) that when-gated restrictions need a default-deny companion policy.
One architectural decision is also worth making. candidate_constraint is currently last-writer-wins, excluded from validate_immutable, and passes through filter_extensions, meaning another plugin could overwrite or remove restrictions produced by the policy engine. Nothing consumes the field yet, so this isn't exploitable today, but once Praxis starts enforcing it, the integrity model should already be defined—either by restricting writes or by making updates monotonic instead of overwriting.
Test that should be corrected. missing_inner_key_keeps_require_fail_closed claims to verify the fail-closed behavior of require(...), but it exercises the IsTrue branch rather than the IsFalse path that require actually compiles to. As written, the invariant it claims to test isn't actually covered. I'd also consider adding coverage for unrankable ceiling values, StringSet interpolation keys, and end-to-end accumulation across parallel branches.
A few smaller items:
with_datarebuilds the flattened attribute bag on every request (twice per request), so the documentation shouldn't claimdata.*reads are free on the hot path unless that optimization is implemented.is_empty()is duplicated across several structs; a field-parity test would help avoid future bugs where constraints are silently dropped.Effect,Extensions, andRouteDecisionhave grown public API surface without#[non_exhaustive].- This seems significant enough to warrant a CHANGELOG entry.
Summary
Adds the APL
restricteffect — a policy verb that shapes which backends a request may be routed to, withoutpicking one or allowing/denying — and the static attribute layer (
data.*) it reads from. Together they letpolicy express things like data-sovereignty pinning and per-agent model allow-lists declaratively, keyed on the
request, with the enforcement living in CPEX-owned state rather than the prompt.
restrictis an accumulating effect in the same family astaint: it never halts, it only narrows, and multiplerestrictions compose by conjunction. It emits a typed constraint that the host router (Praxis) reads off the returned
extensions and honors at selection time.
What's included
restricteffectEffect::Restrict), accumulated through the evaluator intoRouteDecision.constraints. Composes top-level, inwhen/do, insequential/parallel, and in a PDPon_allowon_emptyExtensions.candidate_constraintslot the host reads in-process — **nofilter_metadatahop**. Fail-closed on acustomcontradiction.data.*)AttributeSourcetrait (syncload) +FileAttributeSource(deep-merge,data:wrapper). Flattened into the bag viaBagBuilder::with_data. Declarative loading viaglobal.apl.attribute_files; programmatic injection viavisitor.set_attribute_tree.data.tenants[subject.tenant].data_region— index the tree by a request value, resolved atrequirefail-closed).data.*reference instead of a literal — `allow_models:CandidateConstraintExtension::accepts(backend_labels, tier_rank)incpex-core— the executable≤ every ceiling(= min) for tiers via aNotable design decisions
cpex_coreand readsPipelineResult.modified_extensionstyped (the same channel as minted delegation tokens). Sorestrictrides a realcandidate_constraint: Option<Arc<CandidateConstraintExtension>>slot — no serialization, nofilter_metadatakey.(
max_cost_tiers); the matcher reduces≤ every ceilingto≤ minusing atier_rankthe host supplies. Thematching algorithm is ours; the tier vocabulary/order stays the host's.
AttributeSource::loadis synchronous. It runs once at startup, off the request path, so a syncloadavoidsasync-in-sync plumbing with the (sync) config walk. Hot-reload/
watchdeferred.data.*config lives underglobal.apl, not a genericsettings:. Attribute files are APL-specific and sitnext to
global.apl.pdp/session_store; the shared config format is untouched.customcontradiction all narrow/deny rather than silently widen.RestrictSpec(fields may be literal or reference) is the parsed form; theevaluator resolves it against the request bag into a literal
CandidateConstraintbefore accumulating — referencesnever reach the fold or the wire.
Public API surface
cpex-core::extensions::routing:CandidateConstraintExtension,OnEmpty,BackendLabels(impl'd forBTreeMap/HashMap),LABEL_{MODEL,REGION,SITE,COST_TIER}, andCandidateConstraintExtension::accepts(...). NewExtensions.candidate_constraintslot (plumbed throughcow_copy/merge_owned/filter_extensions).apl-core:constraint::{CandidateConstraint, RestrictSpec, StringSetSpec, OnEmpty};attribute_source::{AttributeSource, AttributeTree, AttributeError};AttributeBag::resolve_key;Effect::Restrict;RouteDecision.constraints.apl-cmf:BagBuilder::with_data/extract_data.apl-cpex:FileAttributeSource,merge_attribute_docs,fold_candidate_constraints,visitor.set_attribute_tree;global.apl.attribute_filesconfig.Testing
Full workspace green, clippy clean. Coverage added:
when-gating, parallel merge, interpolation resolution, reference resolution + fail-closed),constraint,attribute_source.routingmatcher: 18 tests (glob prefix/suffix/middle,acceptsper-field + combined +HashMaplabels + unrankable-fail-closed) + a runnable doctest.
PluginManager:restrict_e2e.rs(4) and
attribute_source_e2e.rs(11: declarative load, merge, precedence, interpolation, per-caller fieldreferences).
Docs
docs/content/docs/apl/restrict.md,docs/content/docs/apl/attributes.md; edits toeffects.md,extensions.md,apl/_index.md.git submodule update --init docs/themes/hugo-book, thenmake docs.Deferred (follow-ups)
apply_candidate_constraintin the policy filter callingaccepts,sibling to
attach_delegated_tokens. This PR ships the CPEX side only.??for defaults;max_cost_tier/customfield references; hot-reload (watch); capability-gating thecandidate_constraintwrite; per-request data-tree flatten pre-compute.