--- issue: 591 issue_title: "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`, one method, returning `allow | deny` — there is **no `defer` verdict today**. - `selectAuthorizer(ctx, deps)` performs a hardcoded three-way dispatch, returning a **single** `Authorizer`: `hasUI` → `LocalUserAuthorizer` (human, decides); `isSubagent` → `ParentAuthorizer` (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. ```typescript 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; } /** The terminal link: structurally cannot defer. */ interface TerminalAuthorizer { authorize( details: PromptPermissionDetails, query: PermissionQuery, ): Promise; // { 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 `TerminalAuthorizer`s; `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"). ### 2. Chain composition: registered links, then the context-selected terminal `selectAuthorizer` (single terminal) generalizes to `composeAuthorizerChain` (ordered non-terminal links + terminal). The terminal keeps today's context selection unchanged. ```typescript 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: ```typescript async authorize(details: PromptPermissionDetails): Promise { 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. ```typescript /** 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: ```typescript 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`: ```typescript 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: ```text 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. ```jsonc // 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 } } ``` ```jsonc // 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 9–10 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. [#267]: https://github.com/gotgenes/pi-packages/issues/267 [#296]: https://github.com/gotgenes/pi-packages/issues/296 [#309]: https://github.com/gotgenes/pi-packages/issues/309 [#472]: https://github.com/gotgenes/pi-packages/issues/472 [#509]: https://github.com/gotgenes/pi-packages/issues/509 [#581]: https://github.com/gotgenes/pi-packages/issues/581