ADR 0006 — Cedar policy stage/action annotations for pre/post governance¶
Status: accepted
Context¶
Every enforcement point built so far — the standalone HTTP gateway
(gateway/src/parapetai_gateway/server/app.py) and parapetai-agent's MAF middleware
(parapetai-agent/src/parapetai_agent/maf.py) — evaluated a single Cedar decision
before the underlying call: Snapshot (parapetai-agent/src/parapetai_agent/
providers/parsers.py) is documented as "a normalised view of one inbound
request"; nothing in the Cedar action vocabulary (model_call/
tool_call/http_request) or the Cedar sources under policies/ had any
notion of a response/output-side decision.
parapetai-agent's in-process integration doesn't share the gateway's constraint
that makes a second, output-side decision architecturally hostile
(invariant 6: never buffer an SSE relay). MAF's own ChatContext/
FunctionInvocationContext.result is observable, and settable, after
await call_next() — a real hook for a non-streaming response. Two new
requirements followed directly from having that hook:
- Which stage a policy applies to (pre-call request, post-call response, or both) needed to be selectable per rule, driven by the control plane's rule-authoring UI, which renders the annotation into the signed bundle this SDK then loads.
- A post-call decision needed a richer outcome than allow/deny: specifically, the ability to mutate a model response or tool result before it propagates into a subsequent call, so a bad payload doesn't become part of the next turn's input — without that content having already been irreversibly delivered (the streaming case, where it's too late either way).
Decision¶
Cedar's own decision stays strictly binary. permit/forbid →
Allow/Deny is unchanged; PolicyEngine.evaluate()'s core Decision.allowed
contract does not change shape, and cedarpy is used exactly as before —
this is a hard constraint, not a preference: invariant 3 (default-deny,
forbid always beats permit) is defined in terms of that binary
decision, and doing anything else would mean re-deriving or forking that
guarantee outside Cedar's own engine.
Both new capabilities are expressed as standard Cedar policy annotations, not new Cedar constructs:
@stage("pre"|"post")scopes a policy to one of two filtered variants of the compiled policy set, built atPolicyEnginereload time viacedarpy.policies_to_json_str()/policies_from_json_str()(verified directly: cedarpy round-trips arbitrary custom annotations through its JSON policy representation, and does NOT preserve a policy's original positional id across a filter — a policy that waspolicy2in the full set can becomepolicy1in a filtered variant, so each variant needs its own annotation map built from its own post-filter survival order, not sliced from the full set's). No annotation means the policy is included in both filtered variants — "applies to both" falls out of the filtering rule itself, never a special case a bundle author has to remember.evaluate()gained an optionalstage: str | None = Noneparameter, defaultNonemeaning today's exact full-set behavior — every existing caller (the HTTP gateway included) is unaffected.@action("alter")+@alter_with("<name>")on apermit— never aforbid, a hard deny is never softened by an annotation — names a transform a caller should apply to the content before letting it propagate, when that policy is the one that allowed a post-call decision.Decisiongained one generic field,annotations: dict[str, str], merged from whichever policy(ies) determined an allowed decision (empty on a deny).PolicyEngineitself stays completely unaware of what "alter" means — it only ever surfaces raw Cedar annotation data; the semantics live one layer up, inparapetai_agent.policy.hooks.GovernanceHook(resolvesalter_withfromDecision.annotations) andparapetai-agent's MAF middleware (applies a named transform from a caller-supplied registry, or fails closed to a deny if the name isn't registered).
ALTER is post-call only. Pre-call code paths never read
HookResult.alter_with — that omission is the entire enforcement
boundary for "ALTER only applies post-call," not a validation rule that
rejects a misplaced annotation. An @action("alter") on a pre-scoped
policy is simply inert everywhere except a post-call hook.
OBSERVE is not a new decision type at all. It's a control-plane
authoring concept: a rule marked "observe" renders as a plain permit
(optionally tagged @action("observe") purely for the control plane's own
UI bookkeeping) — parapetai-agent's runtime needs zero new logic for it, since it
behaves identically to any other allow.
Streaming inherits DENY's existing audit-only limitation, not a new
tradeoff. A streamed chat response can't be altered before chunks
already reached the caller, verified directly against
agent_framework's ChatMiddlewarePipeline.execute(): context.
stream_result_hooks are wired onto the returned ResponseStream only
after the whole middleware chain (this one included) has returned, and
that hook itself only fires once the stream is fully finalized — which
requires the caller to have already consumed every chunk. So a streaming
post-call ALTER (or DENY) can only audit what happened
(post_call_would_deny_streaming/post_call_would_alter_streaming),
never block or rewrite it — the same physical fact that already made the
HTTP gateway's SSE relay unable to do output-side decisions at all
(invariant 6), independently re-confirmed here for parapetai-agent rather than
assumed to transfer.
Alternatives considered¶
- A
context.stagevalue individual policieswhen-check, instead of a policy-level annotation. Rejected: it would make "applies to both" require every policy author to remember to omit or handle awhenclause correctly, and it couples the stage concept to the Cedar evaluation rather than to policy selection — the control plane authors the stage choice, it shouldn't have to also author defensive Cedar conditions to express it. - A fourth persisted decision value (
OBSERVE/ALTERas first-class outcomes threaded throughDecision.effect/audit records). Rejected per explicit product direction: Cedar's decision stays binary, and both new behaviors collapse to something Cedar already understands (an allow, optionally annotated) plus caller-side interpretation — forking Cedar's own decision spec to add a third/fourth outcome was never on the table. - A parametric alter-transform mini-language embedded in the
annotation (e.g. a regex or field path carried directly in
@alter_with(...)), instead of a named, adopter-registered callable. Rejected as premature: this repo has no real redaction/transform logic to generalize from yet (DEFAULT_ALTER_TRANSFORMSships exactly one placeholder,redact_all) — a named-callable registry (build_middleware(alter_transforms={...})) is the minimal surface that works today and doesn't foreclose a richer spec later if a concrete need shows up.
Consequences¶
- A bundle with no
@stage/@actionannotations anywhere behaves identically to before this ADR, at every existing call site — this was verified with a regression test (parapetai-agent/tests/test_maf.py::TestPostCallRegressionWithRealBundle) against the realpolicies/bundle, not just asserted. - parapetai-agent now runs two Cedar decisions per model/tool call (pre and
post), not one — visible in the audit log as two
decisionevents per call, and doubling evaluation cost per call. Always on, no toggle: which Cedar rules actually do anything at each stage is controlled entirely by the bundle's annotations, never a parapetai-agent-side flag, so "some governance silently skipped" stays impossible (invariant 1). - An unresolved
@alter_withname fails closed to a deny, both for chat (GovernanceDeniedraised) and tool calls (context.resultsubstituted) — a control-plane-authored transform name that doesn't match anything an enforcing process registered is a blocked call with a clear reason in the audit log, never a silent pass-through of the original, unaltered content. gateway/is unaffected. It never passesstage=toevaluate(), so it keeps evaluating the full, unfiltered policy set exactly as before — this ADR's scope isparapetai-agentand control-plane's rule-authoring path only.