CMF extensions and the attribute bag
On this page
A policy is written against a flat AttributeBag. The bag is filled by
praxis-policy-apl-cmf: each present slot on Extensions is walked into dotted
keys. Plugins that received the typed slot still see the original struct. This
document is the contract for what the bridge emits, how original collections
relate to flattened booleans, and which keys exist.
The twelve slots dispatched by extract_extensions are listed below.
raw_credentials and candidate_constraint are not among them: credentials
never enter the bag, and a routing constraint is not a policy attribute.
Contents
- Absent values
- Original collections and flattened booleans
subject.claims- What each decision point does with a missing key
- The twelve slots
- Payloads that are not slots
Absent values
The rule is per attribute type, and it only applies inside a present slot. An absent slot writes nothing for its namespace. CEL then reports an undeclared reference for that namespace; synthesizing empty namespaces for missing slots is out of scope.
| Type | When the field is empty or None | Why |
|---|---|---|
StringSet | Present and empty. Membership is false. | CEL treats a missing key as an evaluation error. !("banned" in subject.roles) would deny every subject with no roles — a routine state, including a plugin that lacks read_roles and is handed an empty set. |
Bool as a real field (delegation.delegated) | Present, including false. | The field is not optional on the struct. |
Bool as a flattened member (role.hr) | Omitted. Presence means true. Only undotted membership names receive aliases. | Emitting false for every name that is not a member is impossible. APL reads a missing flattened bool as false. Do not guard CEL with has(role.hr): when no role.* keys exist the role namespace was never written, and has(role.hr) is an evaluation error. Use the always-present subject.roles set ("hr" in subject.roles). |
Bool derived (authenticated) | Omitted unless subject.id is set. | Absence is “not authenticated” in APL (!authenticated is true; require(authenticated) denies). has(authenticated) is a compile error (has() rejects a bare name), so that key itself cannot be guarded in CEL. When the subject namespace exists — a present subject writes empty subject.roles / permissions / teams even with no id — has(subject.id) is a valid substitute. Only a completely absent subject (no subject.* keys) leaves CEL with no guard; the deny then reaches the operator as a key error. Contrast delegation.delegated, a non-option field that is always written, including false. |
String | Omitted when Option::None. A non-option string (client.client_id) is always written, even if empty. | Empty string and missing are different questions (exists(subject.id) vs subject.id == ""). |
Int | Omitted when Option::None (http.status, agent.turn, completion.latency_ms). A non-option int (delegation.depth) is always written, including 0. | Emitting 0 for an unset HTTP status would make http.status >= 500 and http.status == 0 both lie. |
Float | Same as Int. delegation.age_seconds is non-option and always written, including 0.0. | Same reason: a missing telemetry field is not zero. |
| JSON object / claims map | No parent key. Each scalar (or scalar-array) child is written under a dotted path. {}, null, and an array holding a nested container set nothing. | The bag has no map type. See subject.claims. |
ppe-pdp-diff is the executable form of this table for the keys Cedar can
see. Empty subject.teams and a subject with no roles (no role.* keys,
empty subject.roles) must Deny on APL, CEL, cedar-direct, and OPA when the
policy is a membership or flattened-bool gate. Unguarded presence,
equality, membership, and order probes of an omitted claim scalar also
Deny on all four (CEL and Cedar report a key error rather than a policy
false). That agreement does not cover APL != or not in: those
evaluate true on a missing key, so require(claim.tenant != "acme")
Allows in APL while CEL, cedar-direct, and OPA Deny. not in Allows in
APL (and in OPA, where not of undefined is true) while CEL and
cedar-direct Deny. A flattened bool whose namespace was never
written, and a missing subject.id, remain allowlisted: CEL is an eval
error, Cedar cannot build a principal without an id, and making those
agree needs CEL root seeding and a generated Cedar schema, which is a
follow-up to #18.
Original collections and flattened booleans
Pull request #7 added the original CMF collections as bag keys alongside the flattened booleans that were already there.
| Original (the set) | Flattened (presence-only) | Write |
|---|---|---|
subject.roles | role.<name> = true | Alias for each undotted member. |
subject.permissions | perm.<name> = true | Alias for each undotted member. |
subject.teams | team.<name> = true | Alias for each undotted member. |
client.roles | client.role.<name> = true | Alias for each undotted member. |
client.permissions | client.perm.<name> = true | Alias for each undotted member. |
The set is the primary form. The five flattened collections exist because
require(role.hr) predates the original sets; no more are added. Nine
other StringSets are set-only — client.teams,
client.authorized_scopes, client.authorized_audiences,
caller_workload.selectors, this_workload.selectors, security.labels,
agent.conversation.topics, meta.tags, llm.capabilities — so
require(tag.pii) is false forever, by design.
Membership names are atomic values. A dotted name such as admin.readonly
stays unchanged in subject.roles, but it does not emit
role.admin.readonly. Emitting that key would make CEL construct nested maps
and incorrectly satisfy has(role.admin). Address dotted names through the
original set: subject.roles contains "admin.readonly" in APL or
"admin.readonly" in subject.roles in CEL.
Authors should use the original set for membership (subject.roles contains "hr" in APL, "hr" in subject.roles in CEL, "hr" in input.subject.roles in
OPA). That key is present whenever the subject (or client) sub-record is, so
the four decision points agree on empty.
Flattened booleans are an APL convenience: require(role.hr) is false when the
key is missing. They are not a second source of truth. The bridge always
derives them from the set, so as emitted they cannot disagree. A later
AttributeBag::set that writes one and not the other last write wins on
that key; the other key is left as it was. Do not mix a hand-built bag with
the bridge if you need them to stay paired.
Cedar does not read the bag the way CEL and OPA do. principal.roles,
principal.permissions, and principal.teams are read from the canonical
subject.roles, subject.permissions, and subject.teams sets, preserving
dotted membership names. When a manually built bag omits a canonical roles or
permissions set, Cedar falls back to flattened role.* / perm.* trues for
compatibility. A present canonical set is authoritative. Cedar does not
surface client.*, http.*, or the other slots as principal attributes. A
Cedar policy that needs those values does not get them from this mapping.
subject.claims
There is no subject.claims bag key, and there will not be one until the bag
gains a map type.
AttributeValue is Bool, Int, Float, String, or StringSet. A JWT
claim object is none of those. The bridge walks each claim through the same
JSON flattener as custom.* and args.*:
- a scalar lands at
claim.<name>with its type kept - a scalar array, empty included, lands as a
StringSet(numbers and bools rendered as strings) {},null, and an array holding a nested container set no key- a nested object sets only the children (
claim.realm_access.roles), never the parent (claim.realm_access)
Client claims are the same shape under client.claim.<name>.
That is enough for every predicate the language can ask: claim.tenant == "acme", claim.realm_access.roles contains "admin". What it cannot do is
treat the whole map as one value (exists(subject.claims) meaning “any
claim”). Cedar still injects an empty principal.claims record so a probe of
the record itself is not a missing-attribute error; individual missing claim
names inside it follow Cedar’s own rules.
To put a dict in the bag would take a sixth AttributeValue variant, APL
lookup into it, CEL map construction (already nested from dotted keys, so
partly redundant), a Cedar record that is not string-keyed leftovers, and an
OPA object. The flattened keys would still be required for the predicates that
exist today. Until that type exists, claim.* / client.claim.* are the
policy surface, and SubjectExtension.claims remains the typed form plugins
read.
What each decision point does with a missing key
| Engine | Missing key | Empty StringSet |
|---|---|---|
| APL | false for presence, equality, membership, and order. Every negated form is true: !=, !key, !(...), and not in. Negation is spelled !; not is reserved for the not in phrase, so the idiom is !authenticated. | contains / in is false |
| CEL | evaluation error; default OnError::Deny turns it into a denial that reports a key error, not a policy false | in is false |
| cedar-direct | empty roles / permissions / teams / claims on the principal so those names exist; no subject.id is a dispatch error. A missing subject.type defaults to User (PascalCase). The bridge writes lowercase (user / agent / service / system), so a type-scoped policy (principal is user vs User) can miss a principal whose type was omitted. | contains is false |
| OPA | undefined; without default allow := false the query is a default deny. not of undefined is true, so a denylist written not (x in y) Allows when y is missing. | in is false |
require inverts its predicate and denies when that inversion is true, so
the APL row above splits require on a missing key:
| Rule (omitted keys) | APL | Other engines |
|---|---|---|
require(claim.tenant == "acme") | Deny | Deny |
require(subject.roles contains "hr") | Deny | Deny |
require(claim.tenant != "acme") | Allow | Deny |
require(subject.type not in blocked_types) | Allow | CEL and cedar-direct Deny. OPA Allows: not of an undefined set is true. |
Authors who need the denylist to stay closed when the key is missing write
require(exists(claim.tenant) & claim.tenant != "acme").
A policy written against a present-empty set therefore agrees — including
when Cedar and CEL both read subject.roles. A policy written against an
omitted claim scalar agrees on the verdict (all Deny) for ==, order, and
membership, and is an AgreeDeny in ppe-pdp-diff; the cause still differs.
!= and not in do not agree across all four engines: they are
missing-claim-not-eq / missing-not-in on the allowlist. A flattened bool
whose namespace was never written (unguarded CEL role.hr with no role.*
keys), or a missing subject.id, is the missing-collection /
missing-subject-id class of split.
The twelve slots
Keys listed always are written whenever the slot (and, where noted, the
sub-record) is present. The rest are omitted when the field is None or the
map has no entry.
1. security — SecurityExtension
Subject (sec.subject present):
| Key | Type | When |
|---|---|---|
subject.id | String | id is Some |
subject.type | String (user / agent / service / system) | subject_type is Some. cedar-direct, given no key, still builds a principal typed User; bridged values are lowercase. |
subject.roles | StringSet | always |
role.<name> | Bool (true) | each undotted member of roles |
subject.permissions | StringSet | always |
perm.<name> | Bool (true) | each undotted member of permissions |
subject.teams | StringSet | always |
team.<name> | Bool (true) | each undotted member of teams |
claim.<dotted> | flattened JSON | each claim; see subject.claims |
authenticated | Bool (true) | id is Some |
Client (sec.client present):
| Key | Type | When |
|---|---|---|
client.client_id | String | always |
client.client_name | String | Some |
client.trust_level | String | always (first_party / third_party / internal / custom / unknown) |
client.roles | StringSet | always |
client.role.<name> | Bool (true) | each undotted member |
client.permissions | StringSet | always |
client.perm.<name> | Bool (true) | each undotted member |
client.authorized_scopes | StringSet | always |
client.authorized_audiences | StringSet | always |
client.teams | StringSet | always |
client.claim.<dotted> | flattened JSON | each claim |
Workload (caller_workload / this_workload; same shape, two namespaces).
These are not agent.*. agent.* is session context.
| Key | Type | When |
|---|---|---|
<ns>.spiffe_id | String | Some |
<ns>.trust_domain | String | Some |
<ns>.attestor | String | Some |
<ns>.selectors | StringSet | always |
<ns>.client_id | String | Some |
attested_at is not in the bag. request.timestamp and
completion.created_at are carried as plain strings, so the bag does not
refuse timestamps; unifying the three is out of scope here.
Other, written whenever the security slot itself is present:
| Key | Type | When |
|---|---|---|
auth_method | String | Some |
security.labels | StringSet | always |
security.classification | String | Some |
security.objects and security.data are not in the bag. Both live on
SecurityExtension, and filter_extensions copies them to every plugin
(unrestricted sub-fields). The bridge does not flatten
ObjectSecurityProfile or DataPolicy (apply_labels, allowed_actions,
denied_actions, retention). Plugins that need them read the typed slot.
The static data: payload tree (data.* keys) is a different source; see
Payloads that are not slots.
capability_namespaces maps read_* capabilities to bag prefixes. read_labels
unlocks security.labels; read_workload unlocks caller_workload.* and
this_workload.*. Nothing writes a workload.* prefix.
2. delegation — DelegationExtension
| Key | Type | When |
|---|---|---|
delegation.depth | Int | always (0 if none) |
delegation.delegated | Bool | always |
delegated | Bool | always (alias of the previous) |
delegation.origin_subject_id | String | Some |
delegation.actor_subject_id | String | Some |
delegation.age_seconds | Float | always |
Per-hop scopes, audience, and strategy stay on the typed chain.
3. agent — AgentExtension
| Key | Type | When |
|---|---|---|
agent.input | String | Some |
agent.session_id | String | Some |
agent.conversation_id | String | Some |
agent.turn | Int | Some |
agent.agent_id | String | Some |
agent.parent_agent_id | String | Some |
agent.conversation.summary | String | conversation present and summary Some |
agent.conversation.topics | StringSet | conversation present (always then) |
conversation.history is not flattened.
4. meta — MetaExtension
| Key | Type | When |
|---|---|---|
meta.entity_type | String | Some |
meta.entity_name | String | Some |
meta.tags | StringSet | always |
meta.scope | String | Some |
meta.properties.<k> | String | each map entry |
5. request — RequestExtension
| Key | Type | When |
|---|---|---|
request.environment | String | Some |
request.request_id | String | Some |
request.timestamp | String | Some (ISO 8601 text) |
request.trace_id | String | Some |
request.span_id | String | Some |
A default request slot adds nothing.
6. http — HttpExtension
| Key | Type | When |
|---|---|---|
http.method | String | Some |
http.path | String | Some |
http.host | String | Some |
http.scheme | String | Some |
http.status | Int | Some (response half) |
http.request_headers.<name> | String | each header; name lowercased |
http.response_headers.<name> | String | each header; name lowercased |
7. llm — LLMExtension
| Key | Type | When |
|---|---|---|
llm.model_id | String | Some |
llm.provider | String | Some |
llm.capabilities | StringSet | always |
8. mcp — MCPExtension
Tool present:
| Key | Type | When |
|---|---|---|
mcp.tool.name | String | always |
mcp.tool.title | String | Some |
mcp.tool.description | String | Some |
mcp.tool.server_id | String | Some |
mcp.tool.namespace | String | Some |
Resource present:
| Key | Type | When |
|---|---|---|
mcp.resource.uri | String | always |
mcp.resource.name | String | Some |
mcp.resource.description | String | Some |
mcp.resource.mime_type | String | Some |
mcp.resource.server_id | String | Some |
Prompt present:
| Key | Type | When |
|---|---|---|
mcp.prompt.name | String | always |
mcp.prompt.description | String | Some |
mcp.prompt.server_id | String | Some |
Schemas and annotations are not flattened.
9. completion — CompletionExtension
| Key | Type | When |
|---|---|---|
completion.stop_reason | String | Some (end / return / call / max_tokens / stop_sequence) |
completion.tokens.input | Int | tokens present |
completion.tokens.output | Int | tokens present |
completion.tokens.total | Int | tokens present |
completion.model | String | Some |
completion.raw_format | String | Some |
completion.created_at | String | Some |
completion.latency_ms | Int | Some |
10. provenance — ProvenanceExtension
| Key | Type | When |
|---|---|---|
provenance.source | String | Some |
provenance.message_id | String | Some |
provenance.parent_id | String | Some |
11. framework — FrameworkExtension
| Key | Type | When |
|---|---|---|
framework.framework | String | Some |
framework.framework_version | String | Some |
framework.node_id | String | Some |
framework.graph_id | String | Some |
framework.metadata.<dotted> | flattened JSON | each metadata entry |
12. custom — HashMap<String, Value>
| Key | Type | When |
|---|---|---|
custom.<dotted> | flattened JSON | each map entry |
An empty map adds nothing.
Payloads that are not slots
These use the same walker and the same absent-value rules, but they are not
extract_extensions slots:
| Source | Keys |
|---|---|
| Request arguments | Object fields: args.<dotted>. A top-level scalar is the key args (String / Bool / Int / Float). A top-level scalar array is args as a StringSet (empty included). A top-level array of objects or nested arrays sets nothing. null sets nothing. |
| Upstream result | Same shapes under result / result.<dotted>. |
Static data: tree | Same walker under data / data.<dotted>. |
| Route identifier | route.key |