Files
my-pi/pi-permission-system/docs/plans/0555-authorizer-spine.md
T

27 KiB
Raw Blame History

issue, issue_title
issue issue_title
555 pi-permission-system: introduce the Authorizer spine — interface, three implementations, once-per-session selection

Introduce the Authorizer spine

Phase 9 Step 1: give the live-authority path (what happens on ask) a single owner — the Authorizer interface, its three implementations, and a once-per-activation selection — replacing the three-way hasUI/isSubagent/deny dispatch that is currently smeared across PromptingGateway, PermissionPrompter, and ApprovalEscalator.

Release Recommendation

Release: ship independently

This is Step 1 of Phase 9's five-step roadmap, tagged Release: independent there. It is a behavior-neutral refactor (refactor: — a hidden changelog type), so it cuts no release on its own and auto-batches into the next feat:/fix:/unhidden-docs: release. Phase 9 has no multi-step release batch — every step leaves the package consistent on its own, and the two feat: steps (#557, #558) each cut their own release when they land. No coordination is required at ship time.

Problem Statement

The deontic question "who may decide this ask, and how do we reach them" has no single owner. It is answered by an accretion of collaborators, each re-deriving the same two context predicates:

  • GateRunner asks GatePrompter.canConfirm().
  • PromptingGateway.canConfirm() computes hasUI || isSubagent(ctx).
  • PermissionPrompter.prompt(ctx, details) reads ctx.hasUI again to decide whether to emit the UI-prompt event.
  • ApprovalEscalator.requestApproval(ctx, …) re-branches on the same predicates a third time: hasUI → direct dialog, !isSubagent → deny, else → forward.

The result is a relay chain of four role interfaces (GatePrompter, PermissionPrompterApi, ApprovalRequester, plus the gateway lifecycle) to reach one dialog, with hasUI/isSubagent evaluated 3+ times per prompt and "no authority reachable" represented twice. The architecture doc's authority model names the missing concept: authority, held by an Authorizer selected once per session. Phase 8 tidied the ground for exactly this change (yolo into the ruleset, the escalator/server split, the single SubagentDetection collaborator).

Goals

  • Introduce Authorizer (authorize(details): Promise<PermissionPromptDecision>) as the single live-authority role, with three implementations: LocalUserAuthorizer, ParentAuthorizer, DenyingAuthorizer.
  • Concentrate the three-way hasUI/isSubagent/deny dispatch into one pure function, selectAuthorizer, evaluated once per session activation.
  • Route the ask path through the selected Authorizer: PermissionPrompter keeps its review-log bracketing but delegates to the selected authorizer and drops per-call ctx threading.
  • Land the elicitation modules in their authority/ home as they are rewritten (authorizer-selection.ts, permission-prompter.ts, the three authorizer files), so #559 moves only the mechanical remainder.
  • Behavior-neutral: existing review-log, decision-event, UI-prompt-event, and forwarding round-trip tests pass unchanged.

Non-Goals

  • Dissolving canConfirm() — the GatePrompter.canConfirm() surface survives this step (answered by the selection) and is dissolved in #556 (Phase 9 Step 2), which deletes gate-prompter.ts and drops the pre-check.
  • Rebuilding the serving side (ForwardedRequestServer.processInbox) onto evaluate() + the serving Authorizer — that is #557 (Step 3); this step leaves forwarded-request-server.ts untouched.
  • Grant-scope selection on forwarded approvals — #558 (Step 4).
  • Moving the remaining flat modules (permission-dialog.ts, permission-forwarding.ts, subagent-registry.ts, subagent-lifecycle-events.ts, forwarding-manager.ts) into authority/#559 (Step 5).
  • The ModelTriageAuthorizer decorator — deferred to a later phase with its own decision record; the Authorizer interface introduced here is its extension point.

Background

Relevant existing modules (all under packages/pi-permission-system/src/):

  • prompting-gateway.tsPromptingGateway implements GatePrompter, PromptingGatewayLifecycle. Stores ExtensionContext at activate, computes canConfirm() from hasUI || detection.isSubagent(ctx), and delegates prompt(details) to the injected PermissionPrompter.
  • permission-prompter.tsPermissionPrompter implements PermissionPrompterApi. Brackets the flow with review-log waiting/approved/denied entries, builds the UI-prompt event via buildDirectUiPrompt(details), emits it on permissions:ui_prompt only when ctx.hasUI, then calls forwarder.requestApproval(ctx, message, options, forwarded). Owns the PromptPermissionDetails type.
  • authority/approval-escalator.tsApprovalEscalator implements ApprovalRequester. requestApproval(ctx, …) is the three-way dispatch: ctx.hasUIrequestPermissionDecisionFromUi(ctx.ui, …); !detection.isSubagent(ctx){ approved: false, state: "denied" }; else → waitForForwardedApproval (build request file, poll for the parent's response).
  • gate-prompter.ts — the GatePrompter interface (canConfirm() + prompt(details)) that GateRunner depends on.
  • authority/subagent-detection.tsSubagentDetection implements SubagentDetector (isSubagent(ctx)); the selection predicate (#529).
  • permission-dialog.tsPermissionPromptDecision type, requestPermissionDecisionFromUi, RequestPermissionOptions.
  • permission-ui-prompt.tsbuildDirectUiPrompt(details), source/surface/value derivation.
  • permission-events.tsemitUiPromptEvent, PermissionEventBus.

Wiring (index.ts): escalator = new ApprovalEscalator({ forwardingDir, detection, registry, logger, requestPermissionDecisionFromUi })prompter = new PermissionPrompter({ logger, events, forwarder: escalator })gateway = new PromptingGateway({ detection, prompter }). The gateway is passed to PermissionSession (as PromptingGatewayLifecycle) and to GateRunner (as GatePrompter). PermissionSession.activate(ctx) forwards to gateway.activate(ctx); activate runs on every tool call (permission-gate-handler.ts), not only at session_start.

AGENTS.md / skill constraints that apply:

  • docs/architecture/architecture.md inline-copies module descriptions in a module-structure tree — any move/rename/add must update that tree in the same doc-update commit.
  • A dead export or unused module fails pnpm fallow dead-code (CI-gated) — new modules must be wired in the same commit that introduces them.
  • Import sibling modules via the #src/ / #test/ aliases, never relative paths.
  • The session-created and forwarding constraints (#296, #302) are unaffected — this refactor does not touch registration timing or the process-global registry.

Design Overview

The Authorizer interface

// src/authority/authorizer.ts
import type { PromptPermissionDetails } from "#src/authority/permission-prompter";
import type { PermissionPromptDecision } from "#src/permission-dialog";

/** The live-authority role: rule on a single ask, told the decision. */
export interface Authorizer {
  authorize(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
}

One method, one responsibility (ISP-clean). The PromptPermissionDetails command object is the request descriptor already threaded today; DenyingAuthorizer ignores it, LocalUserAuthorizer reads message/sessionLabel and derives the UI event from it, ParentAuthorizer reads message and derives the forwarded display from it.

The three implementations

// src/authority/local-user-authorizer.ts
export class LocalUserAuthorizer implements Authorizer {
  constructor(private readonly deps: {
    ui: PermissionDecisionUi;
    events: PermissionEventBus;
    requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
  }) {}

  authorize(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
    const uiPrompt = buildDirectUiPrompt(details);
    emitUiPromptEvent(this.deps.events, uiPrompt); // moved here from PermissionPrompter's ctx.hasUI arm
    return this.deps.requestPermissionDecisionFromUi(
      this.deps.ui,
      "Permission Required",
      details.message,
      details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
    );
  }
}
// src/authority/denying-authorizer.ts
export class DenyingAuthorizer implements Authorizer {
  authorize(): Promise<PermissionPromptDecision> {
    return Promise.resolve({ approved: false, state: "denied" });
  }
}

ParentAuthorizer owns the forwarding machinery currently in ApprovalEscalator, with ctx bound at construction (dropping the per-call ctx param). Its authorize(details) builds the forwarded display via buildDirectUiPrompt(details) and runs the existing request-write/poll flow.

selectAuthorizer is the pure dispatch, evaluated once per activation:

// src/authority/authorizer.ts
export interface AuthorizerSelectionDeps {
  detection: SubagentDetector;
  events: PermissionEventBus;
  requestPermissionDecisionFromUi: typeof requestPermissionDecisionFromUi;
  forwardingDir: string;
  registry?: SubagentSessionRegistry;
  logger: DebugReviewLogger;
}

export function selectAuthorizer(
  ctx: ExtensionContext,
  deps: AuthorizerSelectionDeps,
): Authorizer {
  if (ctx.hasUI) {
    return new LocalUserAuthorizer({
      ui: ctx.ui,
      events: deps.events,
      requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
    });
  }
  if (deps.detection.isSubagent(ctx)) {
    return new ParentAuthorizer(ctx, {
      forwardingDir: deps.forwardingDir,
      detection: deps.detection,
      registry: deps.registry,
      logger: deps.logger,
    });
  }
  return new DenyingAuthorizer();
}

Note: the issue/roadmap write the shorthand selectAuthorizer(ctx, detection). The real signature is selectAuthorizer(ctx, deps) — the leaf authorizers need construction inputs (events, requestPermissionDecisionFromUi, forwardingDir, registry, logger) beyond detection. AuthorizerSelectionDeps is the same composition-root wiring set ApprovalEscalator + PermissionPrompter already receive today, relocated onto one bag — not a new dependency, and not a widening (the escalator sheds requestPermissionDecisionFromUi, which moves to LocalUserAuthorizer).

The selection owner (rewrite of PromptingGateway)

// src/authority/authorizer-selection.ts
export interface AuthorizerSelectionLifecycle {
  activate(ctx: ExtensionContext): void;
  deactivate(): void;
}

export class AuthorizerSelection
  implements GatePrompter, AuthorizerSelectionLifecycle
{
  private selected: Authorizer | null = null;
  private confirmable = false;

  constructor(private readonly deps: AuthorizerSelectionDeps & {
    prompter: PermissionPrompter;
  }) {}

  activate(ctx: ExtensionContext): void {
    this.selected = selectAuthorizer(ctx, this.deps);
    // Transitional: canConfirm survives Step 1 (dissolved in #556). Recomputing
    // the predicate here duplicates selectAuthorizer's branch, but keeps the
    // ask-path byte-identical until #556 derives it from a DenyingAuthorizer marker.
    this.confirmable = ctx.hasUI || this.deps.detection.isSubagent(ctx);
  }

  deactivate(): void {
    this.selected = null;
    this.confirmable = false;
  }

  canConfirm(): boolean {
    return this.selected !== null && this.confirmable;
  }

  prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
    if (this.selected === null) {
      return Promise.reject(
        new Error("prompt called before the session was activated"),
      );
    }
    return this.deps.prompter.prompt(this.selected, details);
  }
}

The bracketing prompter (moved, signature changed)

PermissionPrompter moves to src/authority/permission-prompter.ts and drops per-call ctx:

export class PermissionPrompter {
  constructor(private readonly deps: { logger: ReviewLogger }) {}

  async prompt(
    authorizer: Authorizer,
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision> {
    this.writeReviewEntry("permission_request.waiting", details);
    const decision = await authorizer.authorize(details);
    this.writeReviewEntry(
      decision.approved
        ? "permission_request.approved"
        : "permission_request.denied",
      { ...details, resolution: decision.state, denialReason: decision.denialReason },
    );
    return decision;
  }
  // writeReviewEntry unchanged
}

The prompter sheds its events and forwarder deps (both concerns move into LocalUserAuthorizer / ParentAuthorizer); only the review logger remains. PromptPermissionDetails moves with this file; PermissionPrompterApi and ApprovalRequester are removed (no consumer after the rewire).

Behavior-neutrality trace

The ask path is byte-identical across every branch:

  • UI present — prompter writes waiting; LocalUserAuthorizer.authorize emits the UI-prompt event (only here, matching today's ctx.hasUI guard) then shows the dialog; prompter writes approved/denied. Event-before-dialog order preserved.
  • Subagent — prompter writes waiting; ParentAuthorizer.authorize forwards and polls (no UI event, matching today); prompter writes the outcome.
  • No authority — prompter writes waiting; DenyingAuthorizer.authorize denies (no UI event, matching the current !isSubagent arm); prompter writes denied.
  • yolo — unchanged: the composition-stage ask→allow rewrite (#526) means an ask never reaches this path, and the runner's yolo fast-path (check.origin === "yolo") is upstream of the prompter. Untouched.
  • canConfirm() — same value (hasUI || isSubagent, false before activation), consumed by the runner exactly as today.

Consumer call-site sketch (Law of Demeter)

GateRunner is unchanged — it still holds a GatePrompter and calls canConfirm() / prompt(details). The selection owner captures ctx.ui into LocalUserAuthorizer at selection time rather than reaching ctx.ui per prompt, and binds ctx into ParentAuthorizer once — removing the per-call ctx relay through the prompter → forwarder chain (a net reduction in parameter relay, not an addition).

Module-Level Changes

New files under src/authority/:

  • authorizer.tsAuthorizer interface, AuthorizerSelectionDeps, selectAuthorizer(ctx, deps).
  • local-user-authorizer.tsLocalUserAuthorizer (owns ui + events + requestPermissionDecisionFromUi; emits the UI-prompt event).
  • denying-authorizer.tsDenyingAuthorizer (least-privilege deny).
  • authorizer-selection.tsAuthorizerSelection (implements GatePrompter, AuthorizerSelectionLifecycle); the rewrite of prompting-gateway.ts.
  • permission-prompter.ts — moved from src/permission-prompter.ts; PermissionPrompter.prompt(authorizer, details); owns PromptPermissionDetails and PermissionReviewSource.

Changed files:

  • src/authority/approval-escalator.ts — becomes the ParentAuthorizer: sheds the ctx.hasUI and !isSubagent arms and the ApprovalRequester seam; requestApproval(ctx, …) becomes authorize(details) with ctx bound at construction; keeps the forwarding request-write/poll machinery.
  • src/index.ts — rewire: build AuthorizerSelectionDeps, construct PermissionPrompter({ logger }), construct AuthorizerSelection, pass it to PermissionSession and GateRunner (replacing the escalator/prompter/gateway trio).
  • src/permission-session.ts — import AuthorizerSelectionLifecycle from #src/authority/authorizer-selection (was PromptingGatewayLifecycle from #src/prompting-gateway); constructor param type and the doc comment update; no logic change.
  • src/handlers/gates/descriptor.tsPromptPermissionDetails import path → #src/authority/permission-prompter.
  • src/session-logger.ts — doc-comment references (Injected into PermissionPrompter …, Injected into ConfigStore, ApprovalEscalator …) updated to name the new owners.

Removed files:

  • src/prompting-gateway.ts — replaced by authority/authorizer-selection.ts.
  • src/permission-prompter.ts — moved to authority/permission-prompter.ts.

Test files:

  • New: test/authority/authorizer.test.ts (selectAuthorizer 3-way dispatch), test/authority/local-user-authorizer.test.ts, test/authority/denying-authorizer.test.ts.
  • Moved/rewritten: test/prompting-gateway.test.tstest/authority/authorizer-selection.test.ts; test/permission-prompter.test.tstest/authority/permission-prompter.test.ts (pass a fake Authorizer instead of ctx; drop the UI-event-emission assertions, which move to local-user-authorizer.test.ts).
  • Updated: test/authority/approval-escalator.test.ts — delete the "UI fast path" and "non-UI, non-subagent" tests (behavior moved to the leaf-authorizer tests); retarget the forwarding tests to ParentAuthorizer.authorize.
  • Fixtures: test/helpers/session-fixtures.ts (makeGateway → returns an AuthorizerSelectionLifecycle); test/helpers/forwarding-fixtures.ts (makeEscalatorDeps drops requestPermissionDecisionFromUi, builds ParentAuthorizer inputs). test/helpers/gate-fixtures.ts, handler-fixtures.ts, external-directory-fixtures.ts are unchanged — they mock the surviving GatePrompter interface ({ canConfirm, prompt }), which is stable this step.

Doc updates (in the implementation doc-update commit):

  • docs/architecture/architecture.md — module-structure tree: relocate permission-prompter.ts and prompting-gateway.ts entries into the authority/ block, rewrite the approval-escalator.ts entry as ParentAuthorizer, add the four new authority/ entries; update the authority/ block header; update the Target: the authority model note (line ~497: "the Authorizer interface itself is still Phase 9" → the interface now exists as of Step 1); mark Phase 9 Step 1 complete ( on the step heading and the S1 Mermaid node). Leave the phase-exit metrics table unchanged — canConfirm occurrences and the role-interface count are Phase-9-exit targets not met until #556.
  • docs/architecture/permission-prompter.md — update the PermissionPrompter responsibility/interface sections: the UI-event branch and the UI/forwarding dispatch move to the authorizers; prompt(ctx, details)prompt(authorizer, details); the ApprovalRequester seam is gone.
  • .pi/skills/package-pi-permission-system/SKILL.md — the forwarding round-trip testing note (ApprovalEscalator.requestApproval polls …) → ParentAuthorizer.authorize.

docs/plans/ and docs/retro/ files that name these symbols are historical per-issue records and are not updated.

Test Impact Analysis

  1. New tests the extraction enables — each live-authority channel becomes independently testable:
    • selectAuthorizer — the 3-way dispatch given (hasUI, isSubagent), previously only reachable through ApprovalEscalator.requestApproval + the prompter.
    • LocalUserAuthorizer — UI-event emission + dialog call in isolation (was entangled across PermissionPrompter and the escalator's hasUI arm).
    • DenyingAuthorizer — the least-privilege deny as its own unit (was the escalator's !isSubagent arm).
  2. Redundant tests to remove/simplifyapproval-escalator.test.ts's "UI fast path" and "non-UI, non-subagent" tests become redundant (behavior owned by the leaf authorizers); delete them. permission-prompter.test.ts's UI-event-emission assertions move to local-user-authorizer.test.ts; the prompter tests simplify to bracketing (waitingapproved/denied around a fake Authorizer).
  3. Tests that must stay — the forwarding round-trip tests (permission-forwarding.test.ts, composition-root.test.ts's "subagent registry sharing") genuinely exercise the forwarding transport; keep them, retargeting only where they construct the escalator directly. permission-ui-prompt.test.ts (event-shape contract, #292) stays as-is.

Invariants at risk

This surface was refactored by Phase 8 (#526 yolo-into-ruleset, #529 SubagentDetection, #530 escalator/server split). Step 1 must not regress their documented outcomes:

  • Review-log paritywaitingapproved/denied bracketing, and the yolo single auto_approved entry (#526). Pinned by test/authority/permission-prompter.test.ts and test/handlers/gates/runner.test.ts (yolo fast-path is in the runner, upstream of the ask path — untouched).
  • UI-prompt-event contract — event emitted only when hasUI, forwarded path carries the display fields (#292). Pinned by permission-ui-prompt.test.ts and the new local-user-authorizer.test.ts; add an assertion in the latter that DenyingAuthorizer/ParentAuthorizer do not emit.
  • Forwarding transport — request-write/poll, target resolution, timeout (#530, #398). Pinned by permission-forwarding.test.ts, forwarded-request-server.test.ts (serving side, untouched here), and the composition-root round-trip.
  • canConfirm() valuehasUI || isSubagent, false before activation. Pinned by the migrated authorizer-selection.test.ts.

TDD Order

The blast radius is contained: the GatePrompter interface survives Step 1, so GateRunner and its fixtures (gate-fixtures, handler-fixtures, external-directory-fixtures) are untouched. Two implementation steps, using a short-lived transitional wrapper so each commit stays green and reviewable, then a docs step.

  1. Introduce the Authorizer spine and route the ask path through it. Test surface: new test/authority/authorizer.test.ts (selectAuthorizer dispatch), local-user-authorizer.test.ts, denying-authorizer.test.ts; migrated authorizer-selection.test.ts and authority/permission-prompter.test.ts. Covers: the three-way selection, each authorizer's behavior in isolation, the prompter's bracketing around a fake Authorizer, and canConfirm() parity. Implementation: add authorizer.ts + local-user-authorizer.ts + denying-authorizer.ts; add a ParentAuthorizer in approval-escalator.ts that wraps the existing ApprovalEscalator instance (ctx bound at construction; authorize(details) builds the forwarded display and calls escalator.requestApproval(ctx, details.message, undefined, forwarded)) — the escalator class and its requestApproval/ApprovalRequester seam stay intact this step; rewrite prompting-gateway.tsauthority/authorizer-selection.ts; move permission-prompter.tsauthority/permission-prompter.ts with the new signature; rewire index.ts; update permission-session.ts, handlers/gates/descriptor.ts, session-logger.ts imports; migrate the two test files and update session-fixtures.makeGateway. This is the atomic type-break commit (removing PromptingGateway, changing PermissionPrompter.prompt's signature, and dropping PermissionPrompterApi break every consumer at once); all new modules are wired, so no dead-code failure. Run pnpm run check immediately after (shared-interface change). Commit: refactor(pi-permission-system): route the ask path through the Authorizer spine.

  2. Collapse ApprovalEscalator into ParentAuthorizer; remove the dead dispatch arms. Test surface: test/authority/approval-escalator.test.ts (retargeted to ParentAuthorizer.authorize), forwarding-fixtures.ts. Covers: forwarding via ParentAuthorizer.authorize; the removed UI/deny arms are already covered by the leaf-authorizer tests from Step 1. Implementation: after Step 1 the only caller of escalator.requestApproval is the ParentAuthorizer wrapper, always with hasUI=false/isSubagent=true, so the ctx.hasUI and !isSubagent arms are dead — fold the forwarding machinery directly into ParentAuthorizer, remove the wrapper indirection, the two dead arms, requestApproval, and the now-unused ApprovalRequester interface; drop requestPermissionDecisionFromUi from the forwarding deps (it stays consumed by LocalUserAuthorizer and ForwardedRequestServer). Delete the "UI fast path" and "non-UI, non-subagent" tests from approval-escalator.test.ts. Run pnpm run check. Commit: refactor(pi-permission-system): fold ApprovalEscalator into ParentAuthorizer.

  3. Update architecture docs and mark Phase 9 Step 1 complete. Update docs/architecture/architecture.md (module tree, authority-model note, Step 1 heading + S1 node), docs/architecture/permission-prompter.md, and .pi/skills/package-pi-permission-system/SKILL.md per Module-Level Changes. Run pnpm run lint (markdown). Commit: docs(pi-permission-system): mark Phase 9 Step 1 complete — Authorizer spine. (A docs: commit here is hidden-adjacent narrative; it batches with the refactor: steps and cuts no release.)

Risks and Mitigations

  • Risk: a hidden third read of ctx or events inside the old ask path is dropped in the move. Mitigation: the behavior-neutrality trace enumerates every branch; the migrated permission-prompter/authorizer-selection tests plus permission-ui-prompt.test.ts pin the review-log and event contracts.
  • Risk: activate runs per tool call, so selectAuthorizer reconstructs an authorizer each call. Mitigation: authorizer construction is a cheap object allocation and the predicates are session-stable, so the selected authorizer is identical each call — behavior-neutral. A memoize-by-ctx optimization is possible but unnecessary and out of scope.
  • Risk: the transitional ParentAuthorizer-wraps-ApprovalEscalator seam is mistaken for the final shape. Mitigation: Step 2 removes it in the same session; a prose comment (not @deprecated, per the no-deprecated lint rule) marks it transitional.
  • Risk: fallow dead-code flags a new module if wiring lags introduction. Mitigation: Step 1 introduces and wires in one commit; run pnpm fallow dead-code before pushing.

Open Questions

  • None. The direction is fully settled in the architecture doc's authority model and the Phase 9 roadmap; no follow-up issues are named by this plan beyond the already-filed Phase 9 steps (#556#559).