Files
my-pi/pi-permission-system/docs/plans/0341-slim-permission-session-state-lifecycle-owner.md

239 lines
25 KiB
Markdown
Raw Permalink 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: 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 67:
- `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