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

330 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<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`: `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<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 `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<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.
```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 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.
[#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