Files
my-pi/pi-permission-system/docs/plans/0323-gate-runner-class.md
T

288 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 323
issue_title: "Replace GateRunnerDeps with a GateRunner class injected with role collaborators"
---
# Replace `GateRunnerDeps` with an injected `GateRunner` class
## Problem Statement
`PermissionGateHandler.handleToolCall` still hand-assembles the gate runner's collaborators as closures and threads them through every gate call.
The runner is a free function, `runGateCheck(descriptor, agentName, toolCallId, deps)`, whose `deps` is a `GateRunnerDeps` bag holding `resolve`, `recordSessionApproval`, `reporter`, `canConfirm`, and `promptPermission`.
After #319 (`PermissionResolver`) and #322 (`DecisionReporter`) landed, that bag resolves to four distinct role collaborators — a permission resolver, a session-approval recorder, a prompter, and a decision reporter — that are "built once in the orchestrator and reused for all gates," as the runner's own doc comment says.
That is constructor injection waiting to happen.
The handler additionally owns a `runGate` closure that performs the null / bypass / descriptor dispatch around `runGateCheck`; that dispatch belongs on the runner, not in an anonymous handler closure.
## Goals
- Add a `GatePrompter` role (`canConfirm()` + `promptPermission(details)`) and a `SessionApprovalRecorder` role (`recordSessionApproval(approval)`); `PermissionSession` implements both, the prompter via stored-context adapters over its existing `canPrompt(ctx)` / `prompt(ctx, details)`.
- Convert `runGateCheck` into a `GateRunner` class constructed with `PermissionResolver`, `SessionApprovalRecorder`, `GatePrompter`, and `DecisionReporter`, exposing `run(gate, agentName, toolCallId)`.
- Consolidate the null / bypass / descriptor dispatch (the handler's `runGate` closure) into `GateRunner.run`.
- Delete the `GateRunnerDeps` interface; `PermissionGateHandler` constructs one `GateRunner` in its constructor and calls `run(...)` per gate.
- Keep the change behavior-preserving; no public npm export changes (every touched module is internal `#src`).
## Non-Goals
- Retyping the `PermissionGateHandler` constructor against the narrow role set and dropping the `as unknown as PermissionSession` casts in its session mocks — that is #325, the phase capstone.
This plan leaves the handler constructor taking the concrete `PermissionSession` and keeps the `as unknown as` mocks, adding only the delegating prompter methods those mocks need to keep passing.
- Changing any permission decision, log entry, or decision-event payload.
- Touching `handleInput` — it prompts via `session.prompt(ctx, details)` directly, never through the runner, and stays as-is.
- Folding the `GatePrompter` role into `handleInput` or sharing more of the runner's decision-building with `handleInput`.
## Background
- `src/handlers/gates/runner.ts``runGateCheck(descriptor, agentName, toolCallId, deps)` runs the check→log→emit→approve cycle using `deps.resolve`, `deps.reporter.writeReviewLog`/`emitDecision`, `deps.canConfirm`, `deps.promptPermission`, and `deps.recordSessionApproval`.
It handles only `GateDescriptor` inputs.
- `src/handlers/gates/descriptor.ts``interface GateRunnerDeps extends PermissionResolver` adds `recordSessionApproval`, `reporter: DecisionReporter`, `canConfirm()`, `promptPermission(details)`.
The file also defines `GateDescriptor`, `GateBypass`, `GateResult`, `isGateBypass`, `isGateDescriptor`.
- `src/handlers/permission-gate-handler.ts` — builds `canConfirm`/`promptPermission`/`recordSessionApproval` closures over `ctx` and `this.session`, packs them plus `this.reporter` into a `runnerDeps: GateRunnerDeps` bag per `handleToolCall`, and owns a `runGate` closure that does the null / bypass (log+emit) / descriptor dispatch.
`this.reporter` is already a `GateDecisionReporter` built once in the constructor (#322).
- `src/permission-session.ts` — already `implements PermissionResolver`; stores `this.context` via `activate(ctx)`; exposes `recordSessionApproval`, `canPrompt(ctx)`, `prompt(ctx, details)`.
`activate(ctx)` runs at the top of `handleToolCall`, so the stored context is current before any gate runs.
- `src/permission-resolver.ts` and `src/decision-reporter.ts` are the precedent role modules (a narrow interface, SDK-free, co-located with its implementor where natural).
- `src/handlers/gates/types.ts``GateOutcome = { action: "allow" } | { action: "block"; reason: string }`.
Constraints from AGENTS.md / `code-design`:
- When a shared interface references a collaborator, use the narrow interface type, not the concrete class.
- Keep Pi SDK imports out of pure role modules.
- Removing an exported interface breaks every consumer at the type level in one commit; lift-and-shift large test-file migrations rather than rewriting the whole file at once.
Design-review (from the `design-review` checklist) of the resulting `GateRunner`:
- Dependency width: four narrow role collaborators (12 methods each), all used by the runner — no wide bag.
- Law of Demeter: the transitional `deps.reporter.x()` field-then-call on a parameter bag (the #322 "track and watch") becomes `this.reporter.x()` on a direct field — resolved.
- Output arguments / scattered resets: none.
- Parameter relay: the stable collaborators become constructor fields; only the genuine per-call data (`gate`, `agentName`, `toolCallId`) flows to `run` — the relay is gone.
- Missing intermediate abstractions: `GatePrompter` and `SessionApprovalRecorder` name the last two implicit roles, letting `GateRunnerDeps` be deleted entirely.
## Design Overview
### Two new role interfaces
```typescript
// src/gate-prompter.ts
import type { PermissionPromptDecision } from "./permission-dialog";
import type { PromptPermissionDetails } from "./permission-prompter";
/**
* The prompting role the gate runner needs: a yes/no on whether an
* interactive confirmation is possible, and the prompt itself. The context
* is bound by the implementor, not threaded per call.
*/
export interface GatePrompter {
canConfirm(): boolean;
promptPermission(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision>;
}
```
```typescript
// src/session-approval-recorder.ts
import type { SessionApproval } from "./session-approval";
/** Records a granted session-scoped approval into the session ruleset. */
export interface SessionApprovalRecorder {
recordSessionApproval(approval: SessionApproval): void;
}
```
Separate one-role-per-file modules mirror `permission-resolver.ts` / `decision-reporter.ts` and keep both roles SDK-free; co-locating `SessionApprovalRecorder` inside `session-approval.ts` was considered and rejected for consistency with that precedent.
### `PermissionSession` implements the prompter via stored-context adapters
`PermissionSession` already stores `this.context` (set by `activate`) and exposes `canPrompt(ctx)` / `prompt(ctx, details)`.
The `GatePrompter` adapters read the stored context so the runner never threads it:
```typescript
class PermissionSession
implements PermissionResolver, SessionApprovalRecorder, GatePrompter
{
canConfirm(): boolean {
return this.context !== null && this.canPrompt(this.context);
}
promptPermission(
details: PromptPermissionDetails,
): Promise<PermissionPromptDecision> {
if (this.context === null) {
throw new Error(
"promptPermission called before the session was activated",
);
}
return this.prompt(this.context, details);
}
}
```
`canConfirm()` returns `false` when no context is active, so `applyPermissionGate` never reaches `promptForApproval`; the `null` guard in `promptPermission` is therefore unreachable in correct use and exists only as a defensive invariant.
`canPrompt(ctx)` / `prompt(ctx, details)` stay public — `handleInput` still calls them directly.
### `GateRunner` class
```typescript
// src/handlers/gates/runner.ts
export class GateRunner {
constructor(
private readonly resolver: PermissionResolver,
private readonly recorder: SessionApprovalRecorder,
private readonly prompter: GatePrompter,
private readonly reporter: DecisionReporter,
) {}
async run(
gate: GateResult,
agentName: string | null,
toolCallId: string,
): Promise<GateOutcome> {
if (!gate) {
return { action: "allow" };
}
if (isGateBypass(gate)) {
if (gate.log) {
this.reporter.writeReviewLog(gate.log.event, gate.log.details);
}
if (gate.decision) {
this.reporter.emitDecision(gate.decision);
}
return { action: "allow" };
}
return this.runDescriptor(gate, agentName, toolCallId);
}
private async runDescriptor(
descriptor: GateDescriptor,
agentName: string | null,
toolCallId: string,
): Promise<GateOutcome> {
/* the current runGateCheck body, using this.resolver / this.prompter /
this.reporter / this.recorder instead of deps.* */
}
}
```
`run` returns `GateOutcome` for all three input shapes (null and bypass both resolve to `{ action: "allow" }` after any bypass side effects).
### Handler call site
```typescript
constructor(session, events, toolRegistry, customFormatters?) {
this.reporter = new GateDecisionReporter(session.logger, events);
this.runner = new GateRunner(session, session, session, this.reporter);
}
// in handleToolCall, replacing the runnerDeps bag + runGate closure:
for (const produce of gateProducers) {
const outcome = await this.runner.run(
await produce(),
tcc.agentName,
tcc.toolCallId,
);
if (outcome.action === "block") {
return { block: true, reason: outcome.reason };
}
}
return {};
```
The handler passes `session` for three roles (it implements `PermissionResolver`, `SessionApprovalRecorder`, `GatePrompter`) and `this.reporter` for the fourth.
This is Tell-Don't-Ask (tell the runner to run the gate) and respects the Law of Demeter (the handler talks to the runner, not its collaborators).
The collaborator closures (`canConfirm`, `promptPermission`, `recordSessionApproval`, the `resolve` lambda) and the `runGate` closure are deleted.
### Transition strategy (lift-and-shift)
`GateRunnerDeps` structurally already supplies all four roles (`resolve` → resolver, `recordSessionApproval` → recorder, `canConfirm` + `promptPermission` → prompter, `reporter` → reporter).
So `GateRunner` is introduced alongside `runGateCheck`, and `runGateCheck` temporarily becomes a thin wrapper:
```typescript
export async function runGateCheck(descriptor, agentName, toolCallId, deps) {
return new GateRunner(deps, deps, deps, deps.reporter).run(
descriptor,
agentName,
toolCallId,
);
}
```
This keeps `runner.test.ts` and the handler green while the handler (next step) and the large runner test (after) migrate independently; the wrapper, `GateRunnerDeps`, and `makeRunnerDeps` are deleted only once nothing references them.
### Edge cases
- `run(null, …)` returns `{ action: "allow" }`, matching the old `runGate` returning `undefined` (the handler treats any non-block outcome as continue).
- The bypass branch fires `writeReviewLog` / `emitDecision` through the reporter exactly as the handler's `runGate` did — byte-identical side effects.
- Stored context equals the `handleToolCall` `ctx`: `activate(ctx)` sets `this.context = ctx` before the gate loop, so the prompter adapters see the same context the old closures captured.
- The handler integration-test session mocks are `as unknown as PermissionSession`, so they do not structurally require the new methods; at runtime the runner calls `session.canConfirm()` / `session.promptPermission(details)`, so the three mocks gain delegating adapters (see Module-Level Changes) to keep their `prompt`-override and `prompt`-call-count tests passing.
## Module-Level Changes
- `src/gate-prompter.ts`**new**: `GatePrompter` interface.
- `src/session-approval-recorder.ts`**new**: `SessionApprovalRecorder` interface.
- `src/permission-session.ts` — add `SessionApprovalRecorder, GatePrompter` to the `implements` clause; add `canConfirm()` and `promptPermission(details)` stored-context adapters; import both interface types.
- `src/handlers/gates/runner.ts` — add the `GateRunner` class (with the moved `runDescriptor` body and the consolidated null/bypass dispatch); import `PermissionResolver`, `SessionApprovalRecorder`, `GatePrompter`, `DecisionReporter`, and `isGateBypass`; (step 2) reduce `runGateCheck` to a wrapper; (final step) delete `runGateCheck`.
- `src/handlers/gates/descriptor.ts` — (final step) delete the `GateRunnerDeps` interface and remove its now-unused imports (`DecisionReporter`, `PermissionResolver`, `PromptPermissionDetails`, `PermissionPromptDecision`); keep `DenialContext`, `PermissionDecisionEvent`, `SessionApproval`, `PermissionCheckResult`, `PermissionState`, and the descriptor/guard exports.
- `src/handlers/permission-gate-handler.ts` — add a `private readonly runner: GateRunner` field built in the constructor; replace the `runnerDeps` bag, the collaborator closures, and the `runGate` closure with `this.runner.run(...)` in the gate loop; drop the now-unused imports (`runGateCheck`, `GateRunnerDeps`, `isGateBypass`, `PermissionResolver` if unused after the closure removal, `PromptPermissionDetails` if unused); keep `GateResult` (gate-producer typing).
- `test/helpers/gate-fixtures.ts` — add `makeGateRunner(overrides)` returning `{ runner, deps }` (builds the four role mocks and a `GateRunner`); keep `makeReporter` and `makeResolver`; (final step) delete `makeRunnerDeps` and the `GateRunnerDeps` import.
- `test/helpers/handler-fixtures.ts``makeSession`: add delegating `canConfirm` (→ mock `canPrompt`) and `promptPermission` (→ mock `prompt`) adapters, guarded with `Object.hasOwn` like the existing `resolve` delegation.
- `test/handlers/external-directory-integration.test.ts` and `test/handlers/external-directory-session-dedup.test.ts` — add the same delegating `canConfirm` / `promptPermission` to their local session mocks.
- `test/handlers/gates/runner.test.ts` — migrate each `runGateCheck(d, a, t, makeRunnerDeps(X))` to `const { runner, deps } = makeGateRunner(X); runner.run(d, a, t)`, keeping the `deps.reporter.*` / `deps.resolve` / `deps.promptPermission` / `deps.recordSessionApproval` assertions unchanged.
- `test/permission-session.test.ts` — new unit tests for `canConfirm` / `promptPermission` (delegation and null-context behavior).
- `.pi/skills/package-pi-permission-system/SKILL.md` — update the `gate-fixtures.ts` listing: replace `makeRunnerDeps` with `makeGateRunner` (constructs a `GateRunner` with role mocks, returns `{ runner, deps }`).
- `docs/architecture/architecture.md` — mark Phase 3 step 8 (#323) and the row-6 `GateRunner` clause ✅; update the `runner.ts` and `descriptor.ts` `src/` tree lines (`GateRunner` class; `GateRunnerDeps` removed); update the `S8` Mermaid node and the Track C summary; add a `✅ … (#323)` entry to the numbered improvement steps.
A repo-wide grep confirms `runGateCheck` and `GateRunnerDeps` are referenced only in `runner.ts`, `descriptor.ts`, `permission-gate-handler.ts`, `gate-fixtures.ts`, and `runner.test.ts` (plus the architecture doc); no other `SKILL.md` references them.
## Test Impact Analysis
1. New tests enabled.
`PermissionSession.canConfirm` / `promptPermission` become directly unit-testable (delegation to `canPrompt` / `prompt`; `canConfirm` false and the `promptPermission` throw when no context is active) — behavior previously buried in per-`handleToolCall` closures.
`GateRunner.run`'s null and bypass dispatch become directly unit-testable; that dispatch lived in the handler's anonymous `runGate` closure and was only reachable through full handler integration tests.
2. Redundant / simplified tests.
None are removed.
`runner.test.ts` keeps every assertion; only the call form changes (`runGateCheck(…, deps)``makeGateRunner(…).runner.run(…)` with the same `deps.*` mocks).
The handler integration tests that exercise the infra-read bypass still pass through the handler, now additionally covered at the unit level by `GateRunner.run`.
3. Tests that stay as-is (behavior-preserving).
The handler integration suites (`tool-call`, `tool-call-events`, `external-directory-integration`, `external-directory-session-dedup`) keep their `prompt` overrides and `session.prompt` call-count assertions working because the new mock `promptPermission` / `canConfirm` delegate to the mock's own `prompt` / `canPrompt`.
`input` / `input-events` are untouched (`handleInput` still calls `session.prompt(ctx, …)` directly).
Every gate descriptor test (`path`, `bash-path`, `bash-external-directory`, `bash-command`, etc.) is unaffected — they depend on `makeResolver`, not the runner.
## TDD Order
1. Add the `GatePrompter` and `SessionApprovalRecorder` roles and implement them on `PermissionSession`.
Surface: `test/permission-session.test.ts`.
Covers: `canConfirm` delegates to `canPrompt` with the stored context and returns `false` when inactive; `promptPermission` delegates to `prompt` with the stored context and throws when inactive.
No other consumers yet — repo stays green.
Commit: `feat: add GatePrompter and SessionApprovalRecorder session roles`.
2. Add the `GateRunner` class alongside `runGateCheck`; reduce `runGateCheck` to a wrapper delegating to `new GateRunner(deps, deps, deps, deps.reporter).run(...)`; add `makeGateRunner` to `gate-fixtures.ts`.
Surface: new `GateRunner.run` null/bypass tests in `test/handlers/gates/runner.test.ts` (existing `runGateCheck` tests stay green via the wrapper).
Run `pnpm run check` after this step (new class + transitional wrapper).
Commit: `feat: add GateRunner class consolidating gate dispatch`.
3. Migrate `PermissionGateHandler`: build `this.runner = new GateRunner(session, session, session, this.reporter)` in the constructor; replace the `runnerDeps` bag, the collaborator closures, and the `runGate` closure with `this.runner.run(...)` in the gate loop; drop the now-unused imports.
Add delegating `canConfirm` / `promptPermission` to `makeSession` and the two `external-directory-*` local session mocks so the runtime runner calls resolve through the mocks' `canPrompt` / `prompt`.
Surface: existing handler integration suites stay green (behavior-preserving).
Run `pnpm run check` after this step (constructor signature is unchanged, but the runner wiring and mock shapes change).
Commit: `refactor: run permission gates through an injected GateRunner`.
4. Migrate `runner.test.ts` to `makeGateRunner(...).runner.run(...)`; delete the `runGateCheck` wrapper, the `GateRunnerDeps` interface (and its now-unused descriptor imports), and the `makeRunnerDeps` fixture; update the `gate-fixtures.ts` entry in `SKILL.md`.
Surface: `test/handlers/gates/runner.test.ts` (full assertion set preserved).
Commit: `refactor: remove GateRunnerDeps and runGateCheck`.
5. Update `docs/architecture/architecture.md`: mark step 8 (#323) and the row-6 `GateRunner` clause ✅; update the `runner.ts` / `descriptor.ts` tree lines, the `S8` Mermaid node, and the Track C summary.
Commit: `docs: record the GateRunner extraction in the Phase 3 roadmap`.
Step 2's wrapper keeps the handler and the large runner test green so steps 3 and 4 migrate independently; the wrapper, interface, and fixture are deleted only in step 4, once no consumer remains.
## Risks and Mitigations
- The runtime runner calls `session.canConfirm()` / `session.promptPermission()`, which the `as unknown as PermissionSession` mocks do not structurally require — so a missing method would fail at runtime, not at `pnpm run check` (exactly the #319 friction).
Mitigation: step 3 adds the delegating adapters to all three session mocks (grepped: `handler-fixtures.ts` `makeSession`, `external-directory-integration.test.ts`, `external-directory-session-dedup.test.ts`) and runs the full handler suite before committing.
- The delegating-mock tactic is itself a decoupling smell.
Mitigation: it is transitional; #325 retypes the handler against the role interfaces and removes the `as unknown as` casts, at which point the delegation is unnecessary.
- Deleting `GateRunnerDeps` ripples to the descriptor, runner, handler, fixture, and runner test.
Mitigation: the wrapper isolates the test migration (step 4) from the handler migration (step 3); the interface is deleted only when both are done.
- `runDescriptor` is a verbatim move of the `runGateCheck` body; a transcription slip could change behavior.
Mitigation: the existing `runner.test.ts` assertions (run through the wrapper in steps 23, directly in step 4) guard the descriptor path unchanged.
## Open Questions
- The residual session-member cluster (`activate`, `resolveAgentName`, `config`, `getInfrastructureDirs`, `getInfrastructureReadPaths`, `getActiveSkillEntries`, `createPermissionRequestId`) has no role yet and is deferred to #325; this plan introduces only `GatePrompter` and `SessionApprovalRecorder`, the last two roles the runner needs.
- Whether `handleInput` should eventually prompt through a `GatePrompter` rather than `session.prompt(ctx, …)` directly is out of scope; it does not run through the gate runner.