Status: RFC / request for input
Area: apl-core (compiler), apl-cpex (dispatch), policy filter (praxis integration)
Problem
Today the policy filter resolves identity in the header phase (on_request)
and runs the entire APL pre_invocation in the body phase
(on_request_body), because CMF dispatch needs the protocol classifier's
mcp.method / mcp.name metadata and the materialized call arguments.
That means: for a route whose policy is decidable from identity alone
(require(role.hr), require(team.x), session/taint gates), we still buffer the
whole request body before we can deny an unauthorized caller. Two consequences:
- Cost / footprint — bodies get buffered (StreamBuffer) even when the deny
never needed them.
- A real ordering hazard — because a body-buffering filter forces a
pre-read, the body phase (which resolves identity, authorizes, and strips
the inbound X-User-Token on the delegate path) runs before the
header-phase early identity gate. The gate then re-resolves identity against
already-stripped headers and spuriously 401s. (Seen live; currently patched
by having the early gate skip when identity is already resolved.)
Both point at the same gap: CPEX policy has been implicitly invoke-args-centric,
but a lot of policy only needs Extensions (identity/agent/session), not the
call payload.
Goal
Let identity/header-only policy evaluate and deny without requiring the body,
and buffer the body only when a route actually needs it — while keeping the
authoring model simple and preserving evaluation order.
Proposed model: three phases, not two
1. identity.resolve → produces Extensions (header time, no body)
2. invoke(Extensions) → header-phase policy subset (no payload)
3. invoke(Extensions, payload) → body-phase policy subset (MessageView)
- Identity resolution is a phase of its own, upstream of both. It only needs
headers, runs once, and its Extensions feed both invokes. It is not "in"
either policy subset — it's the substrate. (This is why splitting policy at the
route level breaks: authentication: config has no non-duplicative home.)
- The payload invoke is a superset of the extensions invoke — anything the
header phase can decide, the body phase can too. So "run everything in the body
phase" stays a valid default and today's behavior is unchanged.
Mechanically this is PluginManager.invoke with and without a payload; the open
question is how each rule/step is assigned to one of the two.
Design question 1 — how is the split expressed? (primary input wanted)
A. Explicit subsections inside a route
- tool: get_compensation
pre_invocation:
header: [ "require(role.hr)" ]
body: [ "redact(args.ssn) when !perm.view_ssn" ]
- ➕ Phase is visible in the source.
- ➖ Author hand-assigns every rule; the compiler still must validate a
header: rule doesn't reference args.* — so you do the compiler's job and
keep its check. Verbose; a new way to be wrong.
B. Separate routes by suffix
get_compensation.header: { pre_invocation: [ "require(role.hr)" ] }
get_compensation: { pre_invocation: [ "redact(args.ssn) ..." ] }
- ➖ Overloads the route-key namespace (
mcp.name match vs a magic phase suffix);
a typo becomes a dead route.
- ➖ Split-brain: one tool's policy lives in two entries.
- ➖ Identity has nowhere clean to live (resolves at header time, needed by
both) → duplication or implicit cross-route coupling. This is the tell that the
route level is the wrong axis.
C. Flat list, compiler derives the phase
- tool: get_compensation
pre_invocation:
- "require(role.hr)" # subject-only → header phase
- "redact(args.ssn) when !perm.view_ssn" # args.* → body phase
- ➕ No new syntax; existing configs keep working; author's model stays "an
ordered list."
- ➕ The optimization applies everywhere for free (header subset is empty and
skipped for routes that have none).
- ➖ Phase isn't visible by eye; the body-buffering decision is implicit (see Q4).
C′. C + a control knob (author's current leaning)
- tool: get_compensation
buffer_body: auto # auto (default) | never | always
pre_invocation: [ ... ]
auto — derive (the 90% case).
never — assert "deniable without reading the body"; compiler errors if
any rule references args.* / result.*. Control as a checked guarantee.
always — force buffering (today's behavior; escape hatch for plugin steps
whose body-need can't be inferred).
The knob gives real control exactly where the phase is a requirement (security:
"never read an unauthorized caller's body"; cost tuning) without per-rule
busywork elsewhere.
Design question 2 — how is a rule's phase determined?
- APL predicates/steps: by the attributes they reference.
role.*,
team.*, perm.*, session.*, agent.* → header-evaluable; any args.* /
result.* reference → body-required. The compiler already parses these.
- Plugin steps (
run(x)): by which hook family the plugin registers for —
an Extensions-only hook vs the CMF/MessageView hook. Registration is the
body-need declaration; no guessing.
Design question 3 — ordering semantics
APL rules are ordered (rule[0] denies before rule[1]). Preserve it: buffer
the body lazily starting at the first body-requiring rule. Everything before it
runs in the header phase; a header-only rule that happens to sit after a body
rule just runs in the body phase (still correct, just not optimized). No author
constraint, no reordering of observable effects.
Design question 4 — control & visibility
The phase is mostly an optimization, but it is observable in two ways an
author may care about: (a) whether the body is buffered at all (cost + the
security property "unauthorized bodies are never read"), and (b) where a deny
lands on the wire (before vs after the body). Mitigations:
buffer_body: never|always for a checked guarantee (Q1 C′).
- A compiler
--explain / doc-gen view so the derived split is inspectable
without cluttering source:
route get_compensation (buffers body: yes)
header phase: require(role.hr)
body phase: redact(args.ssn) when !perm.view_ssn
Backward compatibility / rollout
Incremental and safe:
- Land the two invoke points; default all policy to the payload invoke →
behavior unchanged.
- Turn on derivation → header-evaluable rules migrate up automatically.
- Add the
buffer_body knob + --explain.
Open questions
- Naming:
buffer_body vs body: auto|never|always vs a phase: hint.
- Does the header phase need its own deny-envelope semantics (deny before body
read → can we still return a JSON-RPC error, or only a transport-level reject)?
- Should tag/group-level policy also be phase-derived, or always body?
- Interaction with
result: / post_invocation (response phase) — symmetric?
Author's current leaning
C′ — flat ordered list, compiler-derived phases, buffer_body: auto|never|always
as the single control surface, --explain for visibility. Simple by default,
explicit-and-checked exactly where the phase is load-bearing. Identity stays the
upstream resolve step feeding both invokes.
Related
- Registration-time capability validation (a separate lifecycle / payload — the
definition, not the args). Same theme: CPEX policy beyond the invoke-args
hook. See companion issue (to be filed).
Status: RFC / request for input
Area: apl-core (compiler), apl-cpex (dispatch), policy filter (praxis integration)
Problem
Today the
policyfilter resolves identity in the header phase (on_request)and runs the entire APL
pre_invocationin the body phase(
on_request_body), because CMF dispatch needs the protocol classifier'smcp.method/mcp.namemetadata and the materialized callarguments.That means: for a route whose policy is decidable from identity alone
(
require(role.hr),require(team.x), session/taint gates), we still buffer thewhole request body before we can deny an unauthorized caller. Two consequences:
never needed them.
pre-read, the body phase (which resolves identity, authorizes, and strips
the inbound
X-User-Tokenon the delegate path) runs before theheader-phase early identity gate. The gate then re-resolves identity against
already-stripped headers and spuriously 401s. (Seen live; currently patched
by having the early gate skip when identity is already resolved.)
Both point at the same gap: CPEX policy has been implicitly invoke-args-centric,
but a lot of policy only needs
Extensions(identity/agent/session), not thecall payload.
Goal
Let identity/header-only policy evaluate and deny without requiring the body,
and buffer the body only when a route actually needs it — while keeping the
authoring model simple and preserving evaluation order.
Proposed model: three phases, not two
headers, runs once, and its
Extensionsfeed both invokes. It is not "in"either policy subset — it's the substrate. (This is why splitting policy at the
route level breaks:
authentication:config has no non-duplicative home.)header phase can decide, the body phase can too. So "run everything in the body
phase" stays a valid default and today's behavior is unchanged.
Mechanically this is
PluginManager.invokewith and without a payload; the openquestion is how each rule/step is assigned to one of the two.
Design question 1 — how is the split expressed? (primary input wanted)
A. Explicit subsections inside a route
header:rule doesn't referenceargs.*— so you do the compiler's job andkeep its check. Verbose; a new way to be wrong.
B. Separate routes by suffix
mcp.namematch vs a magic phase suffix);a typo becomes a dead route.
both) → duplication or implicit cross-route coupling. This is the tell that the
route level is the wrong axis.
C. Flat list, compiler derives the phase
ordered list."
skipped for routes that have none).
C′. C + a control knob (author's current leaning)
auto— derive (the 90% case).never— assert "deniable without reading the body"; compiler errors ifany rule references
args.*/result.*. Control as a checked guarantee.always— force buffering (today's behavior; escape hatch for plugin stepswhose body-need can't be inferred).
The knob gives real control exactly where the phase is a requirement (security:
"never read an unauthorized caller's body"; cost tuning) without per-rule
busywork elsewhere.
Design question 2 — how is a rule's phase determined?
role.*,team.*,perm.*,session.*,agent.*→ header-evaluable; anyargs.*/result.*reference → body-required. The compiler already parses these.run(x)): by which hook family the plugin registers for —an
Extensions-only hook vs the CMF/MessageView hook. Registration is thebody-need declaration; no guessing.
Design question 3 — ordering semantics
APL rules are ordered (
rule[0]denies beforerule[1]). Preserve it: bufferthe body lazily starting at the first body-requiring rule. Everything before it
runs in the header phase; a header-only rule that happens to sit after a body
rule just runs in the body phase (still correct, just not optimized). No author
constraint, no reordering of observable effects.
Design question 4 — control & visibility
The phase is mostly an optimization, but it is observable in two ways an
author may care about: (a) whether the body is buffered at all (cost + the
security property "unauthorized bodies are never read"), and (b) where a deny
lands on the wire (before vs after the body). Mitigations:
buffer_body: never|alwaysfor a checked guarantee (Q1 C′).--explain/ doc-gen view so the derived split is inspectablewithout cluttering source:
Backward compatibility / rollout
Incremental and safe:
behavior unchanged.
buffer_bodyknob +--explain.Open questions
buffer_bodyvsbody: auto|never|alwaysvs aphase:hint.read → can we still return a JSON-RPC error, or only a transport-level reject)?
result:/post_invocation(response phase) — symmetric?Author's current leaning
C′ — flat ordered list, compiler-derived phases,
buffer_body: auto|never|alwaysas the single control surface,
--explainfor visibility. Simple by default,explicit-and-checked exactly where the phase is load-bearing. Identity stays the
upstream resolve step feeding both invokes.
Related
definition, not the args). Same theme: CPEX policy beyond the invoke-args
hook. See companion issue (to be filed).