---
issue: 598
issue_title: "pi-permission-system: Authorizer chain infrastructure (allow/deny/defer verdicts)"
---

# Authorizer chain infrastructure (allow/deny/defer verdicts)

## Release Recommendation

**Release:** mid-batch — defer (batch "authorizer-chain"); confirm at ship time

This is Step 4 of the Phase 12 roadmap (Track B — the Authorizer chain) and the head of the two-step release batch "authorizer-chain" whose tail is Step 5 ([#599]).
The roadmap's `Release batches` subsection lists Steps 4 and 5 shipping together with Step 5 as the tail, so Step 4 lands on `main` and its release-please PR stays open until Step 5 ships.
The work is refactor-only (`refactor:`/`test:` commits are `hidden: true` and do not cut a release), with one `docs:` step-completion commit that will appear in the pending release PR but must not be merged until the batch tail.

## Problem Statement

The Phase 9 spine selects exactly one terminal `Authorizer` per session activation (`LocalUserAuthorizer`, `ParentAuthorizer`, or `DenyingAuthorizer`), each of which must decide.
That shape is closed against a link that reviews an `ask` and passes it on — the structural reason a case-by-case model judge ([#472]) has had no home since the spine was built.
ADR 0007 (`docs/decisions/0007-model-judge-authorizer-chain-adr.md`, accepted) settles the design: model the live-authority layer as a Chain of Responsibility whose links return `allow | deny | defer`, ending at a terminal that cannot defer.

This issue is the infrastructure step: introduce the verdict type and the chain composition, refactor `selectAuthorizer` into the terminal-selection step of the chain, and register **zero** links — so behavior is identical to today, pinned by the existing authorizer-selection tests.
The chain seam exists for Step 5 ([#599]) to expose via `registerAuthorizer`.

## Goals

- Introduce `AuthorizerVerdict` (`allow | deny | defer`), with `deny` carrying an optional teaching `reason`, in `src/authority/authorizer.ts`.
- Reshape the interface vocabulary to match ADR 0007: `Authorizer` becomes the **non-terminal** chain link (returns `AuthorizerVerdict`); a new `TerminalAuthorizer` is the terminal (always decides, returns `PermissionPromptDecision`).
- Add `composeAuthorizerChain(links, terminal)` in a new `src/authority/authorizer-chain.ts`: registered non-terminal links first, then the context-selected terminal; the terminal-cannot-defer invariant is enforced at the **type level**.
- Route `AuthorizerSelection.activate` through `composeAuthorizerChain([], terminal)` so the chain seam is live with an empty link list.
- Preserve behavior exactly: with zero registered links the composed chain is the selected terminal, pinned by the existing `authorizer.test.ts` and `authorizer-selection.test.ts`.

This change is **not breaking**: no observable behavior, output shape, config, or default changes on upgrade.

## Non-Goals

- The `registerAuthorizer` service method, the `authorizerChain` config field, and the bounded-delegation enforcement checkpoint — those are Step 5 ([#599]).
- The `PermissionQuery` injection into a link's `authorize` — ADR 0007 §3 ties it to the registration seam; a Step-4 link signature takes only `PromptPermissionDetails` (no link exists yet to consume a query).
  Deferred to Step 5.
- The first-party dogfood link (`@gotgenes/pi-permission-model-judge`) — Step 6 ([#600]).
- Collapsing the terminal's return to ADR 0007's illustrative minimal `TerminalVerdict` (`{ kind: "allow" } | { kind: "deny"; reason? }`).
  The terminal keeps returning the rich `PermissionPromptDecision` (session-scope states, `confirmationUnavailable`, `denialReason`), which is what preserves behavior; the ADR sketch is explicitly illustrative ("the essentials follow").
- Moving `selectAuthorizer` out of `authorizer.ts` into `authorizer-selection.ts`.
  It stays in `authorizer.ts`; only its return type changes. (The issue's phrasing "`authorizer-selection.ts` — `selectAuthorizer` becomes the terminal-selection step" refers to the selection concern, not a file relocation.)

## Background

Relevant modules (all under `src/authority/`):

- `authorizer.ts` — currently declares the `Authorizer` interface (`authorize(details): Promise<PermissionPromptDecision>`), `AuthorizerSelectionDeps`, and `selectAuthorizer(ctx, deps): Authorizer` (the once-per-activation `hasUI` / `isSubagent` / deny dispatch).
- `local-user-authorizer.ts`, `denying-authorizer.ts`, `approval-escalator.ts` (`ParentAuthorizer`) — the three concrete terminals, each `implements Authorizer`, each returning `PermissionPromptDecision`.
- `authorizer-selection.ts` — `AuthorizerSelection` (the `AskEscalator` implementation): `activate(ctx)` runs `selectAuthorizer` and stores the result in `selected`; `escalate(details)` delegates to `prompter.prompt(this.selected, details)`.
- `permission-prompter.ts` — `PermissionPrompterApi.prompt(authorizer: Authorizer, details)` brackets the review-log entries around `authorizer.authorize(details)` and returns its `PermissionPromptDecision`.
- `permission-dialog.ts` — `PermissionPromptDecision` type plus `createDeniedPermissionDecision(reason?)` (maps a reason to `denied_with_reason` / `denied`), reused by the chain's verdict→decision mapping.

AGENTS.md constraints that apply:

- Architecture-doc module-tree entries describe **current behavior**; cite an issue only for an active constraint.
  The reshape updates the `authorizer.ts` tree entry's signature and adds an `authorizer-chain.ts` entry.
- The package skill's rule: mark the completed roadmap step `✅` (heading + Mermaid node) in the implementation doc-update commit, not a deferred ship commit.
- `refactor:`/`test:` commits are `hidden: true`; an unhidden `docs:` commit is release-visible but, mid-batch, its release-please PR is not merged until the batch tail.

## Design Overview

### Verdict type and the two interfaces

`src/authority/authorizer.ts` gains the verdict union and splits the interface into non-terminal and terminal per ADR 0007 §2:

```typescript
/** A non-terminal chain link's ruling: decide (allow/deny) or pass on (defer). */
export type AuthorizerVerdict =
  | { kind: "allow" }
  | { kind: "deny"; reason?: string }
  | { kind: "defer" };

/** A non-terminal chain link: reviews an ask and may decide or defer. */
export interface Authorizer {
  authorize(details: PromptPermissionDetails): Promise<AuthorizerVerdict>;
}

/** The terminal link: structurally cannot defer — always returns a full decision. */
export interface TerminalAuthorizer {
  authorize(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
}
```

The terminal-cannot-defer invariant is **type-level**: a `TerminalAuthorizer` returns `PermissionPromptDecision` (which always carries `approved: boolean` — it cannot express "defer"), while a deferring link returns `AuthorizerVerdict`. `composeAuthorizerChain`'s signature (below) accepts links as `Authorizer[]` and the terminal as `TerminalAuthorizer`, so a deferring link cannot occupy the terminal slot — the compiler rejects it.

The three concrete terminals (`LocalUserAuthorizer`, `DenyingAuthorizer`, `ParentAuthorizer`) change `implements Authorizer` → `implements TerminalAuthorizer`; their bodies are unchanged (they already return `PermissionPromptDecision`). `selectAuthorizer`'s return type changes `Authorizer` → `TerminalAuthorizer`. `PermissionPrompterApi.prompt` and `AuthorizerSelection.selected` retype to `TerminalAuthorizer`.

### The chain composition

`src/authority/authorizer-chain.ts` (new) folds the links ahead of the terminal:

```typescript
export function composeAuthorizerChain(
  links: readonly Authorizer[],
  terminal: TerminalAuthorizer,
): TerminalAuthorizer {
  if (links.length === 0) {
    return terminal; // identity: zero links ⇒ behavior is the terminal's
  }
  return {
    async authorize(details) {
      for (const link of links) {
        const verdict = await link.authorize(details);
        if (verdict.kind === "allow") {
          return { approved: true, state: "approved" };
        }
        if (verdict.kind === "deny") {
          return createDeniedPermissionDecision(verdict.reason);
        }
        // defer → try the next link
      }
      return terminal.authorize(details);
    },
  };
}
```

The composite is a `TerminalAuthorizer` — it always decides, because the terminal always decides.
The verdict→decision mapping is the seam Step 5 exercises with real links:

- `allow` → `{ approved: true, state: "approved" }` — a link grant is **non-persistent** (state `approved`, never `approved_for_session`), matching ADR 0007's off-by-default, non-persistence envelope.
- `deny` → `createDeniedPermissionDecision(reason)` → `denied_with_reason` when a reason is present, else `denied` — carrying the teaching signal use case 1 needs.
- `defer` → the next link, then the terminal.

The `links.length === 0` short-circuit returning the terminal **identity** is a behavioral invariant, not an optimization: `authorizer-selection.test.ts` asserts `prompter.prompt` is called with `expect.any(LocalUserAuthorizer)`, which only holds if the composed value **is** the selected terminal instance when links are empty.

The extracted module's upstream interaction is minimal and Tell-Don't-Ask-clean: it imports the `Authorizer` / `TerminalAuthorizer` / `AuthorizerVerdict` types and `createDeniedPermissionDecision` from `permission-dialog.ts`; it is a pure function over its two parameters, mutates nothing, and reaches through nothing.

### Consumer call site

`AuthorizerSelection.activate` routes the selected terminal through the (empty) chain:

```typescript
activate(ctx: ExtensionContext): void {
  const terminal = selectAuthorizer(ctx, this.deps);
  this.selected = composeAuthorizerChain([], terminal);
}
```

With the literal `[]`, `composeAuthorizerChain` returns `terminal`, so `escalate` still hands the real `LocalUserAuthorizer` / `ParentAuthorizer` / `DenyingAuthorizer` to `prompter.prompt` — identical behavior.
Step 5 replaces `[]` with the registered links resolved from `authorizerChain` config.

### Design-review checklist

Ran the `design-review` checklist against the reshaped interface and the new wiring:

- **Dependency width** — `composeAuthorizerChain(links, terminal)`: two parameters, both used.
  No wide bag.
- **Law of Demeter** — no reach-through; the function talks only to its two parameters.
- **Output arguments** — none; returns a value, mutates nothing.
- **Parameter relay** — `details` flows link→terminal; each endpoint genuinely consumes it.
- **Repeated discriminators** — the `verdict.kind` switch is a **single** dispatch point (the composition function), not scattered `===` across modules.
  OCP-compliant.
- **Test mock depth** — the prompter test's `makeAuthorizer` becomes a one-method `TerminalAuthorizer` stub; no `as unknown as`, no nesting.

No structural smells; the change is fit for a single PR.

## Module-Level Changes

Source (`src/authority/`):

- `authorizer.ts` — add `AuthorizerVerdict`; repurpose `Authorizer` as the non-terminal link (`authorize(details): Promise<AuthorizerVerdict>`); add `TerminalAuthorizer` (`authorize(details): Promise<PermissionPromptDecision>`); change `selectAuthorizer`'s return type to `TerminalAuthorizer`.
  `AuthorizerSelectionDeps` unchanged.
  Import `PermissionPromptDecision` for the terminal signature (already imported).
- `authorizer-chain.ts` — **new**: `composeAuthorizerChain(links, terminal)`; imports `Authorizer` / `TerminalAuthorizer` / `AuthorizerVerdict` from `./authorizer`, `createDeniedPermissionDecision` + `PermissionPromptDecision` from `./permission-dialog`.
- `local-user-authorizer.ts`, `denying-authorizer.ts`, `approval-escalator.ts` — `implements Authorizer` → `implements TerminalAuthorizer` (bodies unchanged).
- `permission-prompter.ts` — `PermissionPrompterApi.prompt(authorizer: TerminalAuthorizer, …)` and the `PermissionPrompter.prompt` parameter; doc comment reference `{@link Authorizer}` → `{@link TerminalAuthorizer}`.
- `authorizer-selection.ts` — retype `private selected: TerminalAuthorizer | null`; `activate` calls `composeAuthorizerChain([], selectAuthorizer(ctx, this.deps))`; import `composeAuthorizerChain`.

Tests (`test/authority/`):

- `authorizer-chain.test.ts` — **new** (see TDD Order).
- `permission-prompter.test.ts` — `makeAuthorizer(decision): TerminalAuthorizer`; the `import type { Authorizer }` becomes `TerminalAuthorizer`; `vi.fn<Authorizer["authorize"]>` → `vi.fn<TerminalAuthorizer["authorize"]>`.
- `denying-authorizer.test.ts` — `import type { Authorizer }` → `TerminalAuthorizer`; the `const authorizer: Authorizer = new DenyingAuthorizer()` annotation → `TerminalAuthorizer`.
- `authorizer.test.ts` — unchanged (asserts `instanceof` on `selectAuthorizer`'s result; the concrete classes are unchanged).
  Stays green as the behavior pin.
- `authorizer-selection.test.ts` — unchanged (asserts `prompter.prompt` called with `expect.any(LocalUserAuthorizer)`; the empty-chain identity preserves it).
  Stays green as the behavior pin.

Docs:

- `docs/architecture/architecture.md`:
  - Module tree — rewrite the `authorizer.ts` entry (line ~760) to `AuthorizerVerdict` + non-terminal `Authorizer` (`authorize(details): Promise<AuthorizerVerdict>`) + `TerminalAuthorizer` (`authorize(details): Promise<PermissionPromptDecision>`) + `AuthorizerSelectionDeps` + `selectAuthorizer(ctx, deps): TerminalAuthorizer`; add a new `authorizer-chain.ts` entry (`composeAuthorizerChain` — non-terminal links then the terminal; terminal-cannot-defer is type-level; empty-links identity).
    Refine the `local-user-authorizer.ts` / `denying-authorizer.ts` / `approval-escalator.ts` and `authorizer-selection.ts` / `permission-prompter.ts` entries where they call the concrete classes "Authorizer" to "`TerminalAuthorizer`" (current-behavior accuracy).
  - Step 4 completion — add `✅` to the `#### Step 4:` heading and the `S4` Mermaid node, and a `Landed:` note under Step 4's Outcome.
  - Do **not** edit the fixed `Baseline (2026-07-15)` column or the Step-5 `authorizerChain` schema-sites metric row (that metric is Step 5's).
- `docs/configuration.md`, `README.md` — no change (no config field or command added in Step 4).
- `.pi/skills/package-pi-permission-system/SKILL.md` — no change; its only reference is `ParentAuthorizer.authorize` (`src/authority/approval-escalator.ts`), whose signature (returns `PermissionPromptDecision` as a `TerminalAuthorizer`) is unchanged.

No `package.json` `exports`, event channel, or `Symbol.for()` surface changes (the reshaped types are package-internal), so no wider `docs/` grep is warranted; the greps above (`docs/architecture/`, `docs/configuration.md`, `README.md`, package skill) found every reference.

## Test Impact Analysis

1. **New tests the extraction enables** — `composeAuthorizerChain` is a pure function, unit-testable in isolation for the first time: empty-links identity; `allow`→`{approved:true,state:"approved"}`; `deny` with reason→`denied_with_reason` + `denialReason`; `deny` without reason→`denied`; `defer`→next link; a mid-list decide short-circuits (first non-defer wins, later links not called); all-defer→terminal.
   Previously this dispatch did not exist.
2. **Redundant existing tests** — none.
   No prior test covered chain composition (it did not exist); `authorizer.test.ts` and `authorizer-selection.test.ts` still exercise selection and escalation and are not superseded.
3. **Tests that must stay as-is** — `authorizer.test.ts` (terminal selection by context) and `authorizer-selection.test.ts` (escalate/reject contract + `expect.any(LocalUserAuthorizer)` identity) are the behavior pins that prove the reshape is a no-op with zero links.
   They must pass unchanged; changing them would defeat the "behavior identical" guarantee.

## Invariants at risk

The change touches the Phase 9 spine (`selectAuthorizer` / `AuthorizerSelection`), whose documented outcome is "exactly one terminal `Authorizer` selected per activation; `escalate` delegates to the prompter with the selected authorizer" and "#556 dissolved `canConfirm()`".

| Invariant                                                                                       | Pinned by                                                          |
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| One terminal selected per activation by `hasUI` / `isSubagent` / deny dispatch                  | `authorizer.test.ts` (`instanceof` per context)                    |
| `escalate` hands the **selected terminal instance** to `prompter.prompt` (empty-chain identity) | `authorizer-selection.test.ts` (`expect.any(LocalUserAuthorizer)`) |
| `escalate` rejects before activate / after deactivate; returns the prompter decision            | `authorizer-selection.test.ts`                                     |
| No separate confirmability predicate (#556)                                                     | unchanged — `DenyingAuthorizer` still answers by denying           |

All invariants live in existing tests; no new pinning test is needed for them.
The reshape must keep the empty-links identity so the second row holds.

## TDD Order

1. **Reshape the interfaces and add `composeAuthorizerChain`.**
   Red — add `test/authority/authorizer-chain.test.ts` covering the seven cases in Test Impact Analysis §1 (empty-links identity via `toBe(terminal)`; each verdict mapping; first-non-defer-wins with a `not.toHaveBeenCalled` on the trailing link; all-defer→terminal).
   Green — in `authorizer.ts` add `AuthorizerVerdict`, repurpose `Authorizer` as the non-terminal link, add `TerminalAuthorizer`, retype `selectAuthorizer`; add `authorizer-chain.ts`; migrate the three concrete terminals and `permission-prompter.ts` to `TerminalAuthorizer`; retype `AuthorizerSelection.selected`; migrate `permission-prompter.test.ts` and `denying-authorizer.test.ts`.
   This is one commit: repurposing the exported `Authorizer` type breaks every implementer and consumer at compile time, so the reshape, all consumer updates, and the two consumer-test updates land together.
   Commit: `refactor(pi-permission-system): reshape live-authority layer as an Authorizer chain (#598)`
2. **Route activation through the empty chain.**
   Green — `AuthorizerSelection.activate` calls `composeAuthorizerChain([], selectAuthorizer(ctx, this.deps))`; import `composeAuthorizerChain`.
   No new test: `authorizer-selection.test.ts` pins the behavior (empty-chain identity preserves `expect.any(LocalUserAuthorizer)`); run it to confirm green.
   Commit: `refactor(pi-permission-system): route activation through composeAuthorizerChain (#598)`
3. **Mark Step 4 complete and refresh the module tree.**
   Update `docs/architecture/architecture.md`: `✅` on the Step 4 heading and the `S4` Mermaid node, a `Landed:` note, the rewritten `authorizer.ts` tree entry, and the new `authorizer-chain.ts` tree entry (plus the terminal-class prose touch-ups).
   Commit: `docs(pi-permission-system): mark Phase 12 Step 4 complete (#598)`

## Risks and Mitigations

- **A silent behavior change from the reshape.**
  Mitigation: the empty-links identity (`composeAuthorizerChain([], t) === t`) keeps `escalate` handing the real terminal instance to the prompter; `authorizer.test.ts` and `authorizer-selection.test.ts` pass unchanged as the pins.
  Any drift breaks the `expect.any(LocalUserAuthorizer)` assertion.
- **A dropped `import type` in the atomic reshape edit (AGENTS.md: `tsc` passes on an unused type import).**
  Mitigation: after Step 1, re-read `authorizer.ts` / `permission-prompter.ts` / `authorizer-selection.ts` and run `pnpm --filter @gotgenes/pi-permission-system run check` + `run lint` (lint flags unused imports), not just `tsc`.
- **Vocabulary drift from ADR 0007.**
  Mitigation: the operator confirmed the ADR-faithful rename (`Authorizer` = non-terminal link, `TerminalAuthorizer` = terminal); Steps 5/6 inherit the ADR vocabulary directly.
- **Fallow dead-code on the dormant seam.**
  `composeAuthorizerChain` is consumed by `AuthorizerSelection.activate` (Step 2) and covered by its own tests, so it is not dead; `AuthorizerVerdict` and the non-terminal `Authorizer` are referenced by `composeAuthorizerChain`'s signature and tests.
  Run `pnpm fallow dead-code` before pushing.

## Open Questions

- **Link `authorize` signature gains `PermissionQuery` in Step 5.**
  ADR 0007 §3 injects a narrow `PermissionQuery` into each link at `authorize` time.
  Step 4's `Authorizer.authorize(details)` omits it (no link consumes it yet); Step 5 widens the signature when it wires registration and query injection.
  Deferred to [#599] by design, not an oversight.
- **`allow`/`deny` verdict → decision mapping richness.**
  Step 4 maps `allow`→`state:"approved"` (non-persistent) and `deny`→`createDeniedPermissionDecision`.
  Whether a future allow-capable slice needs a session-scoped or audited (`origin:"authorizer:model"`) decision shape is Step 5/6 envelope work per ADR 0007 §6; not in scope here.

[#472]: https://github.com/gotgenes/pi-packages/issues/472
[#599]: https://github.com/gotgenes/pi-packages/issues/599
[#600]: https://github.com/gotgenes/pi-packages/issues/600
