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 thirteen slots dispatched by extract_extensions are listed below. raw_credentials, candidate_constraint, and llm_request are not among them: credentials never enter the bag, a routing constraint is not a policy attribute, and the parsed LLM request reaches PDPs as structured JSON instead (see Structured request input).

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.

Acquired metadata is the deliberate exception to the last row: each present object, mixed array, or null writes NonScalar at its own path so an installed result remains distinguishable from a missing one in APL. CEL and OPA skip NonScalar entries until the evaluator-native projection lands, so there this presence is not yet visible. An integer outside the i64 range also projects as NonScalar rather than a rounded float. The structured record remains available to later evaluator-native projections.

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.

That rule is also why the list above is closed rather than merely unfinished. The flattened form cannot represent a dotted name, so it is lossy wherever one occurs, while the set always holds every member. Mirroring booleans onto a tenth collection would spread an incomplete projection rather than complete the vocabulary.

client.teams is the case that looks like an oversight and is not. subject.teams carries both forms and client.teams carries only the set, so the asymmetry sits inside one namespace pair. It stays set-only: both namespaces already have the complete form, and what subject.* has in addition is the lossy sugar, not a capability client.* is missing. An author wanting client team membership writes client.teams contains "platform", which is also the only form that works when a team name contains a dot.

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.

Unlike OPA (input.http.query) and CEL (http.query), Cedar’s context is not auto-populated with the http.* slot — the builder injects only context.delegation, context.meta, and context.security. A Cedar step reaches the request line and query through a ${…} template reference, which resolves from the same bag keys the HTTP extractor populates:

- cedar:
    action: 'Action::"read"'
    resource:
      type: Request
      id: ${http.path}
      attributes:
        method: ${http.method}
        query:  ${http.query}

${http.query} resolves to the raw query string (no leading ?). The reference is whole-string only. A request that carried no query leaves http.query out of the bag, so ${http.query} fails loudly rather than resolving empty — a query-specific Cedar rule cannot be silently satisfied by an absent query. As with OPA and CEL, an equality on the raw query is an exact-string check, not a parsed-parameter gate.


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 thirteen 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.

These tables are checked, not maintained by hand. crates/ppe-apl-cmf/tests/key_catalog/ declares the key universe and asserts three things against it: that flattening a fully populated Extensions emits exactly those keys with those types, that the tables below list exactly those keys with the same Type cell and under the slot each key comes from, and that capability_namespaces unlocks each one through the capability it is gated on while leaving the ungated ones alone. A key cannot be added to a table here without being added to the catalog, and a key the bridge starts writing fails the test until both list it. A field added to an extension stops that test’s fixture compiling, which is what turns leaving it unbridged into a decision someone makes rather than one nobody notices.

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>.attested_atStringSome. RFC3339, second precision, Z.
<ns>.attested_at_epochIntSome. The same instant as Unix seconds.
<ns>.selectorsStringSetalways
<ns>.client_idStringSome

attested_at renders as RFC3339 with second precision and a literal Z. At fixed precision the string sorts the same way the instant does, which is why the format is pinned: variable precision would break it, because . sorts before Z and 00.5Z would compare as earlier than 00Z.

APL cannot order it. APL’s <, <=, > and >= are numeric: an operand that will not coerce to a finite f64 is unorderable, and the phase fails closed. So caller_workload.attested_at < "2026-10-01T00:00:00Z" does not test freshness, it denies every request, including one whose attestation is recent:

order comparison on `caller_workload.attested_at` failed (fail-closed):
value is not a finite number

attested_at_epoch carries the same instant as Unix seconds, which APL does order, on numeric_compare’s exact integer path. That is what makes an event-pinned floor writable:

caller_workload.attested_at_epoch < 1790812800: deny

“reject anything attested before the CA rotation” is a lasting control rather than a window that goes stale, so the literal never needs editing. What is not writable is a rolling window: APL compares an attribute against a literal, with no arithmetic and no now(), so “attested within the last hour” cannot be expressed against either form. That needs language support and is tracked separately.

CEL and Rego order both forms, the string lexicographically and the epoch numerically.

This is the one of three date keys the engine controls the format of. request.timestamp and completion.created_at are host-supplied strings and carry no format guarantee at all, so they are not ordered reliably anywhere. Giving the bag a date type, and APL the operators to compare one, is what makes a staleness rule writable in the native language.

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. read_teams unlocks team.* alongside subject.teams, the way read_roles and read_permissions unlock their flattened aliases.

auth_method and security.classification are not gated. filter_extensions treats them as unrestricted sub-fields and includes them whatever a plugin declared, so no capability names them: naming one would describe a grant an operator could withhold, and these cannot be withheld.

They are not alone. Eight whole slots are AccessPolicy::Unrestricted in praxis_policy_core::extensions::filter — request, provenance, completion, llm, framework, mcp, meta and custom — so every key under them reaches every plugin and every PDP regardless of capabilities. The read_request, read_provenance, read_completion, read_llm, read_framework, read_mcp, read_meta and read_custom capabilities gate nothing on the bag, and capability_namespaces returns no prefixes for them.

Capabilities withhold the credential-bearing slots: security.* and its sub-records, delegation.*, agent.* and http.*. Those are the ones where declaring less actually shows a plugin less.

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

chain is a Vec<DelegationHop> and nothing from it is bridged, so delegation.depth <= 2 is expressible and “deny if any hop granted write:payroll” is not. A plugin that needs per-hop grants reads the typed chain.

Putting any of it on the bag first requires deciding what aggregation means, and the answer differs per field. A union over scopes_granted reports a scope one hop granted and a later hop narrowed, so the key would claim more authority than the chain conveys, and an author reading it could not tell. from_cache wants an any-hop flag rather than a union, and whether one cached hop taints the whole chain is an operator’s decision. Per-hop strategy and ttl_seconds have no meaningful union at all. Indexing by position (delegation.hop.2.scopes) is not an answer either, since chain position is not stable across requests.

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, and there is no length key either. The transcript is a Vec<Value> of turns, so flattening it would put message text on the bag under indexed keys, which is the opposite of what the bag is for. A plugin that needs the transcript reads the typed slot through read_agent.

Turn-count gating needs no new key: agent.turn is an Int, so agent.turn > 10 works today.

4. meta — MetaExtension

KeyTypeWhen
meta.entity_typeStringSome
meta.entity_nameStringSome
meta.tagsStringSetalways
meta.scopeStringSome
meta.properties.<name>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.queryStringSome when the request carried a query (value excludes the leading ?); omitted otherwise. Readable by a rule, but its value is redacted in diagnostics — see the note below
http.hostStringSome
http.schemeStringSome
http.statusIntSome (response half)
http.request_headers.<name>Stringeach header; name lowercased
http.response_headers.<name>Stringeach header; name lowercased

A rule reads http.query and the header values directly. Built-in PDP deny diagnostics render these potentially secret values as type labels, and HttpExtension renders a present query as [REDACTED] in its serialized and Debug output. Plugin views include the raw query only with read_query; read_headers alone cannot read it. The canonical AttributeBag still holds the raw value for policy evaluation; its Debug and Serialize implementations are not redacted, so it must not be used directly as a log or audit record. A host must populate the canonical query after any serialization boundary because HttpExtension serialization replaces its value with [REDACTED].

7. llm — LLMExtension

KeyTypeWhen
llm.model.idStringSome
llm.model.providerStringSome
llm.model.capabilitiesStringSetalways
llm.model_idStringdeprecated alias of llm.model.id
llm.providerStringdeprecated alias of llm.model.provider
llm.capabilitiesStringSetdeprecated alias of llm.model.capabilities

The aliases have identical values. Keep them until old policy references can be detected at load time; removing a deny rule’s key without that check could weaken a policy.

8. llm_request — LlmRequest

One gated, un-serialized carrier holds an optional parsed body and optional normalized parts. The following keys come only from reported normalized parts. A plugin needs read_llm_request to read the carrier and its bag keys.

KeyTypeWhen
llm.input.offered_toolsStringSetalways when parts reported; tool names
llm.input.stop_sequencesStringSetalways when parts reported
llm.input.tool_choiceStringSome (auto / none / required / tool)
llm.input.forced_toolStringchoice is tool
llm.input.max_tokensInteffective normalized completion cap, Some
llm.input.candidate_countIntSome
llm.input.temperatureFloatSome
llm.input.top_pFloatSome
llm.input.streamBoolSome
llm.input.instructions_digestStringalways when parts reported, sha256-v1:<hex>

With no normalized parts, none of these keys is written. A reported empty tool list writes an empty set. Instruction text and tool schemas stay off the bag. The digest uses the ordered system and developer instruction records: b"ppe.llm.instructions.v1\0", a big-endian u32 record count, then for each record a role byte (system 0, developer 1), a big-endian u64 UTF-8 byte length, and the exact text bytes. The digest is lowercase SHA-256. instruction_digest in ppe-apl-cmf::llm computes a policy pin.

The parsed body is available to OPA and CEL at llm.request and to Cedar at context.llm.request; it is not a bag key. A parts-only carrier creates bag keys without inventing a body.

9. mcp — MCPExtension

Tool present:

KeyTypeWhen
mcp.tool.nameStringalways
mcp.tool.titleStringSome
mcp.tool.descriptionStringSome
mcp.tool.server_idStringSome
mcp.tool.namespaceStringSome
mcp.tool.annotations.<name>flattened JSONeach map entry

Resource present:

KeyTypeWhen
mcp.resource.uriStringalways
mcp.resource.nameStringSome
mcp.resource.descriptionStringSome
mcp.resource.mime_typeStringSome
mcp.resource.server_idStringSome
mcp.resource.annotations.<name>flattened JSONeach map entry

Prompt present:

KeyTypeWhen
mcp.prompt.nameStringalways
mcp.prompt.descriptionStringSome
mcp.prompt.server_idStringSome
mcp.prompt.annotations.<name>flattened JSONeach map entry

annotations flattens through the same JSON walker as custom.*, framework.metadata.* and claim.*, so readOnlyHint and destructiveHint are readable and a rule can refuse a destructive tool. Being free-form is not what keeps a map off the bag, or none of those four would be on it.

ToolMetadata.input_schema, ToolMetadata.output_schema and PromptMetadata.arguments stay off. They describe the shape of a call rather than a fact about this one, so a rule over them would assert something about the contract instead of the request. The argument values themselves reach policy as args.*; see Payloads that are not slots.

10. completion — CompletionExtension

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

The completion.* aliases remain value-identical during migration.

11. provenance — ProvenanceExtension

KeyTypeWhen
provenance.sourceStringSome
provenance.message_idStringSome
provenance.parent_idStringSome

12. framework — FrameworkExtension

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

13. custom — HashMap<String, Value>

KeyTypeWhen
custom.<dotted>flattened JSONeach map entry

An empty map adds nothing.

13. metadata — MetadataExtension

KeyTypeWhen
metadata.<name>acquired JSONeach installed instance and its derived value/provenance paths

Each instance is present as NonScalar. Object response members project directly below it; a scalar, array, or null root uses the stable value leaf. Scalar leaves keep their type, string arrays become StringSet, and objects, mixed arrays, and null are present as NonScalar. The structured AcquiredFacts value remains authoritative and is not reconstructed from these flattened keys.

Reserved provenance leaves are $source, $outcome, $fetched_at, $cache, $status, $stale, and $error_code; optional leaves appear only when they apply. $source carries the registered plugin kind, while the containing namespace carries its instance name. $fetched_at is an RFC 3339 UTC string at second precision with a Z suffix, such as 2026-10-19T12:00:00Z, matching <ns>.attested_at. APL reads these leaves as ordinary path segments, as in metadata.account.$outcome. A plugin sees this slot and namespace only with read_metadata; route predicates always can.


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

On tool: routes, OPA and CEL do not read the request argument rows above. They receive the arguments as native JSON instead, so arrays of objects, array order, duplicates, numbers, and null survive. Cedar receives the same JSON, sanitized, as context.args. APL still reads the flattened keys. See Structured request input.