Files
my-pi/pi-permission-system/docs/plans/0591-model-judge-authorizer-chain-adr.md

25 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
591 pi-permission-system: design the model-assisted permission judge (tool-augmented, deny-first, extensible)

ADR 0007 — model-assisted permission judge as an Authorizer chain

Release Recommendation

Release: ship independently

This is Phase 11 Step 7, tagged Release: independent in the roadmap. It is a documentation-only decision record: it touches docs/decisions/ and docs/architecture/, both release-please exclude-paths, so it cuts no physical release on its own — the same finding #581 reached before it was reverted. The design it records is implemented later under #472, which releases on its own merits.

Problem Statement

#472 asks for a case-by-case judge that participates in a permission decision alongside the static rule engine and yoloMode, deciding per ask rather than blanket-approving. It was deferred by name in Phases 9 and 10; #581 then attempted the decision record but treated it as transcription of the architecture doc's settled ModelTriageAuthorizer prose, and that ADR was found premature and reverted. 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. A light model reviews external_directory asks, defers the ones that do not match a configured typo pattern, and denies 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 decomposes the opaque command, queries the deterministic engine per sub-command, and allows if clearly fine, denies if it hits a denied path, or defers if it cannot decide confidently.

This issue is the design gate for #472 and supersedes #581. The deliverable is a decision, not code — so the design below was settled interactively with the operator, not inferred from the existing prose.

Goals

  • Record docs/decisions/0007-model-judge-authorizer-chain-adr.md settling the full design across both use cases, so #472 becomes schedulable on its own merits.
  • Establish the verdict range as allow | deny | defer — a superset of the reverted ADR's allow-or-escalate framing, driven by use case 1 being deny-first.
  • Model the live-authority layer as a Chain of Responsibility: each link decides or defers; the terminal link cannot defer and pauses the system until it decides (today, the human).
  • Keep the package model-agnostic: it makes no LLM call, exposes a named-capability registration seam plus an injected query capability, and owns only the safety policy it enforces.
  • Reconcile the architecture doc's Discriminating delegation and pluggable escalation seam sections with the chain model, and mark Step 7 complete.
  • Record the dogfooding objective as slice 1's acceptance criterion: a first-party monorepo package (e.g. packages/pi-permission-model-judge) implementing the deny-first typo-path reviewer, so the registerAuthorizer seam is born consumed (the #267 vacant-surface guard) and the config split proves itself concretely.

Non-Goals

  • Implementing the judge, the chain, the defer verdict, the registration seam, or the downstream extension — that is #472's, which this ADR unblocks.
  • Deciding #472's implementation decomposition (chain infrastructure, deny-first slice, allow-capable slice, the downstream orchestrator package) — that is #472's own /plan-issue.
  • Building registration for terminal-replacement backends (a chat-bot or remote reviewer as the authority, not a non-terminal link) — the same seam's other role, built when a real non-subagent backend needs it.
  • The non-deterministic access-intent classifier that reshapes intent before evaluate() — a different seam feeding recorded authority; it keeps its own future decision record and stays out of scope.
  • Any src/, test/, README.md, config.example.json, or schemas/ change — no runtime surface references the not-yet-built symbols (verified: ModelTriageAuthorizer appears only in docs/).

Background

  • Authorizer (src/authority/authorizer.ts) is the live-authority role: authorize(details) → Promise<PermissionPromptDecision>, one method, returning allow | deny — there is no defer verdict today.
  • selectAuthorizer(ctx, deps) performs a hardcoded three-way dispatch, returning a single Authorizer: hasUILocalUserAuthorizer (human, decides); isSubagentParentAuthorizer (forwards up to the parent node's authority); else → DenyingAuthorizer (headless, always denies). Evaluated once per session activation (AuthorizerSelection.activate). So the chain is effectively length 1 today.
  • PermissionsService (src/service.ts), published via Symbol.for() and consumed cross-extension, already answers the query primitives the judge needs: checkPermission(surface, value?, agentName?) is "is this external?" (external_directory), "what does this bash resolve to / does it hit a deny?" (bash, decomposed at gate parity via resolveBashAdvisoryCheck, #309), and per-surface rule queries; getToolPermission answers tool-level state.
  • registerToolAccessExtractor(toolName, extractor) / registerToolInputFormatter(toolName, formatter) are the established named-capability registration seams this design mirrors.
  • ADR 0005 (docs/decisions/0005-serving-authorizer-provenance.md) established that determinism governs recorded authority (evaluate()), never the live-authority layer — the enabling premise for a non-deterministic model holding an Authorizer role.

Standing constraints from AGENTS.md and the package skill that the ADR must honor:

  • Registration must land synchronously and be visible before the session's first ask; cross-session visibility rides globalThis + Symbol.for() (the #296 bus-split lesson).
  • permissions:ready is emitted when the service is (re)published, surviving /reload.
  • Default to least privilege: a session no live authority claims selects DenyingAuthorizer.
  • A declared config field not read at runtime is a maintenance trap — so this package must not hold model-prompt config it never reads.
  • The arch doc inline-copies rule.ts types; this design changes no rule type, so that listing is untouched.

Design Overview

The ADR records the following settled design. It is documentation; the TypeScript below is the design the ADR commits to, for #472 to build.

1. The live-authority layer is a Chain of Responsibility

Each link either decides (allow / deny) or defers to the next link. The terminal link cannot defer; the chain ends there and the system pauses until it decides.

type AuthorizerVerdict =
  | { kind: "allow" }
  | { kind: "deny"; reason?: string } // reason is the teaching signal (use case 1)
  | { kind: "defer" };

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

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

The "terminal cannot defer" invariant is enforced at the type level: TerminalAuthorizer returns only allow | deny, so a link that could defer cannot occupy the terminal slot — violating it is a compile error, not a runtime assertion. LocalUserAuthorizer and DenyingAuthorizer are TerminalAuthorizers; ModelTriageAuthorizer is an Authorizer (non-terminal). ParentAuthorizer is terminal for its node — it forwards up and returns the parent node's allow | deny, which is the multi-hop recursion ("a node's terminal hands off to the parent node's chain").

selectAuthorizer (single terminal) generalizes to composeAuthorizerChain (ordered non-terminal links + terminal). The terminal keeps today's context selection unchanged.

function composeAuthorizerChain(
  ctx: ExtensionContext,
  configuredChain: string[], // operator policy: ordered link NAMES
  registry: AuthorizerRegistry,
  query: PermissionQuery,
  policy: DelegationPolicy,
): AuthorizerChain {
  const links = configuredChain
    .map((name) => registry.get(name) ?? warnSkip(name)) // missing → skip, fail-safe
    .filter((link): link is Authorizer => link !== undefined);
  return new AuthorizerChain(links, selectTerminal(ctx), policy); // terminal last
}

The chain walks links until one decides, then applies the enforcement checkpoint, then falls to the terminal:

async authorize(details: PromptPermissionDetails): Promise<TerminalVerdict> {
  for (const link of this.links) {
    const verdict = await link.authorize(details, this.query);
    if (verdict.kind === "deny") return verdict;
    if (verdict.kind === "allow") {
      // Enforcement checkpoint (owned HERE, not the link):
      // an allow on an excluded surface is downgraded to defer.
      if (this.policy.isDelegationExcluded(details.surface)) continue;
      return verdict;
    }
    // defer -> next link
  }
  return this.terminal.authorize(details, this.query); // never defers
}

Three invariants fall out and belong in the ADR:

  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 link removes only allow/deny shortcuts; the ask still reaches the terminal. Absence of a judge means more prompting, never less.
  3. Registration alone grants no authority. A link decides nothing until the operator names it in authorizerChain — the opt-in activation model.

3. The query capability is injected, not imported

The judge never reaches for 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 of PermissionsService 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.

4. Named-capability registration, opt-in activation, permissions:ready hook

Registration mirrors registerToolAccessExtractor: a downstream extension offers a named capability. The PermissionsService interface gains one method:

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

The consumer call site (the downstream pi-permission-model-judge extension) registers in a permissions:ready handler so it is robust to load order and survives /reload:

pi.events.on("permissions:ready", () => {
  const orchestrator = createOrchestrator(myConfig); // reads model/provider/prompt HERE
  getPermissionsService()?.registerAuthorizer("model-judge", (details, query) =>
    orchestrator.judge(details, query), // query injected; no service reach-through
  );
});

The ordering contract the ADR fixes:

1. load     pi-permission-system publishes PermissionsService + emits `permissions:ready`
2. register downstream (on `permissions:ready`) offers registerAuthorizer("model-judge", fn)
              → parked in the registry; grants NO authority yet
3. compose  per session, pi-permission-system reads config.authorizerChain and binds
              names → registered capabilities; a name with no registration is skipped + warned
4. ask      chain walked; each link gets injected PermissionQuery; enforcement checkpoint
              downgrades an excluded-surface allow to defer; terminal always decides

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"],           // ordered link names; the activation gate
  "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 the chain's checkpoint (§2), so a buggy or over-eager external judge can never exceed what the operator's policy permits. This is why the split is safe: policy lives where it is enforced; mechanism lives where the LLM call is made.

6. Two slices, a capability gradient

Both use cases are the same ModelTriageAuthorizer 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 ADR states plainly that the "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.

Slice 1 is validated by dogfooding: a first-party extension in this monorepo (e.g. packages/pi-permission-model-judge) implementing the typo-path reviewer against the real seam. This is a design safeguard, not just a demo — the arch doc's #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 (provider/model/instructions) exercises the config split end to end. The ADR's Consequences section names this objective; the concrete issue is filed by the next /plan-improvements pass when the phase is scoped.

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.

Module-Level Changes

Documentation only. No src/, test/, README.md, config, or schema change.

  • New: packages/pi-permission-system/docs/decisions/0007-model-judge-authorizer-chain-adr.md — the ADR recording §1–§6, rejected alternatives (opt-out activation; judge imports PermissionsService; a single terminal instead of a chain; ask-only allow-or-escalate verdict range), and accepted limitations (#472 owns provider/prompt/threshold/timeout tuning, the slice decomposition, and the downstream package; terminal-replacement registration deferred; the pre-evaluate() classifier keeps its own future ADR). The Consequences section names the dogfooding objective: slice 1 is accepted by a first-party monorepo judge extension registering against the real seam. The reverted 0007 slot is free, so this is ADR 0007.
  • Changed: packages/pi-permission-system/docs/architecture/architecture.md
    • Rewrite the Discriminating delegation: a model Authorizer section (line ~604): the chain model, verdict range allow | deny | defer, deny-first two-slice gradient, injected PermissionQuery, opt-in named registration, config split — superseding the ask-only allow-or-escalate framing.
    • Reconcile the Beyond the target: a pluggable escalation seam section (line ~681): it is now subsumed by the chain — registering a link is the seam; a terminal-replacement backend is the same seam's terminal role, deferred (not a mechanism beside the chain).
    • Leave the Beyond the target: a non-deterministic access-intent classifier section (line ~668) intact — still out of scope with its own future ADR.
    • Reword the aspirational-extension-points sentence (line ~499): the model-triage Authorizer and the pluggable escalation seam are now designed (link ADR 0007); the classifier remains aspirational.
    • Reconcile the Resolved direction recursion language (line ~633+) from "a node's Authorizer" to the chain framing where the terminal hands off to the parent node's chain.
    • Mark Phase 11 Step 7 complete: on the #### Step 7: heading and the S7 Mermaid node, and link ADR 0007 from both the step target and the Discriminating delegation section.
    • Update the #472 deferral dispositions (lines ~900, ~1041) to record that #472 now carries this ADR and is schedulable.
  • Not edited: docs/architecture/history/phase-8|9|10-*.md, docs/plans/0509|0555|0556|0557|0581-*.md, docs/retro/0581-*.md, docs/retro/phase-9-*.md — these are frozen point-in-time records that mention ModelTriageAuthorizer; they are not live design docs and must not be rewritten.

Test Impact Analysis

Not applicable in this issue — the deliverable is a decision record with no code. The tests the design enables for #472 (recorded here so #472's TDD plan inherits them): a chain walk that stops at the first deciding link; the defer verdict advancing the cursor; the terminal-cannot-defer type constraint; a missing configured link name skipping fail-safe; the enforcement checkpoint downgrading an excluded-surface allow to defer; opt-in activation (a registered-but-unconfigured link deciding nothing); and the injected PermissionQuery answering at gate parity.

Invariants at risk

This is the exact failure mode that reverted #581: an internally consistent ADR that contradicts un-reconciled prose elsewhere in the architecture doc. The pre-completion reviewer must confirm cross-doc consistency, not just the ADR's internal coherence.

  • Cross-doc verdict-range consistency. The pre-revert Discriminating delegation prose frames the judge as ask-only, allow-or-escalate; the new design is allow | deny | defer. Grep the whole architecture doc for stale framing before finalizing: grep -nE "ask-only|allow-or-escalate|escalate|ModelTriageAuthorizer|quarantine|a model .Authorizer" architecture.md. (#581 missed the non-persistence parenthetical at line ~627 because its grep targeted one section; sweep the whole file.)
  • Recursion language. The Resolved direction and the recursion passages describe "a node's Authorizer"; under the chain they describe a node's chain whose terminal hands off upward. Both must read consistently.
  • Aspirational list. Line ~499 lists the model-triage Authorizer and the pluggable escalation seam as aspirational; ADR 0007 designs both, so the list must move them to "designed, pending #472."

Build Order

Documentation-only, so /build-plan (no red→green cycles). Numbered docs: commits, each leaving the docs internally consistent.

  1. Author the ADR. Write docs/decisions/0007-model-judge-authorizer-chain-adr.md (§1–§6, rejected alternatives, accepted limitations, reference-link definitions). Verify with pnpm exec rumdl check on the new file. Commit: docs(pi-permission-system): record ADR 0007 for the model-judge Authorizer chain (#591).
  2. Reconcile the architecture doc and mark Step 7 complete. Rewrite Discriminating delegation, reconcile the pluggable escalation seam and Resolved direction sections and the aspirational list, mark Step 7 (heading + S7 node), link ADR 0007, and update the #472 dispositions — in one commit so the doc is never half-reconciled. Run the whole-file grep from Invariants at risk to confirm no stale framing remains, and verify the four Mermaid diagrams still render. Commit: docs(pi-permission-system): reconcile architecture with ADR 0007 and mark Phase 11 Step 7 (#591).

Marking Step 7 completes all seven Phase 11 steps. Flipping the Phase 11 heading to (complete) and extracting its detail to history/phase-11-*.md is a distinct phase-close activity (the pattern Phases 910 follow), out of scope here — route it to /finish-phase as #581 did.

Risks and Mitigations

  • Risk: another transcription-not-decision slip. Mitigated: the design above was settled interactively (chain model, injected query, opt-in activation, config split) rather than lifted from the reverted prose; the ADR records why each fork went the way it did (rejected alternatives), which is the deliberation an ADR exists to carry.
  • Risk: the ADR over-commits implementation detail that belongs to #472. Mitigated: provider/prompt/threshold/timeout, the slice decomposition, and the downstream package are explicit accepted-limitations deferred to #472; the ADR settles architecture and safety envelope only.
  • Risk: stale architecture-doc prose survives reconciliation (the #581 failure). Mitigated: the Invariants at risk whole-file grep and the pre-completion reviewer's cross-doc check.
  • Risk: scope creep into building the chain. Mitigated: Non-Goals fences this to docs; no src//test/ change; #472 implements.

Open Questions

  • #472's implementation decomposition. This design is materially larger than #472's original "support a case-by-case judge" framing (a chain refactor of the Authorizer spine + defer verdict + named registration + injected PermissionQuery + config + two judge slices + a new downstream orchestrator package). The next /plan-improvements pass sequences this: #472's decomposition (chain infrastructure; deny-first slice; allow-capable slice) plus the dogfood extension become roadmap-step candidates, and the dogfood-extension issue is filed there — deferred deliberately, not filed speculatively here. The dogfood extension lives in this monorepo as a new package (per the AGENTS.md new-package checklist), settled during planning.
  • Terminal-replacement registration. Registering a backend as the terminal authority (a chat-bot / remote reviewer replacing the human) is the chain seam's other role, deferred until a real non-subagent backend needs it; noted in the ADR as future, not filed.