25 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 341 | 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:
GateRunnerreceivesSessionRules(a distinct collaborator) as itsSessionApprovalRecorder, so the runner's three roles map to three different objects (resolver,recorder,prompter). - Rewire
AgentPrepHandlerandSessionLifecycleHandlerto depend onPermissionResolverfor the permission-query surface (getToolPermission,getPolicyCacheStamp,getConfigIssues, and theSkillPermissionCheckercheckPermissionpass). - 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 concretePermissionSessionfor state/lifecycle and onPermissionResolverfor queries (user-confirmed Option A). - Split or remove the 17-field
makeSession/MockGateHandlerSessionfixture; handler tests build a realPermissionSession+PermissionResolverfrom small per-collaborator fakes promoted intotest/helpers/. - Behavior-preserving — the suite stays green at every commit.
Non-Goals
- No change to
PromptingGateway(#339) orPermissionResolver's resolution behavior (#340) — only their wiring into handlers. - No change to
ToolCallGateInputsorSkillInputGateInputs— these are genuine narrow input contracts for the pipelines, not fig leaves;PermissionSessionkeeps satisfyingToolCallGateInputsstructurally and the resolver satisfiesSkillInputGateInputs. - No change to
SkillPermissionChecker— it stays a narrow interface; the production caller switches from the session to the resolver. - Not the
permission-system.test.tscatch-all carve — that is Step 9 (#342). - No further
PermissionSessiondecomposition (anActiveAgentTracker, a cache-key owner, an infra-path helper) — deferred to Phase 5. - No change to the
permission-event-rpc.tsorconfig-modal.tssession usage: RPC readssession.getRuntimeContext()and the modal readssession.lastKnownActiveAgentName— both stay on the session.
Background
Relevant modules and how they relate after Steps 6–7:
permission-session.ts—PermissionSessionclass, currentlyimplements SessionApprovalRecorder, GateHandlerSession, AgentPrepSession, SessionLifecycleSession. Holdspaths,logger,forwarding,permissionManager,sessionRules,configStore,gateway. After this step it keepspermissionManager(forconfigureForCwdinresetForNewSession/reload) andsessionRules(forclear()inshutdown), but sheds all permission-query and recorder/ruleset methods.permission-resolver.ts—ScopedPermissionResolverinterface ({ resolve }) + concretePermissionResolverclass. Already carriescheckPermission/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—SessionRulesclass withrecord(approval),getRuleset(),approve(),clear().recordis called only bysession.recordSessionApproval.session-approval-recorder.ts—SessionApprovalRecorderinterface (recordSessionApproval(approval)), depended on byGateRunner.handlers/gates/runner.ts—GateRunner(resolver, recorder, prompter, reporter); production passessessionasrecorder.handlers/permission-gate-handler.ts— depends onGateHandlerSession(activate,resolveAgentName); the runner it holds already owns the resolver.handlers/before-agent-start.ts—AgentPrepHandlerdepends onAgentPrepSession; callssession.getToolPermission,session.getPolicyCacheStamp, and passessessionas theSkillPermissionCheckertoresolveSkillPromptEntries.handlers/lifecycle.ts—SessionLifecycleHandlerdepends onSessionLifecycleSession; callssession.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, andSkillPermissionCheckerremain narrow interfaces. @typescript-eslint/require-await: keep handlerasynconly where anawaitremains.- 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).
fallowsuppression grammar: the kind token must be the exact singularunused-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.mddocuments 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:
// 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.
// 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
// 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):
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 realSessionRulesused both as the recorder and inside a real resolver — the dedup now works natively (record →getRuleset()sees the session rule).handler-fixtures.ts: rebuildmakeHandlerto construct a real session + resolver +SessionRulesrecorder + real pipelines + runner. PreservemakeHandler'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 addrecorderto the returned bag for the dedup assertions.
Module-Level Changes
Source (src/):
session-rules.ts— renamerecord(approval)→recordSessionApproval(approval); addimplements SessionApprovalRecorder(import the interface).permission-session.ts— removecheckPermission,getToolPermission,getConfigIssues,getPolicyCacheStamp,getSessionRuleset,recordSessionApproval; remove theimplementsclause forSessionApprovalRecorder,GateHandlerSession,AgentPrepSession,SessionLifecycleSession; drop their imports (and theSessionApproval/Rule/PermissionCheckResult/PermissionStateimports that become unused); update the class doc comment.permission-resolver.ts— remove the three// fallow-ignore-next-line unused-class-memberdirectives ongetToolPermission,getConfigIssues,getPolicyCacheStamp(now they have callers).handlers/permission-gate-handler.ts— retype thesessionparameter fromGateHandlerSessiontoPermissionSession; drop theGateHandlerSessionimport.handlers/before-agent-start.ts— add aresolver: PermissionResolverconstructor parameter; retypesessiontoPermissionSession; routegetToolPermission/getPolicyCacheStamp/ theresolveSkillPromptEntriesSkillPermissionCheckerarg tothis.resolver; drop theAgentPrepSessionimport.handlers/lifecycle.ts— add aresolver: PermissionResolverconstructor parameter; retypesessiontoPermissionSession; routegetConfigIssuestothis.resolver; drop theSessionLifecycleSessionimport.skill-prompt-sanitizer.ts— update theSkillPermissionCheckerdoc comment ("PermissionManagerandPermissionResolversatisfy this structurally").index.ts— move theresolver = new PermissionResolver(...)construction above the handler construction; passresolverintoAgentPrepHandlerandSessionLifecycleHandler; changeGateRunner's recorder argument fromsessiontosessionRules.- 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 frompermission-session.test.ts). test/permission-session.test.ts— import the promoted factory; remove the "constructor and delegation" tests for the six removed methods; rework theshutdown"clears session rules" test to drivesessionRules.recordSessionApproval/sessionRules.getRulesetdirectly.test/session-rules.test.ts— renamerecordtests torecordSessionApproval; add aSessionApprovalRecorder-conformance test.test/handlers/lifecycle.test.ts— replace the localmakeSession(SessionLifecycleSessionmock) withmakeRealSession+makeRealResolver; retargetgetConfigIssuesassertions onto the resolver/manager.test/handlers/before-agent-start.test.ts— replace the localmakeSession(AgentPrepSessionmock) withmakeRealSession+makeRealResolver; retargetgetToolPermission/getPolicyCacheStamp/checkPermissionassertions onto the resolver/manager.test/helpers/handler-fixtures.ts— rebuildmakeHandler/makeSessionto construct real session + resolver +SessionRulesrecorder; remove theMockGateHandlerSessionintersection type and theSessionApprovalRecorder/GateHandlerSessionimports; keepmakeSurfaceCheck/makeBashCommandCheckbut retarget them to feed the resolver's fake manager; addrecorderto the returned bag.test/handlers/external-directory-session-dedup.test.ts— replace the stateful mock session with a real session + real resolver sharing one realSessionRules.test/helpers/gate-fixtures.ts—makeGateRunneralready builds a{ recordSessionApproval }recorder; no change required (verify only).
Docs:
docs/architecture/architecture.md— module-structure block: delete thegate-handler-session.ts/agent-prep-session.ts/session-lifecycle-session.tslines; update thepermission-session.ts,session-rules.ts,permission-resolver.ts, and the threehandlers/entries; note the recorder is nowSessionRulesand the runner receives three distinct objects. Update Finding 2's narrative and the "Current health metrics" row (PermissionSessionrole 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 thehandler-fixtures.tsdescription:MockGateHandlerSessionand the widemakeSessionare gone;makeHandlerbuilds a real session + resolver +SessionRulesrecorder and returnsrecorder; note the newtest/helpers/session-fixtures.ts.
Test Impact Analysis
-
New unit tests enabled by the extraction:
SessionRulesgains a directrecordSessionApproval/SessionApprovalRecorder-conformance test (previously the behavior was only observed throughsession.recordSessionApproval).- The resolver's query methods (
getToolPermission/getConfigIssues/getPolicyCacheStamp) are now exercised through real handler wiring rather than fallow-suppressed dead members.
-
Existing tests that become redundant:
- The six "constructor and delegation" tests in
permission-session.test.ts(delegation ofcheckPermission/getToolPermission/getConfigIssues/getPolicyCacheStamp/getSessionRuleset/recordSessionApprovalto the manager/rules) — the methods are gone; the behavior moves toPermissionResolvertests andSessionRulestests, which already exist or are added. - The hand-rolled stateful recorder + resolver-delegation scaffolding in
external-directory-session-dedup.test.tscollapses into a realSessionRules+ real resolver.
- The six "constructor and delegation" tests in
-
Existing tests that must stay as-is (they exercise the layer being kept):
permission-session.test.tstests foractivate/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.tsrecorder assertions stay; only the injected recorder object's identity changes (still asserted via thedeps.recordSessionApprovalmock fromgate-fixtures.ts).- The 104
makeHandlercall 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.
-
Promote the real-session fixture to
test/helpers/.- Surface: new
test/helpers/session-fixtures.ts(makeRealSession,makeFakePermissionManager,makeRealResolver, collaborator makers);permission-session.test.tsimports them. - Covers: pure test refactor — no production change; the suite stays green.
- Commit:
test: promote real-session fixture to test/helpers (#341).
- Surface: new
-
Move the recorder role to
SessionRules.- Surface:
session-rules.ts(renamerecord→recordSessionApproval,implements SessionApprovalRecorder),index.ts(runner recorder =sessionRules),permission-session.ts(removerecordSessionApproval+getSessionRuleset+ theSessionApprovalRecorderimplements/import),session-rules.test.ts,permission-session.test.ts(remove the two delegation tests; reworkshutdowntest),handler-fixtures.ts+external-directory-session-dedup.test.ts(recorder = real/fakeSessionRules; droprecordSessionApproval/getSessionRulesetfrom 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).
- Surface:
-
Retire
SessionLifecycleSession; rewire the lifecycle handler to the resolver.- Surface:
lifecycle.ts(addresolver, retypesessiontoPermissionSession,this.resolver.getConfigIssues), deletesrc/session-lifecycle-session.ts,permission-session.ts(removegetConfigIssues+ the interface implements/import),permission-resolver.ts(un-suppressgetConfigIssues),index.ts(construct resolver before lifecycle; pass it in),lifecycle.test.ts(real session + resolver),permission-session.test.ts(remove thegetConfigIssuesdelegation test). - Covers: lifecycle handler depends on concrete session + resolver;
SessionLifecycleSessionis gone. - Commit:
refactor: retire SessionLifecycleSession; depend on resolver (#341).
- Surface:
-
Retire
AgentPrepSession; rewire the agent-prep handler to the resolver.- Surface:
before-agent-start.ts(addresolver, retypesession, routegetToolPermission/getPolicyCacheStamp/ the skill-checker arg to the resolver), deletesrc/agent-prep-session.ts,permission-session.ts(removegetToolPermission/getPolicyCacheStamp/checkPermission+ the interface implements/import),permission-resolver.ts(un-suppressgetToolPermission/getPolicyCacheStamp),skill-prompt-sanitizer.ts(doc comment),index.ts(pass resolver intoAgentPrepHandler),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;
AgentPrepSessionis gone. - Commit:
refactor: retire AgentPrepSession; depend on resolver (#341).
- Surface:
-
Retire
GateHandlerSession; rebuild the gate-handler fixture.- Surface:
permission-gate-handler.ts(retypesessiontoPermissionSession; drop the import), deletesrc/gate-handler-session.ts,permission-session.ts(remove the lastGateHandlerSessionimport — class now implements nothing explicitly),handler-fixtures.ts(rebuildmakeHandler/makeSessionon real session + resolver +SessionRulesrecorder + real pipelines; removeMockGateHandlerSession; preserve override-bag keys and return shape; addrecorder),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).
- Surface:
-
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
SessionRulesrecorder, and the new test fixtures. - Commit:
docs: update architecture + skill for slimmed PermissionSession (#341).
- Surface:
Risks and Mitigations
-
Risk: the
makeHandlerrebuild (Step 5) ripples into 104 call sites. Mitigation: preservemakeHandler'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.tsassertions that spy onsession.refreshConfig/resetForNewSessionno 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 usevi.spyOnon the real instance where the delegation target is internal. -
Risk: removing
session.checkPermissionwhile a test fixture still passes the session toSkillInputGatePipeline. Mitigation:handler-fixturesuses its ownMockGateHandlerSession(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:
fallowflags the resolver query methods if a handler rewiring is missed. Mitigation: remove each suppression in the same commit that adds the first real caller; runpnpm fallow dead-codein 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.tshelpers — no casts; this is the constructibility payoff Step 1 set up.
Open Questions
- Whether
makeSurfaceCheck/makeBashCommandCheckshould move intosession-fixtures.tsalongside the resolver helpers or stay inhandler-fixtures.ts— decide during Step 5 based on which files import them after the rebuild (defer until the call-site set is known).