Skip to content

Commit 68d6da9

Browse files
committed
feat: added restrict() effects to APL with static attribute configurations.
Signed-off-by: Teryl Taylor <terylt@ibm.com>
1 parent 8b18994 commit 68d6da9

27 files changed

Lines changed: 3477 additions & 33 deletions

crates/apl-cmf/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ pub use http::extract_http;
6969
pub use llm::extract_llm;
7070
pub use mcp::extract_mcp;
7171
pub use meta::extract_meta;
72-
pub use payload::{extract_args, extract_result};
72+
pub use payload::{extract_args, extract_data, extract_result};
7373
pub use provenance::extract_provenance;
7474
pub use request::extract_request;
7575
pub use security::{extract_client, extract_security, extract_workload};
@@ -127,6 +127,14 @@ impl BagBuilder {
127127
self
128128
}
129129

130+
/// Flatten a static attribute tree into the `data.*` namespace
131+
/// (design §4.2). Typically called once with a shared, startup-loaded
132+
/// tree so every request's bag carries the same policy-side constants.
133+
pub fn with_data(mut self, tree: &apl_core::AttributeTree) -> Self {
134+
extract_data(tree, &mut self.bag);
135+
self
136+
}
137+
130138
/// Set the route key under `route.key` for policy predicates that
131139
/// branch on which route is running (mostly useful in default/policy
132140
/// bundles applied across routes).

crates/apl-cmf/src/payload.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ pub fn extract_result(result: &Value, bag: &mut AttributeBag) {
3737
walk(result, BAG_RESULT_PREFIX.trim_end_matches('.'), bag);
3838
}
3939

40+
/// Flatten a static attribute tree into `data.*` keys (design §4.2).
41+
/// Same walk as args/result — nested objects recurse, string arrays
42+
/// become `StringSet`s (so `data.tenants.x.allowed_models` supports
43+
/// `contains` and R3b `restrict` references).
44+
pub fn extract_data(tree: &apl_core::AttributeTree, bag: &mut AttributeBag) {
45+
walk(tree.as_value(), "data", bag);
46+
}
47+
4048
pub(crate) fn walk(value: &Value, prefix: &str, bag: &mut AttributeBag) {
4149
match value {
4250
Value::Object(map) => {
@@ -151,4 +159,29 @@ mod tests {
151159
extract_args(&args, &mut bag);
152160
assert_eq!(bag.get_float("args.score"), Some(0.92));
153161
}
162+
163+
#[test]
164+
fn data_tree_flattens_under_data_namespace() {
165+
let tree = apl_core::AttributeTree::new(json!({
166+
"org": { "default_region": "us" },
167+
"tenants": {
168+
"acme-eu": { "data_region": "eu", "allowed_models": ["anthropic/*", "vllm/*"] }
169+
}
170+
}));
171+
let mut bag = AttributeBag::new();
172+
extract_data(&tree, &mut bag);
173+
174+
assert_eq!(bag.get_string("data.org.default_region"), Some("us"));
175+
assert_eq!(bag.get_string("data.tenants.acme-eu.data_region"), Some("eu"));
176+
// String arrays become a StringSet (ready for `contains` / R3b).
177+
assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "anthropic/*"));
178+
assert!(bag.set_contains("data.tenants.acme-eu.allowed_models", "vllm/*"));
179+
}
180+
181+
#[test]
182+
fn empty_data_tree_adds_nothing() {
183+
let mut bag = AttributeBag::new();
184+
extract_data(&apl_core::AttributeTree::empty(), &mut bag);
185+
assert!(bag.is_empty());
186+
}
154187
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Location: ./crates/apl-core/src/attribute_source.rs
2+
// Copyright 2026
3+
// SPDX-License-Identifier: Apache-2.0
4+
// Authors: Teryl Taylor
5+
//
6+
// Static attribute provisioning — the `data.*` bag namespace.
7+
//
8+
// `restrict` predicates (and policy predicates generally) read attributes
9+
// that aren't carried by any token or fetched from anywhere: backend-free
10+
// policy constants like tenant→region maps, per-agent model allow-lists,
11+
// org defaults. Those come from a plain, operator-organized data tree that
12+
// lands in the evaluation bag under `data.*` (see
13+
// docs/apl-restrict-effect-design.md §4).
14+
//
15+
// This module is the pure contract: the `AttributeSource` trait (where a
16+
// tree comes from) and the `AttributeTree` value (what it is). The default
17+
// file-backed source and the bag flattening live at the outer layers
18+
// (apl-cpex / apl-cmf) — apl-core stays free of I/O and config deps.
19+
20+
use serde_json::Value;
21+
use thiserror::Error;
22+
23+
/// The static attribute tree — the whole `data:` document, a plain nested
24+
/// value the operator organizes however they like. It carries *literal
25+
/// values only*: no conditionals, no computed fields (that guardrail is
26+
/// structural — there is no syntax in a data tree to express logic).
27+
///
28+
/// Flattened into the bag under `data.*` by the bag builder (apl-cmf):
29+
/// `{ org: { default_region: us } }` → `data.org.default_region = "us"`.
30+
#[derive(Debug, Clone, PartialEq)]
31+
pub struct AttributeTree(Value);
32+
33+
impl AttributeTree {
34+
/// Wrap a loaded `data` document. Expected to be a JSON/YAML object;
35+
/// a non-object is tolerated but flattens to nothing useful.
36+
pub fn new(value: Value) -> Self {
37+
Self(value)
38+
}
39+
40+
/// The empty tree — no static attributes. The default when no source
41+
/// is configured.
42+
pub fn empty() -> Self {
43+
Self(Value::Object(serde_json::Map::new()))
44+
}
45+
46+
/// Borrow the underlying value (the bag builder walks this).
47+
pub fn as_value(&self) -> &Value {
48+
&self.0
49+
}
50+
51+
/// True when the tree holds nothing (no keys).
52+
pub fn is_empty(&self) -> bool {
53+
match &self.0 {
54+
Value::Object(m) => m.is_empty(),
55+
Value::Null => true,
56+
_ => false,
57+
}
58+
}
59+
}
60+
61+
impl Default for AttributeTree {
62+
fn default() -> Self {
63+
Self::empty()
64+
}
65+
}
66+
67+
/// Where the `data.*` tree comes from — a **trait object injected at
68+
/// construction**, not a CPEX hook-plugin. The host implements it over a
69+
/// file, etcd, Postgres, a k8s ConfigMap, etc., and hands the object to
70+
/// the runtime at startup.
71+
///
72+
/// `load` is **synchronous and one-shot**: it runs once at startup, never
73+
/// on the request hot path, so blocking the init thread on file/network
74+
/// I/O is fine — and it lets the (synchronous) runtime setup call it
75+
/// directly, no async plumbing. A source that fronts a genuinely async
76+
/// store can block on its own runtime at this edge.
77+
///
78+
/// v1 is **snapshot only**. Hot-reload (a `watch` that streams fresh
79+
/// trees) is deferred — that is the one genuinely-async concern, and its
80+
/// shape (stream vs channel vs callback) is best decided when it's built,
81+
/// not stubbed now. A lazy per-key `resolve(path)` for huge stores is
82+
/// likewise deferred.
83+
pub trait AttributeSource: Send + Sync {
84+
/// Load the full attribute tree (a snapshot).
85+
fn load(&self) -> Result<AttributeTree, AttributeError>;
86+
}
87+
88+
/// Why an attribute source failed to produce a tree. Loading is a
89+
/// startup/config concern, so these surface as configuration errors at
90+
/// the runtime boundary (fail-fast, not per-request).
91+
#[derive(Debug, Error)]
92+
pub enum AttributeError {
93+
/// The backing store could not be read (missing file, I/O error,
94+
/// unreachable etcd, …).
95+
#[error("attribute source load failed: {0}")]
96+
Load(String),
97+
98+
/// The loaded bytes were not valid for the source's format.
99+
#[error("attribute source parse failed: {0}")]
100+
Parse(String),
101+
102+
/// Two inputs set the *same* leaf path to different values — a real
103+
/// conflict the source refuses to silently resolve (fail-fast merge).
104+
#[error("attribute conflict at `{path}`: `{existing}` vs `{incoming}`")]
105+
Conflict {
106+
path: String,
107+
existing: String,
108+
incoming: String,
109+
},
110+
}
111+
112+
#[cfg(test)]
113+
mod tests {
114+
use super::*;
115+
use serde_json::json;
116+
117+
#[test]
118+
fn empty_tree_is_empty() {
119+
assert!(AttributeTree::empty().is_empty());
120+
assert!(AttributeTree::default().is_empty());
121+
}
122+
123+
#[test]
124+
fn populated_tree_is_not_empty() {
125+
let t = AttributeTree::new(json!({ "org": { "default_region": "us" } }));
126+
assert!(!t.is_empty());
127+
assert_eq!(
128+
t.as_value().pointer("/org/default_region"),
129+
Some(&json!("us"))
130+
);
131+
}
132+
}

crates/apl-core/src/attributes.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,51 @@ impl AttributeBag {
145145
.unwrap_or(false)
146146
}
147147

148+
/// Resolve an attribute path to its concrete flat key, expanding any
149+
/// `[inner]` interpolation groups (design §4.3). Each `[inner]` looks
150+
/// `inner` up in this bag and substitutes `.` + its scalar value:
151+
/// `data.tenants[subject.tenant].data_region` with `subject.tenant =
152+
/// "acme-eu"` → `data.tenants.acme-eu.data_region`. The common
153+
/// bracket-free key is returned borrowed (no allocation).
154+
///
155+
/// Returns `None` when any `inner` key is missing or not a scalar — the
156+
/// caller treats that as an absent attribute, so a lookup keyed on an
157+
/// unknown request value fails to match rather than hitting a
158+
/// half-substituted key. Shared by predicate evaluation and
159+
/// `restrict` field references.
160+
pub fn resolve_key<'a>(&self, key: &'a str) -> Option<std::borrow::Cow<'a, str>> {
161+
if !key.contains('[') {
162+
return Some(std::borrow::Cow::Borrowed(key));
163+
}
164+
let mut out = String::with_capacity(key.len());
165+
let mut rest = key;
166+
while let Some(open) = rest.find('[') {
167+
out.push_str(&rest[..open]);
168+
let after = &rest[open + 1..];
169+
// The lexer guarantees a matching `]`; `?` is defensive.
170+
let close = after.find(']')?;
171+
let inner = after[..close].trim();
172+
out.push('.');
173+
out.push_str(&self.scalar_as_string(inner)?);
174+
rest = &after[close + 1..];
175+
}
176+
out.push_str(rest);
177+
Some(std::borrow::Cow::Owned(out))
178+
}
179+
180+
/// The scalar at `key`, stringified for use as a path segment. Numbers
181+
/// and bools coerce to their text form (a tenant id may be numeric); a
182+
/// `StringSet` cannot index a path, so it yields `None`.
183+
fn scalar_as_string(&self, key: &str) -> Option<String> {
184+
match self.get(key)? {
185+
AttributeValue::String(s) => Some(s.clone()),
186+
AttributeValue::Int(i) => Some(i.to_string()),
187+
AttributeValue::Bool(b) => Some(b.to_string()),
188+
AttributeValue::Float(f) => Some(f.to_string()),
189+
AttributeValue::StringSet(_) => None,
190+
}
191+
}
192+
148193
pub fn len(&self) -> usize {
149194
self.attrs.len()
150195
}

0 commit comments

Comments
 (0)