Files
my-pi/pi-permission-system/docs/plans/0598-authorizer-chain-infrastructure.md
T

21 KiB

issue, issue_title
issue issue_title
598 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.tsselectAuthorizer 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.tsAuthorizerSelection (the AskEscalator implementation): activate(ctx) runs selectAuthorizer and stores the result in selected; escalate(details) delegates to prompter.prompt(this.selected, details).
  • permission-prompter.tsPermissionPrompterApi.prompt(authorizer: Authorizer, details) brackets the review-log entries around authorizer.authorize(details) and returns its PermissionPromptDecision.
  • permission-dialog.tsPermissionPromptDecision 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:

/** 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 Authorizerimplements TerminalAuthorizer; their bodies are unchanged (they already return PermissionPromptDecision). selectAuthorizer's return type changes AuthorizerTerminalAuthorizer. PermissionPrompterApi.prompt and AuthorizerSelection.selected retype to TerminalAuthorizer.

The chain composition

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

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.
  • denycreateDeniedPermissionDecision(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:

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 widthcomposeAuthorizerChain(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 relaydetails 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.tsnew: composeAuthorizerChain(links, terminal); imports Authorizer / TerminalAuthorizer / AuthorizerVerdict from ./authorizer, createDeniedPermissionDecision + PermissionPromptDecision from ./permission-dialog.
  • local-user-authorizer.ts, denying-authorizer.ts, approval-escalator.tsimplements Authorizerimplements TerminalAuthorizer (bodies unchanged).
  • permission-prompter.tsPermissionPrompterApi.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.tsnew (see TDD Order).
  • permission-prompter.test.tsmakeAuthorizer(decision): TerminalAuthorizer; the import type { Authorizer } becomes TerminalAuthorizer; vi.fn<Authorizer["authorize"]>vi.fn<TerminalAuthorizer["authorize"]>.
  • denying-authorizer.test.tsimport 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 enablescomposeAuthorizerChain 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-isauthorizer.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 allowstate:"approved" (non-persistent) and denycreateDeniedPermissionDecision. 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.