Skip to content

feat: add restrict effect#114

Open
terylt wants to merge 1 commit into
devfrom
feat/restrict_effect
Open

feat: add restrict effect#114
terylt wants to merge 1 commit into
devfrom
feat/restrict_effect

Conversation

@terylt

@terylt terylt commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds the APL restrict effect — a policy verb that shapes which backends a request may be routed to, without
picking one or allowing/denying — and the static attribute layer (data.*) it reads from. Together they let
policy 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.

restrict is an accumulating effect in the same family as taint: it never halts, it only narrows, and multiple
restrictions compose by conjunction. It emits a typed constraint that the host router (Praxis) reads off the returned
extensions and honors at selection time.

# Pin an EU-resident tenant's inference to EU backends, fail closed.
routes:
  - tool: fetch_customer
    post_invocation:
      - when: "data.tenants[subject.tenant].data_region == 'eu'"
        do: ["taint(eu_resident, session)"]
  - llm: "*"
    pre_invocation:
      - when: "security.labels contains 'eu_resident'"
        do:
          - restrict: { allow_regions: [eu], on_empty: deny }

What's included

Area Change
restrict effect Parser + AST (Effect::Restrict), accumulated through the evaluator into
RouteDecision.constraints. Composes top-level, in when/do, in sequential/parallel, and in a PDP on_allow
block.
Fold → typed extension Multiple restrictions fold (allow ∩, deny/custom ∪, tier ceilings collected, on_empty
strictest) into one typed Extensions.candidate_constraint slot the host reads in-process — **no
JSON/filter_metadata hop**. Fail-closed on a custom contradiction.
Static attributes (data.*) AttributeSource trait (sync load) + FileAttributeSource (deep-merge,
fail-fast conflicts, data: wrapper). Flattened into the bag via BagBuilder::with_data. Declarative loading via
global.apl.attribute_files; programmatic injection via visitor.set_attribute_tree.
Path interpolation data.tenants[subject.tenant].data_region — index the tree by a request value, resolved at
eval time. Missing value → absent (predicate false / require fail-closed).
Restrict field references Set fields accept a data.* reference instead of a literal — `allow_models:
"data.agents[subject.id].allowed_models"` — so one rule serves every caller, values maintained in config. Absent ref →
empty set (fail-closed).
Matcher API CandidateConstraintExtension::accepts(backend_labels, tier_rank) in cpex-core — the executable
half of the seam. Glob for models, equality for regions/sites, ≤ every ceiling (= min) for tiers via a
host-supplied ranking, equality for custom. Praxis calls it instead of reimplementing the matcher.

Notable design decisions

  • Typed extension slot, not a JSON blob. The Praxis policy filter links cpex_core and reads
    PipelineResult.modified_extensions typed (the same channel as minted delegation tokens). So restrict rides a real
    candidate_constraint: Option<Arc<CandidateConstraintExtension>> slot — no serialization, no filter_metadata key.
  • Ownership split for tiers. CPEX can't order tier names, so the fold emits the set of ceilings
    (max_cost_tiers); the matcher reduces ≤ every ceiling to ≤ min using a tier_rank the host supplies. The
    matching algorithm is ours; the tier vocabulary/order stays the host's.
  • AttributeSource::load is synchronous. It runs once at startup, off the request path, so a sync load avoids
    async-in-sync plumbing with the (sync) config walk. Hot-reload/watch deferred.
  • data.* config lives under global.apl, not a generic settings:. Attribute files are APL-specific and sit
    next to global.apl.pdp / session_store; the shared config format is untouched.
  • Fail-closed throughout. An unresolved interpolation, an absent field reference, an unrankable tier, or a
    custom contradiction all narrow/deny rather than silently widen.
  • Clean authoring/resolved split. RestrictSpec (fields may be literal or reference) is the parsed form; the
    evaluator resolves it against the request bag into a literal CandidateConstraint before accumulating — references
    never reach the fold or the wire.

Public API surface

  • cpex-core::extensions::routing: CandidateConstraintExtension, OnEmpty, BackendLabels (impl'd for
    BTreeMap/HashMap), LABEL_{MODEL,REGION,SITE,COST_TIER}, and CandidateConstraintExtension::accepts(...). New
    Extensions.candidate_constraint slot (plumbed through cow_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_files config.

Testing

Full workspace green, clippy clean. Coverage added:

  • apl-core — parser (restrict shapes, references, interpolation lexing, error cases), evaluator (accumulation,
    when-gating, parallel merge, interpolation resolution, reference resolution + fail-closed), constraint,
    attribute_source.
  • cpex-corerouting matcher: 18 tests (glob prefix/suffix/middle, accepts per-field + combined + HashMap
    labels + unrankable-fail-closed) + a runnable doctest.
  • apl-cpex — fold (13), file source / merge (10), and end-to-end through a real PluginManager: restrict_e2e.rs
    (4) and attribute_source_e2e.rs (11: declarative load, merge, precedence, interpolation, per-caller field
    references).

Docs

  • New site pages: docs/content/docs/apl/restrict.md, docs/content/docs/apl/attributes.md; edits to effects.md,
    extensions.md, apl/_index.md.
  • Building the site needs the theme submodule: git submodule update --init docs/themes/hugo-book, then make docs.

Deferred (follow-ups)

  • Praxis-side reader (separate repo): an apply_candidate_constraint in the policy filter calling accepts,
    sibling to attach_delegated_tokens. This PR ships the CPEX side only.
  • Coalesce ?? for defaults; max_cost_tier / custom field references; hot-reload (watch); capability-gating the
    candidate_constraint write; per-request data-tree flatten pre-compute.

…tions.

Signed-off-by: Teryl Taylor <terylt@ibm.com>
@terylt terylt requested review from araujof and jonpspri as code owners July 8, 2026 20:33
@terylt terylt changed the base branch from main to dev July 8, 2026 20:34
@araujof araujof changed the title Feat/restrict effect feat: restrict effect Jul 9, 2026
@araujof araujof changed the title feat: restrict effect feat: add restrict effect Jul 9, 2026

@araujof araujof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_data rebuilds the flattened attribute bag on every request (twice per request), so the documentation shouldn't claim data.* 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, and RouteDecision have grown public API surface without #[non_exhaustive].
  • This seems significant enough to warrant a CHANGELOG entry.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants