mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
---
|
||||
issue: 555
|
||||
issue_title: "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](../architecture/architecture.md#the-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.ts` — `PromptingGateway implements GatePrompter, PromptingGatewayLifecycle`.
|
||||
Stores `ExtensionContext` at `activate`, computes `canConfirm()` from `hasUI || detection.isSubagent(ctx)`, and delegates `prompt(details)` to the injected `PermissionPrompter`.
|
||||
- `permission-prompter.ts` — `PermissionPrompter 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.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` — the `GatePrompter` interface (`canConfirm()` + `prompt(details)`) that `GateRunner` depends on.
|
||||
- `authority/subagent-detection.ts` — `SubagentDetection implements SubagentDetector` (`isSubagent(ctx)`); the selection predicate ([#529]).
|
||||
- `permission-dialog.ts` — `PermissionPromptDecision` type, `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.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
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```typescript
|
||||
// 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`)
|
||||
|
||||
```typescript
|
||||
// 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`:
|
||||
|
||||
```typescript
|
||||
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.ts` — `Authorizer` interface, `AuthorizerSelectionDeps`, `selectAuthorizer(ctx, deps)`.
|
||||
- `local-user-authorizer.ts` — `LocalUserAuthorizer` (owns `ui` + `events` + `requestPermissionDecisionFromUi`; emits the UI-prompt event).
|
||||
- `denying-authorizer.ts` — `DenyingAuthorizer` (least-privilege deny).
|
||||
- `authorizer-selection.ts` — `AuthorizerSelection` (`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.ts` — `PromptPermissionDetails` 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.ts` → `test/authority/authorizer-selection.test.ts`; `test/permission-prompter.test.ts` → `test/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/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 to `local-user-authorizer.test.ts`; the prompter tests simplify to bracketing (`waiting` → `approved`/`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 parity** — `waiting` → `approved`/`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()` value** — `hasUI || 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.ts` → `authority/authorizer-selection.ts`; move `permission-prompter.ts` → `authority/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](../architecture/architecture.md#the-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]).
|
||||
|
||||
[#292]: https://github.com/gotgenes/pi-packages/issues/292
|
||||
[#296]: https://github.com/gotgenes/pi-packages/issues/296
|
||||
[#302]: https://github.com/gotgenes/pi-packages/issues/302
|
||||
[#398]: https://github.com/gotgenes/pi-packages/issues/398
|
||||
[#526]: https://github.com/gotgenes/pi-packages/issues/526
|
||||
[#529]: https://github.com/gotgenes/pi-packages/issues/529
|
||||
[#530]: https://github.com/gotgenes/pi-packages/issues/530
|
||||
[#556]: https://github.com/gotgenes/pi-packages/issues/556
|
||||
[#557]: https://github.com/gotgenes/pi-packages/issues/557
|
||||
[#558]: https://github.com/gotgenes/pi-packages/issues/558
|
||||
[#559]: https://github.com/gotgenes/pi-packages/issues/559
|
||||
Reference in New Issue
Block a user