CMF Extensions and the Attribute Bag#

Policy reads a flat attribute bag. The engine fills it by walking each present slot on the request’s Extensions into dotted keys. Plugins that are handed a typed slot still see the original struct. This page is the contract for what the bridge writes, how collections relate to the flattened booleans, and what each decision point does with a missing key.

Extensions & Capability-Gating covers which capability unlocks each slot. This page covers the keys.

Twelve slots are bridged. Three slots never enter the bag: raw_credentials (credentials stay out of policy), candidate_constraint (a routing constraint, not an attribute) and llm_request (it reaches PDPs as structured JSON instead).

Setting slots from each surface#

The host supplies the extensions on every invocation.

  • Rust: build cpex::praxis_policy_core::extensions::Extensions directly. Every slot is available.
  • Python: pass a dict as the extensions argument of invoke_hook. It is deserialized into the same Extensions type, so every slot on this page can be set, for example {"meta": {...}, "security": {"subject": {...}}, "http": {"request_headers": {...}}}.
  • Go: fill cpex.Extensions. It declares a subset of the fields. It has no security.client, caller_workload or this_workload, no HTTP request line or status (method, path, host, scheme, status), and SubjectExtension.Claims holds strings only, so nested claims cannot be set from Go. Keys that depend on those fields are never written for a Go request unless a plugin on the engine side sets them, such as an identity resolver.

Which slots a route’s policy sees#

A route’s rules read the bag through the route’s own handler, and that handler is capability-gated like a plugin. It always holds read_subject, read_roles, read_permissions, read_teams, read_claims, read_labels, read_delegation, read_agent, read_meta and read_headers, plus every capability declared by a plugin the route references. Other slots are filtered out. For example, a host that sets security.client on a route that references no plugin with read_client gets a deny from require(client.client_id == "app-1"), because client.* is never written for that route. A Rust host can change the base set with AplOptions::base_capabilities and register_apl; install_builtins and the bindings use the default.

Absent values#

The rule is per attribute type, and it applies only inside a present slot. An absent slot writes nothing for its namespace.

TypeWhen the field is empty or unsetWhy
String setPresent and empty. Membership is false.CEL treats a missing key as an error, so !("banned" in subject.roles) would deny every subject with no roles.
Bool field (delegation.delegated)Present, including false.The field is not optional.
Flattened bool (role.hr)Omitted. Presence means true. Only membership names without dots get one.APL reads a missing flattened bool as false. In CEL, do not guard with has(role.hr): if no role.* key exists, that is an evaluation error. Use "hr" in subject.roles.
Derived bool (authenticated)Omitted unless subject.id is set.!authenticated is true and require(authenticated) denies. In CEL, use has(subject.id) as the guard.
StringOmitted when unset. A non-optional string (client.client_id) is always written, even if empty.Empty and missing are different questions.
Int, FloatOmitted when unset (http.status, agent.turn, completion.latency_ms). Non-optional ones (delegation.depth, delegation.age_seconds) are always written.A missing status is not zero: http.status >= 500 and http.status == 0 would both lie.
JSON object or claimsNo parent key. Each scalar or scalar-array child is written under a dotted path. {}, null, and arrays holding containers write nothing.The bag has no map type.

Collections and flattened booleans#

CollectionFlattened form
subject.rolesrole.<name> = true
subject.permissionsperm.<name> = true
subject.teamsteam.<name> = true
client.rolesclient.role.<name> = true
client.permissionsclient.perm.<name> = true

The set is the primary form. Only these five get flattened aliases. Other sets 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 always false; use meta.tags contains "pii".

A dotted membership name such as admin.readonly stays in subject.roles but gets no role.admin.readonly key, because CEL would read that key as nested maps. Test dotted names through the set.

Prefer the set for membership in every dialect: subject.roles contains "hr" in APL, "hr" in subject.roles in CEL, "hr" in input.subject.roles in OPA. The set is present whenever the subject is, so all decision points agree on empty.

Cedar reads principal.roles, principal.permissions and principal.teams from the sets. It does not surface client.*, http.* or other slots as principal attributes.

Claims#

There is no subject.claims key. Each claim goes 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 string set
  • {}, null, and an array holding a container write nothing
  • a nested object writes only its children (claim.realm_access.roles), never the parent

Client claims use the same shape under client.claim.<name>. That covers claim.tenant == "acme" and claim.realm_access.roles contains "admin", but not “any claim at all”. Plugins read the typed SubjectExtension.claims.

Missing keys per decision point#

EngineMissing keyEmpty set
APLFalse for presence, equality, membership and order. Every negated form is true: !=, !key, !(...), not in.contains / in is false
CELEvaluation error, which the default on_error: deny turns into a deny that reports a key errorin is false
cedar-directEmpty roles, permissions, teams and claims exist on the principal. No subject.id is a dispatch error. A missing subject.type defaults to User; the bridge writes lowercase types.contains is false
OPAUndefined. Without default allow := false, the query is a default deny. not of undefined is true.in is false

require denies when its predicate is false, so on omitted keys:

RuleAPLOther engines
require(claim.tenant == "acme")DenyDeny
require(subject.roles contains "hr")DenyDeny
require(claim.tenant != "acme")AllowDeny
require(subject.type not in blocked_types)AllowCEL and cedar-direct deny; OPA allows

To keep a denylist closed when the key is missing, write require(exists(claim.tenant) & claim.tenant != "acme").

The twelve slots#

Keys marked always are written whenever the slot (or the noted sub-record) is present. The rest are omitted when unset.

security#

Subject (when present):

KeyTypeWhen
subject.idStringset
subject.typeString (user, agent, service, system)set
subject.roles, subject.permissions, subject.teamsString setalways
role.<name>, perm.<name>, team.<name>Booleach member without a dot
claim.<dotted>flattened JSONeach claim
authenticatedBoolsubject.id is set

Client (when present):

KeyTypeWhen
client.client_idStringalways
client.client_nameStringset
client.trust_levelString (first_party, third_party, internal, custom, unknown)always
client.roles, client.permissions, client.teamsString setalways
client.role.<name>, client.perm.<name>Booleach member without a dot
client.authorized_scopes, client.authorized_audiencesString setalways
client.claim.<dotted>flattened JSONeach claim

Workload (caller_workload and this_workload, same shape):

KeyTypeWhen
<ns>.spiffe_id, <ns>.trust_domain, <ns>.attestor, <ns>.client_idStringset
<ns>.selectorsString setalways

Other, whenever the security slot is present: auth_method (String, set), security.labels (String set, always), security.classification (String, set). security.objects and security.data are not in the bag; plugins read them from the typed slot.

delegation#

KeyTypeWhen
delegation.depthIntalways
delegation.delegated, delegatedBoolalways
delegation.origin_subject_id, delegation.actor_subject_idStringset
delegation.age_secondsFloatalways

Per-hop scopes, audience and strategy stay on the typed chain.

agent#

KeyTypeWhen
agent.input, agent.session_id, agent.conversation_id, agent.agent_id, agent.parent_agent_idStringset
agent.turnIntset
agent.conversation.summaryStringconversation present and summary set
agent.conversation.topicsString setconversation present

meta#

KeyTypeWhen
meta.entity_type, meta.entity_name, meta.scopeStringset
meta.tagsString setalways
meta.properties.<k>Stringeach entry

request#

request.environment, request.request_id, request.timestamp, request.trace_id, request.span_id: String, when set.

http#

KeyTypeWhen
http.method, http.path, http.host, http.schemeStringset
http.statusIntset (response half)
http.request_headers.<name>, http.response_headers.<name>Stringeach header, name lowercased

llm#

llm.model_id, llm.provider (String, set) and llm.capabilities (String set, always). The parsed request body is the separate llm_request slot: OPA and CEL read it as llm.request, Cedar as context.llm.request.

mcp#

KeyTypeWhen
mcp.tool.nameStringtool present
mcp.tool.title, mcp.tool.description, mcp.tool.server_id, mcp.tool.namespaceStringset
mcp.resource.uriStringresource present
mcp.resource.name, mcp.resource.description, mcp.resource.mime_type, mcp.resource.server_idStringset
mcp.prompt.nameStringprompt present
mcp.prompt.description, mcp.prompt.server_idStringset

Schemas and annotations are not flattened.

completion#

KeyTypeWhen
completion.stop_reasonStringset
completion.tokens.input, completion.tokens.output, completion.tokens.totalInttokens present
completion.model, completion.raw_format, completion.created_atStringset
completion.latency_msIntset

provenance#

provenance.source, provenance.message_id, provenance.parent_id: String, when set.

framework#

framework.framework, framework.framework_version, framework.node_id, framework.graph_id (String, set) and framework.metadata.<dotted> (flattened JSON).

custom#

custom.<dotted>: flattened JSON for each entry.

Keys that do not come from slots#

SourceKeys
Request argumentsargs.<dotted>. A top-level scalar is args; a top-level scalar array is args as a string set.
Upstream resultThe same shapes under result and result.<dotted>.
Static data: treedata and data.<dotted>.
Routeroute.key

On tool: routes, OPA and CEL do not read the flattened args.* keys. They get the arguments as native JSON, so arrays of objects, order, numbers and null survive. Cedar gets the same JSON, sanitized, as context.args. APL still reads the flattened keys. See PDP.

Go deeper#