--- issue: 325 issue_title: "Depend on session role interfaces in PermissionGateHandler, not the concrete PermissionSession class" --- # Depend on session role interfaces in PermissionGateHandler ## Problem Statement `PermissionGateHandler`'s constructor takes `session: PermissionSession` — the concrete class, with 36 public members and private fields — but the handler touches only a handful of them. Because the parameter is a concrete class, every hand-rolled test mock must `as unknown as PermissionSession` to satisfy the type, which disables TypeScript's structural check. A consumer that calls a session method the mock lacks then fails at runtime, not at `pnpm run check` — exactly what happened during [#319], where adding `resolve()` broke three session mocks with `resolver.resolve is not a function` instead of a compile error. This issue retypes the handler against narrow role interfaces and drops the casts so mock completeness is enforced at type-check time. ## Goals - Type the handler's `session` dependency against a narrow role interface, not the concrete `PermissionSession` class. - Inject the pre-built `GateRunner` (constructed in the composition root) so the handler stops building collaborators in its constructor and stops reaching through `session.logger`. - Drop the `events` constructor parameter — it exists only to build the reporter. - Drop the `as unknown as PermissionSession` casts in `handler-fixtures.ts` `makeSession`, `external-directory-integration.test.ts`, and `external-directory-session-dedup.test.ts`. - Behavior-preserving — no decision, event, or log output changes. ## Non-Goals - Extracting the skill-input gate assembly out of `handleInput` — tracked in [#329]; this plan keeps that assembly inline and therefore keeps `checkPermission` + `createPermissionRequestId` on the handler's session role. - Relocating `createPermissionRequestId` off `PermissionSession` — tracked in [#330]. - Narrowing `AgentPrepHandler` and `SessionLifecycleHandler` against role interfaces, or touching their local `makeSession` casts — tracked in [#331]. - Changing the skill-input pre-check from `checkPermission` (no session rules) to `resolve` (session rules) — a behavior change, deferred to [#329]. - Reframing `index.ts` as collaborator injection — that is Step 15 ([#320]); this plan only adds two construction sites that feed it. ## Background Relevant modules and how they relate: - `src/handlers/permission-gate-handler.ts` — the consumer being narrowed. Its constructor currently builds `this.reporter = new GateDecisionReporter(session.logger, events)` and `this.runner = new GateRunner(session, session, session, this.reporter)`, then `handleToolCall` / `handleInput` use `this.runner` and the injected `this.pipeline`. - `src/permission-session.ts` — the concrete class. It already `implements PermissionResolver, SessionApprovalRecorder, GatePrompter`; this plan adds one more role to that list. - `src/permission-resolver.ts`, `src/decision-reporter.ts`, `src/gate-prompter.ts`, `src/session-approval-recorder.ts` — the existing role interfaces from [#319], [#322], [#323], all in top-level `src/` and implemented by `PermissionSession`. - `src/handlers/gates/runner.ts` (`GateRunner`) and `src/handlers/gates/tool-call-gate-pipeline.ts` (`ToolCallGatePipeline` + `ToolCallGateInputs`) — the collaborators the handler delegates to. `ToolCallGateInputs` is the precedent for a narrow, structurally-satisfied session view; it lives in the handler layer and `extends PermissionResolver`. - `test/helpers/handler-fixtures.ts` — the shared `makeSession` / `makeHandler`, used **only** by `PermissionGateHandler` tests (`input*.test.ts`, `tool-call*.test.ts`). `before-agent-start.test.ts` and `lifecycle.test.ts` define their own local `makeSession` and import only `makeCtx`, so narrowing the shared fixture does not touch them. After [#326] (skill-input unification) and [#327] (`ToolCallGatePipeline` extraction), the handler's residual `PermissionSession` surface is exactly four members: `activate`, `resolveAgentName`, `checkPermission`, `createPermissionRequestId`, plus the `logger` read in the constructor and the three roles passed to `GateRunner`. Constraints from AGENTS.md and the package skill: - Role interfaces that `PermissionSession` implements must live in top-level `src/` (a domain module cannot import from the `handlers/` layer without inverting the dependency). - `pnpm fallow dead-code` must stay clean — the new interface must have a consumer in the same commit it is introduced. - Adding to a barrel requires a real consumer; do not add speculative re-exports. Design-review checklist (run before finalizing): | Smell | Location | Evidence | Fix | | ----------------- | --------------------------------- | ------------------------------------------------- | ------------------------------------------- | | Wide interface | `PermissionGateHandler` ctor | `session: PermissionSession` (36 members), uses 4 | Narrow `GateHandlerSession` role | | LoD reach-through | `permission-gate-handler.ts` ctor | `new GateDecisionReporter(session.logger, …)` | Build reporter in `index.ts`; inject runner | | Parameter relay | `events` ctor param | only relayed into the reporter | Drop `events`; reporter built upstream | | Test-mock depth | 3 `makeSession` fixtures | `as unknown as PermissionSession` | Type against the role intersection | ## Design Overview Introduce one narrow role interface and inject the runner so the handler depends on assembled collaborators, not a god-object. ### The role interface ```typescript // src/gate-handler-session.ts import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { PermissionCheckResult } from "./types"; /** * The session surface PermissionGateHandler invokes directly: bind the * per-event context, identify the agent, and (for the skill-input gate) run a * raw permission check and mint a request id. * * Transitional: #329 (SkillInputGatePipeline) absorbs the skill-input * assembly, after which checkPermission + createPermissionRequestId leave this * role and it collapses to a two-method context role. */ export interface GateHandlerSession { activate(ctx: ExtensionContext): void; resolveAgentName(ctx: ExtensionContext): string | null; checkPermission( surface: string, input: unknown, agentName?: string, ): PermissionCheckResult; createPermissionRequestId(prefix: string): string; } ``` `PermissionSession` adds `GateHandlerSession` to its `implements` list — it already has all four methods (the class's four-argument `checkPermission` and two-argument `resolveAgentName` remain assignable to the narrower role signatures). ### Handler constructor ```typescript export class PermissionGateHandler { constructor( private readonly session: GateHandlerSession, private readonly toolRegistry: ToolRegistry, private readonly pipeline: ToolCallGatePipeline, private readonly runner: GateRunner, ) {} // handleToolCall / handleInput bodies unchanged: they call // this.session.activate / resolveAgentName / checkPermission / // createPermissionRequestId, this.pipeline.evaluate, this.runner.run. } ``` The `reporter` field, the `GateDecisionReporter` / `GateRunner` construction, and the `events` parameter are removed. ### Composition-root wiring (the call site) The runner and reporter move to `index.ts`, where the real `PermissionSession` is in scope, so `session.logger` is a direct field read by the owner — not a reach-through by a downstream handler: ```typescript const reporter = new GateDecisionReporter(session.logger, pi.events); const gateRunner = new GateRunner(session, session, session, reporter); const toolCallGatePipeline = new ToolCallGatePipeline(session, formatterRegistry); const gates = new PermissionGateHandler( session, toolRegistry, toolCallGatePipeline, gateRunner, ); ``` This is Tell-Don't-Ask at the seam: the handler is told its runner; it no longer assembles one from session internals. ### Test-fixture return type The shared `makeSession` (and the two integration-test mocks) build one object used as the pipeline input, the three runner roles, the reporter's logger source, and the handler's session role. Its return type becomes the precise intersection — no cast — so a missing member fails `pnpm run check`: ```typescript type MockGateHandlerSession = ToolCallGateInputs & SessionApprovalRecorder & GatePrompter & GateHandlerSession & { // logger source for the reporter the fixture builds logger: SessionLogger; // internal delegation helpers resolve/canConfirm/promptPermission read getSessionRuleset(): Rule[]; canPrompt(ctx: ExtensionContext): boolean; prompt( ctx: ExtensionContext, details: PromptPermissionDetails, ): Promise; }; ``` `ToolCallGateInputs` already `extends PermissionResolver`, so `resolve` is covered. The two vestigial members the current mocks carry only to satisfy the concrete class — `getToolPermission` and `config` — are dropped (no consumer on the gate path reads them). Edge case — fixture self-reference: the mock's `resolve` delegates to `checkPermission` + `getSessionRuleset`, and `canConfirm` / `promptPermission` delegate to `canPrompt` / `prompt`, so integration tests can drive outcomes through the production-named stubs. Today this works because the delegations are assigned **after** the `as unknown as` cast. Without the cast the object literal must satisfy the type at creation, so define the three delegations inline in the literal as closures that read the final `session` object at call time, then spread `...overrides` last (overrides win, and the closures pick up an overridden `checkPermission`). This replaces the current `Object.hasOwn(overrides, …)` guards. ## Module-Level Changes - `src/gate-handler-session.ts` — **new**: the `GateHandlerSession` interface. - `src/permission-session.ts` — add `GateHandlerSession` to the `implements` list; import it. No method-body changes. - `src/handlers/permission-gate-handler.ts` — constructor signature `(session: GateHandlerSession, toolRegistry, pipeline, runner)`; remove the `events` param, the `reporter` field, and the `GateDecisionReporter` / `GateRunner` construction. Imports: drop `GateDecisionReporter` + `DecisionReporter` (`#src/decision-reporter`), `PermissionEventBus` (`#src/permission-events`), and `PermissionSession`; add `GateHandlerSession` (`#src/gate-handler-session`); change `GateRunner` to a type-only import. - `src/index.ts` — build `reporter` and `gateRunner`, pass `gateRunner` to the handler, drop the `pi.events` argument; add imports for `GateDecisionReporter` (`./decision-reporter`) and `GateRunner` (`./handlers/gates/runner`). - `test/helpers/handler-fixtures.ts` — `makeSession` return type → `MockGateHandlerSession` (cast removed, `getToolPermission` + `config` dropped, delegations inlined); narrow the `overrides` key type from `keyof PermissionSession` to `MockGateHandlerSession`; `makeHandler` builds `reporter` + `runner` from the mock and passes the runner, dropping the `events` handler argument (still returns `events` for `getDecisionEvents`). - `test/handlers/external-directory-integration.test.ts` — local `makeSession` retyped and cast dropped (same delegation restructuring); `makeHandler` builds reporter + runner. - `test/handlers/external-directory-session-dedup.test.ts` — local `makeSession` retyped and cast dropped; `makeHandlerForSession` builds reporter + runner. - `packages/pi-permission-system/docs/architecture/architecture.md` — module-structure listing (add `gate-handler-session.ts`; update the `permission-gate-handler.ts` and `permission-session.ts` descriptions) and Phase 3 Step 11 (record the runner injection + the new role, and the [#329] / [#330] / [#331] follow-ups). Symbol-removal grep results (per AGENTS.md): the only `new PermissionGateHandler(...)` sites are `index.ts` and the three test fixtures above; `composition-root.test.ts` drives the handler through `pi.fire`, not its constructor, so it needs no change. The package skill (`.pi/skills/package-pi-permission-system/SKILL.md`) names `makeSession` but not its type or the handler's constructor arity, so no skill edit is required. ## Test Impact Analysis 1. New tests enabled — the change is type-level; its payoff is compile-time enforcement (the `implements` clause plus the precise fixture intersection), not a new runtime test. Naming `GateHandlerSession` does make a future minimal four-method handler unit test possible, but the existing integration tests already cover the behavior, so none is added here. 2. Tests that become redundant — none. No assertion is duplicated or obviated. 3. Tests that must stay as-is — the gate-handler integration suites (`tool-call*.test.ts`, `input*.test.ts`, `external-directory-*.test.ts`) genuinely exercise the handler → pipeline → runner → reporter stack with a mocked session boundary. Only their fixture wiring changes (build/inject the runner; retype the mock); the assertions are untouched. ## TDD Order This is a behavior-preserving refactor; the existing suite plus `pnpm run check` are the safety net, so the cycles are "change → green" rather than "new red test → green". 1. **Introduce the role, inject the runner, retype the handler** — add `src/gate-handler-session.ts`; add `implements GateHandlerSession` to `PermissionSession`; change the handler constructor (inject runner, drop `events`, drop in-constructor construction); update all four call sites (`index.ts` + the three test fixtures) in this commit, since the constructor signature change breaks them all at the type level. The mocks keep their `as unknown as PermissionSession` casts for now (a `PermissionSession` still satisfies the narrow role). Verify `pnpm run check` and the full package suite are green. Commit: `refactor: inject GateRunner and type PermissionGateHandler against GateHandlerSession (#325)`. 2. **Drop the casts** — retype the three `makeSession` mocks to the `MockGateHandlerSession` intersection, remove the casts, inline the delegations, drop the vestigial `getToolPermission` / `config` members, and narrow the `overrides` key type. `pnpm run check` now enforces mock completeness. Because `handler-fixtures.ts` is a shared helper, run the full package suite, not just one file. Commit: `refactor: drop as-unknown-as PermissionSession casts in handler mocks (#325)`. 3. **Document** — update the architecture module-structure listing and Phase 3 Step 11. Commit: `docs: record GateHandlerSession retyping in architecture (#325)`. ## Risks and Mitigations - **Risk:** dropping a cast surfaces a missing mock member. **Mitigation:** that is the intended win — `pnpm run check` names the gap; the intersection type in the plan lists every required member so the mock is complete. - **Risk:** the fixture delegation breaks if the self-referencing closures are restructured incorrectly, silently changing how `external-directory-session-dedup.test.ts` drives session-approval state. **Mitigation:** keep the closures reading the final `session` object at call time and spread `...overrides` last; run the full suite (the dedup test is the canary). - **Risk:** injecting the runner perturbs `index.ts` wiring. **Mitigation:** `composition-root.test.ts` drives via `pi.fire` and asserts registration + behavior; keep it green. - **Risk:** excess-property errors when removing the cast if a vestigial member lingers. **Mitigation:** drop `getToolPermission` and `config` from the mocks (unused on the gate path); the literal then matches the intersection exactly. ## Open Questions - Should `GateHandlerSession` already split into a two-method `SessionContext` (`activate` + `resolveAgentName`) base that it `extends`? Deferred: a second consumer for `SessionContext` arrives only with [#329] / [#331], and introducing it now would be a speculative abstraction `fallow` could flag. This plan keeps a flat four-method role and lets [#329] shrink it. - Should the skill-input pre-check apply session rules (`resolve`) rather than the raw `checkPermission`? It does not today; changing it is a behavior change recorded against [#329]. [#319]: https://github.com/gotgenes/pi-packages/issues/319 [#320]: https://github.com/gotgenes/pi-packages/issues/320 [#322]: https://github.com/gotgenes/pi-packages/issues/322 [#323]: https://github.com/gotgenes/pi-packages/issues/323 [#326]: https://github.com/gotgenes/pi-packages/issues/326 [#327]: https://github.com/gotgenes/pi-packages/issues/327 [#329]: https://github.com/gotgenes/pi-packages/issues/329 [#330]: https://github.com/gotgenes/pi-packages/issues/330 [#331]: https://github.com/gotgenes/pi-packages/issues/331