17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 325 | 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
sessiondependency against a narrow role interface, not the concretePermissionSessionclass. - Inject the pre-built
GateRunner(constructed in the composition root) so the handler stops building collaborators in its constructor and stops reaching throughsession.logger. - Drop the
eventsconstructor parameter — it exists only to build the reporter. - Drop the
as unknown as PermissionSessioncasts inhandler-fixtures.tsmakeSession,external-directory-integration.test.ts, andexternal-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 keepscheckPermission+createPermissionRequestIdon the handler's session role. - Relocating
createPermissionRequestIdoffPermissionSession— tracked in #330. - Narrowing
AgentPrepHandlerandSessionLifecycleHandleragainst role interfaces, or touching their localmakeSessioncasts — tracked in #331. - Changing the skill-input pre-check from
checkPermission(no session rules) toresolve(session rules) — a behavior change, deferred to #329. - Reframing
index.tsas 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 buildsthis.reporter = new GateDecisionReporter(session.logger, events)andthis.runner = new GateRunner(session, session, session, this.reporter), thenhandleToolCall/handleInputusethis.runnerand the injectedthis.pipeline.src/permission-session.ts— the concrete class. It alreadyimplements 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-levelsrc/and implemented byPermissionSession.src/handlers/gates/runner.ts(GateRunner) andsrc/handlers/gates/tool-call-gate-pipeline.ts(ToolCallGatePipeline+ToolCallGateInputs) — the collaborators the handler delegates to.ToolCallGateInputsis the precedent for a narrow, structurally-satisfied session view; it lives in the handler layer andextends PermissionResolver.test/helpers/handler-fixtures.ts— the sharedmakeSession/makeHandler, used only byPermissionGateHandlertests (input*.test.ts,tool-call*.test.ts).before-agent-start.test.tsandlifecycle.test.tsdefine their own localmakeSessionand import onlymakeCtx, 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
PermissionSessionimplements must live in top-levelsrc/(a domain module cannot import from thehandlers/layer without inverting the dependency). pnpm fallow dead-codemust 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
// 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
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:
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:
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<PermissionPromptDecision>;
};
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: theGateHandlerSessioninterface.src/permission-session.ts— addGateHandlerSessionto theimplementslist; import it. No method-body changes.src/handlers/permission-gate-handler.ts— constructor signature(session: GateHandlerSession, toolRegistry, pipeline, runner); remove theeventsparam, thereporterfield, and theGateDecisionReporter/GateRunnerconstruction. Imports: dropGateDecisionReporter+DecisionReporter(#src/decision-reporter),PermissionEventBus(#src/permission-events), andPermissionSession; addGateHandlerSession(#src/gate-handler-session); changeGateRunnerto a type-only import.src/index.ts— buildreporterandgateRunner, passgateRunnerto the handler, drop thepi.eventsargument; add imports forGateDecisionReporter(./decision-reporter) andGateRunner(./handlers/gates/runner).test/helpers/handler-fixtures.ts—makeSessionreturn type →MockGateHandlerSession(cast removed,getToolPermission+configdropped, delegations inlined); narrow theoverrideskey type fromkeyof PermissionSessiontoMockGateHandlerSession;makeHandlerbuildsreporter+runnerfrom the mock and passes the runner, dropping theeventshandler argument (still returnseventsforgetDecisionEvents).test/handlers/external-directory-integration.test.ts— localmakeSessionretyped and cast dropped (same delegation restructuring);makeHandlerbuilds reporter + runner.test/handlers/external-directory-session-dedup.test.ts— localmakeSessionretyped and cast dropped;makeHandlerForSessionbuilds reporter + runner.packages/pi-permission-system/docs/architecture/architecture.md— module-structure listing (addgate-handler-session.ts; update thepermission-gate-handler.tsandpermission-session.tsdescriptions) 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
- New tests enabled — the change is type-level; its payoff is compile-time enforcement (the
implementsclause plus the precise fixture intersection), not a new runtime test. NamingGateHandlerSessiondoes make a future minimal four-method handler unit test possible, but the existing integration tests already cover the behavior, so none is added here. - Tests that become redundant — none. No assertion is duplicated or obviated.
- 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".
- Introduce the role, inject the runner, retype the handler — add
src/gate-handler-session.ts; addimplements GateHandlerSessiontoPermissionSession; change the handler constructor (inject runner, dropevents, 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 theiras unknown as PermissionSessioncasts for now (aPermissionSessionstill satisfies the narrow role). Verifypnpm run checkand the full package suite are green. Commit:refactor: inject GateRunner and type PermissionGateHandler against GateHandlerSession (#325). - Drop the casts — retype the three
makeSessionmocks to theMockGateHandlerSessionintersection, remove the casts, inline the delegations, drop the vestigialgetToolPermission/configmembers, and narrow theoverrideskey type.pnpm run checknow enforces mock completeness. Becausehandler-fixtures.tsis a shared helper, run the full package suite, not just one file. Commit:refactor: drop as-unknown-as PermissionSession casts in handler mocks (#325). - 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 checknames 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.tsdrives session-approval state. Mitigation: keep the closures reading the finalsessionobject at call time and spread...overrideslast; run the full suite (the dedup test is the canary). - Risk: injecting the runner perturbs
index.tswiring. Mitigation:composition-root.test.tsdrives viapi.fireand asserts registration + behavior; keep it green. - Risk: excess-property errors when removing the cast if a vestigial member lingers.
Mitigation: drop
getToolPermissionandconfigfrom the mocks (unused on the gate path); the literal then matches the intersection exactly.
Open Questions
- Should
GateHandlerSessionalready split into a two-methodSessionContext(activate+resolveAgentName) base that itextends? Deferred: a second consumer forSessionContextarrives only with #329 / #331, and introducing it now would be a speculative abstractionfallowcould 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 rawcheckPermission? It does not today; changing it is a behavior change recorded against #329.