27 KiB
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:
GateRunnerasksGatePrompter.canConfirm().PromptingGateway.canConfirm()computeshasUI || isSubagent(ctx).PermissionPrompter.prompt(ctx, details)readsctx.hasUIagain 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:PermissionPrompterkeeps its review-log bracketing but delegates to the selected authorizer and drops per-callctxthreading. - 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()— theGatePrompter.canConfirm()surface survives this step (answered by the selection) and is dissolved in #556 (Phase 9 Step 2), which deletesgate-prompter.tsand drops the pre-check. - Rebuilding the serving side (
ForwardedRequestServer.processInbox) ontoevaluate()+ the servingAuthorizer— that is #557 (Step 3); this step leavesforwarded-request-server.tsuntouched. - 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) intoauthority/— #559 (Step 5). - The
ModelTriageAuthorizerdecorator — deferred to a later phase with its own decision record; theAuthorizerinterface introduced here is its extension point.
Background
Relevant existing modules (all under packages/pi-permission-system/src/):
prompting-gateway.ts—PromptingGateway implements GatePrompter, PromptingGatewayLifecycle. StoresExtensionContextatactivate, computescanConfirm()fromhasUI || detection.isSubagent(ctx), and delegatesprompt(details)to the injectedPermissionPrompter.permission-prompter.ts—PermissionPrompter implements PermissionPrompterApi. Brackets the flow with review-logwaiting/approved/deniedentries, builds the UI-prompt event viabuildDirectUiPrompt(details), emits it onpermissions:ui_promptonly whenctx.hasUI, then callsforwarder.requestApproval(ctx, message, options, forwarded). Owns thePromptPermissionDetailstype.authority/approval-escalator.ts—ApprovalEscalator implements ApprovalRequester.requestApproval(ctx, …)is the three-way dispatch:ctx.hasUI→requestPermissionDecisionFromUi(ctx.ui, …);!detection.isSubagent(ctx)→{ approved: false, state: "denied" }; else →waitForForwardedApproval(build request file, poll for the parent's response).gate-prompter.ts— theGatePrompterinterface (canConfirm()+prompt(details)) thatGateRunnerdepends on.authority/subagent-detection.ts—SubagentDetection implements SubagentDetector(isSubagent(ctx)); the selection predicate (#529).permission-dialog.ts—PermissionPromptDecisiontype,requestPermissionDecisionFromUi,RequestPermissionOptions.permission-ui-prompt.ts—buildDirectUiPrompt(details), source/surface/value derivation.permission-events.ts—emitUiPromptEvent,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.mdinline-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-createdand 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.authorizeemits the UI-prompt event (only here, matching today'sctx.hasUIguard) then shows the dialog; prompter writesapproved/denied. Event-before-dialog order preserved. - Subagent — prompter writes
waiting;ParentAuthorizer.authorizeforwards and polls (no UI event, matching today); prompter writes the outcome. - No authority — prompter writes
waiting;DenyingAuthorizer.authorizedenies (no UI event, matching the current!isSubagentarm); prompter writesdenied. - yolo — unchanged: the composition-stage
ask→allowrewrite (#526) means anasknever 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.ts—Authorizerinterface,AuthorizerSelectionDeps,selectAuthorizer(ctx, deps).local-user-authorizer.ts—LocalUserAuthorizer(ownsui+events+requestPermissionDecisionFromUi; emits the UI-prompt event).denying-authorizer.ts—DenyingAuthorizer(least-privilege deny).authorizer-selection.ts—AuthorizerSelection(implements GatePrompter,AuthorizerSelectionLifecycle); the rewrite ofprompting-gateway.ts.permission-prompter.ts— moved fromsrc/permission-prompter.ts;PermissionPrompter.prompt(authorizer, details); ownsPromptPermissionDetailsandPermissionReviewSource.
Changed files:
src/authority/approval-escalator.ts— becomes theParentAuthorizer: sheds thectx.hasUIand!isSubagentarms and theApprovalRequesterseam;requestApproval(ctx, …)becomesauthorize(details)withctxbound at construction; keeps the forwarding request-write/poll machinery.src/index.ts— rewire: buildAuthorizerSelectionDeps, constructPermissionPrompter({ logger }), constructAuthorizerSelection, pass it toPermissionSessionandGateRunner(replacing theescalator/prompter/gatewaytrio).src/permission-session.ts— importAuthorizerSelectionLifecyclefrom#src/authority/authorizer-selection(wasPromptingGatewayLifecyclefrom#src/prompting-gateway); constructor param type and the doc comment update; no logic change.src/handlers/gates/descriptor.ts—PromptPermissionDetailsimport 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 byauthority/authorizer-selection.ts.src/permission-prompter.ts— moved toauthority/permission-prompter.ts.
Test files:
- New:
test/authority/authorizer.test.ts(selectAuthorizer3-way dispatch),test/authority/local-user-authorizer.test.ts,test/authority/denying-authorizer.test.ts. - Moved/rewritten:
test/prompting-gateway.test.ts→test/authority/authorizer-selection.test.ts;test/permission-prompter.test.ts→test/authority/permission-prompter.test.ts(pass a fakeAuthorizerinstead ofctx; drop the UI-event-emission assertions, which move tolocal-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 toParentAuthorizer.authorize. - Fixtures:
test/helpers/session-fixtures.ts(makeGateway→ returns anAuthorizerSelectionLifecycle);test/helpers/forwarding-fixtures.ts(makeEscalatorDepsdropsrequestPermissionDecisionFromUi, buildsParentAuthorizerinputs).test/helpers/gate-fixtures.ts,handler-fixtures.ts,external-directory-fixtures.tsare unchanged — they mock the survivingGatePrompterinterface ({ canConfirm, prompt }), which is stable this step.
Doc updates (in the implementation doc-update commit):
docs/architecture/architecture.md— module-structure tree: relocatepermission-prompter.tsandprompting-gateway.tsentries into theauthority/block, rewrite theapproval-escalator.tsentry asParentAuthorizer, add the four newauthority/entries; update theauthority/block header; update theTarget: the authority modelnote (line ~497: "theAuthorizerinterface itself is still Phase 9" → the interface now exists as of Step 1); mark Phase 9 Step 1 complete (✅ on the step heading and theS1Mermaid node). Leave the phase-exit metrics table unchanged —canConfirmoccurrences and the role-interface count are Phase-9-exit targets not met until #556.docs/architecture/permission-prompter.md— update thePermissionPrompterresponsibility/interface sections: the UI-event branch and the UI/forwarding dispatch move to the authorizers;prompt(ctx, details)→prompt(authorizer, details); theApprovalRequesterseam 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
- New tests the extraction enables — each live-authority channel becomes independently testable:
selectAuthorizer— the 3-way dispatch given(hasUI, isSubagent), previously only reachable throughApprovalEscalator.requestApproval+ the prompter.LocalUserAuthorizer— UI-event emission + dialog call in isolation (was entangled acrossPermissionPrompterand the escalator'shasUIarm).DenyingAuthorizer— the least-privilege deny as its own unit (was the escalator's!isSubagentarm).
- Redundant tests to remove/simplify —
approval-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 tolocal-user-authorizer.test.ts; the prompter tests simplify to bracketing (waiting→approved/deniedaround a fakeAuthorizer). - 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 parity —
waiting→approved/deniedbracketing, and the yolo singleauto_approvedentry (#526). Pinned bytest/authority/permission-prompter.test.tsandtest/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 bypermission-ui-prompt.test.tsand the newlocal-user-authorizer.test.ts; add an assertion in the latter thatDenyingAuthorizer/ParentAuthorizerdo 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()value —hasUI || isSubagent, false before activation. Pinned by the migratedauthorizer-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.
-
Introduce the
Authorizerspine and route the ask path through it. Test surface: newtest/authority/authorizer.test.ts(selectAuthorizerdispatch),local-user-authorizer.test.ts,denying-authorizer.test.ts; migratedauthorizer-selection.test.tsandauthority/permission-prompter.test.ts. Covers: the three-way selection, each authorizer's behavior in isolation, the prompter's bracketing around a fakeAuthorizer, andcanConfirm()parity. Implementation: addauthorizer.ts+local-user-authorizer.ts+denying-authorizer.ts; add aParentAuthorizerinapproval-escalator.tsthat wraps the existingApprovalEscalatorinstance (ctx bound at construction;authorize(details)builds the forwarded display and callsescalator.requestApproval(ctx, details.message, undefined, forwarded)) — the escalator class and itsrequestApproval/ApprovalRequesterseam stay intact this step; rewriteprompting-gateway.ts→authority/authorizer-selection.ts; movepermission-prompter.ts→authority/permission-prompter.tswith the new signature; rewireindex.ts; updatepermission-session.ts,handlers/gates/descriptor.ts,session-logger.tsimports; migrate the two test files and updatesession-fixtures.makeGateway. This is the atomic type-break commit (removingPromptingGateway, changingPermissionPrompter.prompt's signature, and droppingPermissionPrompterApibreak every consumer at once); all new modules are wired, so no dead-code failure. Runpnpm run checkimmediately after (shared-interface change). Commit:refactor(pi-permission-system): route the ask path through the Authorizer spine. -
Collapse
ApprovalEscalatorintoParentAuthorizer; remove the dead dispatch arms. Test surface:test/authority/approval-escalator.test.ts(retargeted toParentAuthorizer.authorize),forwarding-fixtures.ts. Covers: forwarding viaParentAuthorizer.authorize; the removed UI/deny arms are already covered by the leaf-authorizer tests from Step 1. Implementation: after Step 1 the only caller ofescalator.requestApprovalis theParentAuthorizerwrapper, always withhasUI=false/isSubagent=true, so thectx.hasUIand!isSubagentarms are dead — fold the forwarding machinery directly intoParentAuthorizer, remove the wrapper indirection, the two dead arms,requestApproval, and the now-unusedApprovalRequesterinterface; droprequestPermissionDecisionFromUifrom the forwarding deps (it stays consumed byLocalUserAuthorizerandForwardedRequestServer). Delete the "UI fast path" and "non-UI, non-subagent" tests fromapproval-escalator.test.ts. Runpnpm run check. Commit:refactor(pi-permission-system): fold ApprovalEscalator into ParentAuthorizer. -
Update architecture docs and mark Phase 9 Step 1 complete. Update
docs/architecture/architecture.md(module tree, authority-model note, Step 1 ✅ heading +S1node),docs/architecture/permission-prompter.md, and.pi/skills/package-pi-permission-system/SKILL.mdper Module-Level Changes. Runpnpm run lint(markdown). Commit:docs(pi-permission-system): mark Phase 9 Step 1 complete — Authorizer spine. (Adocs:commit here ishidden-adjacent narrative; it batches with therefactor:steps and cuts no release.)
Risks and Mitigations
- Risk: a hidden third read of
ctxoreventsinside the old ask path is dropped in the move. Mitigation: the behavior-neutrality trace enumerates every branch; the migratedpermission-prompter/authorizer-selectiontests pluspermission-ui-prompt.test.tspin the review-log and event contracts. - Risk:
activateruns per tool call, soselectAuthorizerreconstructs 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-ctxoptimization is possible but unnecessary and out of scope. - Risk: the transitional
ParentAuthorizer-wraps-ApprovalEscalatorseam is mistaken for the final shape. Mitigation: Step 2 removes it in the same session; a prose comment (not@deprecated, per theno-deprecatedlint rule) marks it transitional. - Risk:
fallow dead-codeflags a new module if wiring lags introduction. Mitigation: Step 1 introduces and wires in one commit; runpnpm fallow dead-codebefore 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).