Skip to content

[FEATURE]: APL policy phases: evaluate identity/header policy without buffering the body #118

Description

@terylt

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:

  1. Cost / footprint — bodies get buffered (StreamBuffer) even when the deny
    never needed them.
  2. 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).
  • neverassert "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:

  1. Land the two invoke points; default all policy to the payload invoke →
    behavior unchanged.
  2. Turn on derivation → header-evaluable rules migrate up automatically.
  3. 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).

Metadata

Metadata

Assignees

Projects

Status
In review

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions