diff --git a/crates/apl-cmf/src/lib.rs b/crates/apl-cmf/src/lib.rs index 47c63a48..59f9f2bf 100644 --- a/crates/apl-cmf/src/lib.rs +++ b/crates/apl-cmf/src/lib.rs @@ -69,7 +69,7 @@ pub use http::extract_http; pub use llm::extract_llm; pub use mcp::extract_mcp; pub use meta::extract_meta; -pub use payload::{extract_args, extract_result}; +pub use payload::{extract_args, extract_data, extract_result}; pub use provenance::extract_provenance; pub use request::extract_request; pub use security::{extract_client, extract_security, extract_workload}; @@ -127,6 +127,14 @@ impl BagBuilder { self } + /// Flatten a static attribute tree into the `data.*` namespace + /// (design §4.2). Typically called once with a shared, startup-loaded + /// tree so every request's bag carries the same policy-side constants. + pub fn with_data(mut self, tree: &apl_core::AttributeTree) -> Self { + extract_data(tree, &mut self.bag); + self + } + /// Set the route key under `route.key` for policy predicates that /// branch on which route is running (mostly useful in default/policy /// bundles applied across routes). diff --git a/crates/apl-cmf/src/payload.rs b/crates/apl-cmf/src/payload.rs index 9e11b0e2..a7abb2c6 100644 --- a/crates/apl-cmf/src/payload.rs +++ b/crates/apl-cmf/src/payload.rs @@ -37,6 +37,14 @@ pub fn extract_result(result: &Value, bag: &mut AttributeBag) { walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag); } +/// Flatten a static attribute tree into `data.*` keys (design §4.2). +/// Same walk as args/result — nested objects recurse, string arrays +/// become `StringSet`s (so `data.tenants.x.allowed_models` supports +/// `contains` and R3b `restrict` references). +pub fn extract_data(tree: &apl_core::AttributeTree, bag: &mut AttributeBag) { + walk(tree.as_value(), "data", bag); +} + pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) { match value { Value::Object(map) => { @@ -151,4 +159,29 @@ mod tests { extract_args(&args, &mut bag); assert_eq!(bag.get_float("args.score"), Some(0.92)); } + + #[test] + fn data_tree_flattens_under_data_namespace() { + let tree = apl_core::AttributeTree::new(json!({ + "org": { "default_region": "us" }, + "tenants": { + "acme-eu": { "data_region": "eu", "allowed_models": ["anthropic/*", "vllm/*"] } + } + })); + let mut bag = AttributeBag::new(); + extract_data(&tree, &mut bag); + + assert_eq!(bag.get_string("data.org.default_region"), Some("us")); + assert_eq!(bag.get_string("data.tenants.acme-eu.data_region"), Some("eu")); + // String arrays become a StringSet (ready for `contains` / R3b). + assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "anthropic/*")); + assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "vllm/*")); + } + + #[test] + fn empty_data_tree_adds_nothing() { + let mut bag = AttributeBag::new(); + extract_data(&apl_core::AttributeTree::empty(), &mut bag); + assert!(bag.is_empty()); + } } diff --git a/crates/apl-core/src/attribute_source.rs b/crates/apl-core/src/attribute_source.rs new file mode 100644 index 00000000..548e629c --- /dev/null +++ b/crates/apl-core/src/attribute_source.rs @@ -0,0 +1,132 @@ +// Location: ./crates/apl-core/src/attribute_source.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Static attribute provisioning — the `data.*` bag namespace. +// +// `restrict` predicates (and policy predicates generally) read attributes +// that aren't carried by any token or fetched from anywhere: backend-free +// policy constants like tenant→region maps, per-agent model allow-lists, +// org defaults. Those come from a plain, operator-organized data tree that +// lands in the evaluation bag under `data.*` (see +// docs/apl-restrict-effect-design.md §4). +// +// This module is the pure contract: the `AttributeSource` trait (where a +// tree comes from) and the `AttributeTree` value (what it is). The default +// file-backed source and the bag flattening live at the outer layers +// (apl-cpex / apl-cmf) — apl-core stays free of I/O and config deps. + +use serde_json::Value; +use thiserror::Error; + +/// The static attribute tree — the whole `data:` document, a plain nested +/// value the operator organizes however they like. It carries *literal +/// values only*: no conditionals, no computed fields (that guardrail is +/// structural — there is no syntax in a data tree to express logic). +/// +/// Flattened into the bag under `data.*` by the bag builder (apl-cmf): +/// `{ org: { default_region: us } }` → `data.org.default_region = "us"`. +#[derive(Debug, Clone, PartialEq)] +pub struct AttributeTree(Value); + +impl AttributeTree { + /// Wrap a loaded `data` document. Expected to be a JSON/YAML object; + /// a non-object is tolerated but flattens to nothing useful. + pub fn new(value: Value) -> Self { + Self(value) + } + + /// The empty tree — no static attributes. The default when no source + /// is configured. + pub fn empty() -> Self { + Self(Value::Object(serde_json::Map::new())) + } + + /// Borrow the underlying value (the bag builder walks this). + pub fn as_value(&self) -> &Value { + &self.0 + } + + /// True when the tree holds nothing (no keys). + pub fn is_empty(&self) -> bool { + match &self.0 { + Value::Object(m) => m.is_empty(), + Value::Null => true, + _ => false, + } + } +} + +impl Default for AttributeTree { + fn default() -> Self { + Self::empty() + } +} + +/// Where the `data.*` tree comes from — a **trait object injected at +/// construction**, not a CPEX hook-plugin. The host implements it over a +/// file, etcd, Postgres, a k8s ConfigMap, etc., and hands the object to +/// the runtime at startup. +/// +/// `load` is **synchronous and one-shot**: it runs once at startup, never +/// on the request hot path, so blocking the init thread on file/network +/// I/O is fine — and it lets the (synchronous) runtime setup call it +/// directly, no async plumbing. A source that fronts a genuinely async +/// store can block on its own runtime at this edge. +/// +/// v1 is **snapshot only**. Hot-reload (a `watch` that streams fresh +/// trees) is deferred — that is the one genuinely-async concern, and its +/// shape (stream vs channel vs callback) is best decided when it's built, +/// not stubbed now. A lazy per-key `resolve(path)` for huge stores is +/// likewise deferred. +pub trait AttributeSource: Send + Sync { + /// Load the full attribute tree (a snapshot). + fn load(&self) -> Result; +} + +/// Why an attribute source failed to produce a tree. Loading is a +/// startup/config concern, so these surface as configuration errors at +/// the runtime boundary (fail-fast, not per-request). +#[derive(Debug, Error)] +pub enum AttributeError { + /// The backing store could not be read (missing file, I/O error, + /// unreachable etcd, …). + #[error("attribute source load failed: {0}")] + Load(String), + + /// The loaded bytes were not valid for the source's format. + #[error("attribute source parse failed: {0}")] + Parse(String), + + /// Two inputs set the *same* leaf path to different values — a real + /// conflict the source refuses to silently resolve (fail-fast merge). + #[error("attribute conflict at `{path}`: `{existing}` vs `{incoming}`")] + Conflict { + path: String, + existing: String, + incoming: String, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn empty_tree_is_empty() { + assert!(AttributeTree::empty().is_empty()); + assert!(AttributeTree::default().is_empty()); + } + + #[test] + fn populated_tree_is_not_empty() { + let t = AttributeTree::new(json!({ "org": { "default_region": "us" } })); + assert!(!t.is_empty()); + assert_eq!( + t.as_value().pointer("/org/default_region"), + Some(&json!("us")) + ); + } +} diff --git a/crates/apl-core/src/attributes.rs b/crates/apl-core/src/attributes.rs index c7fcca40..002350a6 100644 --- a/crates/apl-core/src/attributes.rs +++ b/crates/apl-core/src/attributes.rs @@ -145,6 +145,51 @@ impl AttributeBag { .unwrap_or(false) } + /// Resolve an attribute path to its concrete flat key, expanding any + /// `[inner]` interpolation groups (design §4.3). Each `[inner]` looks + /// `inner` up in this bag and substitutes `.` + its scalar value: + /// `data.tenants[subject.tenant].data_region` with `subject.tenant = + /// "acme-eu"` → `data.tenants.acme-eu.data_region`. The common + /// bracket-free key is returned borrowed (no allocation). + /// + /// Returns `None` when any `inner` key is missing or not a scalar — the + /// caller treats that as an absent attribute, so a lookup keyed on an + /// unknown request value fails to match rather than hitting a + /// half-substituted key. Shared by predicate evaluation and + /// `restrict` field references. + pub fn resolve_key<'a>(&self, key: &'a str) -> Option> { + if !key.contains('[') { + return Some(std::borrow::Cow::Borrowed(key)); + } + let mut out = String::with_capacity(key.len()); + let mut rest = key; + while let Some(open) = rest.find('[') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + // The lexer guarantees a matching `]`; `?` is defensive. + let close = after.find(']')?; + let inner = after[..close].trim(); + out.push('.'); + out.push_str(&self.scalar_as_string(inner)?); + rest = &after[close + 1..]; + } + out.push_str(rest); + Some(std::borrow::Cow::Owned(out)) + } + + /// The scalar at `key`, stringified for use as a path segment. Numbers + /// and bools coerce to their text form (a tenant id may be numeric); a + /// `StringSet` cannot index a path, so it yields `None`. + fn scalar_as_string(&self, key: &str) -> Option { + match self.get(key)? { + AttributeValue::String(s) => Some(s.clone()), + AttributeValue::Int(i) => Some(i.to_string()), + AttributeValue::Bool(b) => Some(b.to_string()), + AttributeValue::Float(f) => Some(f.to_string()), + AttributeValue::StringSet(_) => None, + } + } + pub fn len(&self) -> usize { self.attrs.len() } diff --git a/crates/apl-core/src/constraint.rs b/crates/apl-core/src/constraint.rs new file mode 100644 index 00000000..44ebf3ff --- /dev/null +++ b/crates/apl-core/src/constraint.rs @@ -0,0 +1,217 @@ +// Location: ./crates/apl-core/src/constraint.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Backend candidate-constraint IR for the `restrict` effect. +// +// `restrict` narrows the set of backends the host's router/load-balancer +// may select from — it never picks a backend and never allows/denies the +// request (see docs/apl-restrict-effect-design.md). It is an accumulating +// effect in the same family as `taint`: the evaluator collects the +// constraints a route emits into `RouteDecision.constraints`, and the +// bridge (apl-cpex) folds them into a typed `CandidateConstraintExtension` +// the host reads off the returned `Extensions`. This type is the *authoring* +// IR — one constraint per `restrict` effect. It stays pure-data with no +// cpex-core dependency, matching the rest of `rules.rs`; the fold + the +// wire/extension type live at the bridge layer. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::attributes::{AttributeBag, AttributeValue}; + +/// One backend-eligibility constraint emitted by a `restrict` effect. +/// +/// Every field describes a requirement a candidate backend must satisfy; +/// the host evaluates them against each backend's labels. The shape is a +/// deliberately **simple set of typed fields plus a `custom` label map** — +/// not a general predicate language — so the host only has to run a small +/// label matcher (set membership, glob, tier compare, equality), not a +/// predicate interpreter. +/// +/// All fields are optional/empty by default; an all-empty +/// `CandidateConstraint` places no restriction (see [`Self::is_empty`]). +/// Constraints are **monotone**: combining two of them (the bridge's fold) +/// can only ever shrink the eligible set (allow-sets intersect, deny-sets +/// and `custom` union), never widen it. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct CandidateConstraint { + /// Candidate `model` label must be in this set (glob-matched, e.g. + /// `"anthropic/claude-sonnet-*"`). `None` = no model allow-list. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_models: Option>, + + /// Candidate `model` label must NOT match any of these (glob-matched). + /// Empty = no model deny-list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny_models: Vec, + + /// Candidate `region` label must be in this set (equality). `None` = + /// no region constraint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_regions: Option>, + + /// Candidate `site` label must be in this set (equality). `None` = no + /// site constraint. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_sites: Option>, + + /// Candidate `cost_tier` label must be ≤ this tier. The *ordering* of + /// tiers is defined on the host (the matcher), so this stays a plain + /// label here — CPEX passes it through without needing to know the + /// order. `None` = no tier ceiling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_tier: Option, + + /// Arbitrary backend labels the candidate must carry, matched by plain + /// equality (k8s `nodeSelector` semantics). The escape hatch for + /// backend attributes without a typed field above. Empty = none. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub custom: BTreeMap, + + /// What the host should do if the constraint prunes every candidate. + /// Fail-closed by default (see [`OnEmpty`]). + #[serde(default)] + pub on_empty: OnEmpty, +} + +impl CandidateConstraint { + /// True when this constraint restricts nothing — every field is unset. + /// The evaluator skips emitting an all-empty constraint, and it's a + /// useful guard in tests. + pub fn is_empty(&self) -> bool { + self.allow_models.is_none() + && self.deny_models.is_empty() + && self.allow_regions.is_none() + && self.allow_sites.is_none() + && self.max_cost_tier.is_none() + && self.custom.is_empty() + } +} + +/// What the host does when a constraint leaves no eligible backend. +/// +/// CPEX cannot decide this itself — only the router knows which backends +/// are actually reachable/healthy at selection time — so the choice rides +/// out with the constraint. The default is fail-closed. +/// +/// Mirrors `cpex_core::extensions::OnEmpty` (the bridge maps between them); +/// kept here so apl-core stays free of a cpex-core dependency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnEmpty { + /// Reject the request (fail-closed). Correct for hard constraints like + /// data sovereignty — never silently escape the region. + #[default] + Deny, + /// Fall back to the unconstrained candidate set. Explicit opt-in for + /// "prefer, but don't fail" cases. + Fallback, +} + +/// A `restrict` string-set field: either a literal set or a `data.*`/bag +/// reference resolved against the request at eval time (design §4.3). The +/// YAML shape disambiguates — a sequence is a literal, a bare scalar is a +/// reference: +/// +/// ```yaml +/// allow_models: [vllm/*, anthropic/*] # Literal +/// allow_models: data.agents[subject.id].allowed_models # Ref +/// ``` +/// +/// A reference lets one rule serve every caller — the per-agent / +/// per-tenant set lives in the [static attribute tree][crate::AttributeTree], +/// not hard-coded in the route. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StringSetSpec { + /// A `data.*` / bag path resolved to a set at eval time. A bare scalar + /// in YAML (`allow_models: data.agents[subject.id].allowed_models`). + Ref(String), + /// A literal set. A YAML sequence (`allow_models: [vllm/*]`). + Literal(Vec), +} + +impl StringSetSpec { + /// Resolve to a concrete set. A `Literal` is returned as-is; a `Ref` + /// looks its path up in the request bag (expanding `[...]` + /// interpolation) and reads the `StringSet` there. An absent or + /// non-set reference resolves to the **empty set** — fail-closed: an + /// allow-list that didn't resolve qualifies no candidate, and the + /// host's `on_empty` decides. A single string value is taken as a + /// one-element set. + fn resolve(&self, bag: &AttributeBag) -> Vec { + match self { + StringSetSpec::Literal(v) => v.clone(), + StringSetSpec::Ref(path) => match bag.resolve_key(path) { + Some(key) => match bag.get(&key) { + Some(AttributeValue::StringSet(s)) => { + let mut v: Vec = s.iter().cloned().collect(); + v.sort(); + v + }, + Some(AttributeValue::String(s)) => vec![s.clone()], + _ => Vec::new(), + }, + None => Vec::new(), + }, + } + } +} + +/// The authoring form of a `restrict` effect. Same fields as +/// [`CandidateConstraint`], except the string-set fields may be a literal +/// **or** a `data.*` reference ([`StringSetSpec`]). The parser produces +/// this; the evaluator calls [`Self::resolve`] to turn it into a literal +/// `CandidateConstraint` before accumulating — references never reach the +/// fold or the wire. (`max_cost_tier` and `custom` are literal-only in v1.) +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct RestrictSpec { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_models: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deny_models: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_regions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_sites: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_cost_tier: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub custom: BTreeMap, + #[serde(default)] + pub on_empty: OnEmpty, +} + +impl RestrictSpec { + /// True when no constraint field is set. The parser rejects an empty + /// `restrict:` on this (`on_empty` alone constrains nothing). + pub fn is_empty(&self) -> bool { + self.allow_models.is_none() + && self.deny_models.is_none() + && self.allow_regions.is_none() + && self.allow_sites.is_none() + && self.max_cost_tier.is_none() + && self.custom.is_empty() + } + + /// Resolve every `data.*` reference against the request bag, producing + /// the literal `CandidateConstraint` the evaluator accumulates. + pub fn resolve(&self, bag: &AttributeBag) -> CandidateConstraint { + CandidateConstraint { + allow_models: self.allow_models.as_ref().map(|s| s.resolve(bag)), + deny_models: self + .deny_models + .as_ref() + .map(|s| s.resolve(bag)) + .unwrap_or_default(), + allow_regions: self.allow_regions.as_ref().map(|s| s.resolve(bag)), + allow_sites: self.allow_sites.as_ref().map(|s| s.resolve(bag)), + max_cost_tier: self.max_cost_tier.clone(), + custom: self.custom.clone(), + on_empty: self.on_empty, + } + } +} diff --git a/crates/apl-core/src/evaluator.rs b/crates/apl-core/src/evaluator.rs index bcfd1889..fef8ceff 100644 --- a/crates/apl-core/src/evaluator.rs +++ b/crates/apl-core/src/evaluator.rs @@ -88,18 +88,37 @@ fn eval_expression(expr: &Expression, bag: &AttributeBag) -> bool { fn eval_condition(cond: &Condition, bag: &AttributeBag) -> bool { match cond { - Condition::IsTrue { key } => bag.get_bool(key).unwrap_or(false), - Condition::IsFalse { key } => !bag.get_bool(key).unwrap_or(false), - Condition::Exists { key } => bag.contains(key), - Condition::Comparison { key, op, value } => eval_comparison(key, *op, value, bag), + // An unresolvable interpolated path (a missing request value) is + // treated as an absent key: `IsTrue`/`Exists`/`Comparison` are + // false, but `IsFalse` is true (absent is falsy — keeps + // `require(...)` fail-closed when the keyed lookup can't resolve). + Condition::IsTrue { key } => bag + .resolve_key(key) + .map(|k| bag.get_bool(&k).unwrap_or(false)) + .unwrap_or(false), + Condition::IsFalse { key } => bag + .resolve_key(key) + .map(|k| !bag.get_bool(&k).unwrap_or(false)) + .unwrap_or(true), + Condition::Exists { key } => bag + .resolve_key(key) + .map(|k| bag.contains(&k)) + .unwrap_or(false), + Condition::Comparison { key, op, value } => match bag.resolve_key(key) { + Some(k) => eval_comparison(&k, *op, value, bag), + None => false, + }, Condition::InSet { value_key, set_key, negate, } => { - let in_set = match (bag.get_string(value_key), bag.get_string_set(set_key)) { - (Some(s), Some(set)) => set.contains(s), - _ => false, // missing key or wrong type → not in set + let in_set = match bag.resolve_key(value_key).zip(bag.resolve_key(set_key)) { + Some((vk, sk)) => match (bag.get_string(&vk), bag.get_string_set(&sk)) { + (Some(s), Some(set)) => set.contains(s), + _ => false, // missing key or wrong type → not in set + }, + None => false, // an interpolated key didn't resolve }; if *negate { !in_set @@ -201,6 +220,7 @@ pub async fn evaluate_effects( payload: &mut crate::route::RoutePayload, ) -> StepsEvaluation { let mut taints: Vec = Vec::new(); + let mut constraints: Vec = Vec::new(); let mut args_modified = false; let mut result_modified = false; for effect in effects { @@ -220,6 +240,7 @@ pub async fn evaluate_effects( delegations, phase, &mut taints, + &mut constraints, &mut args_modified, &mut result_modified, payload, @@ -228,13 +249,20 @@ pub async fn evaluate_effects( { EffectOutcome::Continue => {}, EffectOutcome::Halt(decision) => { - return StepsEvaluation::deny(decision, taints, args_modified, result_modified); + return StepsEvaluation::deny( + decision, + taints, + constraints, + args_modified, + result_modified, + ); }, } } StepsEvaluation { decision: Decision::Allow, taints, + constraints, args_modified, result_modified, } @@ -254,6 +282,12 @@ pub async fn evaluate_effects( pub struct StepsEvaluation { pub decision: Decision, pub taints: Vec, + /// Backend candidate constraints emitted by any `restrict` effects + /// that ran. Accumulated even when the phase ultimately denies (same + /// discipline as `taints`). Empty in the common case — most phases + /// have no `restrict`. A higher layer (apl-cpex) folds these into a + /// single `CandidateConstraintExtension` for the host's router. + pub constraints: Vec, pub args_modified: bool, pub result_modified: bool, } @@ -262,12 +296,14 @@ impl StepsEvaluation { fn deny( d: Decision, taints: Vec, + constraints: Vec, args_modified: bool, result_modified: bool, ) -> Self { Self { decision: d, taints, + constraints, args_modified, result_modified, } @@ -309,6 +345,7 @@ async fn dispatch_effect( delegations: &Arc, phase: crate::step::DispatchPhase, taints: &mut Vec, + constraints: &mut Vec, args_modified: &mut bool, result_modified: &mut bool, payload: &mut crate::route::RoutePayload, @@ -427,6 +464,22 @@ async fn dispatch_effect( EffectOutcome::Continue }, + Effect::Restrict { spec } => { + // Resolve any `data.*` field references against this request's + // bag (design §4.3), then accumulate the literal constraint — + // same discipline as `Taint`: never halts, always continues. + // An all-empty result is dropped (the parser rejects a literal + // empty `restrict:`, but a reference that resolves to nothing + // could still leave every field unset). Folding / intersection + // of the accumulated constraints happens at the bridge + // (apl-cpex → `CandidateConstraintExtension`). + let constraint = spec.resolve(bag); + if !constraint.is_empty() { + constraints.push(constraint); + } + EffectOutcome::Continue + }, + Effect::FieldOp { path, stages } => { dispatch_field_op( path, @@ -458,6 +511,7 @@ async fn dispatch_effect( delegations, phase, taints, + constraints, args_modified, result_modified, payload, @@ -485,6 +539,7 @@ async fn dispatch_effect( delegations, phase, taints, + constraints, payload, ) .await @@ -511,6 +566,7 @@ async fn dispatch_effect( delegations, phase, taints, + constraints, args_modified, result_modified, payload, @@ -546,6 +602,7 @@ async fn dispatch_effect( delegations, phase, taints, + constraints, args_modified, result_modified, payload, @@ -574,6 +631,7 @@ async fn dispatch_effect( delegations, phase, taints, + constraints, args_modified, result_modified, payload, @@ -641,6 +699,7 @@ fn dispatch_parallel<'a>( delegations: &'a Arc, phase: crate::step::DispatchPhase, taints: &'a mut Vec, + constraints: &'a mut Vec, payload: &'a crate::route::RoutePayload, ) -> futures::future::BoxFuture<'a, EffectOutcome> { Box::pin(async move { @@ -658,8 +717,12 @@ fn dispatch_parallel<'a>( // * an owned copy of the effect to evaluate (clone is cheap // for the variants `Parallel` can hold: Allow, Deny, Plugin, // Taint, Sequential, Parallel, When, Pdp). - let mut branches: Vec)>> = - Vec::with_capacity(effects.len()); + type BranchResult = ( + EffectOutcome, + Vec, + Vec, + ); + let mut branches: Vec> = Vec::with_capacity(effects.len()); for effect in effects.iter() { let effect = effect.clone(); let fallback = fallback_source.to_string(); @@ -670,6 +733,8 @@ fn dispatch_parallel<'a>( let delegations = Arc::clone(delegations); branches.push(Box::pin(async move { let mut branch_taints: Vec = Vec::new(); + let mut branch_constraints: Vec = + Vec::new(); let mut branch_args_modified = false; let mut branch_result_modified = false; let outcome = Box::pin(dispatch_effect( @@ -681,12 +746,13 @@ fn dispatch_parallel<'a>( &delegations, phase, &mut branch_taints, + &mut branch_constraints, &mut branch_args_modified, &mut branch_result_modified, &mut branch_payload, )) .await; - (outcome, branch_taints) + (outcome, branch_taints, branch_constraints) })); } @@ -698,13 +764,9 @@ fn dispatch_parallel<'a>( timeout_per_branch: None, short_circuit_on_deny: true, }; - let outcomes = run_branches( - branches, - cfg, - |v: &(EffectOutcome, Vec)| { - matches!(v.0, EffectOutcome::Halt(_)) - }, - ) + let outcomes = run_branches(branches, cfg, |v: &BranchResult| { + matches!(v.0, EffectOutcome::Halt(_)) + }) .await; // Aggregate in input order: append every branch's taints; pick @@ -717,8 +779,13 @@ fn dispatch_parallel<'a>( let mut first_halt: Option = None; for (idx, outcome) in outcomes.into_iter().enumerate() { match outcome { - BranchOutcome::Completed((effect_outcome, branch_taints)) => { + BranchOutcome::Completed((effect_outcome, branch_taints, branch_constraints)) => { + // Branch state merges back append-only, same as taints: + // each branch's `restrict`-emitted constraints land in + // the outer accumulator (they intersect at fold time, + // so order-independent — safe to concatenate). taints.extend(branch_taints); + constraints.extend(branch_constraints); if first_halt.is_none() { if let EffectOutcome::Halt(d) = effect_outcome { first_halt = Some(d); @@ -1274,6 +1341,110 @@ mod tests { Expression::Condition(c) } + // ----- R3b: data.* path interpolation ----- + + /// Parse a predicate and evaluate it against `bag`. + fn eval_pred(src: &str, bag: &AttributeBag) -> bool { + let expr = crate::parser::parse_predicate(src).expect("parse predicate"); + eval_expression(&expr, bag) + } + + fn eu_tenant_bag() -> AttributeBag { + let mut bag = AttributeBag::new(); + bag.set("subject.tenant", "acme-eu"); + bag.set("data.tenants.acme-eu.data_region", "eu"); + bag.set("data.tenants.acme-us.data_region", "us"); + bag.set("data.org.default_region", "us"); + bag + } + + #[test] + fn interpolation_resolves_request_value_into_path() { + let bag = eu_tenant_bag(); + // subject.tenant = acme-eu → data.tenants.acme-eu.data_region = eu. + assert!(eval_pred( + "data.tenants[subject.tenant].data_region == 'eu'", + &bag + )); + assert!(!eval_pred( + "data.tenants[subject.tenant].data_region == 'us'", + &bag + )); + } + + #[test] + fn interpolation_picks_up_different_request_value() { + let mut bag = eu_tenant_bag(); + bag.set("subject.tenant", "acme-us"); // now resolves to the US row + assert!(eval_pred( + "data.tenants[subject.tenant].data_region == 'us'", + &bag + )); + } + + #[test] + fn missing_inner_key_makes_comparison_false() { + let mut bag = eu_tenant_bag(); + // Drop the request value the bracket indexes on. + bag = { + let mut b = AttributeBag::new(); + for (k, v) in bag.iter() { + if k != "subject.tenant" { + b.set(k, v.clone()); + } + } + b + }; + assert!(!eval_pred( + "data.tenants[subject.tenant].data_region == 'eu'", + &bag + )); + } + + #[test] + fn missing_inner_key_keeps_require_fail_closed() { + // `require(X)` desugars to IsFalse(X); an unresolvable path is + // absent → falsy → IsFalse true → the require denies. + let bag = AttributeBag::new(); // no subject.tenant, no data.* + let expr = + crate::parser::parse_predicate("data.tenants[subject.tenant].data_region").unwrap(); + // Bare identifier predicate is IsTrue → false when unresolvable. + assert!(!eval_expression(&expr, &bag)); + } + + #[test] + fn interpolation_works_with_contains_on_a_set() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "support-bot"); + bag.set( + "data.agents.support-bot.allowed_models", + std::collections::HashSet::from(["vllm/*".to_string(), "anthropic/*".to_string()]), + ); + assert!(eval_pred( + "data.agents[subject.id].allowed_models contains 'vllm/*'", + &bag + )); + assert!(!eval_pred( + "data.agents[subject.id].allowed_models contains 'openai/*'", + &bag + )); + } + + #[test] + fn numeric_request_value_coerces_into_path() { + let mut bag = AttributeBag::new(); + bag.set("subject.tier", 2i64); + bag.set("data.limits.2.max_cost", "cheap"); + assert!(eval_pred("data.limits[subject.tier].max_cost == 'cheap'", &bag)); + } + + #[test] + fn non_interpolated_keys_still_work() { + let bag = eu_tenant_bag(); + assert!(eval_pred("data.org.default_region == 'us'", &bag)); + assert!(eval_pred("subject.tenant == 'acme-eu'", &bag)); + } + // ----- Decision-level semantics ----- #[test] @@ -2775,6 +2946,160 @@ mod tests { ); } + // ----- R1: restrict effect accumulation ----- + + fn restrict_regions(regions: &[&str]) -> Effect { + use crate::constraint::{RestrictSpec, StringSetSpec}; + Effect::Restrict { + spec: RestrictSpec { + allow_regions: Some(StringSetSpec::Literal( + regions.iter().map(|s| s.to_string()).collect(), + )), + ..Default::default() + }, + } + } + + async fn eval(effects: &[Effect], bag: &mut AttributeBag) -> StepsEvaluation { + evaluate_effects( + effects, + bag, + &(Arc::new(FakePdp { + decision: Decision::Allow, + }) as Arc), + &null_plugins(), + &noop_delegations(), + crate::step::DispatchPhase::Pre, + &mut crate::route::RoutePayload::new(serde_json::Value::Null), + ) + .await + } + + #[tokio::test] + async fn restrict_accumulates_and_continues() { + // `restrict` never halts; a later rule still runs, and the + // constraint lands in `constraints`. + let mut bag = AttributeBag::new(); + let effects = vec![restrict_regions(&["eu"]), Effect::Allow]; + let e = eval(&effects, &mut bag).await; + assert_eq!(e.decision, Decision::Allow); + assert_eq!(e.constraints.len(), 1); + assert_eq!( + e.constraints[0].allow_regions.as_deref(), + Some(&["eu".to_string()][..]) + ); + } + + #[tokio::test] + async fn restrict_accumulates_even_when_phase_denies() { + // Same discipline as taint — a constraint emitted before a deny + // still surfaces (audit / the host may still want it). + let mut bag = AttributeBag::new(); + let effects = vec![ + restrict_regions(&["eu"]), + Effect::Deny { + reason: Some("later".into()), + code: None, + }, + ]; + let e = eval(&effects, &mut bag).await; + assert!(matches!(e.decision, Decision::Deny { .. })); + assert_eq!(e.constraints.len(), 1); + } + + #[tokio::test] + async fn restrict_gated_by_when_only_fires_when_true() { + // The composition-layer gate: constraint emits only if `when` holds. + let gated = |key: &str| Effect::When { + condition: Expression::Condition(Condition::IsTrue { key: key.into() }), + body: vec![restrict_regions(&["eu"])], + source: "p[0]".into(), + }; + + let mut off = AttributeBag::new(); + assert!(eval(&[gated("eu_resident")], &mut off).await.constraints.is_empty()); + + let mut on = AttributeBag::new(); + on.set("eu_resident", true); + assert_eq!(eval(&[gated("eu_resident")], &mut on).await.constraints.len(), 1); + } + + #[tokio::test] + async fn restrict_inside_parallel_merges_back() { + // A `restrict` in one parallel branch merges into the outer + // accumulator, alongside a sibling branch's work. + let mut bag = AttributeBag::new(); + let effects = vec![Effect::Parallel(vec![ + Effect::Allow, + restrict_regions(&["eu"]), + ])]; + let e = eval(&effects, &mut bag).await; + assert_eq!(e.decision, Decision::Allow); + assert_eq!(e.constraints.len(), 1); + assert_eq!( + e.constraints[0].allow_regions.as_deref(), + Some(&["eu".to_string()][..]) + ); + } + + fn restrict_allow_models_ref(path: &str) -> Effect { + use crate::constraint::{RestrictSpec, StringSetSpec}; + Effect::Restrict { + spec: RestrictSpec { + allow_models: Some(StringSetSpec::Ref(path.to_string())), + ..Default::default() + }, + } + } + + #[tokio::test] + async fn restrict_ref_resolves_from_data_tree() { + // A `data.*` reference resolves the caller's allow-list from the + // static tree at eval time — one rule, per-caller value. + let mut bag = AttributeBag::new(); + bag.set("subject.id", "support-bot"); + bag.set( + "data.agents.support-bot.allowed_models", + std::collections::HashSet::from(["vllm/*".to_string(), "anthropic/*".to_string()]), + ); + let effects = vec![restrict_allow_models_ref("data.agents[subject.id].allowed_models")]; + let e = eval(&effects, &mut bag).await; + assert_eq!(e.constraints.len(), 1); + // Resolved to the tree's set (sorted). + assert_eq!( + e.constraints[0].allow_models.as_deref(), + Some(&["anthropic/*".to_string(), "vllm/*".to_string()][..]) + ); + } + + #[tokio::test] + async fn restrict_ref_picks_up_different_caller() { + let mut bag = AttributeBag::new(); + bag.set("subject.id", "research-bot"); + bag.set( + "data.agents.research-bot.allowed_models", + std::collections::HashSet::from(["openai/*".to_string()]), + ); + let effects = vec![restrict_allow_models_ref("data.agents[subject.id].allowed_models")]; + let e = eval(&effects, &mut bag).await; + assert_eq!( + e.constraints[0].allow_models.as_deref(), + Some(&["openai/*".to_string()][..]) + ); + } + + #[tokio::test] + async fn restrict_ref_absent_resolves_to_empty_fail_closed() { + // No subject.id / no tree entry → the allow-list resolves to the + // empty set: a real (impossible) constraint that the host's + // on_empty then decides, never silently unconstrained. + let mut bag = AttributeBag::new(); + let effects = vec![restrict_allow_models_ref("data.agents[subject.id].allowed_models")]; + let e = eval(&effects, &mut bag).await; + assert_eq!(e.constraints.len(), 1); + assert_eq!(e.constraints[0].allow_models.as_deref(), Some(&[][..])); + } + // ----- E2: FieldOp end-to-end through evaluate_steps ----- #[tokio::test] diff --git a/crates/apl-core/src/lib.rs b/crates/apl-core/src/lib.rs index 74078d1a..622ea8d0 100644 --- a/crates/apl-core/src/lib.rs +++ b/crates/apl-core/src/lib.rs @@ -13,7 +13,9 @@ #![doc = "APL — Authorization Policy Language. See docs/specs/apl-design.md."] +pub mod attribute_source; pub mod attributes; +pub mod constraint; pub mod evaluator; pub mod parser; pub mod pipeline; @@ -22,6 +24,7 @@ pub mod route; pub mod rules; pub mod step; +pub use attribute_source::{AttributeError, AttributeSource, AttributeTree}; pub use attributes::{AttributeBag, AttributeExtractor, AttributeValue}; pub use evaluator::{ evaluate_effects, evaluate_pipeline, evaluate_rules, Decision, FieldOutcome, PipelineEvaluation, diff --git a/crates/apl-core/src/parser.rs b/crates/apl-core/src/parser.rs index 9901f21c..e7ae68e3 100644 --- a/crates/apl-core/src/parser.rs +++ b/crates/apl-core/src/parser.rs @@ -195,7 +195,7 @@ impl<'a> Lexer<'a> { }, b'"' | b'\'' => self.lex_string(b)?, b'-' | b'0'..=b'9' => self.lex_number()?, - b if is_ident_start(b) => self.lex_ident_or_keyword(), + b if is_ident_start(b) => self.lex_ident_or_keyword()?, _ => return Err(self.err(&format!("unexpected char `{}`", b as char))), }; out.push(tok); @@ -257,17 +257,50 @@ impl<'a> Lexer<'a> { } } - fn lex_ident_or_keyword(&mut self) -> Tok { + fn lex_ident_or_keyword(&mut self) -> Result { let start = self.pos; - while let Some(b) = self.peek() { - if is_ident_cont(b) { - self.pos += 1; + // An attribute path is ident-cont runs interleaved with `[...]` + // interpolation groups: `data.tenants[subject.tenant].data_region`. + // The bracket content is a nested attribute key the evaluator + // resolves at eval time (R3b) — the lexer only delimits it. + let mut has_bracket = false; + loop { + while let Some(b) = self.peek() { + if is_ident_cont(b) { + self.pos += 1; + } else { + break; + } + } + if self.peek() == Some(b'[') { + has_bracket = true; + self.pos += 1; // consume `[` + let mut closed = false; + while let Some(b) = self.peek() { + self.pos += 1; + match b { + b']' => { + closed = true; + break; + }, + b'[' => return Err(self.err("nested `[` in attribute path")), + _ => {}, + } + } + if !closed { + return Err(self.err("unterminated `[` in attribute path")); + } + // Continue: more ident-cont chars or another `[...]` may follow. } else { break; } } let s = &self.src[start..self.pos]; - match s { + // A path with an interpolation group is never a keyword. + if has_bracket { + return Ok(Tok::Ident(s.to_string())); + } + Ok(match s { "true" => Tok::BoolLit(true), "false" => Tok::BoolLit(false), "contains" => Tok::Contains, @@ -278,7 +311,7 @@ impl<'a> Lexer<'a> { // phrase. The parser handles that as an Ident("not") + Tok::In // sequence in parse_identifier_predicate. _ => Tok::Ident(s.to_string()), - } + }) } fn err(&self, msg: &str) -> ParseError { @@ -1225,6 +1258,15 @@ fn parse_step_map(m: &serde_yaml::Mapping, source: &str) -> Result stays uniform. @@ -1433,6 +1475,7 @@ fn parse_effect_value(val: &serde_yaml::Value, source: &str) -> Result return parse_sequential_effect(v, source), "parallel" => return parse_parallel_effect(v, source), + "restrict" => return parse_restrict_effect(v, source), _ => {}, } } @@ -1497,6 +1540,208 @@ fn parse_parallel_effect(body: &serde_yaml::Value, source: &str) -> Result Result { + let spec = parse_restrict_spec(body, source)?; + Ok(Effect::Restrict { spec }) +} + +/// Parse a `restrict:` body map into a [`RestrictSpec`] +/// (`docs/apl-restrict-effect-design.md` §2.3, §4.3). Every field is +/// optional, but an entirely empty `restrict:` is rejected — it would +/// constrain nothing, so it's an author error. Unknown keys are a hard +/// error: the constraint is a fixed contract we ask the host's router to +/// honor, and a typo'd field must never silently widen the eligible set. +/// +/// The string-set fields (`allow_models` / `deny_models` / `allow_regions` +/// / `allow_sites`) accept either a literal YAML list **or** a bare +/// scalar `data.*` reference resolved per request (§4.3). +fn parse_restrict_spec( + body_val: &serde_yaml::Value, + source: &str, +) -> Result { + use crate::constraint::{OnEmpty, RestrictSpec}; + + let body = body_val.as_mapping().ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`restrict:` body must be a map of constraint fields (allow_models / \ + deny_models / allow_regions / allow_sites / max_cost_tier / custom / \ + on_empty)" + .to_string(), + })?; + + let mut spec = RestrictSpec::default(); + + for (k, v) in body.iter() { + let key = k.as_str().ok_or_else(|| ParseError::Rule { + rule: source.to_string(), + msg: "`restrict:` field keys must be strings".to_string(), + })?; + // Field-scoped error so authors see e.g. `restrict.allow_models: ...`. + let field_err = |msg: String| ParseError::Rule { + rule: source.to_string(), + msg: format!("`restrict.{}`: {}", key.trim(), msg), + }; + match key.trim() { + "allow_models" => { + spec.allow_models = Some(parse_string_set_spec(v).map_err(&field_err)?); + }, + "deny_models" => { + spec.deny_models = Some(parse_string_set_spec(v).map_err(&field_err)?); + }, + "allow_regions" => { + spec.allow_regions = Some(parse_string_set_spec(v).map_err(&field_err)?); + }, + "allow_sites" => { + spec.allow_sites = Some(parse_string_set_spec(v).map_err(&field_err)?); + }, + "max_cost_tier" => { + let tier = v + .as_str() + .ok_or_else(|| field_err("must be a string tier".to_string()))?; + if tier.trim().is_empty() { + return Err(field_err("tier must not be empty".to_string())); + } + spec.max_cost_tier = Some(tier.trim().to_string()); + }, + "custom" => { + spec.custom = parse_label_map(v).map_err(&field_err)?; + }, + "on_empty" => { + let s = v + .as_str() + .ok_or_else(|| field_err("must be `deny` or `fallback`".to_string()))?; + spec.on_empty = match s.trim() { + "deny" => OnEmpty::Deny, + "fallback" => OnEmpty::Fallback, + other => { + return Err(field_err(format!( + "unknown value `{}` (expected `deny` or `fallback`)", + other + ))) + }, + }; + }, + other => { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: format!( + "unknown `restrict:` field `{}` (allowed: allow_models, \ + deny_models, allow_regions, allow_sites, max_cost_tier, \ + custom, on_empty)", + other + ), + }); + }, + } + } + + // `is_empty()` ignores `on_empty` (a bare `on_empty:` constrains + // nothing), so this also rejects `restrict: { on_empty: deny }`. + if spec.is_empty() { + return Err(ParseError::Rule { + rule: source.to_string(), + msg: "`restrict:` declares no constraint fields — it would restrict nothing; \ + remove it or add at least one of allow_models / deny_models / \ + allow_regions / allow_sites / max_cost_tier / custom" + .to_string(), + }); + } + + Ok(spec) +} + +/// Parse a `restrict` string-set field. A YAML **sequence** is a literal +/// set of strings; a bare **scalar string** is a `data.*` reference +/// resolved per request (design §4.3) — e.g. +/// `allow_models: data.agents[subject.id].allowed_models`. +fn parse_string_set_spec( + v: &serde_yaml::Value, +) -> Result { + use crate::constraint::StringSetSpec; + match v { + serde_yaml::Value::Sequence(_) => Ok(StringSetSpec::Literal(parse_string_list(v)?)), + serde_yaml::Value::String(s) => { + let s = s.trim(); + if s.is_empty() { + return Err("reference path must not be empty".to_string()); + } + Ok(StringSetSpec::Ref(s.to_string())) + }, + _ => Err("must be a list of strings or a `data.*` reference string".to_string()), + } +} + +/// Parse a YAML value expected to be a non-empty list of non-empty +/// strings (the `allow_*` / `deny_*` constraint fields). Surrounding +/// whitespace is trimmed; interior characters (e.g. a `*` glob or a `/` +/// in a model id) are preserved. +fn parse_string_list(v: &serde_yaml::Value) -> Result, String> { + let seq = v + .as_sequence() + .ok_or_else(|| "must be a list of strings".to_string())?; + let mut out = Vec::with_capacity(seq.len()); + for item in seq { + let s = item + .as_str() + .ok_or_else(|| "list entries must be strings".to_string())?; + if s.trim().is_empty() { + return Err("list entries must not be empty".to_string()); + } + out.push(s.trim().to_string()); + } + if out.is_empty() { + return Err("list must not be empty".to_string()); + } + Ok(out) +} + +/// Parse a YAML value expected to be a flat map of `label: value` +/// pairs (the `custom` field). Scalar values (string / bool / number) +/// are coerced to their string form, matching the label-map contract +/// (design §2.3.1) — `custom` is equality-matched labels, not typed +/// values. +fn parse_label_map(v: &serde_yaml::Value) -> Result, String> { + let map = v + .as_mapping() + .ok_or_else(|| "must be a map of `label: value` pairs".to_string())?; + let mut out = std::collections::BTreeMap::new(); + for (k, val) in map { + let key = k + .as_str() + .ok_or_else(|| "label keys must be strings".to_string())?; + if key.trim().is_empty() { + return Err("label keys must not be empty".to_string()); + } + let value = scalar_to_string(val).ok_or_else(|| { + format!( + "label `{}` must be a scalar (string / bool / number)", + key.trim() + ) + })?; + out.insert(key.trim().to_string(), value); + } + if out.is_empty() { + return Err("`custom` map must not be empty".to_string()); + } + Ok(out) +} + +/// Coerce a scalar YAML value to its string form for a `custom` label. +/// Non-scalars (sequences, maps, null) return `None` — a label value +/// must be a single comparable token. +fn scalar_to_string(v: &serde_yaml::Value) -> Option { + match v { + serde_yaml::Value::String(s) => Some(s.clone()), + serde_yaml::Value::Bool(b) => Some(b.to_string()), + serde_yaml::Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + /// Parse one effect string. Reuses [`parse_step_string`] for forms /// shared with top-level steps (`plugin(...)`, `taint(...)`, /// `delegate(...)`, predicate-action rules), then collapses the @@ -1659,6 +1904,7 @@ pub(crate) fn step_to_top_level_effect(step: Step) -> Result Step::Plugin { name } => Ok(Effect::Plugin { name }), Step::Delegate(d) => Ok(Effect::Delegate(d)), Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + Step::Restrict { spec } => Ok(Effect::Restrict { spec }), } } @@ -1667,6 +1913,7 @@ fn step_to_effect(step: Step, source: &str) -> Result { Step::Plugin { name } => Ok(Effect::Plugin { name }), Step::Delegate(d) => Ok(Effect::Delegate(d)), Step::Taint { label, scopes } => Ok(Effect::Taint { label, scopes }), + Step::Restrict { spec } => Ok(Effect::Restrict { spec }), Step::Rule(rule) => { // Nested when/do inside a do: list isn't supported in E1 // — only control effects (allow/deny) flatten cleanly. @@ -2527,6 +2774,59 @@ mod tests { assert!(format!("{}", err).contains("expected `==`")); } + // ----- R3b: interpolated attribute paths ----- + + #[test] + fn lex_interpolated_path_is_one_ident() { + let toks = Lexer::new("data.tenants[subject.tenant].data_region") + .tokenize_all() + .unwrap(); + assert_eq!( + toks, + vec![Tok::Ident("data.tenants[subject.tenant].data_region".into())] + ); + } + + #[test] + fn lex_interpolated_path_in_comparison() { + let toks = Lexer::new("data.tenants[subject.tenant].data_region == 'eu'") + .tokenize_all() + .unwrap(); + assert_eq!( + toks, + vec![ + Tok::Ident("data.tenants[subject.tenant].data_region".into()), + Tok::Eq, + Tok::StringLit("eu".into()), + ] + ); + } + + #[test] + fn lex_rejects_unterminated_bracket() { + let err = Lexer::new("data.tenants[subject.tenant").tokenize_all().unwrap_err(); + assert!(format!("{}", err).contains("unterminated"), "got: {}", err); + } + + #[test] + fn lex_rejects_nested_bracket() { + let err = Lexer::new("data.x[a[b]]").tokenize_all().unwrap_err(); + assert!(format!("{}", err).contains("nested"), "got: {}", err); + } + + #[test] + fn interpolated_predicate_parses_to_comparison() { + let e = parse_predicate("data.tenants[subject.tenant].data_region == 'eu'").unwrap(); + assert_eq!( + e, + Expression::Condition(Condition::Comparison { + key: "data.tenants[subject.tenant].data_region".into(), + op: CompareOp::Eq, + value: Literal::String("eu".into()), + }) + ); + } + // ----- Predicate parser ----- #[test] @@ -3371,6 +3671,199 @@ sequential: assert!(format!("{}", err).contains("empty")); } + // ----- R1: restrict effect ----- + + /// A literal `StringSetSpec` for terse assertions. + fn lit(items: &[&str]) -> Option { + Some(crate::constraint::StringSetSpec::Literal( + items.iter().map(|s| s.to_string()).collect(), + )) + } + + #[test] + fn top_level_restrict_full_shape() { + // Every field exercised at once, including `custom` scalar + // coercion and an explicit `on_empty`. + let yaml = r#" +restrict: + allow_models: ["vllm/*", "anthropic/claude-sonnet-*"] + deny_models: ["openai/*"] + allow_regions: [eu] + allow_sites: [site-a] + max_cost_tier: cheap + custom: { gpu: h100, dedicated: true } + on_empty: fallback +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Restrict { spec } = step else { + panic!("expected Step::Restrict, got {:?}", step); + }; + use crate::constraint::OnEmpty; + assert_eq!(spec.allow_models, lit(&["vllm/*", "anthropic/claude-sonnet-*"])); + assert_eq!(spec.deny_models, lit(&["openai/*"])); + assert_eq!(spec.allow_regions, lit(&["eu"])); + assert_eq!(spec.allow_sites, lit(&["site-a"])); + assert_eq!(spec.max_cost_tier.as_deref(), Some("cheap")); + // `custom` coerces the bool `true` to the string "true". + assert_eq!(spec.custom.get("gpu"), Some(&"h100".to_string())); + assert_eq!(spec.custom.get("dedicated"), Some(&"true".to_string())); + assert_eq!(spec.on_empty, OnEmpty::Fallback); + } + + #[test] + fn restrict_on_empty_defaults_to_deny() { + let step = parse_step_yaml("restrict: { deny_models: [\"openai/*\"] }").unwrap(); + let Step::Restrict { spec } = step else { + panic!("expected Step::Restrict"); + }; + assert_eq!(spec.on_empty, crate::constraint::OnEmpty::Deny); + } + + #[test] + fn restrict_field_reference_parses_as_ref() { + // A scalar `data.*` path is a reference, not a literal. A path + // containing `[...]` must be quoted so YAML doesn't read the + // brackets as a flow sequence (block form works unquoted too). + let yaml = r#" +restrict: + allow_models: "data.agents[subject.id].allowed_models" +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Restrict { spec } = step else { + panic!("expected Step::Restrict"); + }; + assert_eq!( + spec.allow_models, + Some(crate::constraint::StringSetSpec::Ref( + "data.agents[subject.id].allowed_models".to_string() + )) + ); + } + + #[test] + fn restrict_bracketless_reference_parses_unquoted() { + // A reference with no `[...]` is a clean plain scalar — no quoting + // needed even in flow form. + let step = + parse_step_yaml("restrict: { allow_regions: data.tenant_regions }").unwrap(); + let Step::Restrict { spec } = step else { + panic!("expected Step::Restrict"); + }; + assert_eq!( + spec.allow_regions, + Some(crate::constraint::StringSetSpec::Ref("data.tenant_regions".to_string())) + ); + } + + #[test] + fn restrict_inside_when_do_body() { + // The EU-sovereignty shape: gate at the composition layer, + // restrict in the `do:` body. + let yaml = r#" +when: session.labels contains 'eu_resident' +do: + - restrict: { allow_regions: [eu] } +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + match rule.effects.as_slice() { + [Effect::Restrict { spec }] => { + assert_eq!(spec.allow_regions, lit(&["eu"])); + }, + other => panic!("expected single Restrict effect, got {:?}", other), + } + } + + #[test] + fn restrict_inside_pdp_on_allow() { + // `restrict` composes in a PDP reaction — authz says yes, then + // pin routing (design §2.1). + let yaml = r#" +cedar: + action: read + resource: eu_data + on_allow: + - restrict: { allow_regions: [eu] } +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Pdp { on_allow, .. } = step else { + panic!("expected Step::Pdp, got {:?}", step); + }; + match on_allow.as_slice() { + [Step::Restrict { spec }] => { + assert_eq!(spec.allow_regions, lit(&["eu"])); + }, + other => panic!("expected Restrict in on_allow, got {:?}", other), + } + } + + #[test] + fn restrict_empty_body_rejected() { + // A `restrict:` with no constraint fields restricts nothing — + // author error. + let err = parse_step_yaml("restrict: {}").unwrap_err(); + assert!(format!("{}", err).contains("no constraint fields"), "got: {}", err); + } + + #[test] + fn restrict_only_on_empty_rejected() { + // `on_empty` alone still constrains nothing. + let err = parse_step_yaml("restrict: { on_empty: deny }").unwrap_err(); + assert!(format!("{}", err).contains("no constraint fields"), "got: {}", err); + } + + #[test] + fn restrict_unknown_field_rejected() { + let err = parse_step_yaml("restrict: { allow_zones: [eu] }").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("unknown"), "got: {}", msg); + assert!(msg.contains("allow_zones"), "got: {}", msg); + } + + #[test] + fn restrict_bad_on_empty_value_rejected() { + let err = + parse_step_yaml("restrict: { deny_models: [\"openai/*\"], on_empty: maybe }").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("on_empty"), "got: {}", msg); + assert!(msg.contains("maybe"), "got: {}", msg); + } + + #[test] + fn restrict_non_scalar_custom_value_rejected() { + let yaml = r#" +restrict: + custom: + gpu: [h100, a100] +"#; + let err = parse_step_yaml(yaml).unwrap_err(); + assert!(format!("{}", err).contains("scalar"), "got: {}", err); + } + + #[test] + fn restrict_allowed_inside_parallel() { + // `restrict` is non-mutating, so it is *allowed* in parallel — + // this guards that we didn't accidentally class it as a mutation. + let yaml = r#" +parallel: + - "plugin(audit)" + - restrict: { allow_regions: [eu] } +"#; + let step = parse_step_yaml(yaml).unwrap(); + let Step::Rule(rule) = step else { + panic!("expected Rule"); + }; + match rule.effects.as_slice() { + [Effect::Parallel(inner)] => { + assert_eq!(inner.len(), 2); + assert!(matches!(inner[1], Effect::Restrict { .. })); + }, + other => panic!("expected Parallel with Restrict, got {:?}", other), + } + } + #[test] fn nested_orchestration() { // `sequential: [plugin, parallel: [plugin, plugin]]` — the diff --git a/crates/apl-core/src/route.rs b/crates/apl-core/src/route.rs index 13f02aad..628f6444 100644 --- a/crates/apl-core/src/route.rs +++ b/crates/apl-core/src/route.rs @@ -60,6 +60,11 @@ pub struct RouteDecision { pub decision: Decision, /// Taints accumulated from any phase. Empty unless a pipeline emitted them. pub taints: Vec, + /// Backend candidate constraints emitted by `restrict` effects in any + /// phase. Empty unless a `restrict` fired. The host bridge (apl-cpex) + /// folds these into a `CandidateConstraintExtension` it serializes to + /// the router — see `docs/apl-restrict-effect-design.md` §2.5. + pub constraints: Vec, /// True if any args field was rewritten or omitted. pub args_modified: bool, /// True if any result field was rewritten or omitted. @@ -124,6 +129,9 @@ pub async fn evaluate_pre( rule_source: rule.source.clone(), }, taints, + // `restrict` only fires in the policy phase (below); + // an args-pipeline deny short-circuits before it. + constraints: Vec::new(), args_modified, result_modified: false, }; @@ -149,6 +157,7 @@ pub async fn evaluate_pre( RouteDecision { decision: policy_eval.decision, taints, + constraints: policy_eval.constraints, args_modified, result_modified: false, } @@ -210,6 +219,9 @@ pub async fn evaluate_post( rule_source: rule.source.clone(), }, taints, + // `restrict` fires in post_policy (below); a + // result-pipeline deny short-circuits before it. + constraints: Vec::new(), args_modified: false, result_modified, }; @@ -237,6 +249,7 @@ pub async fn evaluate_post( RouteDecision { decision: post_eval.decision, taints, + constraints: post_eval.constraints, args_modified: false, result_modified, } @@ -267,9 +280,12 @@ pub async fn evaluate_route( let post = evaluate_post(route, bag, payload, pdp, plugins, delegations).await; let mut taints = pre.taints; taints.extend(post.taints); + let mut constraints = pre.constraints; + constraints.extend(post.constraints); RouteDecision { decision: post.decision, taints, + constraints, args_modified: pre.args_modified, result_modified: post.result_modified, } diff --git a/crates/apl-core/src/rules.rs b/crates/apl-core/src/rules.rs index 557765fa..6579503d 100644 --- a/crates/apl-core/src/rules.rs +++ b/crates/apl-core/src/rules.rs @@ -179,6 +179,15 @@ pub enum Effect { label: String, scopes: Vec, }, + /// Backend candidate constraint (DSL — the `restrict` effect). Narrows + /// the set of backends the host's router/LB may select from; never + /// picks a backend and never allows/denies. Accumulating, in the same + /// family as `Taint`: the evaluator collects the constraint, a higher + /// layer folds it into a `CandidateConstraintExtension` the host + /// serializes to its router. See docs/apl-restrict-effect-design.md. + Restrict { + spec: crate::constraint::RestrictSpec, + }, /// Content effect (DSL §3) — apply a pipe chain (`redact`, `mask`, /// `omit`, `hash`, validators, transforms) to a field in the /// route's args or result. The author writes @@ -261,7 +270,14 @@ impl Effect { on_allow.iter().any(Effect::contains_mutation) || on_deny.iter().any(Effect::contains_mutation) }, - Effect::Allow | Effect::Deny { .. } | Effect::Plugin { .. } | Effect::Taint { .. } => { + Effect::Allow + | Effect::Deny { .. } + | Effect::Plugin { .. } + | Effect::Taint { .. } + | Effect::Restrict { .. } => { + // `Restrict` emits a candidate constraint, never touches the + // route payload — non-mutating, same as `Taint`. Safe inside + // `parallel:` (its constraint accumulates, like a taint label). false }, } diff --git a/crates/apl-core/src/step.rs b/crates/apl-core/src/step.rs index d0e74acb..e7f881b6 100644 --- a/crates/apl-core/src/step.rs +++ b/crates/apl-core/src/step.rs @@ -73,6 +73,16 @@ pub(crate) enum Step { label: String, scopes: Vec, }, + + /// `restrict: { ... }` — narrow the backend candidate set. Always + /// succeeds; never produces a Deny (accumulating, same family as + /// `Taint`). The evaluator collects the emitted constraint; a higher + /// layer (apl-cpex) folds it into a `CandidateConstraintExtension` + /// the host serializes to its router. See + /// `docs/apl-restrict-effect-design.md`. + Restrict { + spec: crate::constraint::RestrictSpec, + }, } /// One delegation invocation inside `pre_invocation:` or `post_invocation:`. diff --git a/crates/apl-cpex/src/attribute_source.rs b/crates/apl-cpex/src/attribute_source.rs new file mode 100644 index 00000000..1b9c5894 --- /dev/null +++ b/crates/apl-cpex/src/attribute_source.rs @@ -0,0 +1,304 @@ +// Location: ./crates/apl-cpex/src/attribute_source.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// FileAttributeSource — the built-in `data.*` provider (design §4.4.1). +// +// Reads a list of attribute files (YAML, each wrapping everything under a +// top-level `data:` mapping) and deep-merges them into one +// `AttributeTree`. Different subtrees combine freely; a genuine same-leaf +// conflict (two files setting the same path to different values) is a +// **load-time error**, not a silent last-wins clobber. Snapshot only in +// v1 (no `watch`); hot-reload is deferred. +// +// `AttributeSource::load` is synchronous (it runs once at startup), so +// this is just plain file I/O plus the merge — no async, no runtime. + +use std::path::{Path, PathBuf}; + +use serde_json::{Map, Value}; + +use apl_core::attribute_source::{AttributeError, AttributeSource, AttributeTree}; + +/// Built-in file-backed [`AttributeSource`]. Construct from the paths in +/// `settings.attribute_files`; each is read and deep-merged, in order, +/// into the `data.*` tree. +pub struct FileAttributeSource { + paths: Vec, +} + +impl FileAttributeSource { + /// New source over the given files (merged in order). + pub fn new(paths: impl IntoIterator) -> Self { + Self { + paths: paths.into_iter().collect(), + } + } + +} + +impl AttributeSource for FileAttributeSource { + /// Read and merge the files. Synchronous — runs at startup, off the + /// request path. + fn load(&self) -> Result { + let mut docs = Vec::with_capacity(self.paths.len()); + for path in &self.paths { + let text = std::fs::read_to_string(path) + .map_err(|e| AttributeError::Load(format!("{}: {}", path.display(), e)))?; + let doc: Value = serde_yaml::from_str(&text) + .map_err(|e| AttributeError::Parse(format!("{}: {}", path.display(), e)))?; + docs.push((label(path), doc)); + } + merge_attribute_docs(docs) + } +} + +fn label(path: &Path) -> String { + path.display().to_string() +} + +/// Deep-merge a sequence of parsed attribute documents into one tree. +/// Each `doc` is a whole file's parsed YAML (`{ data: { ... } }`); the +/// `String` is a source label used in error messages. Pure and +/// in-memory-testable — [`FileAttributeSource::load`] just reads the +/// files first. +pub fn merge_attribute_docs(docs: I) -> Result +where + I: IntoIterator, +{ + let mut acc: Map = Map::new(); + for (label, doc) in docs { + let data = extract_data(&label, doc)?; + merge_object(&mut acc, data, "data")?; + } + Ok(AttributeTree::new(Value::Object(acc))) +} + +/// Pull the `data:` mapping out of one file's parsed document, enforcing +/// the "everything lives under `data:`" contract. An empty file is fine; +/// stray top-level keys (a forgotten `data:` wrapper) are a hard error. +fn extract_data(label: &str, doc: Value) -> Result, AttributeError> { + match doc { + Value::Null => Ok(Map::new()), + Value::Object(mut m) => { + let data = m.remove("data"); + if !m.is_empty() { + let mut stray: Vec = m.keys().cloned().collect(); + stray.sort(); + return Err(AttributeError::Parse(format!( + "{}: attribute files may only contain a top-level `data:` mapping; \ + found stray key(s): {}", + label, + stray.join(", ") + ))); + } + match data { + None | Some(Value::Null) => Ok(Map::new()), + Some(Value::Object(d)) => Ok(d), + Some(other) => Err(AttributeError::Parse(format!( + "{}: `data:` must be a mapping, got {}", + label, + type_name(&other) + ))), + } + }, + other => Err(AttributeError::Parse(format!( + "{}: top-level must be a `data:` mapping, got {}", + label, + type_name(&other) + ))), + } +} + +/// Recursively merge `incoming` into `acc`. Objects merge key-by-key; +/// two different scalars at the same path are a [`AttributeError::Conflict`]. +/// Identical values are a no-op (harmless overlap). `prefix` is the dotted +/// path for error context (`data.tenants.acme-eu.data_region`). +fn merge_object( + acc: &mut Map, + incoming: Map, + prefix: &str, +) -> Result<(), AttributeError> { + for (k, v) in incoming { + let path = format!("{}.{}", prefix, k); + if let Some(existing) = acc.get_mut(&k) { + match (existing, v) { + (Value::Object(e), Value::Object(iv)) => merge_object(e, iv, &path)?, + (existing_val, incoming_val) => { + if *existing_val != incoming_val { + return Err(AttributeError::Conflict { + path, + existing: existing_val.to_string(), + incoming: incoming_val.to_string(), + }); + } + }, + } + } else { + acc.insert(k, v); + } + } + Ok(()) +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "mapping", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn doc(label: &str, v: Value) -> (String, Value) { + (label.to_string(), v) + } + + #[test] + fn disjoint_subtrees_combine() { + let tree = merge_attribute_docs([ + doc("org.yaml", json!({ "data": { "org": { "default_region": "us" } } })), + doc( + "tenants.yaml", + json!({ "data": { "tenants": { "acme-eu": { "data_region": "eu" } } } }), + ), + ]) + .unwrap(); + let v = tree.as_value(); + assert_eq!(v.pointer("/org/default_region"), Some(&json!("us"))); + assert_eq!(v.pointer("/tenants/acme-eu/data_region"), Some(&json!("eu"))); + } + + #[test] + fn nested_objects_deep_merge() { + // Two files contribute different keys under the same subtree. + let tree = merge_attribute_docs([ + doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), + doc("b.yaml", json!({ "data": { "org": { "tier": "gold" } } })), + ]) + .unwrap(); + let v = tree.as_value(); + assert_eq!(v.pointer("/org/region"), Some(&json!("us"))); + assert_eq!(v.pointer("/org/tier"), Some(&json!("gold"))); + } + + #[test] + fn identical_leaf_is_not_a_conflict() { + let tree = merge_attribute_docs([ + doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), + doc("b.yaml", json!({ "data": { "org": { "region": "us" } } })), + ]) + .unwrap(); + assert_eq!(tree.as_value().pointer("/org/region"), Some(&json!("us"))); + } + + #[test] + fn conflicting_leaf_fails_fast() { + let err = merge_attribute_docs([ + doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), + doc("b.yaml", json!({ "data": { "org": { "region": "eu" } } })), + ]) + .unwrap_err(); + match err { + AttributeError::Conflict { + path, + existing, + incoming, + } => { + assert_eq!(path, "data.org.region"); + assert_eq!(existing, "\"us\""); + assert_eq!(incoming, "\"eu\""); + }, + other => panic!("expected Conflict, got {:?}", other), + } + } + + #[test] + fn object_vs_scalar_at_same_path_conflicts() { + let err = merge_attribute_docs([ + doc("a.yaml", json!({ "data": { "org": { "region": "us" } } })), + doc("b.yaml", json!({ "data": { "org": "flat" } })), + ]) + .unwrap_err(); + assert!(matches!(err, AttributeError::Conflict { .. })); + } + + #[test] + fn stray_top_level_key_rejected() { + // Forgot the `data:` wrapper. + let err = merge_attribute_docs([doc( + "oops.yaml", + json!({ "org": { "region": "us" } }), + )]) + .unwrap_err(); + match err { + AttributeError::Parse(msg) => { + assert!(msg.contains("stray key"), "got: {}", msg); + assert!(msg.contains("org"), "got: {}", msg); + }, + other => panic!("expected Parse, got {:?}", other), + } + } + + #[test] + fn empty_and_missing_data_are_ok() { + let tree = merge_attribute_docs([ + doc("empty.yaml", Value::Null), + doc("nodata.yaml", json!({ "data": null })), + doc("real.yaml", json!({ "data": { "org": { "region": "us" } } })), + ]) + .unwrap(); + assert_eq!(tree.as_value().pointer("/org/region"), Some(&json!("us"))); + } + + #[test] + fn data_not_a_mapping_rejected() { + let err = merge_attribute_docs([doc("bad.yaml", json!({ "data": "flat" }))]).unwrap_err(); + assert!(matches!(err, AttributeError::Parse(_))); + } + + #[test] + fn load_reads_and_merges_real_files() { + // Exercise the file-reading path against temp files. + let dir = std::env::temp_dir().join(format!("apl_attr_test_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("org.yaml"); + let b = dir.join("tenants.yaml"); + std::fs::write(&a, "data:\n org:\n default_region: us\n").unwrap(); + std::fs::write( + &b, + "data:\n tenants:\n acme-eu:\n data_region: eu\n", + ) + .unwrap(); + + let src = FileAttributeSource::new([a.clone(), b.clone()]); + let tree = src.load().unwrap(); + assert_eq!( + tree.as_value().pointer("/org/default_region"), + Some(&json!("us")) + ); + assert_eq!( + tree.as_value().pointer("/tenants/acme-eu/data_region"), + Some(&json!("eu")) + ); + + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn missing_file_is_load_error() { + let src = FileAttributeSource::new([PathBuf::from("/no/such/attrs.yaml")]); + assert!(matches!( + src.load().unwrap_err(), + AttributeError::Load(_) + )); + } +} diff --git a/crates/apl-cpex/src/candidate_constraint.rs b/crates/apl-cpex/src/candidate_constraint.rs new file mode 100644 index 00000000..8230b459 --- /dev/null +++ b/crates/apl-cpex/src/candidate_constraint.rs @@ -0,0 +1,360 @@ +// Location: ./crates/apl-cpex/src/candidate_constraint.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Fold — combine the `restrict` constraints a request emitted (apl-core +// authoring IR) into one typed `CandidateConstraintExtension` (cpex-core +// wire type) the host router reads off the returned `Extensions`. This is +// the bridge between the pure policy language and the framework's typed +// extension slot, the same role `apply_session_taints` plays for taints. +// See docs/apl-restrict-effect-design.md §2.4/§2.5. + +use apl_core::constraint::{CandidateConstraint, OnEmpty as AplOnEmpty}; +use cpex_core::extensions::{CandidateConstraintExtension, OnEmpty}; + +use std::collections::{BTreeMap, BTreeSet}; + +/// Two `restrict` effects require the same `custom` label to equal two +/// different values — no backend can satisfy both. The route handler maps +/// this to a fail-closed deny (never silently drops a requirement). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConstraintConflict { + pub key: String, + pub existing: String, + pub incoming: String, +} + +impl std::fmt::Display for ConstraintConflict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "conflicting `restrict` custom label `{}`: `{}` vs `{}` \ + (no backend can be both)", + self.key, self.existing, self.incoming + ) + } +} + +/// Fold a request's emitted constraints into one typed extension. Returns +/// `Ok(None)` when there is nothing to emit (no constraints, or they fold +/// to an unrestricting result). Order-independent — the input may arrive +/// in any order (constraints from parallel branches merge unsorted). +/// +/// Monotone semantics (design §2.4): allow-sets **intersect**, +/// `deny_models` **union**, `max_cost_tier` ceilings **collect** into +/// `max_cost_tiers` (CPEX can't order tier names — the host reduces to the +/// min), `custom` **union**, `on_empty` takes the **strictest** +/// (`Deny` beats `Fallback`). A `custom` key required to hold two +/// different values is an unsatisfiable contradiction → `Err`. +pub fn fold_candidate_constraints( + constraints: &[CandidateConstraint], +) -> Result, ConstraintConflict> { + if constraints.is_empty() { + return Ok(None); + } + + let mut allow_models: Option> = None; + let mut allow_regions: Option> = None; + let mut allow_sites: Option> = None; + let mut deny_models: BTreeSet = BTreeSet::new(); + let mut tiers: BTreeSet = BTreeSet::new(); + let mut custom: BTreeMap = BTreeMap::new(); + // Start at the least strict and tighten. A restrict with no explicit + // policy still defaults to `Deny` (the parser default), so the fold + // lands on `Deny` in the common case. + let mut on_empty = OnEmpty::Fallback; + + for c in constraints { + intersect_into(&mut allow_models, c.allow_models.as_deref()); + intersect_into(&mut allow_regions, c.allow_regions.as_deref()); + intersect_into(&mut allow_sites, c.allow_sites.as_deref()); + deny_models.extend(c.deny_models.iter().cloned()); + if let Some(tier) = &c.max_cost_tier { + tiers.insert(tier.clone()); + } + for (k, v) in &c.custom { + if let Some(existing) = custom.get(k) { + if existing != v { + return Err(ConstraintConflict { + key: k.clone(), + existing: existing.clone(), + incoming: v.clone(), + }); + } + } else { + custom.insert(k.clone(), v.clone()); + } + } + on_empty = strictest(on_empty, map_on_empty(c.on_empty)); + } + + let folded = CandidateConstraintExtension { + allow_models, + deny_models: deny_models.into_iter().collect(), + allow_regions, + allow_sites, + max_cost_tiers: tiers.into_iter().collect(), + custom, + on_empty, + }; + + if folded.is_empty() { + Ok(None) + } else { + Ok(Some(folded)) + } +} + +/// The stricter of two `on_empty` policies — `Deny` beats `Fallback`. +fn strictest(a: OnEmpty, b: OnEmpty) -> OnEmpty { + match (a, b) { + (OnEmpty::Fallback, OnEmpty::Fallback) => OnEmpty::Fallback, + _ => OnEmpty::Deny, + } +} + +/// Map the apl-core authoring enum to the cpex-core wire enum. Kept +/// explicit (rather than a `From`) because the two live in crates that +/// don't depend on each other. +fn map_on_empty(v: AplOnEmpty) -> OnEmpty { + match v { + AplOnEmpty::Deny => OnEmpty::Deny, + AplOnEmpty::Fallback => OnEmpty::Fallback, + } +} + +/// Intersect `incoming` (a candidate's allow-set, or `None` for "no +/// constraint") into `acc`. `None` is the universe: intersecting with it +/// is a no-op, and the first `Some` seeded into a `None` accumulator +/// becomes the running set. Result is sorted + de-duplicated for a +/// deterministic blob. An empty result (`Some([])`) is retained — it means +/// "no candidate qualifies", which the host resolves via `on_empty`. +fn intersect_into(acc: &mut Option>, incoming: Option<&[String]>) { + let Some(incoming) = incoming else { + return; // no constraint from this restrict — universe, no-op + }; + let incoming: BTreeSet<&String> = incoming.iter().collect(); + match acc { + None => *acc = Some(incoming.into_iter().cloned().collect()), + Some(existing) => existing.retain(|e| incoming.contains(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c() -> CandidateConstraint { + CandidateConstraint::default() + } + fn strs(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + fn fold(cs: &[CandidateConstraint]) -> CandidateConstraintExtension { + fold_candidate_constraints(cs).unwrap().unwrap() + } + + #[test] + fn empty_input_is_none() { + assert_eq!(fold_candidate_constraints(&[]).unwrap(), None); + } + + #[test] + fn single_passes_through_with_default_deny() { + let folded = fold(&[CandidateConstraint { + allow_regions: Some(strs(&["eu"])), + ..c() + }]); + assert_eq!(folded.allow_regions, Some(strs(&["eu"]))); + assert_eq!(folded.on_empty, OnEmpty::Deny); + } + + #[test] + fn allow_sets_intersect() { + let folded = fold(&[ + CandidateConstraint { + allow_models: Some(strs(&["vllm/*", "anthropic/*", "openai/*"])), + ..c() + }, + CandidateConstraint { + allow_models: Some(strs(&["anthropic/*", "openai/*", "cohere/*"])), + ..c() + }, + ]); + assert_eq!(folded.allow_models, Some(strs(&["anthropic/*", "openai/*"]))); + } + + #[test] + fn unconstrained_allow_is_noop() { + let folded = fold(&[ + CandidateConstraint { + allow_regions: Some(strs(&["eu"])), + ..c() + }, + CandidateConstraint { + deny_models: strs(&["openai/*"]), + ..c() + }, + ]); + assert_eq!(folded.allow_regions, Some(strs(&["eu"]))); + assert_eq!(folded.deny_models, strs(&["openai/*"])); + } + + #[test] + fn empty_intersection_retained_for_on_empty() { + let folded = fold(&[ + CandidateConstraint { + allow_regions: Some(strs(&["eu"])), + ..c() + }, + CandidateConstraint { + allow_regions: Some(strs(&["us"])), + ..c() + }, + ]); + assert_eq!(folded.allow_regions, Some(vec![])); + assert!(!folded.is_empty()); + } + + #[test] + fn deny_sets_union() { + let folded = fold(&[ + CandidateConstraint { + deny_models: strs(&["openai/*"]), + ..c() + }, + CandidateConstraint { + deny_models: strs(&["cohere/*", "openai/*"]), + ..c() + }, + ]); + assert_eq!(folded.deny_models, strs(&["cohere/*", "openai/*"])); + } + + #[test] + fn cost_tiers_collect_all_distinct() { + // Option-1 fold: CPEX can't order tiers, so emit every ceiling. + let folded = fold(&[ + CandidateConstraint { + max_cost_tier: Some("cheap".into()), + ..c() + }, + CandidateConstraint { + max_cost_tier: Some("standard".into()), + ..c() + }, + CandidateConstraint { + max_cost_tier: Some("cheap".into()), + ..c() + }, + ]); + assert_eq!(folded.max_cost_tiers, strs(&["cheap", "standard"])); + } + + #[test] + fn custom_union() { + let folded = fold(&[ + CandidateConstraint { + custom: [("gpu".to_string(), "h100".to_string())].into(), + ..c() + }, + CandidateConstraint { + custom: [("tenancy".to_string(), "dedicated".to_string())].into(), + ..c() + }, + ]); + assert_eq!(folded.custom.get("gpu"), Some(&"h100".to_string())); + assert_eq!(folded.custom.get("tenancy"), Some(&"dedicated".to_string())); + } + + #[test] + fn custom_same_key_same_value_ok() { + let folded = fold(&[ + CandidateConstraint { + custom: [("gpu".to_string(), "h100".to_string())].into(), + ..c() + }, + CandidateConstraint { + custom: [("gpu".to_string(), "h100".to_string())].into(), + ..c() + }, + ]); + assert_eq!(folded.custom.get("gpu"), Some(&"h100".to_string())); + } + + #[test] + fn custom_conflict_fails_closed() { + let err = fold_candidate_constraints(&[ + CandidateConstraint { + custom: [("gpu".to_string(), "h100".to_string())].into(), + ..c() + }, + CandidateConstraint { + custom: [("gpu".to_string(), "a100".to_string())].into(), + ..c() + }, + ]) + .unwrap_err(); + assert_eq!( + err, + ConstraintConflict { + key: "gpu".into(), + existing: "h100".into(), + incoming: "a100".into(), + } + ); + } + + #[test] + fn on_empty_strictest_wins() { + let folded = fold(&[ + CandidateConstraint { + allow_regions: Some(strs(&["eu"])), + on_empty: AplOnEmpty::Fallback, + ..c() + }, + CandidateConstraint { + deny_models: strs(&["openai/*"]), + on_empty: AplOnEmpty::Deny, + ..c() + }, + ]); + assert_eq!(folded.on_empty, OnEmpty::Deny); + } + + #[test] + fn all_fallback_stays_fallback() { + let folded = fold(&[ + CandidateConstraint { + allow_regions: Some(strs(&["eu"])), + on_empty: AplOnEmpty::Fallback, + ..c() + }, + CandidateConstraint { + deny_models: strs(&["openai/*"]), + on_empty: AplOnEmpty::Fallback, + ..c() + }, + ]); + assert_eq!(folded.on_empty, OnEmpty::Fallback); + } + + #[test] + fn fold_is_order_independent() { + let a = CandidateConstraint { + allow_models: Some(strs(&["vllm/*", "anthropic/*"])), + deny_models: strs(&["openai/*"]), + ..c() + }; + let b = CandidateConstraint { + allow_models: Some(strs(&["anthropic/*", "cohere/*"])), + max_cost_tier: Some("cheap".into()), + ..c() + }; + assert_eq!( + fold_candidate_constraints(&[a.clone(), b.clone()]).unwrap(), + fold_candidate_constraints(&[b, a]).unwrap() + ); + } +} diff --git a/crates/apl-cpex/src/lib.rs b/crates/apl-cpex/src/lib.rs index 4afe5cf3..b0924e0f 100644 --- a/crates/apl-cpex/src/lib.rs +++ b/crates/apl-cpex/src/lib.rs @@ -31,6 +31,8 @@ // MessageView attributes. See the APL implementation memory's // "list-with-matchers" deferred item. +pub mod attribute_source; +pub mod candidate_constraint; pub mod cmf_invoker; pub mod delegation_invoker; pub mod dispatch_plan; @@ -42,6 +44,8 @@ pub mod session_resolver; pub mod session_store; pub mod visitor; +pub use attribute_source::{merge_attribute_docs, FileAttributeSource}; +pub use candidate_constraint::{fold_candidate_constraints, ConstraintConflict}; pub use cmf_invoker::CmfPluginInvoker; pub use delegation_invoker::DelegationPluginInvoker; pub use dispatch_plan::{DispatchCache, RouteDispatchPlan, RoutePluginEntry}; diff --git a/crates/apl-cpex/src/route_handler.rs b/crates/apl-cpex/src/route_handler.rs index 1048b1cd..ffe791b5 100644 --- a/crates/apl-cpex/src/route_handler.rs +++ b/crates/apl-cpex/src/route_handler.rs @@ -42,7 +42,10 @@ use cpex_core::registry::AnyHookHandler; use apl_cmf::{extract_args, extract_result, BagBuilder}; use apl_core::evaluator::Decision; +use apl_core::AttributeTree; use apl_core::plugin_decl::PluginRegistry; + +use crate::candidate_constraint::fold_candidate_constraints; use apl_core::route::{evaluate_post, evaluate_pre, RoutePayload}; use apl_core::rules::CompiledRoute; use apl_core::step::PdpResolver; @@ -86,6 +89,10 @@ pub struct AplRouteHandler { /// install resolvers via [`Self::with_pdp`] or /// [`Self::with_pdp_router`]. pdp: Arc, + /// Static `data.*` attribute tree, flattened into every request's + /// bag. Shared `Arc` (the visitor hands the same tree to every + /// handler); empty by default when no source was configured. + attribute_tree: Arc, } impl AplRouteHandler { @@ -109,9 +116,18 @@ impl AplRouteHandler { session_store, manager, pdp: Arc::new(PdpRouter::new()), + attribute_tree: Arc::new(apl_core::AttributeTree::empty()), } } + /// Install the static `data.*` attribute tree flattened into every + /// request's bag. Defaults to empty; the visitor sets it from the + /// configured [`AttributeSource`](apl_core::AttributeSource). + pub fn with_attribute_tree(mut self, tree: Arc) -> Self { + self.attribute_tree = tree; + self + } + /// Install a `PdpResolver`. Pass a [`PdpRouter`] when the host needs /// to support multiple dialects (Cedar + OPA + NeMo) on the same /// route — the router dispatches each `pdp(...)` step by dialect. @@ -240,6 +256,7 @@ impl AnyHookHandler for AplRouteHandler { let mut bag = BagBuilder::new() .with_extensions(&post_extensions) .with_route_key(&self.route.route_key) + .with_data(&self.attribute_tree) .build(); // Build `RoutePayload.args` from the message. Per-content shape: @@ -343,6 +360,17 @@ impl AnyHookHandler for AplRouteHandler { // (see TS2). No-op when no taints emitted. invoker.apply_session_taints(&decision.taints).await; + // R2: fold this request's `restrict` constraints into one typed + // `CandidateConstraintExtension`. A custom-label contradiction + // (two restricts requiring the same label to differ) cannot be + // honored by any backend, so it fails closed below (mirrors the + // persist-failure handling). `Ok(None)` = no restrict fired. + let (folded_constraint, constraint_conflict) = + match fold_candidate_constraints(&decision.constraints) { + Ok(folded) => (folded, None), + Err(e) => (None, Some(e)), + }; + // Commit any session-scoped labels accumulated during this // request. No-op when there was no session id. The result is // folded into the decision below (R18) — captured here because @@ -402,12 +430,27 @@ impl AnyHookHandler for AplRouteHandler { None }; - let modified_extensions = if extensions_changed(extensions, &final_extensions) { + let mut modified_extensions = if extensions_changed(extensions, &final_extensions) { Some(final_extensions.cow_copy()) } else { None }; + // R2: write the folded constraint into the typed + // `candidate_constraint` extension slot so the host router reads + // it TYPED off `PipelineResult.modified_extensions` — the same + // in-process, type-shared channel `raw_credentials.delegated_tokens` + // rides (design §2.5). `extensions_changed` doesn't track this + // slot, so we force `modified_extensions` to `Some` here to + // guarantee the constraint reaches the executor's merge. + if let Some(constraint) = folded_constraint { + let mut owned = modified_extensions + .take() + .unwrap_or_else(|| final_extensions.cow_copy()); + owned.candidate_constraint = Some(constraint); + modified_extensions = Some(owned); + } + let (mut continue_processing, mut violation) = match decision.decision { Decision::Allow => (true, None), Decision::Deny { @@ -451,6 +494,26 @@ impl AnyHookHandler for AplRouteHandler { } } + // R2 fail-closed: a `restrict` custom-label contradiction means no + // backend can satisfy the request's routing constraints. Deny + // rather than emit an unhonorable constraint. On an already-denied + // request, keep the original policy attribution (same precedence + // as the persist-failure block above). + if let Some(conflict) = constraint_conflict { + tracing::warn!( + route = %self.route.route_key, + error = %conflict, + "restrict constraints conflict; failing request closed" + ); + if continue_processing { + continue_processing = false; + violation = Some(PluginViolation::new( + "policy.restrict_conflict", + conflict.to_string(), + )); + } + } + Ok(Box::new(ErasedResultFields { continue_processing, modified_payload, diff --git a/crates/apl-cpex/src/visitor.rs b/crates/apl-cpex/src/visitor.rs index a1ebf26b..b64334e3 100644 --- a/crates/apl-cpex/src/visitor.rs +++ b/crates/apl-cpex/src/visitor.rs @@ -61,6 +61,7 @@ use cpex_core::manager::PluginManager; use cpex_core::plugin::PluginConfig; use cpex_core::visitor::{ConfigVisitor, VisitorError}; +use apl_core::attribute_source::{AttributeSource, AttributeTree}; use apl_core::parser::compile_policy_block_value; use apl_core::plugin_decl::{PluginDeclaration, PluginRegistry}; use apl_core::rules::CompiledRoute; @@ -137,6 +138,13 @@ pub struct AplConfigVisitor { /// single-threaded config walk — never on the request hot path, /// where each handler holds its own cloned `Arc`. session_store: RwLock>, + /// Static `data.*` attribute tree, shared into every installed + /// handler. Set once before the config walk via + /// [`Self::set_attribute_tree`]; defaults to empty. Behind a `RwLock` + /// only because it's set after construction — like `session_store`, + /// it's touched only during the single-threaded config walk, never on + /// the request hot path (handlers hold their own cloned `Arc`). + attribute_tree: RwLock>, manager: Weak, /// Baseline capabilities granted to every synthetic `AplRouteHandler` /// the visitor installs. Unioned with the per-route plugin @@ -166,6 +174,7 @@ impl AplConfigVisitor { state: RwLock::new(VisitorState::default()), dispatch_cache, session_store: RwLock::new(session_store), + attribute_tree: RwLock::new(Arc::new(AttributeTree::empty())), manager, base_capabilities: default_base_capabilities(), pdp_factories: HashMap::new(), @@ -183,6 +192,17 @@ impl AplConfigVisitor { state.pdp_router.register(resolver); } + /// Install the static `data.*` attribute tree. Call after + /// `register_apl` and **before** `load_config_yaml` (handlers capture + /// the tree during the config walk). Load it from any + /// [`AttributeSource`](apl_core::AttributeSource) — e.g. + /// `FileAttributeSource::new(paths).load()?` — or hand-build one. + /// Replacing a previously-set tree is allowed (last set wins). + pub fn set_attribute_tree(&self, tree: AttributeTree) { + let mut slot = self.attribute_tree.write().unwrap_or_else(|p| p.into_inner()); + *slot = Arc::new(tree); + } + /// Register a PDP factory by its `kind()`. Called during /// `register_apl` setup; the visitor uses these to instantiate /// resolvers from `global.apl.pdp[]` config blocks. @@ -237,6 +257,56 @@ impl AplConfigVisitor { Ok(()) } + /// Load the static `data.*` tree from a `global.apl.attribute_files` + /// list and install it. Paths resolve relative to the process CWD + /// (the config is loaded from a string, so there is no config-file + /// directory to anchor to). Fail-fast: a missing file or a same-leaf + /// merge conflict aborts config load. + /// + /// Precedence: a tree injected via + /// [`AplConfigVisitor::set_attribute_tree`] before the config walk + /// wins — declarative `attribute_files` is skipped when a non-empty + /// tree is already present (injected > attribute_files > none). + fn build_attribute_tree_from_config( + &self, + entries: &serde_yaml::Sequence, + ) -> Result<(), VisitorError> { + { + let current = self + .attribute_tree + .read() + .unwrap_or_else(|p| p.into_inner()); + if !current.is_empty() { + tracing::info!( + "global.apl.attribute_files present but an attribute tree was already \ + injected via set_attribute_tree — keeping the injected tree \ + (injected > attribute_files)" + ); + return Ok(()); + } + } + + let mut paths = Vec::with_capacity(entries.len()); + for (i, entry) in entries.iter().enumerate() { + let s = entry.as_str().ok_or_else(|| { + format!("global.apl.attribute_files[{}] must be a string path", i) + })?; + paths.push(std::path::PathBuf::from(s)); + } + if paths.is_empty() { + return Ok(()); + } + + let tree = crate::attribute_source::FileAttributeSource::new(paths) + .load() + .map_err(|e| format!("global.apl.attribute_files failed to load: {}", e))?; + *self + .attribute_tree + .write() + .unwrap_or_else(|p| p.into_inner()) = Arc::new(tree); + Ok(()) + } + /// Replace the baseline capability set granted to every installed /// `AplRouteHandler`. Default covers read-only attributes APL /// predicates commonly touch (subject, role, labels, delegation, @@ -380,6 +450,17 @@ impl ConfigVisitor for AplConfigVisitor { self.build_session_store_from_config(block)?; } + // Process an optional `global.apl.attribute_files` list: load + + // merge the static `data.*` tree before `visit_route` clones it + // into handlers. A tree already injected via `set_attribute_tree` + // takes precedence (injected > attribute_files > none). + if let Some(files) = apl_block.get("attribute_files") { + let entries = files.as_sequence().ok_or_else(|| { + "global.apl.attribute_files must be a list of file paths".to_string() + })?; + self.build_attribute_tree_from_config(entries)?; + } + // The `pdp:` / `session_store:` sub-keys aren't APL DSL fields; // strip them before handing the block to // `compile_policy_block_value` so the compiler doesn't see unknown @@ -587,6 +668,14 @@ impl ConfigVisitor for AplConfigVisitor { .unwrap_or_else(|p| p.into_inner()) .clone(); + // Snapshot the static attribute tree (set before the walk). + // Each handler captures its own `Arc` clone — shared, not copied. + let attribute_tree = self + .attribute_tree + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + // Install Pre + Post handlers. Each handler instance is bound to // ONE phase so the executor can pick the right entry-point off // the (entity_type, entity_name, scope, hook_name) key. @@ -604,6 +693,7 @@ impl ConfigVisitor for AplConfigVisitor { &self.manager, Some(Arc::clone(&pdp_router_arc)), &self.base_capabilities, + Arc::clone(&attribute_tree), ); install_handler( mgr, @@ -619,6 +709,7 @@ impl ConfigVisitor for AplConfigVisitor { &self.manager, Some(Arc::clone(&pdp_router_arc)), &self.base_capabilities, + attribute_tree, ); } @@ -645,6 +736,7 @@ fn install_handler( manager: &Weak, pdp: Option>, base_capabilities: &std::collections::HashSet, + attribute_tree: Arc, ) { // Capability gating at the synthetic-handler boundary. cpex-core's // executor calls `filter_extensions(&ext, &caps)` before every @@ -686,7 +778,8 @@ fn install_handler( Arc::clone(dispatch_cache), Arc::clone(session_store), manager.clone(), - ); + ) + .with_attribute_tree(attribute_tree); if let Some(pdp) = pdp { handler = handler.with_pdp(pdp); } @@ -784,7 +877,7 @@ fn warn_unreferenced_plugin_overrides(route: &CompiledRoute) { /// `compile_policy_block_value`, which doesn't model them. Kept as a single /// source of truth shared by [`strip_non_dsl_keys`] and /// [`warn_if_global_only_key_at_nonglobal_scope`]. -const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 2] = ["pdp", "session_store"]; +const GLOBAL_ONLY_NON_DSL_KEYS: [&str; 3] = ["pdp", "session_store", "attribute_files"]; /// Legacy APL config keys, mapped to their replacements. The flat-key path /// in [`apl_subblock`] only copies recognized keys into the synthetic block, diff --git a/crates/apl-cpex/tests/attribute_source_e2e.rs b/crates/apl-cpex/tests/attribute_source_e2e.rs new file mode 100644 index 00000000..d341aa94 --- /dev/null +++ b/crates/apl-cpex/tests/attribute_source_e2e.rs @@ -0,0 +1,400 @@ +// Location: ./crates/apl-cpex/tests/attribute_source_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end: a static `data.*` attribute tree, set on the visitor before +// the config walk, flows into every request's bag so policy predicates can +// read it. Covers R3 of docs/apl-restrict-effect-design.md — the load → +// bag path (static dot-path references; R3b interpolation is separate). + +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::{ + CandidateConstraintExtension, Extensions, MetaExtension, SecurityExtension, SubjectExtension, +}; +use cpex_core::manager::PluginManager; + +use apl_cpex::{merge_attribute_docs, register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +fn cmf_payload(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +/// Build a manager, set the given `data.*` tree, load `yaml`, initialize. +async fn build_manager_with_data(yaml: &str, data_docs_yaml: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + let visitor = register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + session_store_factories: Vec::new(), + base_capabilities: None, + }, + ); + + // Load the tree from an in-memory "file" and install it BEFORE the + // config walk (handlers capture the tree during load_config_yaml). + let doc: serde_json::Value = serde_yaml::from_str(data_docs_yaml).unwrap(); + let tree = merge_attribute_docs([("attrs.yaml".to_string(), doc)]).unwrap(); + visitor.set_attribute_tree(tree); + + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +async fn invoke_tool(mgr: &Arc, tool: &str) -> bool { + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool(tool))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + result.continue_processing +} + +const ROUTE: &str = r#" +routes: + - tool: fetch + apl: + pre_invocation: + - "data.org.default_region == 'eu': deny('eu-restricted')" +"#; + +/// The predicate reads `data.org.default_region`; with the tree saying +/// `eu`, the deny fires. +#[tokio::test] +async fn policy_reads_data_namespace_and_denies() { + let data = "data:\n org:\n default_region: eu\n"; + let mgr = build_manager_with_data(ROUTE, data).await; + assert!( + !invoke_tool(&mgr, "fetch").await, + "data.org.default_region == 'eu' should deny" + ); +} + +/// Same route, different tree value — the predicate is false, request +/// continues. Proves the value actually comes from the tree. +#[tokio::test] +async fn policy_reads_data_namespace_and_allows() { + let data = "data:\n org:\n default_region: us\n"; + let mgr = build_manager_with_data(ROUTE, data).await; + assert!( + invoke_tool(&mgr, "fetch").await, + "data.org.default_region == 'us' should not trip the eu deny" + ); +} + +/// No tree set at all → `data.*` keys are simply absent; an equality +/// predicate against a missing key is false, so the request continues. +#[tokio::test] +async fn missing_data_key_is_absent_not_error() { + // Build without any data tree (empty docs). + let mgr = build_manager_with_data(ROUTE, "data: {}\n").await; + assert!( + invoke_tool(&mgr, "fetch").await, + "absent data.* key must not trip the deny" + ); +} + +// ----- R3b: interpolation end-to-end ----- + +/// Invoke with a subject id so `subject.id` lands in the bag. +async fn invoke_as_subject(mgr: &Arc, tool: &str, subject_id: &str) -> bool { + let mut subject = SubjectExtension::default(); + subject.id = Some(subject_id.to_string()); + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool(tool))), + security: Some(Arc::new(SecurityExtension { + subject: Some(subject), + ..Default::default() + })), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + result.continue_processing +} + +const INTERP_ROUTE: &str = r#" +routes: + - tool: infer + apl: + pre_invocation: + - "data.agents[subject.id].region == 'eu': deny('agent pinned to eu')" +"#; + +const AGENTS_DATA: &str = r#" +data: + agents: + eu-bot: { region: eu } + us-bot: { region: us } +"#; + +/// The request's `subject.id` indexes the data tree at eval time: +/// `eu-bot` resolves to `data.agents.eu-bot.region == eu` → deny. +#[tokio::test] +async fn interpolation_resolves_subject_id_end_to_end() { + let mgr = build_manager_with_data(INTERP_ROUTE, AGENTS_DATA).await; + assert!( + !invoke_as_subject(&mgr, "infer", "eu-bot").await, + "eu-bot resolves to region=eu → deny" + ); +} + +/// Same route + tree, different caller → different resolved path → allow. +#[tokio::test] +async fn interpolation_different_subject_allows() { + let mgr = build_manager_with_data(INTERP_ROUTE, AGENTS_DATA).await; + assert!( + invoke_as_subject(&mgr, "infer", "us-bot").await, + "us-bot resolves to region=us → the eu deny does not fire" + ); +} + +// ----- data.* referenced as a restrict field value ----- + +/// Invoke as a subject and return the emitted candidate constraint, if any. +async fn constraint_as_subject( + mgr: &Arc, + tool: &str, + subject_id: &str, +) -> Option { + let mut subject = SubjectExtension::default(); + subject.id = Some(subject_id.to_string()); + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool(tool))), + security: Some(Arc::new(SecurityExtension { + subject: Some(subject), + ..Default::default() + })), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + result + .modified_extensions + .and_then(|e| e.candidate_constraint.as_ref().map(|a| (**a).clone())) +} + +const REF_ROUTE: &str = r#" +routes: + - tool: infer + apl: + pre_invocation: + - restrict: + allow_models: "data.agents[subject.id].allowed_models" +"#; + +const REF_DATA: &str = r#" +data: + agents: + support-bot: { allowed_models: ["vllm/*"] } + research-bot: { allowed_models: ["anthropic/*", "vllm/*"] } +"#; + +/// One `restrict` rule, per-caller value: the `data.*` reference resolves +/// each agent's own allow-list from the static tree at request time. +#[tokio::test] +async fn restrict_field_reference_resolves_per_caller() { + let mgr = build_manager_with_data(REF_ROUTE, REF_DATA).await; + + let support = constraint_as_subject(&mgr, "infer", "support-bot") + .await + .expect("support-bot constraint"); + assert_eq!(support.allow_models, Some(vec!["vllm/*".to_string()])); + + let research = constraint_as_subject(&mgr, "infer", "research-bot") + .await + .expect("research-bot constraint"); + assert_eq!( + research.allow_models, + Some(vec!["anthropic/*".to_string(), "vllm/*".to_string()]) + ); +} + +/// An agent absent from the tree resolves to an empty allow-list — a real +/// (impossible) constraint that fails closed via `on_empty`, never an +/// unconstrained pass. +#[tokio::test] +async fn restrict_field_reference_absent_agent_is_empty() { + let mgr = build_manager_with_data(REF_ROUTE, REF_DATA).await; + let c = constraint_as_subject(&mgr, "infer", "unknown-bot") + .await + .expect("constraint still emitted"); + assert_eq!(c.allow_models, Some(vec![])); +} + +// ----- Declarative `global.apl.attribute_files` ----- + +fn default_opts() -> AplOptions { + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + session_store_factories: Vec::new(), + base_capabilities: None, + } +} + +/// Write attribute files to a fresh temp dir; returns the dir + the paths. +fn write_attr_files(tag: &str, files: &[(&str, &str)]) -> (std::path::PathBuf, Vec) { + let dir = std::env::temp_dir().join(format!("apl_decl_{}_{}", tag, std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths = files + .iter() + .map(|(name, body)| { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p + }) + .collect(); + (dir, paths) +} + +/// `global.apl.attribute_files` loads the tree during the config walk — +/// no `set_attribute_tree` call — and it flows into the bag. +#[tokio::test] +async fn declarative_attribute_files_load_into_bag() { + let (dir, paths) = write_attr_files("load", &[("org.yaml", "data:\n org:\n default_region: eu\n")]); + + let yaml = format!( + r#" +global: + apl: + attribute_files: + - {path} +routes: + - tool: fetch + apl: + pre_invocation: + - "data.org.default_region == 'eu': deny('eu-restricted')" +"#, + path = paths[0].display() + ); + + let mgr = Arc::new(PluginManager::default()); + register_apl(&mgr, default_opts()); + mgr.load_config_yaml(&yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + assert!( + !invoke_tool(&mgr, "fetch").await, + "declaratively-loaded data.org.default_region == 'eu' should deny" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// Multiple files merge (disjoint subtrees combine). +#[tokio::test] +async fn declarative_multiple_files_merge() { + let (dir, paths) = write_attr_files( + "merge", + &[ + ("org.yaml", "data:\n org:\n default_region: us\n"), + ("agents.yaml", "data:\n agents:\n eu-bot:\n region: eu\n"), + ], + ); + + let yaml = format!( + r#" +global: + apl: + attribute_files: + - {a} + - {b} +routes: + - tool: infer + apl: + pre_invocation: + - "data.agents[subject.id].region == 'eu': deny('pinned')" +"#, + a = paths[0].display(), + b = paths[1].display() + ); + + let mgr = Arc::new(PluginManager::default()); + register_apl(&mgr, default_opts()); + mgr.load_config_yaml(&yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + assert!(!invoke_as_subject(&mgr, "infer", "eu-bot").await, "eu-bot → deny"); + std::fs::remove_dir_all(&dir).ok(); +} + +/// An injected tree (set_attribute_tree) beats declarative attribute_files. +#[tokio::test] +async fn injected_tree_beats_declarative_files() { + let (dir, paths) = write_attr_files("prec", &[("org.yaml", "data:\n org:\n default_region: eu\n")]); + + let yaml = format!( + r#" +global: + apl: + attribute_files: + - {path} +routes: + - tool: fetch + apl: + pre_invocation: + - "data.org.default_region == 'eu': deny('eu-restricted')" +"#, + path = paths[0].display() + ); + + let mgr = Arc::new(PluginManager::default()); + let visitor = register_apl(&mgr, default_opts()); + // Inject a tree saying `us` — must win over the file's `eu`. + let doc: serde_json::Value = + serde_yaml::from_str("data:\n org:\n default_region: us\n").unwrap(); + visitor.set_attribute_tree(merge_attribute_docs([("inj".to_string(), doc)]).unwrap()); + mgr.load_config_yaml(&yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + + assert!( + invoke_tool(&mgr, "fetch").await, + "injected tree (region=us) must win over attribute_files (region=eu)" + ); + std::fs::remove_dir_all(&dir).ok(); +} + +/// A missing attribute file fails config load (fail-fast). +#[tokio::test] +async fn missing_attribute_file_fails_config_load() { + let yaml = r#" +global: + apl: + attribute_files: + - /no/such/attrs.yaml +routes: + - tool: fetch + apl: + pre_invocation: + - "require(authenticated)" +"#; + let mgr = Arc::new(PluginManager::default()); + register_apl(&mgr, default_opts()); + assert!( + mgr.load_config_yaml(yaml).is_err(), + "a missing attribute file must fail config load" + ); +} diff --git a/crates/apl-cpex/tests/restrict_e2e.rs b/crates/apl-cpex/tests/restrict_e2e.rs new file mode 100644 index 00000000..a4b699f4 --- /dev/null +++ b/crates/apl-cpex/tests/restrict_e2e.rs @@ -0,0 +1,190 @@ +// Location: ./crates/apl-cpex/tests/restrict_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// End-to-end: an APL route with `restrict` effects, driven through the +// real PluginManager + APL visitor, must fold the emitted constraints +// and surface them on the typed `candidate_constraint` extension slot +// that the host router reads off `PipelineResult.modified_extensions`. +// A `custom`-label contradiction must fail closed. Covers R2 of +// docs/apl-restrict-effect-design.md. + +use std::sync::Arc; + +use cpex_core::cmf::enums::Role; +use cpex_core::cmf::{CmfHook, Message, MessagePayload}; +use cpex_core::extensions::{CandidateConstraintExtension, Extensions, MetaExtension, OnEmpty}; +use cpex_core::manager::PluginManager; + +use apl_cpex::{register_apl, AplOptions, DispatchCache, MemorySessionStore}; + +fn cmf_payload(text: &str) -> MessagePayload { + MessagePayload { + message: Message::text(Role::User, text), + } +} + +fn meta_for_tool(name: &str) -> MetaExtension { + let mut meta = MetaExtension::default(); + meta.entity_type = Some("tool".to_string()); + meta.entity_name = Some(name.to_string()); + meta +} + +/// Build a manager wired with the APL visitor from `yaml`. `restrict` +/// needs no plugins of its own, so no factories are registered. +async fn build_manager(yaml: &str) -> Arc { + let mgr = Arc::new(PluginManager::default()); + register_apl( + &mgr, + AplOptions { + dispatch_cache: Arc::new(DispatchCache::new()), + session_store: Arc::new(MemorySessionStore::new()), + pdps: Vec::new(), + pdp_factories: Vec::new(), + session_store_factories: Vec::new(), + base_capabilities: None, + }, + ); + mgr.load_config_yaml(yaml).expect("load_config_yaml"); + mgr.initialize().await.expect("initialize"); + mgr +} + +/// Read the folded constraint off the merged extensions' typed slot. +fn constraint(ext: &Extensions) -> Option { + ext.candidate_constraint.as_ref().map(|arc| (**arc).clone()) +} + +/// A single unconditional `restrict` emits its constraint on +/// `cpex.candidate_constraint`, and the request still continues (restrict +/// never denies). +#[tokio::test] +async fn restrict_emits_constraint_on_side_channel() { + const YAML: &str = r#" +routes: + - tool: infer + apl: + pre_invocation: + - restrict: { allow_regions: [eu] } +"#; + let mgr = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("infer"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + + assert!( + result.continue_processing, + "restrict never denies: violation = {:?}", + result.violation + ); + let merged = result + .modified_extensions + .expect("restrict must surface a constraint via modified_extensions"); + let c = constraint(&merged).expect("candidate_constraint slot must be set"); + assert_eq!(c.allow_regions.as_deref(), Some(&["eu".to_string()][..])); + assert_eq!(c.on_empty, OnEmpty::Deny); +} + +/// Two restricts in the same phase fold: allow-sets intersect, deny-sets +/// union, and the blob is the single folded result. +#[tokio::test] +async fn two_restricts_fold_into_one_blob() { + const YAML: &str = r#" +routes: + - tool: infer + apl: + pre_invocation: + - restrict: { allow_models: ["vllm/*", "anthropic/*"], deny_models: ["openai/*"] } + - restrict: { allow_models: ["anthropic/*", "cohere/*"] } +"#; + let mgr = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("infer"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + + assert!(result.continue_processing); + let merged = result.modified_extensions.expect("modified_extensions"); + let c = constraint(&merged).expect("candidate_constraint slot"); + assert_eq!(c.allow_models.as_deref(), Some(&["anthropic/*".to_string()][..])); // intersection + assert_eq!(c.deny_models, vec!["openai/*".to_string()]); // union + assert_eq!(c.on_empty, OnEmpty::Deny); +} + +/// A `when`-gated restrict that does NOT fire (gate false) emits no blob. +#[tokio::test] +async fn gated_restrict_absent_when_gate_false() { + const YAML: &str = r#" +routes: + - tool: infer + apl: + pre_invocation: + - when: "session.labels contains 'eu_resident'" + do: + - restrict: { allow_regions: [eu] } +"#; + let mgr = build_manager(YAML).await; + + // No `eu_resident` label on the session → gate is false. + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("infer"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + + assert!(result.continue_processing); + // Either no modified_extensions at all, or one with an empty slot. + let has_constraint = result + .modified_extensions + .as_ref() + .and_then(constraint) + .is_some(); + assert!( + !has_constraint, + "gate was false — no constraint should be emitted" + ); +} + +/// Two restricts requiring the same `custom` label to differ is an +/// unsatisfiable contradiction — the request fails closed with a +/// `policy.restrict_conflict` violation. +#[tokio::test] +async fn conflicting_custom_labels_fail_closed() { + const YAML: &str = r#" +routes: + - tool: infer + apl: + pre_invocation: + - restrict: { custom: { gpu: h100 } } + - restrict: { custom: { gpu: a100 } } +"#; + let mgr = build_manager(YAML).await; + + let ext = Extensions { + meta: Some(Arc::new(meta_for_tool("infer"))), + ..Default::default() + }; + let (result, _bg) = mgr + .invoke_named::("cmf.tool_pre_invoke", cmf_payload("hi"), ext, None) + .await; + + assert!( + !result.continue_processing, + "contradictory custom labels must fail closed" + ); + let violation = result.violation.expect("conflict must surface a violation"); + assert_eq!(violation.code, "policy.restrict_conflict"); +} diff --git a/crates/cpex-core/src/extensions/container.rs b/crates/cpex-core/src/extensions/container.rs index 51da6a81..c238b02e 100644 --- a/crates/cpex-core/src/extensions/container.rs +++ b/crates/cpex-core/src/extensions/container.rs @@ -27,6 +27,7 @@ use super::meta::MetaExtension; use super::provenance::ProvenanceExtension; use super::raw_credentials::RawCredentialsExtension; use super::request::RequestExtension; +use super::routing::CandidateConstraintExtension; use super::security::SecurityExtension; // --------------------------------------------------------------------------- @@ -63,6 +64,13 @@ pub struct Extensions { #[serde(default, skip_serializing_if = "Option::is_none")] pub security: Option>, + /// Backend candidate constraint emitted by APL `restrict` effects + /// (frozen as Arc — cloned out in OwnedExtensions). The policy + /// engine writes it; the host router reads it typed to narrow its + /// candidate set. A routing directive, never an access decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub candidate_constraint: Option>, + /// Delegation chain (frozen as Arc). #[serde(default, skip_serializing_if = "Option::is_none")] pub delegation: Option>, @@ -126,6 +134,7 @@ impl Clone for Extensions { agent: self.agent.clone(), http: self.http.clone(), security: self.security.clone(), + candidate_constraint: self.candidate_constraint.clone(), delegation: self.delegation.clone(), raw_credentials: self.raw_credentials.clone(), mcp: self.mcp.clone(), @@ -178,6 +187,10 @@ impl Extensions { // Mutable/monotonic/guarded — cloned out of Arc into owned http: self.http.as_ref().map(|arc| Guarded::new((**arc).clone())), security: self.security.as_ref().map(|arc| (**arc).clone()), + candidate_constraint: self + .candidate_constraint + .as_ref() + .map(|arc| (**arc).clone()), delegation: self.delegation.as_ref().map(|arc| (**arc).clone()), custom: self.custom.as_ref().map(|arc| (**arc).clone()), @@ -241,6 +254,7 @@ impl Extensions { pub fn merge_owned(&mut self, owned: OwnedExtensions) { self.http = owned.http.map(|g| Arc::new(g.into_inner())); self.security = owned.security.map(Arc::new); + self.candidate_constraint = owned.candidate_constraint.map(Arc::new); self.delegation = owned.delegation.map(Arc::new); self.custom = owned.custom.map(Arc::new); // `raw_credentials` is shared by Arc in `OwnedExtensions` — @@ -295,6 +309,7 @@ pub struct OwnedExtensions { // Mutable/monotonic/guarded — owned, modifiable pub http: Option>, pub security: Option, + pub candidate_constraint: Option, pub delegation: Option, pub custom: Option>, diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 8bed45a2..5a2a948a 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -295,6 +295,11 @@ pub fn filter_extensions(extensions: &Extensions, capabilities: &HashSet mcp: extensions.mcp.clone(), meta: extensions.meta.clone(), custom: extensions.custom.clone(), + // Pass through like `custom` (ungated in v1): the APL engine + // writes this output slot, and passing it through keeps a later + // plugin's `merge_owned` from clobbering it back to `None`. + // Capability-gating the write is future work. + candidate_constraint: extensions.candidate_constraint.clone(), ..Default::default() }; diff --git a/crates/cpex-core/src/extensions/mod.rs b/crates/cpex-core/src/extensions/mod.rs index 69a57bf3..52fe11f8 100644 --- a/crates/cpex-core/src/extensions/mod.rs +++ b/crates/cpex-core/src/extensions/mod.rs @@ -27,6 +27,7 @@ pub mod monotonic; pub mod provenance; pub mod raw_credentials; pub mod request; +pub mod routing; pub mod security; pub mod tiers; @@ -52,6 +53,7 @@ pub use raw_credentials::{ TokenKind, TokenRole, }; pub use request::RequestExtension; +pub use routing::{BackendLabels, CandidateConstraintExtension, OnEmpty}; pub use security::{ ClientExtension, ClientTrustLevel, DataPolicy, ObjectSecurityProfile, RetentionPolicy, SecurityExtension, SubjectExtension, SubjectType, WorkloadIdentity, diff --git a/crates/cpex-core/src/extensions/routing.rs b/crates/cpex-core/src/extensions/routing.rs new file mode 100644 index 00000000..e205d8ce --- /dev/null +++ b/crates/cpex-core/src/extensions/routing.rs @@ -0,0 +1,487 @@ +// Location: ./crates/cpex-core/src/extensions/routing.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// CandidateConstraintExtension — the backend candidate constraint the +// APL `restrict` effect produces, carried as a typed extension slot. +// +// The policy engine (apl-cpex) folds every `restrict` a request emitted +// into one of these and writes it into `Extensions.candidate_constraint`. +// The host router/load-balancer (Praxis's policy filter) reads it TYPED +// off `PipelineResult.modified_extensions` — the same in-process, +// type-shared channel `raw_credentials.delegated_tokens` rides — and +// narrows its candidate set accordingly. It is a routing directive, not +// an access decision: it never picks a backend and never allows/denies. +// See docs/apl-restrict-effect-design.md. + +use std::collections::{BTreeMap, HashMap}; + +use serde::{Deserialize, Serialize}; + +/// What the host does when the constraint prunes every candidate. +/// +/// The host — not CPEX — makes the empty decision, since only it knows +/// which backends are actually reachable/healthy at selection time. The +/// choice rides out with the constraint. Fail-closed by default. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnEmpty { + /// Reject the request (fail-closed). Correct for hard constraints + /// like data sovereignty — never silently escape the region. + #[default] + Deny, + /// Fall back to the unconstrained candidate set. Explicit opt-in for + /// "prefer, but don't fail" cases. + Fallback, +} + +/// The folded, request-level backend constraint emitted by APL `restrict` +/// effects. One per request; the policy engine intersects every restrict +/// that fired into this single value (allow-sets narrow, deny/custom +/// grow, tier ceilings collect, `on_empty` takes the strictest). All +/// monotone — it can only shrink the eligible set. +/// +/// Field shape mirrors the authoring form (`apl_core::CandidateConstraint`) +/// with one divergence: `max_cost_tier` (one ceiling per restrict) folds +/// to `max_cost_tiers` (the *set* of ceilings). CPEX cannot order tier +/// names — that ordering is host-owned — so it emits every ceiling and +/// the host requires `cost_tier ≤ all of them`, which is `≤ min` once the +/// host applies its own tier order. See the design doc §2.5.1. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)] +pub struct CandidateConstraintExtension { + /// Candidate `model` must be in this set (glob-matched). `None` = no + /// model allow-list. `Some(empty)` means nothing qualifies → the + /// host's `on_empty` fires. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_models: Option>, + + /// Candidate `model` must NOT match any of these (glob-matched). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub deny_models: Vec, + + /// Candidate `region` must be in this set (equality). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_regions: Option>, + + /// Candidate `site` must be in this set (equality). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_sites: Option>, + + /// Every `cost_tier` ceiling emitted, de-duplicated. The host + /// requires `cost_tier ≤` **all** of these (== `≤ min` under the + /// host's tier order). CPEX never orders them itself. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub max_cost_tiers: Vec, + + /// Arbitrary backend labels the candidate must carry, matched by + /// plain equality (k8s `nodeSelector` semantics). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub custom: BTreeMap, + + /// What the host does when the constraint leaves no eligible backend. + #[serde(default)] + pub on_empty: OnEmpty, +} + +impl CandidateConstraintExtension { + /// True when nothing is constrained (every field unset). `on_empty` + /// is ignored — on its own it restricts nothing. The policy engine + /// skips writing an empty constraint into the extension slot. + pub fn is_empty(&self) -> bool { + self.allow_models.is_none() + && self.deny_models.is_empty() + && self.allow_regions.is_none() + && self.allow_sites.is_none() + && self.max_cost_tiers.is_empty() + && self.custom.is_empty() + } + + /// Does `backend` satisfy this constraint? This is the executable half + /// of the seam contract (design §2.6) — the host router (Praxis) calls + /// it per candidate to prune its eligible set, instead of + /// reimplementing the matcher. All field semantics live here, once: + /// + /// - `allow_models` / `deny_models` — **glob** against the `model` + /// label (`anthropic/*`), not equality. + /// - `allow_regions` / `allow_sites` — set membership (equality) on + /// the `region` / `site` labels. + /// - `max_cost_tiers` — the `cost_tier` label must be `≤` **every** + /// ceiling. `tier_rank` maps a tier name to its order (lower = + /// cheaper); **the host owns that ordering** — the algorithm is + /// ours, the vocabulary is theirs. `≤ every ceiling` is `≤ min`. + /// - `custom` — the backend must carry every label with an equal value. + /// + /// **Fail-closed** throughout: a constrained attribute the backend + /// lacks (no `model` label under an `allow_models`, no `cost_tier` + /// under a ceiling, an unrankable tier) excludes the backend rather + /// than matching it. An empty constraint accepts everything. + /// + /// This is per-backend and does **not** apply `on_empty` — that is the + /// caller's decision once it knows the surviving set is empty (only + /// the router knows reachability). See [`Self::on_empty`]. + /// + /// ``` + /// use std::collections::BTreeMap; + /// use cpex_core::extensions::routing::CandidateConstraintExtension; + /// + /// let c = CandidateConstraintExtension { + /// allow_regions: Some(vec!["eu".into()]), + /// ..Default::default() + /// }; + /// let eu: BTreeMap = [("region".into(), "eu".into())].into(); + /// let us: BTreeMap = [("region".into(), "us".into())].into(); + /// assert!(c.accepts(&eu, |_| None)); + /// assert!(!c.accepts(&us, |_| None)); + /// ``` + pub fn accepts( + &self, + backend: &impl BackendLabels, + tier_rank: impl Fn(&str) -> Option, + ) -> bool { + // allow_models: the model must glob-match at least one pattern. + if let Some(allow) = &self.allow_models { + match backend.label(LABEL_MODEL) { + Some(m) if allow.iter().any(|pat| glob_match(pat, m)) => {}, + _ => return false, + } + } + // deny_models: the model must not glob-match any pattern. + if let Some(m) = backend.label(LABEL_MODEL) { + if self.deny_models.iter().any(|pat| glob_match(pat, m)) { + return false; + } + } + // allow_regions / allow_sites: equality set membership. + if let Some(allow) = &self.allow_regions { + match backend.label(LABEL_REGION) { + Some(r) if allow.iter().any(|x| x == r) => {}, + _ => return false, + } + } + if let Some(allow) = &self.allow_sites { + match backend.label(LABEL_SITE) { + Some(s) if allow.iter().any(|x| x == s) => {}, + _ => return false, + } + } + // max_cost_tiers: cost_tier must rank ≤ every ceiling. + if !self.max_cost_tiers.is_empty() { + let Some(bt) = backend.label(LABEL_COST_TIER) else { + return false; + }; + let Some(backend_rank) = tier_rank(bt) else { + return false; // unrankable backend tier — fail closed + }; + for ceiling in &self.max_cost_tiers { + match tier_rank(ceiling) { + Some(ceiling_rank) if backend_rank <= ceiling_rank => {}, + _ => return false, // exceeds ceiling, or ceiling unrankable + } + } + } + // custom: every required label present with an equal value. + for (k, v) in &self.custom { + match backend.label(k) { + Some(bv) if bv == v => {}, + _ => return false, + } + } + true + } +} + +/// Well-known backend label keys the typed constraint fields match +/// against. Everything else in a backend's label set is `custom`. +pub const LABEL_MODEL: &str = "model"; +pub const LABEL_REGION: &str = "region"; +pub const LABEL_SITE: &str = "site"; +pub const LABEL_COST_TIER: &str = "cost_tier"; + +/// A backend's labels, looked up by key — the input to +/// [`CandidateConstraintExtension::accepts`]. Implemented for the standard +/// string maps; a host whose backend labels live elsewhere implements it +/// directly (one method, no allocation). +pub trait BackendLabels { + /// The value of label `key`, or `None` if the backend has no such label. + fn label(&self, key: &str) -> Option<&str>; +} + +impl BackendLabels for BTreeMap { + fn label(&self, key: &str) -> Option<&str> { + self.get(key).map(String::as_str) + } +} + +impl BackendLabels for HashMap { + fn label(&self, key: &str) -> Option<&str> { + self.get(key).map(String::as_str) + } +} + +/// Match `text` against a glob `pattern` where `*` matches any run of +/// characters (including empty). No other metacharacters — model ids are +/// the use case (`anthropic/*`, `*/claude-sonnet-4`, `vllm/*`). Operates +/// on bytes (model ids are ASCII); linear time with single-star backtrack. +fn glob_match(pattern: &str, text: &str) -> bool { + let p = pattern.as_bytes(); + let t = text.as_bytes(); + let (mut pi, mut ti) = (0usize, 0usize); + // Backtrack point: the last `*` seen and the text index to resume from. + let mut star: Option = None; + let mut resume = 0usize; + while ti < t.len() { + if pi < p.len() && p[pi] == t[ti] { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == b'*' { + star = Some(pi); + resume = ti; + pi += 1; // try matching `*` against zero chars first + } else if let Some(s) = star { + // Mismatch after a `*` — let the `*` swallow one more char. + pi = s + 1; + resume += 1; + ti = resume; + } else { + return false; + } + } + // Trailing `*`s match the empty remainder. + while pi < p.len() && p[pi] == b'*' { + pi += 1; + } + pi == p.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_empty_true_for_default() { + assert!(CandidateConstraintExtension::default().is_empty()); + } + + #[test] + fn is_empty_ignores_on_empty() { + let c = CandidateConstraintExtension { + on_empty: OnEmpty::Fallback, + ..Default::default() + }; + assert!(c.is_empty()); + } + + #[test] + fn empty_intersection_is_not_empty() { + // Some([]) is a real (impossible) constraint, not "unset". + let c = CandidateConstraintExtension { + allow_regions: Some(vec![]), + ..Default::default() + }; + assert!(!c.is_empty()); + } + + #[test] + fn json_omits_empty_fields() { + let c = CandidateConstraintExtension { + allow_regions: Some(vec!["eu".into()]), + ..Default::default() + }; + let json = serde_json::to_value(&c).unwrap(); + assert_eq!( + json, + serde_json::json!({ "allow_regions": ["eu"], "on_empty": "deny" }) + ); + } + + #[test] + fn json_roundtrips() { + let c = CandidateConstraintExtension { + allow_models: Some(vec!["vllm/*".into()]), + deny_models: vec!["openai/*".into()], + max_cost_tiers: vec!["cheap".into(), "standard".into()], + custom: [("gpu".to_string(), "h100".to_string())].into(), + on_empty: OnEmpty::Fallback, + ..Default::default() + }; + let back: CandidateConstraintExtension = + serde_json::from_str(&serde_json::to_string(&c).unwrap()).unwrap(); + assert_eq!(c, back); + } + + // ----- glob_match ----- + + #[test] + fn glob_exact_and_star() { + assert!(glob_match("anthropic/claude-4", "anthropic/claude-4")); + assert!(!glob_match("anthropic/claude-4", "anthropic/claude-5")); + assert!(glob_match("*", "anything/at-all")); + assert!(glob_match("*", "")); + } + + #[test] + fn glob_prefix_suffix_middle() { + assert!(glob_match("anthropic/*", "anthropic/claude-sonnet-4")); + assert!(!glob_match("anthropic/*", "openai/gpt-4")); + assert!(glob_match("*/claude-sonnet-4", "anthropic/claude-sonnet-4")); + assert!(glob_match("anthropic/*-4", "anthropic/claude-sonnet-4")); + assert!(!glob_match("anthropic/*-4", "anthropic/claude-sonnet-5")); + } + + #[test] + fn glob_star_matches_empty_run() { + assert!(glob_match("vllm/*", "vllm/")); + assert!(glob_match("a*b*c", "abc")); + assert!(glob_match("a*b*c", "axxbxxc")); + assert!(!glob_match("a*b*c", "axxb")); // missing trailing c + } + + // ----- accepts ----- + + fn backend(pairs: &[(&str, &str)]) -> std::collections::BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + /// cheap < standard < premium. + fn rank(t: &str) -> Option { + match t { + "cheap" => Some(0), + "standard" => Some(1), + "premium" => Some(2), + _ => None, + } + } + + #[test] + fn empty_constraint_accepts_everything() { + let c = CandidateConstraintExtension::default(); + assert!(c.accepts(&backend(&[("region", "eu")]), rank)); + assert!(c.accepts(&backend(&[]), rank)); + } + + #[test] + fn allow_models_globs() { + let c = CandidateConstraintExtension { + allow_models: Some(vec!["anthropic/*".into(), "vllm/*".into()]), + ..Default::default() + }; + assert!(c.accepts(&backend(&[("model", "anthropic/claude-4")]), rank)); + assert!(c.accepts(&backend(&[("model", "vllm/llama")]), rank)); + assert!(!c.accepts(&backend(&[("model", "openai/gpt-4")]), rank)); + // No model label under an allow_models → excluded (fail-closed). + assert!(!c.accepts(&backend(&[("region", "eu")]), rank)); + } + + #[test] + fn deny_models_globs() { + let c = CandidateConstraintExtension { + deny_models: vec!["openai/*".into()], + ..Default::default() + }; + assert!(!c.accepts(&backend(&[("model", "openai/gpt-4")]), rank)); + assert!(c.accepts(&backend(&[("model", "anthropic/claude-4")]), rank)); + // No model label → not denied → passes. + assert!(c.accepts(&backend(&[("region", "eu")]), rank)); + } + + #[test] + fn allow_regions_equality() { + let c = CandidateConstraintExtension { + allow_regions: Some(vec!["eu".into()]), + ..Default::default() + }; + assert!(c.accepts(&backend(&[("region", "eu")]), rank)); + assert!(!c.accepts(&backend(&[("region", "us")]), rank)); + assert!(!c.accepts(&backend(&[]), rank)); // no region → excluded + } + + #[test] + fn max_cost_tiers_ceiling() { + let c = CandidateConstraintExtension { + max_cost_tiers: vec!["standard".into()], + ..Default::default() + }; + assert!(c.accepts(&backend(&[("cost_tier", "cheap")]), rank)); // 0 <= 1 + assert!(c.accepts(&backend(&[("cost_tier", "standard")]), rank)); // 1 <= 1 + assert!(!c.accepts(&backend(&[("cost_tier", "premium")]), rank)); // 2 > 1 + assert!(!c.accepts(&backend(&[]), rank)); // no cost_tier → excluded + } + + #[test] + fn max_cost_tiers_multiple_is_the_min() { + // Backend must be ≤ every ceiling → ≤ min(cheap, standard) = cheap. + let c = CandidateConstraintExtension { + max_cost_tiers: vec!["cheap".into(), "standard".into()], + ..Default::default() + }; + assert!(c.accepts(&backend(&[("cost_tier", "cheap")]), rank)); + assert!(!c.accepts(&backend(&[("cost_tier", "standard")]), rank)); // > cheap + } + + #[test] + fn unrankable_tier_fails_closed() { + let c = CandidateConstraintExtension { + max_cost_tiers: vec!["standard".into()], + ..Default::default() + }; + // Backend tier the host can't rank → excluded, not matched. + assert!(!c.accepts(&backend(&[("cost_tier", "mystery")]), rank)); + } + + #[test] + fn custom_labels_equality() { + let c = CandidateConstraintExtension { + custom: [("gpu".to_string(), "h100".to_string())].into(), + ..Default::default() + }; + assert!(c.accepts(&backend(&[("gpu", "h100")]), rank)); + assert!(!c.accepts(&backend(&[("gpu", "a100")]), rank)); // wrong value + assert!(!c.accepts(&backend(&[("region", "eu")]), rank)); // label absent + } + + #[test] + fn all_fields_must_pass_together() { + let c = CandidateConstraintExtension { + allow_regions: Some(vec!["eu".into()]), + deny_models: vec!["openai/*".into()], + max_cost_tiers: vec!["standard".into()], + custom: [("gpu".to_string(), "h100".to_string())].into(), + ..Default::default() + }; + // Satisfies everything. + assert!(c.accepts( + &backend(&[ + ("region", "eu"), + ("model", "anthropic/claude-4"), + ("cost_tier", "cheap"), + ("gpu", "h100"), + ]), + rank + )); + // One field off (region) → rejected. + assert!(!c.accepts( + &backend(&[ + ("region", "us"), + ("model", "anthropic/claude-4"), + ("cost_tier", "cheap"), + ("gpu", "h100"), + ]), + rank + )); + } + + #[test] + fn accepts_works_with_hashmap_labels() { + let c = CandidateConstraintExtension { + allow_regions: Some(vec!["eu".into()]), + ..Default::default() + }; + let labels: std::collections::HashMap = + [("region".to_string(), "eu".to_string())].into(); + assert!(c.accepts(&labels, rank)); + } +} diff --git a/docs/content/docs/apl/_index.md b/docs/content/docs/apl/_index.md index cba70c8f..d432e0ed 100644 --- a/docs/content/docs/apl/_index.md +++ b/docs/content/docs/apl/_index.md @@ -14,8 +14,10 @@ This page covers the configuration: routes, phases, predicates, rules, and field - [Effects & Sequencing]({{< relref "/docs/apl/effects" >}}): the effects a rule can run, halt-on-deny ordering, and composition. - [PDP Integration]({{< relref "/docs/apl/pdp" >}}): hand a decision to Cedar, CEL, or an external engine. - [Identity & IdP]({{< relref "/docs/apl/identity" >}}): how callers are resolved into the attributes predicates read. +- [Static Attributes]({{< relref "/docs/apl/attributes" >}}): operator-maintained config facts in the `data.*` namespace. - [Delegation]({{< relref "/docs/apl/delegation" >}}): mint scoped downstream credentials via token exchange. - [Session Tainting]({{< relref "/docs/apl/tainting" >}}): information-flow control across requests. +- [Backend Restriction]({{< relref "/docs/apl/restrict" >}}): shape which backends the router may select for a request. ## Routes and phases diff --git a/docs/content/docs/apl/attributes.md b/docs/content/docs/apl/attributes.md new file mode 100644 index 00000000..43a7c341 --- /dev/null +++ b/docs/content/docs/apl/attributes.md @@ -0,0 +1,82 @@ +--- +title: "Static Attributes" +weight: 35 +--- + +# Static Attributes + +Policy reads attributes. Most come from the request: the verified subject and its roles ([Identity]({{< relref "/docs/apl/identity" >}})), request headers, session labels. But some attributes are carried by nothing and fetched from nowhere — they are **operator-maintained facts** known at configuration time. Which region a tenant's data is resident in. Which models an agent is allowed to use. The org's default region. These are the *static attributes*, and they live in a plain data tree under the `data.*` namespace. + +This is the counterpart to identity resolution. Identity turns a token into `subject.*` and `role.*`; static provisioning turns a config file into `data.*`. Both feed the same attribute bag predicates read. + +## The requirement + +An EU tenant's data must stay in-region (see [Backend Restriction]({{< relref "/docs/apl/restrict" >}})). To enforce that, policy has to know *which* region a given tenant is resident in — a fact that is not in the caller's token and does not belong in application code. It is an operator's decision, maintained alongside the deployment. Policy needs to read it per request, keyed by the caller's tenant. + +## The data tree + +Static attributes are a plain nested document. The operator organizes it however they like — by tenant, team, environment — and everything lives under a top-level `data:` mapping: + +```yaml +# attributes/tenants.yaml +data: + org: + default_region: us + tenants: + acme-eu: { data_region: eu, allowed_models: ["anthropic/*", "vllm/*"] } + acme-us: { data_region: us, allowed_models: ["openai/*", "vllm/*"] } +``` + +The whole tree flattens into the bag under `data.*`: `data.org.default_region`, `data.tenants.acme-eu.data_region`, and so on. A list of strings (like `allowed_models`) becomes a set, so `contains` works against it. + +## Loading it + +List the attribute files in APL's config namespace. They deep-merge, in order, into one `data.*` tree: + +```yaml +global: + apl: + attribute_files: + - attributes/org.yaml + - attributes/tenants.yaml + - attributes/agents.yaml +``` + +Different subtrees combine freely — `org.yaml` sets `data.org.*`, `tenants.yaml` sets `data.tenants.*`. The merge is **fail-fast**: two files setting the *same* leaf to different values is a load-time error, not a silent last-wins, and a file that forgets the `data:` wrapper is rejected. A configuration mistake stops the gateway from starting rather than producing quietly-wrong routing. + +The built-in loader reads files. A host whose attributes live in etcd, a database, or a k8s ConfigMap implements the `AttributeSource` trait, loads the tree at startup, and injects it in code — an injected tree takes precedence over the declarative file list. + +## Reading it in policy + +Two ways, depending on whether the path is fixed or keyed by the request. + +**Dot-path** — a fixed lookup: + +```yaml +- "data.org.default_region == 'eu': deny('org is EU-only')" +``` + +**Interpolation** — index the tree by a *request* value, using `[...]`: + +```yaml +routes: + - llm: "*" + pre_invocation: + - when: "data.tenants[subject.tenant].data_region == 'eu'" + do: + - restrict: { allow_regions: [eu], on_empty: deny } +``` + +`[subject.tenant]` is resolved at evaluation time: the caller's `subject.tenant` (say `acme-eu`) is substituted into the path, so the predicate reads `data.tenants.acme-eu.data_region`. This is what makes the tree useful — "look up *this caller's* tenant" rather than a single hard-coded path. + +If the indexed value is missing — no `subject.tenant` on this request — the whole path resolves to *absent*, and the predicate is simply false (and a `require(...)` on it fails closed). A lookup keyed on an unknown value never matches a half-built key. + +Beyond predicates, a `data.*` reference can be the **value of a `restrict` field** — `allow_models: "data.agents[subject.id].allowed_models"` — so a single routing rule reads each caller's own allow-list from the tree. See [Backend Restriction]({{< relref "/docs/apl/restrict" >}}). + +## Data, not a rules engine + +The tree holds **literal values only**: no conditionals, no computed fields, no references to other entries. Any "if X then Y" is policy's job — put it in a route. This guardrail is structural rather than enforced: a plain data document has no syntax to express logic, so the static layer cannot quietly grow into a second, shadow policy engine. It provisions the facts; APL decides with them. + +## How it connects to the pipeline + +`data.*` is an ordinary bag namespace (see [Extensions & Capability-Gating]({{< relref "/docs/extensions" >}})): predicates read it exactly like `subject.*` or `session.labels`, and it composes with them freely — `data.tenants[subject.tenant].data_region` ties a static fact to a per-request identity in one predicate. The tree is loaded once at startup and shared across requests, so reading it costs nothing on the hot path. Where [Identity]({{< relref "/docs/apl/identity" >}}) supplies the *dynamic* attributes a request carries, static provisioning supplies the *stable* ones a deployment maintains — together they are the full picture a predicate reasons about. diff --git a/docs/content/docs/apl/effects.md b/docs/content/docs/apl/effects.md index cb3a6729..c7a45dec 100644 --- a/docs/content/docs/apl/effects.md +++ b/docs/content/docs/apl/effects.md @@ -16,6 +16,7 @@ An APL rule does something. That something is an **effect**. Effects are the bui | `plugin(name)` (alias `run(name)`) | Invoke a registered plugin (PII scan, audit log, custom check). | | `delegate(name, ...)` | Mint a downstream credential via a delegator plugin. See [Delegation]({{< relref "/docs/apl/delegation" >}}). | | `taint(label[, scope])` | Attach a label to the session or message. See [Session Tainting]({{< relref "/docs/apl/tainting" >}}). | +| `restrict: { ... }` | Narrow the set of backends the router may select from. See [Backend Restriction]({{< relref "/docs/apl/restrict" >}}). | | field pipelines | Validate or transform `args`/`result` fields. See [APL]({{< relref "/docs/apl" >}}). | | PDP call (`cedar:`, `cel:`, `opa(...)`) | Delegate the decision to a policy engine. See [PDP Integration]({{< relref "/docs/apl/pdp" >}}). | @@ -53,7 +54,7 @@ pre_invocation: ## Composition: sequential and parallel -Effects can be grouped. `sequential` runs its members in order and halts on the first deny. `parallel` runs independent gates concurrently; any deny fails the group, and taints from the branches accumulate. +Effects can be grouped. `sequential` runs its members in order and halts on the first deny. `parallel` runs independent gates concurrently; any deny fails the group, and accumulating effects from the branches (taints, backend restrictions) all take hold. ```yaml pre_invocation: diff --git a/docs/content/docs/apl/restrict.md b/docs/content/docs/apl/restrict.md new file mode 100644 index 00000000..23c6c87c --- /dev/null +++ b/docs/content/docs/apl/restrict.md @@ -0,0 +1,135 @@ +--- +title: "Backend Restriction" +weight: 60 +--- + +# Backend Restriction + +Some controls are not about *whether* an operation runs, but *where* it runs. "EU customer data must be inferred on EU backends." "This tenant may only use the cheap model tier." The agent asks for a **class** of backend — "inference" — and policy shapes which concrete backends are eligible. That shaping is the `restrict` effect. + +`restrict` declares a predicate over backend attributes, narrowing the set of backends the host's router may select from. It never picks a backend and never allows or denies the request — the router still chooses the best survivor by health and load. Like `taint`, it is an **accumulating** effect: every `restrict` that fires only ever *narrows* the set, and multiple restrictions compose by conjunction. + +## What `restrict` is not + +`restrict` is not an access-control verb. "May I use this *named* backend?" is a `require` / `deny`. `restrict` only shapes a set the router chooses from. + +| Situation | Verb | +|-----------|------| +| The agent named a specific target ("model `gpt-4o`", "tool X at site A") | `require` / `deny` | +| The agent asked for a class ("inference"); policy shapes which backends are eligible | **`restrict`** | + +## The requirement + +A tenant is EU-resident: its data must stay in-region. When the agent runs inference on that tenant's data, only EU inference backends are eligible — and if none are reachable, the request must fail rather than silently spill to a US backend. The agent never named a backend; it asked for "inference." The control has to live in routing, not in the prompt. + +## Declaring a restriction + +The session is marked when EU-resident data is read (see [Session Tainting]({{< relref "/docs/apl/tainting" >}})); a later inference route restricts routing to EU backends when that label is present. Whether the caller's tenant is EU-resident is an operator-maintained fact, looked up from the [`data.*` static attributes]({{< relref "/docs/apl/attributes" >}}): + +```yaml +routes: + # Reading data for an EU-resident tenant marks the session. + - tool: fetch_customer + post_invocation: + - when: "data.tenants[subject.tenant].data_region == 'eu'" + do: + - "taint(eu_resident, session)" + + # Later inference is pinned to EU backends while that label is set. + - llm: "*" + pre_invocation: + - when: "security.labels contains 'eu_resident'" + do: + - restrict: + allow_regions: [eu] + on_empty: deny # fail closed — never leave the region +``` + +`data.tenants[subject.tenant].data_region` indexes the tenant→region map by *this caller's* tenant; when it is `eu`, the session is tainted, and the inference route then narrows the candidate set to backends whose `region` label is `eu`. The router load-balances across the healthy EU backends. + +## The constraint fields + +A restriction is a small set of **typed fields** plus a `custom` label map. The shape is deliberately simple — it is a contract the host router evaluates against each backend's labels, not a predicate language. + +| Field | Backend attribute | Match | +|-------|-------------------|-------| +| `allow_models` | `model` must be in the set | glob (`anthropic/claude-sonnet-*`) | +| `deny_models` | `model` must not be in the set | glob | +| `allow_regions` | `region` must be in the set | equality | +| `allow_sites` | `site` must be in the set | equality | +| `max_cost_tier` | `cost_tier` must be at or below the tier | ordered tiers | +| `custom` | backend must carry every label | equality (like a k8s `nodeSelector`) | +| `on_empty` | what to do when nothing is eligible | `deny` (default) or `fallback` | + +`custom` is the escape hatch for backend attributes without a typed field: `custom: { gpu: h100 }` keeps only backends labelled `gpu=h100`. Unset fields place no constraint. + +## Per-caller values from the attribute tree + +The set-valued fields (`allow_models`, `deny_models`, `allow_regions`, `allow_sites`) take either a literal list *or* a `data.*` reference into the [static attribute tree]({{< relref "/docs/apl/attributes" >}}) — resolved per request. This lets one rule serve every caller instead of hard-coding a block per agent or tenant: + +```yaml +# attributes/agents.yaml +data: + agents: + support-bot: { allowed_models: ["vllm/*"] } + research-bot: { allowed_models: ["anthropic/*", "vllm/*"] } +``` + +```yaml +routes: + - llm: "*" + pre_invocation: + # allow_models is looked up from the tree by the caller's id. + - restrict: + allow_models: "data.agents[subject.id].allowed_models" +``` + +`support-bot` is restricted to `vllm/*`, `research-bot` to `anthropic/*` + `vllm/*` — one rule, values maintained in config. The distinction is by YAML shape: a **list** is a literal set; a bare **scalar** is a reference. Quote a reference that contains `[...]` (as above) so YAML doesn't read the brackets as an inline list. + +If the referenced path doesn't resolve — an agent absent from the tree, a missing `subject.id` — the field resolves to the **empty set**: nothing qualifies, so `on_empty` decides (deny by default). An unknown caller is never silently unconstrained. (`max_cost_tier` and `custom` are literal-only.) + +## Gating happens at the composition layer + +`restrict` has no `when:` field of its own. Whether it fires is handled by APL's normal effect-gating — a `when:`/`do:` rule — exactly like every other effect. This keeps `restrict` orthogonal: it is *only* a set of backend constraints, and *whether* it applies is a normal predicate. So the two layers live apart: the `when:` gate is evaluated now, in CPEX, against the request; the constraint fields are evaluated later, by the host router, against its backends. + +Because it is accumulating, `restrict` composes anywhere an effect can appear — top-level, inside a `when` body, inside `sequential` / `parallel`, and inside a PDP's `on_allow` block. That last one is a first-class pattern: let Cedar make the fine-grained decision, then pin routing on allow. + +```yaml +pre_invocation: + - cedar: + action: 'Action::"read"' + resource: { type: Dataset, id: eu_data } + on_allow: + - restrict: { allow_regions: [eu] } # authz says yes → now pin routing to EU +``` + +## How a restriction reaches the router + +Multiple `restrict` effects in one request **fold** into a single constraint: allow-sets intersect, deny-sets and `custom` grow, tier ceilings combine, and `on_empty` takes the strictest. The result rides out on the typed `candidate_constraint` extension (see [Extensions]({{< relref "/docs/extensions" >}})) — the same in-process channel minted delegation tokens use, not a request header. + +The host router evaluates it. CPEX has no backend list and no live health, so it cannot run the match itself; it emits the constraint and the router — which owns the health-aware backend registry — checks each candidate's labels against it at selection time. + +```mermaid +flowchart LR + R1["fetch_customer
(EU data)"] -->|"taint(eu_resident)"| S["session labels:
{ eu_resident }"] + R2["inference"] --> G{"labels contains
eu_resident?"} + S -.-> G + G -->|yes| RST["restrict
allow_regions: [eu]"] + RST --> C["candidate_constraint
{ allow_regions: [eu] }"] + C --> RT{"router prunes
by region"} + RT -->|EU backend healthy| EU["route to EU"] + RT -->|none eligible| OE{"on_empty"} + OE -->|deny| DENY["fail closed"] + OE -->|fallback| ALL["unconstrained set"] +``` + +## Failing closed + +`on_empty` decides what happens when a restriction prunes every candidate. Only the router knows which backends are actually reachable, so the choice rides out with the constraint: + +- **`deny`** (default) rejects the request. Correct for hard constraints like data sovereignty — never silently escape the region. +- **`fallback`** reverts to the unconstrained set. An explicit opt-in for "prefer, but don't fail." + +## How it connects to the pipeline + +`restrict` is an effect like any other: it sequences with `require`, PDP calls, and `taint` (see [Effects]({{< relref "/docs/apl/effects" >}})), and its gate is an ordinary predicate. What makes it reliable is the same thing that makes tainting reliable — the decision is assembled from CPEX-owned state and handed to the router as a typed constraint, so the untrusted model cannot reword its way onto a backend that policy excluded. The clean split of ownership is the point: which backends exist and their health belong to the host; *which of them this request may use* is policy's to shape. diff --git a/docs/content/docs/extensions.md b/docs/content/docs/extensions.md index 106a5e32..c6b8ce0e 100644 --- a/docs/content/docs/extensions.md +++ b/docs/content/docs/extensions.md @@ -31,8 +31,11 @@ Each extension flattens into bag attributes under its namespace, gated by a read | Framework | agentic framework name and version, node and graph ids, metadata | `framework.*` | `read_framework` | | Custom | free-form host-defined namespace | `custom.*` | `read_custom` | | Raw credentials | inbound tokens and minted delegated tokens | flow through plugin payloads, not the bag | `read_inbound_credentials`, `read_delegated_tokens` | +| Candidate constraint | folded backend routing constraint from `restrict` effects | not a bag namespace — read by the host router | written by the policy engine | -The request arguments and response body are also flattened, under `args.*` and `result.*`, and the route name is available as `route.key`. APL field pipelines (`args:` / `result:`) operate on those. +The request arguments and response body are also flattened, under `args.*` and `result.*`, and the route name is available as `route.key`. APL field pipelines (`args:` / `result:`) operate on those. Operator-maintained static attributes are flattened under `data.*` — these come from config files, not the request, and need no capability (see [Static Attributes]({{< relref "/docs/apl/attributes" >}})). + +Most extensions are **inputs** — resolved before policy runs and flattened into the bag for predicates to read. The **candidate constraint** is the exception: it is an **output**. APL `restrict` effects fold into it (see [Backend Restriction]({{< relref "/docs/apl/restrict" >}})), it rides the returned extensions the same way minted delegation tokens do, and the host router reads it typed to prune its candidate set. Because CPEX links the router in-process, this is a typed value, not a serialized blob. Capability-gating the write is not yet applied — the policy engine is its only writer today. ## Capabilities