CMF extensions and the attribute bag

Map typed Common Message Format extensions into the attribute bag that APL policies inspect.
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

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.

TypeWhen the field is empty or NoneWhy
StringSetPresent 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.
StringOmitted 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 == "").
IntOmitted 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.
FloatSame 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 mapNo 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.rolesrole.<name> = trueAlias for each undotted member.
subject.permissionsperm.<name> = trueAlias for each undotted member.
subject.teamsteam.<name> = trueAlias for each undotted member.
client.rolesclient.role.<name> = trueAlias for each undotted member.
client.permissionsclient.perm.<name> = trueAlias 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

EngineMissing keyEmpty StringSet
APLfalse 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
CELevaluation error; default OnError::Deny turns it into a denial that reports a key error, not a policy falsein is false
cedar-directempty 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
OPAundefined; 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)APLOther 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: 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):

KeyTypeWhen
subject.idStringid is Some
subject.typeString (user / agent / service / system)subject_type is Some. cedar-direct, given no key, still builds a principal typed User; bridged values are lowercase.
subject.rolesStringSetalways
role.<name>Bool (true)each undotted member of roles
subject.permissionsStringSetalways
perm.<name>Bool (true)each undotted member of permissions
subject.teamsStringSetalways
team.<name>Bool (true)each undotted member of teams
claim.<dotted>flattened JSONeach claim; see subject.claims
authenticatedBool (true)id is Some

Client (sec.client present):

KeyTypeWhen
client.client_idStringalways
client.client_nameStringSome
client.trust_levelStringalways (first_party / third_party / internal / custom / unknown)
client.rolesStringSetalways
client.role.<name>Bool (true)each undotted member
client.permissionsStringSetalways
client.perm.<name>Bool (true)each undotted member
client.authorized_scopesStringSetalways
client.authorized_audiencesStringSetalways
client.teamsStringSetalways
client.claim.<dotted>flattened JSONeach claim

Workload (caller_workload / this_workload; same shape, two namespaces). These are not agent.*. agent.* is session context.

KeyTypeWhen
<ns>.spiffe_idStringSome
<ns>.trust_domainStringSome
<ns>.attestorStringSome
<ns>.selectorsStringSetalways
<ns>.client_idStringSome

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:

KeyTypeWhen
auth_methodStringSome
security.labelsStringSetalways
security.classificationStringSome

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

KeyTypeWhen
delegation.depthIntalways (0 if none)
delegation.delegatedBoolalways
delegatedBoolalways (alias of the previous)
delegation.origin_subject_idStringSome
delegation.actor_subject_idStringSome
delegation.age_secondsFloatalways

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

3. agent — AgentExtension

KeyTypeWhen
agent.inputStringSome
agent.session_idStringSome
agent.conversation_idStringSome
agent.turnIntSome
agent.agent_idStringSome
agent.parent_agent_idStringSome
agent.conversation.summaryStringconversation present and summary Some
agent.conversation.topicsStringSetconversation present (always then)

conversation.history is not flattened.

4. meta — MetaExtension

KeyTypeWhen
meta.entity_typeStringSome
meta.entity_nameStringSome
meta.tagsStringSetalways
meta.scopeStringSome
meta.properties.<k>Stringeach map entry

5. request — RequestExtension

KeyTypeWhen
request.environmentStringSome
request.request_idStringSome
request.timestampStringSome (ISO 8601 text)
request.trace_idStringSome
request.span_idStringSome

A default request slot adds nothing.

6. http — HttpExtension

KeyTypeWhen
http.methodStringSome
http.pathStringSome
http.hostStringSome
http.schemeStringSome
http.statusIntSome (response half)
http.request_headers.<name>Stringeach header; name lowercased
http.response_headers.<name>Stringeach header; name lowercased

7. llm — LLMExtension

KeyTypeWhen
llm.model_idStringSome
llm.providerStringSome
llm.capabilitiesStringSetalways

8. mcp — MCPExtension

Tool present:

KeyTypeWhen
mcp.tool.nameStringalways
mcp.tool.titleStringSome
mcp.tool.descriptionStringSome
mcp.tool.server_idStringSome
mcp.tool.namespaceStringSome

Resource present:

KeyTypeWhen
mcp.resource.uriStringalways
mcp.resource.nameStringSome
mcp.resource.descriptionStringSome
mcp.resource.mime_typeStringSome
mcp.resource.server_idStringSome

Prompt present:

KeyTypeWhen
mcp.prompt.nameStringalways
mcp.prompt.descriptionStringSome
mcp.prompt.server_idStringSome

Schemas and annotations are not flattened.

9. completion — CompletionExtension

KeyTypeWhen
completion.stop_reasonStringSome (end / return / call / max_tokens / stop_sequence)
completion.tokens.inputInttokens present
completion.tokens.outputInttokens present
completion.tokens.totalInttokens present
completion.modelStringSome
completion.raw_formatStringSome
completion.created_atStringSome
completion.latency_msIntSome

10. provenance — ProvenanceExtension

KeyTypeWhen
provenance.sourceStringSome
provenance.message_idStringSome
provenance.parent_idStringSome

11. framework — FrameworkExtension

KeyTypeWhen
framework.frameworkStringSome
framework.framework_versionStringSome
framework.node_idStringSome
framework.graph_idStringSome
framework.metadata.<dotted>flattened JSONeach metadata entry

12. custom — HashMap<String, Value>

KeyTypeWhen
custom.<dotted>flattened JSONeach 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:

SourceKeys
Request argumentsObject 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 resultSame shapes under result / result.<dotted>.
Static data: treeSame walker under data / data.<dotted>.
Route identifierroute.key