--- issue: 341 issue_title: "Slim PermissionSession to a state/lifecycle owner; unwind the fig-leaf interfaces" --- # Slim PermissionSession to a state/lifecycle owner; unwind the fig-leaf interfaces ## Problem Statement This is Phase 4, Step 8 (Track C: Split the session) of the pi-permission-system improvement roadmap. Steps 6 ([#339]) and 7 ([#340]) extracted the prompting role into `PromptingGateway` and the resolution role into `PermissionResolver`. What remains is a `PermissionSession` that still implements four role interfaces — `SessionApprovalRecorder`, `GateHandlerSession`, `AgentPrepSession`, `SessionLifecycleSession` — and still carries transitional permission-query duplicates (`checkPermission`, `getToolPermission`, `getConfigIssues`, `getPolicyCacheStamp`) that delegate to its `PermissionManager` even though `PermissionResolver` now owns the same surface. The tell is twofold. First, `new GateRunner(resolver, session, gateway, reporter)` still passes the session as the recorder role — the runner gets a distinct resolver and a distinct prompter, but the recorder is still the god object. Second, the three handler interfaces are fig leaves: narrow interfaces all satisfied by one object, with no second implementer and no plan for one. The test cost is a 17-field `MockGateHandlerSession` intersection mock in `handler-fixtures.ts`, a hand-rolled stateful recorder + resolver-delegation dance in `external-directory-session-dedup.test.ts`, and per-handler mock factories in `lifecycle.test.ts` and `before-agent-start.test.ts`. Now that Step 1 ([#334]) made `PermissionSession` and `PermissionResolver` constructible with test doubles, the handlers can depend on the concrete collaborators directly and the tests can build real instances from small per-collaborator fakes. ## Goals - Move the recorder role off the session: `GateRunner` receives `SessionRules` (a distinct collaborator) as its `SessionApprovalRecorder`, so the runner's three roles map to three different objects (`resolver`, `recorder`, `prompter`). - Rewire `AgentPrepHandler` and `SessionLifecycleHandler` to depend on `PermissionResolver` for the permission-query surface (`getToolPermission`, `getPolicyCacheStamp`, `getConfigIssues`, and the `SkillPermissionChecker` `checkPermission` pass). - Remove the now-dead transitional duplicates from `PermissionSession`: `checkPermission`, `getToolPermission`, `getConfigIssues`, `getPolicyCacheStamp`, `getSessionRuleset`, `recordSessionApproval`. - Retire the three handler role interfaces (`GateHandlerSession`, `AgentPrepSession`, `SessionLifecycleSession`); the three handlers depend on the concrete `PermissionSession` for state/lifecycle and on `PermissionResolver` for queries (user-confirmed Option A). - Split or remove the 17-field `makeSession` / `MockGateHandlerSession` fixture; handler tests build a real `PermissionSession` + `PermissionResolver` from small per-collaborator fakes promoted into `test/helpers/`. - Behavior-preserving — the suite stays green at every commit. ## Non-Goals - No change to `PromptingGateway` ([#339]) or `PermissionResolver`'s resolution behavior ([#340]) — only their wiring into handlers. - No change to `ToolCallGateInputs` or `SkillInputGateInputs` — these are genuine narrow input contracts for the pipelines, not fig leaves; `PermissionSession` keeps satisfying `ToolCallGateInputs` structurally and the resolver satisfies `SkillInputGateInputs`. - No change to `SkillPermissionChecker` — it stays a narrow interface; the production caller switches from the session to the resolver. - Not the `permission-system.test.ts` catch-all carve — that is Step 9 ([#342]). - No further `PermissionSession` decomposition (an `ActiveAgentTracker`, a cache-key owner, an infra-path helper) — deferred to Phase 5. - No change to the `permission-event-rpc.ts` or `config-modal.ts` session usage: RPC reads `session.getRuntimeContext()` and the modal reads `session.lastKnownActiveAgentName` — both stay on the session. ## Background Relevant modules and how they relate after Steps 6–7: - `permission-session.ts` — `PermissionSession` class, currently `implements SessionApprovalRecorder, GateHandlerSession, AgentPrepSession, SessionLifecycleSession`. Holds `paths`, `logger`, `forwarding`, `permissionManager`, `sessionRules`, `configStore`, `gateway`. After this step it keeps `permissionManager` (for `configureForCwd` in `resetForNewSession`/`reload`) and `sessionRules` (for `clear()` in `shutdown`), but sheds all permission-query and recorder/ruleset methods. - `permission-resolver.ts` — `ScopedPermissionResolver` interface (`{ resolve }`) + concrete `PermissionResolver` class. Already carries `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp`, currently `// fallow-ignore-next-line unused-class-member`-suppressed because no handler is wired to them yet. This step removes those suppressions as the handlers adopt the methods. - `session-rules.ts` — `SessionRules` class with `record(approval)`, `getRuleset()`, `approve()`, `clear()`. `record` is called only by `session.recordSessionApproval`. - `session-approval-recorder.ts` — `SessionApprovalRecorder` interface (`recordSessionApproval(approval)`), depended on by `GateRunner`. - `handlers/gates/runner.ts` — `GateRunner(resolver, recorder, prompter, reporter)`; production passes `session` as `recorder`. - `handlers/permission-gate-handler.ts` — depends on `GateHandlerSession` (`activate`, `resolveAgentName`); the runner it holds already owns the resolver. - `handlers/before-agent-start.ts` — `AgentPrepHandler` depends on `AgentPrepSession`; calls `session.getToolPermission`, `session.getPolicyCacheStamp`, and passes `session` as the `SkillPermissionChecker` to `resolveSkillPromptEntries`. - `handlers/lifecycle.ts` — `SessionLifecycleHandler` depends on `SessionLifecycleSession`; calls `session.getConfigIssues`. - `index.ts` — composition root; constructs the resolver and all handlers. Step 5 ([#338]) already finalized the closure-bag collapse, so Step 8 only adjusts constructor arguments and the resolver's construction order. Constraints from AGENTS.md / skills that apply: - The package convention is "narrow role interface, not the concrete class." This step consciously trades that off for the three handler interfaces (user-confirmed Option A), because Step 1's constructibility work means tests build real instances rather than mocks that would need casts — so the concrete dependency does not reintroduce the mock-cast smell the rule guards against. `ScopedPermissionResolver`, `ToolCallGateInputs`, `SkillInputGateInputs`, and `SkillPermissionChecker` remain narrow interfaces. - `@typescript-eslint/require-await`: keep handler `async` only where an `await` remains. - When removing an export, every importer breaks at the type level in that commit — fold the interface deletion, the handler retype, and the consumer-test rewrite into one commit (testing skill). - `fallow` suppression grammar: the kind token must be the exact singular `unused-class-member`, the only text after the directive (from the [#340] retro). - Keep schema/example/docs aligned is not relevant here (no config change), but `.pi/skills/package-pi-permission-system/SKILL.md` documents the test fixtures and must be updated. ## Design Overview ### The recorder becomes a distinct collaborator `session.recordSessionApproval(approval)` only ever calls `this.sessionRules.record(approval)`. Make `SessionRules` implement `SessionApprovalRecorder` directly by renaming `record` → `recordSessionApproval` (its sole caller is the session method being deleted), then pass `sessionRules` as the runner's recorder: ```typescript // index.ts (after) const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter); ``` Runner call site is unchanged (`this.recorder.recordSessionApproval(descriptor.sessionApproval)`); only the injected object changes. This is Tell-Don't-Ask: the runner tells `SessionRules` to record, and `SessionRules` owns the per-pattern fan-out loop it already has. ### The handlers depend on the resolver for queries `AgentPrepHandler` and `SessionLifecycleHandler` gain a `PermissionResolver` (concrete) constructor dependency and call the query methods on it. `PermissionGateHandler` does not — its `GateRunner` already owns the resolver, and it only needs the session's `activate` / `resolveAgentName`. ```typescript // before-agent-start.ts (after) — sketch of the call site shouldExposeTool(toolName, agentName, (t, a) => this.resolver.getToolPermission(t, a)); // ... permissionStamp: this.resolver.getPolicyCacheStamp(agentName ?? undefined), // ... resolveSkillPromptEntries(prompt, this.resolver, agentName, ctx.cwd); // resolver satisfies SkillPermissionChecker ``` ```typescript // lifecycle.ts (after) — sketch of the call site const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined); ``` The resolver is a genuine second collaborator, not a relay: the handlers call distinct query methods on it directly (no reach-through), and the session keeps its own state/lifecycle surface. The session and the resolver share the same injected `PermissionManager` + `SessionRules` (wired in `index.ts`), so there is no split-brain — the same guarantee Steps 4 ([#337]) and 7 ([#340]) established. ### PermissionSession after the step `PermissionSession` becomes a pure state/lifecycle owner: context lifecycle (`activate`/`deactivate`/`getRuntimeContext`), session lifecycle (`resetForNewSession`/`shutdown`/`reload`), agent-start caching, skill entries, agent-name resolution, config gateway (`refreshConfig`/`logResolvedConfigPaths`/`config`), and infra inputs (`getInfrastructureReadDirs`/`getToolPreviewLimits`). It implements no role interfaces explicitly; it still structurally satisfies `ToolCallGateInputs` (passed to `ToolCallGatePipeline`). Removed methods (all dead after the handler rewiring): ```text checkPermission, getToolPermission, getConfigIssues, getPolicyCacheStamp → resolver owns these getSessionRuleset → no production caller (resolver reads the ruleset internally) recordSessionApproval → SessionRules owns it ``` ### Test construction model (Option A) The existing `createSession` factory in `permission-session.test.ts` already builds a real `PermissionSession` from per-collaborator fakes (`makePaths`, `makeLogger`, `makeForwarding`, `makeFakePermissionManager`, `makeConfigStore`, `makeGateway`). Promote it into `test/helpers/session-fixtures.ts` as `makeRealSession(overrides)` and add `makeRealResolver(manager?, sessionRules?)` that constructs a real `PermissionResolver` over the fake manager + a real `SessionRules`. Handler tests then build real collaborators and assert against them: - `lifecycle.test.ts` / `before-agent-start.test.ts`: real session + real resolver; assertions shift from "session.refreshConfig was called" to "configStore.refresh was called with ctx" (and resolver/manager spies for the query methods). - `external-directory-session-dedup.test.ts`: replace the hand-rolled stateful recorder + getSessionRuleset + resolver-delegation with a single real `SessionRules` used both as the recorder and inside a real resolver — the dedup now works natively (record → `getRuleset()` sees the session rule). - `handler-fixtures.ts`: rebuild `makeHandler` to construct a real session + resolver + `SessionRules` recorder + real pipelines + runner. Preserve `makeHandler`'s override-bag keys and return shape so the 104 call sites migrate with minimal or no edits; route permission-result overrides (`checkPermission` / surface-check mocks) into the resolver's fake manager and add `recorder` to the returned bag for the dedup assertions. ## Module-Level Changes Source (`src/`): - `session-rules.ts` — rename `record(approval)` → `recordSessionApproval(approval)`; add `implements SessionApprovalRecorder` (import the interface). - `permission-session.ts` — remove `checkPermission`, `getToolPermission`, `getConfigIssues`, `getPolicyCacheStamp`, `getSessionRuleset`, `recordSessionApproval`; remove the `implements` clause for `SessionApprovalRecorder`, `GateHandlerSession`, `AgentPrepSession`, `SessionLifecycleSession`; drop their imports (and the `SessionApproval` / `Rule` / `PermissionCheckResult` / `PermissionState` imports that become unused); update the class doc comment. - `permission-resolver.ts` — remove the three `// fallow-ignore-next-line unused-class-member` directives on `getToolPermission`, `getConfigIssues`, `getPolicyCacheStamp` (now they have callers). - `handlers/permission-gate-handler.ts` — retype the `session` parameter from `GateHandlerSession` to `PermissionSession`; drop the `GateHandlerSession` import. - `handlers/before-agent-start.ts` — add a `resolver: PermissionResolver` constructor parameter; retype `session` to `PermissionSession`; route `getToolPermission` / `getPolicyCacheStamp` / the `resolveSkillPromptEntries` `SkillPermissionChecker` arg to `this.resolver`; drop the `AgentPrepSession` import. - `handlers/lifecycle.ts` — add a `resolver: PermissionResolver` constructor parameter; retype `session` to `PermissionSession`; route `getConfigIssues` to `this.resolver`; drop the `SessionLifecycleSession` import. - `skill-prompt-sanitizer.ts` — update the `SkillPermissionChecker` doc comment ("`PermissionManager` and `PermissionResolver` satisfy this structurally"). - `index.ts` — move the `resolver = new PermissionResolver(...)` construction above the handler construction; pass `resolver` into `AgentPrepHandler` and `SessionLifecycleHandler`; change `GateRunner`'s recorder argument from `session` to `sessionRules`. - Delete `src/gate-handler-session.ts`, `src/agent-prep-session.ts`, `src/session-lifecycle-session.ts`. Tests (`test/`): - New `test/helpers/session-fixtures.ts` — `makeRealSession`, `makeFakePermissionManager`, `makeRealResolver`, and the small collaborator makers (promoted from `permission-session.test.ts`). - `test/permission-session.test.ts` — import the promoted factory; remove the "constructor and delegation" tests for the six removed methods; rework the `shutdown` "clears session rules" test to drive `sessionRules.recordSessionApproval` / `sessionRules.getRuleset` directly. - `test/session-rules.test.ts` — rename `record` tests to `recordSessionApproval`; add a `SessionApprovalRecorder`-conformance test. - `test/handlers/lifecycle.test.ts` — replace the local `makeSession` (`SessionLifecycleSession` mock) with `makeRealSession` + `makeRealResolver`; retarget `getConfigIssues` assertions onto the resolver/manager. - `test/handlers/before-agent-start.test.ts` — replace the local `makeSession` (`AgentPrepSession` mock) with `makeRealSession` + `makeRealResolver`; retarget `getToolPermission` / `getPolicyCacheStamp` / `checkPermission` assertions onto the resolver/manager. - `test/helpers/handler-fixtures.ts` — rebuild `makeHandler` / `makeSession` to construct real session + resolver + `SessionRules` recorder; remove the `MockGateHandlerSession` intersection type and the `SessionApprovalRecorder` / `GateHandlerSession` imports; keep `makeSurfaceCheck` / `makeBashCommandCheck` but retarget them to feed the resolver's fake manager; add `recorder` to the returned bag. - `test/handlers/external-directory-session-dedup.test.ts` — replace the stateful mock session with a real session + real resolver sharing one real `SessionRules`. - `test/helpers/gate-fixtures.ts` — `makeGateRunner` already builds a `{ recordSessionApproval }` recorder; no change required (verify only). Docs: - `docs/architecture/architecture.md` — module-structure block: delete the `gate-handler-session.ts` / `agent-prep-session.ts` / `session-lifecycle-session.ts` lines; update the `permission-session.ts`, `session-rules.ts`, `permission-resolver.ts`, and the three `handlers/` entries; note the recorder is now `SessionRules` and the runner receives three distinct objects. Update Finding 2's narrative and the "Current health metrics" row (`PermissionSession` role interfaces implemented by one class 4 → 0). The roadmap "Step 8 ✓ complete" marking and the health-score re-measurement are done at `/ship-issue`. - `.pi/skills/package-pi-permission-system/SKILL.md` — update the `handler-fixtures.ts` description: `MockGateHandlerSession` and the wide `makeSession` are gone; `makeHandler` builds a real session + resolver + `SessionRules` recorder and returns `recorder`; note the new `test/helpers/session-fixtures.ts`. ## Test Impact Analysis 1. New unit tests enabled by the extraction: - `SessionRules` gains a direct `recordSessionApproval` / `SessionApprovalRecorder`-conformance test (previously the behavior was only observed through `session.recordSessionApproval`). - The resolver's query methods (`getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp`) are now exercised through real handler wiring rather than fallow-suppressed dead members. 2. Existing tests that become redundant: - The six "constructor and delegation" tests in `permission-session.test.ts` (delegation of `checkPermission` / `getToolPermission` / `getConfigIssues` / `getPolicyCacheStamp` / `getSessionRuleset` / `recordSessionApproval` to the manager/rules) — the methods are gone; the behavior moves to `PermissionResolver` tests and `SessionRules` tests, which already exist or are added. - The hand-rolled stateful recorder + resolver-delegation scaffolding in `external-directory-session-dedup.test.ts` collapses into a real `SessionRules` + real resolver. 3. Existing tests that must stay as-is (they exercise the layer being kept): - `permission-session.test.ts` tests for `activate`/`deactivate`, `resetForNewSession`, `shutdown` (cache/skill clearing), cache-key methods, skill entries, `resolveAgentName`, infra paths, config delegation, `reload`, `getRuntimeContext` — these cover the state/lifecycle surface that remains. - `runner.test.ts` recorder assertions stay; only the injected recorder object's identity changes (still asserted via the `deps.recordSessionApproval` mock from `gate-fixtures.ts`). - The 104 `makeHandler` call sites' behavioral assertions stay; only the fixture internals change. ## TDD Order Lift-and-shift: keep the suite green at every commit by promoting the shared fixture first, moving the recorder, then retiring one interface per commit (each interface deletion + its handler retype + its consumer-test rewrite folded together), and finishing with the gate-handler fixture rebuild and docs. 1. Promote the real-session fixture to `test/helpers/`. - Surface: new `test/helpers/session-fixtures.ts` (`makeRealSession`, `makeFakePermissionManager`, `makeRealResolver`, collaborator makers); `permission-session.test.ts` imports them. - Covers: pure test refactor — no production change; the suite stays green. - Commit: `test: promote real-session fixture to test/helpers (#341)`. 2. Move the recorder role to `SessionRules`. - Surface: `session-rules.ts` (rename `record` → `recordSessionApproval`, `implements SessionApprovalRecorder`), `index.ts` (runner recorder = `sessionRules`), `permission-session.ts` (remove `recordSessionApproval` + `getSessionRuleset` + the `SessionApprovalRecorder` implements/import), `session-rules.test.ts`, `permission-session.test.ts` (remove the two delegation tests; rework `shutdown` test), `handler-fixtures.ts` + `external-directory-session-dedup.test.ts` (recorder = real/fake `SessionRules`; drop `recordSessionApproval`/`getSessionRuleset` from the mock). - Covers: the runner receives a distinct recorder; the session sheds the recorder/ruleset surface. - Commit: `refactor: move session-approval recorder to SessionRules (#341)`. 3. Retire `SessionLifecycleSession`; rewire the lifecycle handler to the resolver. - Surface: `lifecycle.ts` (add `resolver`, retype `session` to `PermissionSession`, `this.resolver.getConfigIssues`), delete `src/session-lifecycle-session.ts`, `permission-session.ts` (remove `getConfigIssues` + the interface implements/import), `permission-resolver.ts` (un-suppress `getConfigIssues`), `index.ts` (construct resolver before lifecycle; pass it in), `lifecycle.test.ts` (real session + resolver), `permission-session.test.ts` (remove the `getConfigIssues` delegation test). - Covers: lifecycle handler depends on concrete session + resolver; `SessionLifecycleSession` is gone. - Commit: `refactor: retire SessionLifecycleSession; depend on resolver (#341)`. 4. Retire `AgentPrepSession`; rewire the agent-prep handler to the resolver. - Surface: `before-agent-start.ts` (add `resolver`, retype `session`, route `getToolPermission` / `getPolicyCacheStamp` / the skill-checker arg to the resolver), delete `src/agent-prep-session.ts`, `permission-session.ts` (remove `getToolPermission` / `getPolicyCacheStamp` / `checkPermission` + the interface implements/import), `permission-resolver.ts` (un-suppress `getToolPermission` / `getPolicyCacheStamp`), `skill-prompt-sanitizer.ts` (doc comment), `index.ts` (pass resolver into `AgentPrepHandler`), `before-agent-start.test.ts` (real session + resolver), `permission-session.test.ts` (remove the three delegation tests). - Covers: agent-prep handler depends on concrete session + resolver; `AgentPrepSession` is gone. - Commit: `refactor: retire AgentPrepSession; depend on resolver (#341)`. 5. Retire `GateHandlerSession`; rebuild the gate-handler fixture. - Surface: `permission-gate-handler.ts` (retype `session` to `PermissionSession`; drop the import), delete `src/gate-handler-session.ts`, `permission-session.ts` (remove the last `GateHandlerSession` import — class now implements nothing explicitly), `handler-fixtures.ts` (rebuild `makeHandler`/`makeSession` on real session + resolver + `SessionRules` recorder + real pipelines; remove `MockGateHandlerSession`; preserve override-bag keys and return shape; add `recorder`), `external-directory-session-dedup.test.ts` (finalize on real session typing). - Covers: the last fig-leaf interface is gone; the gate handler depends on the concrete session; the 17-field intersection mock disappears. - Commit: `refactor: retire GateHandlerSession; rebuild handler fixture (#341)`. 6. Update architecture and skill docs. - Surface: `docs/architecture/architecture.md` (module structure, Finding 2 narrative, metrics row), `.pi/skills/package-pi-permission-system/SKILL.md` (fixture descriptions). - Covers: docs reflect the slimmed session, the `SessionRules` recorder, and the new test fixtures. - Commit: `docs: update architecture + skill for slimmed PermissionSession (#341)`. ## Risks and Mitigations - Risk: the `makeHandler` rebuild (Step 5) ripples into 104 call sites. Mitigation: preserve `makeHandler`'s override-bag keys and return shape; route permission-result overrides into the resolver's fake manager so call sites migrate with minimal or no edits. Enumerate during TDD any call site that overrides a now-computed session method (`getInfrastructureReadDirs` / `getToolPreviewLimits`) and translate it to a collaborator config (`configStore`/`paths`) rather than a method stub. - Risk: `lifecycle.test.ts` / `before-agent-start.test.ts` assertions that spy on `session.refreshConfig` / `resetForNewSession` no longer apply to a real session. Mitigation: shift those assertions to the injected collaborators the real methods drive (`configStore.refresh`, `permissionManager.configureForCwd`, `gateway.activate`), or use `vi.spyOn` on the real instance where the delegation target is internal. - Risk: removing `session.checkPermission` while a test fixture still passes the session to `SkillInputGatePipeline`. Mitigation: `handler-fixtures` uses its own `MockGateHandlerSession` (not the real class), so the real-class removals do not break it until Step 5; in Step 5 wire the skill-input pipeline to the resolver (matching production). - Risk: `fallow` flags the resolver query methods if a handler rewiring is missed. Mitigation: remove each suppression in the same commit that adds the first real caller; run `pnpm fallow dead-code` in the pre-completion check. - Risk: concrete-class dependency reintroduces the "mock must cast" smell the package convention guards against. Mitigation: tests build real instances via the promoted `session-fixtures.ts` helpers — no casts; this is the constructibility payoff Step 1 set up. ## Open Questions - Whether `makeSurfaceCheck` / `makeBashCommandCheck` should move into `session-fixtures.ts` alongside the resolver helpers or stay in `handler-fixtures.ts` — decide during Step 5 based on which files import them after the rebuild (defer until the call-site set is known). [#334]: https://github.com/gotgenes/pi-packages/issues/334 [#337]: https://github.com/gotgenes/pi-packages/issues/337 [#338]: https://github.com/gotgenes/pi-packages/issues/338 [#339]: https://github.com/gotgenes/pi-packages/issues/339 [#340]: https://github.com/gotgenes/pi-packages/issues/340 [#342]: https://github.com/gotgenes/pi-packages/issues/342