Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion crates/apl-cmf/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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).
Expand Down
33 changes: 33 additions & 0 deletions crates/apl-cmf/src/payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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());
}
}
132 changes: 132 additions & 0 deletions crates/apl-core/src/attribute_source.rs
Original file line number Diff line number Diff line change
@@ -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<AttributeTree, AttributeError>;
}

/// 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"))
);
}
}
45 changes: 45 additions & 0 deletions crates/apl-core/src/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::borrow::Cow<'a, str>> {
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<String> {
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()
}
Expand Down
Loading
Loading