Files
my-pi/pi-permission-system/docs/decisions/0007-model-judge-authorizer-chain-adr.md

18 KiB

status, date
status date
accepted 2026-07-15

0007 — Model-assisted permission judge as an Authorizer chain

Status

Accepted. This decision settles the design of a case-by-case model judge (#472); it does not implement it. #472 stays open, tracking the implementation, and carries this ADR. It supersedes the reverted ADR 0007 attempted under #581. Amended 2026-08-14 with §7 (one chain per node), which settles where a subagent's ask is adjudicated (#727).

Context

yoloMode is the only non-static path in a permission decision today: a single boolean that rewrites every ask rule to allow at composition time (origin: "yolo"), suppressing prompts while preserving hard denies. It is all-or-nothing — it cannot approve one clearly safe ask and still prompt on the rest.

#472 asks for a case-by-case judge: a light model (e.g. Claude Haiku) that participates in a decision, deciding per ask. It was deferred by name in Phases 9 and 10. #581 then attempted a decision record but treated it as transcription of the architecture doc's settled ModelTriageAuthorizer prose — an ask-only, allow-or-escalate decorator — and that ADR was found premature and reverted. An ADR's value is the deliberation behind it; the prose itself was the wrong input.

Two concrete use cases, surfaced during the #581 retro, show the real design is broader than — and in one respect contradicts — the reverted prose:

  1. Reject errant "typo" paths automatically. Models frequently invoke tools against a malformed path (e.g. …/pi-permission-system/packages/pi-permission-system/src/x.ts, where the first segment should be pi-packages). These land as external_directory asks that a human hand-denies one by one. A light model should review such asks, defer the ones that do not match a configured typo pattern, and deny the ones that do — returning a teaching reason (wrong path; correct location) so the invoking model self-corrects.
  2. Adjudicate opaque bash commands. Commands the deterministic parser cannot decompose (bash -c "…", eval, unparseable constructs) floor to ask via sentinels. A model should decompose the opaque command, query the deterministic engine per sub-command, and allow if clearly fine, deny if it hits a denied path, or defer if it cannot decide confidently.

The enabling premise is that nothing constrains an Authorizer to be deterministic. LocalUserAuthorizer is already a non-deterministic oracle — the human — and the determinism principle governs recorded authority (evaluate()), never the live-authority layer (ADR docs/decisions/0005-serving-authorizer-provenance.md). A model can hold the Authorizer role on the same terms.

Decision

Model the live-authority layer as a Chain of Responsibility, and place the model judge in it as a non-terminal link.

1. Verdict range is allow | deny | defer

Each link either decides (allow / deny) or defers to the next link. This is a superset of the reverted ADR's ask-only allow-or-escalate framing: use case 1 is deny-first, and an Authorizer already denies (the human does; DenyingAuthorizer always does), so a model in that role can deny an ask too. A deny carries an optional reason — the teaching signal use case 1 needs.

type AuthorizerVerdict =
  | { kind: "allow" }
  | { kind: "deny"; reason?: string }
  | { kind: "defer" };

The chain ends at a terminal that must decide; until it does, the system pauses. Today that terminal is the human (LocalUserAuthorizer), the headless DenyingAuthorizer, or ParentAuthorizer (terminal for its node — it forwards up and returns the parent node's allow | deny, the multi-hop recursion).

The invariant is enforced at the type level, not by a runtime assertion: a terminal returns only allow | deny, so a link that could defer cannot occupy the terminal slot.

/** A non-terminal chain link: may decide or defer. */
interface Authorizer {
  authorize(details: PromptPermissionDetails, query: PermissionQuery, log: AuthorizerLog): Promise<AuthorizerVerdict>;
}

/** The terminal link: structurally cannot defer. */
interface TerminalAuthorizer {
  authorize(details: PromptPermissionDetails, query: PermissionQuery): Promise<TerminalVerdict>;
}
// TerminalVerdict = { kind: "allow" } | { kind: "deny"; reason?: string }

selectAuthorizer (which returns a single Authorizer today) generalizes to composeAuthorizerChain: registered non-terminal links, then the context-selected terminal last. The terminal selection is unchanged.

3. The query capability is injected, not imported

A link never reaches for the cross-extension PermissionsService via Symbol.for() (a Law-of-Demeter reach-through to a global). The chain injects a narrow, session-scoped PermissionQuery into each link at authorize time — a projection limited to what a link needs (ISP), backed by the same resolver the gates use so it answers at gate parity.

/** Narrow, injected projection of PermissionsService. */
interface PermissionQuery {
  checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
  getToolPermission(toolName: string, agentName?: string): PermissionState;
}

The tool-augmented adjudication (use case 2) exposes these primitives to the model as tools: the model decomposes an opaque command and calls checkPermission("bash", subCommand) / checkPermission("external_directory", token) per piece; the deterministic engine answers every sub-question. The model's non-determinism is confined to how it decomposes, never what the rules decide — determinism-of-decision survives at the leaf.

The review-log seam is injected the same way

A link is handed a second narrow capability at authorize time: an AuthorizerLog, for recording its decision trail. The motivation is observability — without it a link's verdict (especially a defer) is unobservable, so a misbehaving link deferring every ask is indistinguishable from a link never running (the pi-permission-model-judge auth-failure that motivated this addition). The seam follows the same injection discipline as PermissionQuery: a link never reaches for the session logger via Symbol.for(); the chain owner passes the session's own logger straight through, so a link's entries land in the same pi-permission-system-permission-review.jsonl as the gate decisions, keyed by requestId.

/** Narrow, injected review-log seam. */
interface AuthorizerLog {
  review(event: string, details?: Record<string, unknown>): void; // durable, default-on audit entry
  debug(event: string, details?: Record<string, unknown>): void; // verbose detail, gated by `debugLog`
}

The seam only records; it grants no authority and cannot alter a verdict, so it is inert with respect to the bounded-delegation invariant below.

4. Named-capability registration, opt-in activation

Registration mirrors registerToolAccessExtractor: a downstream extension offers a named capability on the published service.

registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;

The downstream extension registers in a permissions:ready handler, so registration is robust to load order and survives /reload; it must land before the session's first ask. Composition then reads the operator's configured chain and binds names to registered capabilities.

Three invariants govern the seam:

  1. Config order wins, never registration order. Chain order is security-relevant (an allow-capable link ahead of a deny-capable one changes outcomes), so it is deterministic operator policy — never a function of nondeterministic extension load order.
  2. Skipping any non-terminal link is always fail-safe. A missing or unregistered configured name removes only allow/deny shortcuts; the ask still reaches the terminal. Absence of a judge means more prompting, never less — so a missing name is skipped with a warning.
  3. Registration alone grants no authority. A registered link decides nothing until the operator names it in the authorizerChain config — the opt-in activation model. Installing a judge extension does not silently hand it decision authority.

5. Config split: policy here, mechanism downstream

Two independent extension config files, joined only by the link name — no merged schema. This package declares and enforces the safety policy; the downstream extension declares and uses the model mechanism.

// pi-permission-system config.json — operator-owned policy (read + enforced HERE)
{
  "authorizerChain": ["model-judge"],
  "modelDelegation": {
    "allowedSurfaces": ["bash"],
    "excludedSurfaces": ["external_directory"] // + secret-shaped path always excluded
  }
}
// pi-permission-model-judge config.json — downstream-owned mechanism (read THERE)
{ "provider": "anthropic", "model": "claude-haiku-…", "instructions": "…", "timeoutMs": 5000 }

The bounded-delegation policy is enforced at an enforcement checkpoint the chain owner (this package) applies to every verdict: a link's allow on an excluded surface is downgraded to defer. So the safety envelope lives where it is enforced, and a buggy or over-eager external judge can never exceed the operator's policy. This package holds no model-prompt config it does not read (the "declared-but-unread config is a maintenance trap" priority).

6. Two slices, a capability gradient

Both use cases are the same judge link; they differ only by which verdicts are enabled and how much envelope guards them.

Aspect Slice 1 — deny-first reviewer (use case 1) Slice 2 — allow-capable adjudicator (use case 2)
Verdicts deny, defer + allow
Risk strictly more restrictive — always safe loosens privilege — needs full envelope
Envelope fail-closed only (unreachable/uncertain → defer) + hard exclusions, audit origin:"authorizer:model", non-persistence, off by default, bounded-delegation ruleset
Failure mode a wrong deny — recoverable (agent self-corrects on the reason) decomposition infidelity — mis-split obfuscation (bash -c "safe; evil") allowed on the safe piece alone

The "a tool-augmented model can never grant more than the engine grants for the pieces it identifies" safety property holds only if decomposition is faithful. Obfuscation is the residual risk, and it is exactly why slice 2 is gated behind the whole envelope while slice 1 needs almost none. The gradient is the argument for shipping deny-first.

7. One chain per node

An ask is adjudicated by exactly one node's chain: the node whose terminal decides it.

A node with UI (LocalUserAuthorizer) and a headless node with no reachable authority (DenyingAuthorizer) both decide locally, so both run their chain. A subagent node whose terminal is ParentAuthorizer does not decide — it relays the ask to a serving node, which resolves it against its own recorded authority and escalates it through its own chain over the same child-fixed facts (#635). So a relaying node resolves no links, and its terminal's forwarding is how the ask reaches a chain.

This is a consequence of §2's terminal-for-its-node framing, made explicit because the observable behavior contradicted it: a child's chain resolution found no links (a child cannot host one — #699) and reported each configured name as a fail-safe skip, which reads as the configured judge never running. It runs; it runs one hop up.

The rule is not merely descriptive of the current implementation. Running links on a relaying node as well would adjudicate one ask twice — the same link, over the same facts, once in the child and again on the serving node — with a second model call and a second latency, and would let a link decide an ask the serving node's policy owns.

Two review-log records make the placement observable, since a deferring link decides nothing and otherwise leaves no evidence it was consulted:

  • authorizer_chain_resolved — an adjudicating node's resolved link names, recorded before any of them runs.
  • authorizer_chain_delegated — a relaying node's configured names, recorded as deliberately not run.

This leaves authorizer_chain_unregistered_link meaning what it says: a name the adjudicating node could not resolve, which is a real misconfiguration.

The rule also settles the shape of #699's fix: a sibling extension should skip registering a link in a registered child rather than registering one that would never be consulted.

Relationship to evaluate() and rule-driven promotion

The judge sits on the ask-consuming side of evaluate(), distinct from the ask-producing side (rule-driven promotion, #509). Rule-driven promotion produces the fail-safe false-positive ask (git grep id_rsa prompts); the judge dismisses it on the consuming side without hard-coding per-command file-argument tables. The two compose cleanly: a promoted token emits the same structured descriptor a prefixed path does, so a link needs no promotion-specific knowledge.

Consequences

  • #472 carries a linked, settled ADR and becomes schedulable in a future phase on its own merits.
  • The Authorizer role generalizes from a single per-session selection into a composed chain; selectAuthorizer becomes the terminal-selection step of composeAuthorizerChain, and the interface gains a defer verdict and an injected PermissionQuery.
  • The chain is the one live-authority extensibility seam. A model judge is a non-terminal link; a future terminal-replacement backend (a chat-bot or remote reviewer as the authority) is the same seam's terminal role. This subsumes the architecture doc's separately-sketched "pluggable escalation seam" — registering a link is the seam, not a mechanism beside it.
  • The review log gains a fourth grant provenance (authorizer:model, slice 2) alongside human, policy, and yolo.
  • Dogfooding is slice 1's acceptance criterion. A first-party package in this monorepo (packages/pi-permission-model-judge) implements the deny-first typo-path reviewer against the real seam. This is a design safeguard, not a demo: the #267 history guard warns that an inbound registration surface nobody consumes goes vacant; a first-party consumer registering "model-judge" on day one makes registerAuthorizer born consumed, and its own config file exercises the config split end to end. The concrete issue is filed by the next /plan-improvements pass when the phase is scoped.
  • No code, config, schema, or default changes in this documentation step.

Rejected alternatives

  • Ask-only, allow-or-escalate verdict range (the reverted ADR). Rejected: use case 1 is deny-first, and an Authorizer already denies, so confining a model link to allow-or-escalate cannot express the typo-path reviewer.
  • A single terminal instead of a chain. Rejected: the judge fundamentally decides some asks and hands the rest to the real authority — it needs a successor. A chain with a non-deferring terminal models exactly this, and the operator's mental model was a chain, not a decorated singleton.
  • The judge imports PermissionsService via Symbol.for(). Rejected: a Law-of-Demeter reach-through to a global, and it forces the external extension to import two surfaces. Injecting a narrow PermissionQuery gives one import and an ISP-clean contract.
  • Opt-out activation (a registered link joins the chain automatically; config can only disable it). Rejected: it lets a loaded extension gain decision authority unless explicitly disabled, and lets load order influence security-relevant chain order. Opt-in (config names the chain) is least-privilege by construction.
  • A process-global AuthorizerRegistry, so a child resolves its parent's links (#727). Rejected: it converts every deferring ask into two link runs, and lets a link's verdict short-circuit before the serving node ever sees the request — a privilege change dressed as a plumbing fix. The forwarding round trip is not the cost being avoided; the serving node resolves the request against its own ruleset regardless.
  • The model applies the ruleset itself, or emits a static intent. Rejected: the former couples the model to rule semantics; the latter weakens determinism. Tool-augmented decomposition keeps the model decoupled from rule semantics (a rule edit is honored automatically) and confines its non-determinism to decomposition.

Accepted limitations

  • Open implementation parameters. Model provider, prompt, confidence threshold, and timeout are deliberately left to #472 and the downstream package — they are tuning and mechanism, not architecture.
  • #472's decomposition is deferred. Whether #472 splits into staged issues (chain infrastructure; deny-first slice; allow-capable slice; the dogfood package) is #472's own planning decision, sequenced by the next /plan-improvements pass.
  • Terminal-replacement registration is deferred. Registering a backend as the terminal authority is the chain seam's other role, built when a real non-subagent backend needs it — not now.
  • The pre-evaluate() classifier stays out of scope. A model that classifies access intent before evaluate() feeds recorded authority and weakens the "same (toolName, input) yields the same ruling" property more subtly than this live-authority judge; it warrants its own decision record (see the architecture doc's "Beyond the target: a non-deterministic access-intent classifier").