12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 325 | Depend on session role interfaces in PermissionGateHandler, not the concrete PermissionSession class |
Retro: #325 — Depend on session role interfaces in PermissionGateHandler
Stage: Planning (2026-06-03T04:47:43Z)
Session summary
Produced the implementation plan for retyping PermissionGateHandler against a narrow GateHandlerSession role and dropping the as unknown as PermissionSession casts.
The issue body (written before #326/#327 landed) lists 12 session members and an open "residual cluster" question; the current code already shrank that residual to four methods (activate, resolveAgentName, checkPermission, createPermissionRequestId), so the plan is a small finishing move.
Stepping back per the maintainer's prompt, I expanded the design to inject the pre-built GateRunner (not just the DecisionReporter), filed three follow-up issues, and updated the Phase 3 roadmap.
Observations
- The referenced dependencies #319/#322/#323 are still open in the tracker but their code (
permission-resolver.ts,decision-reporter.ts,gate-prompter.ts,session-approval-recorder.ts) is merged, and later phases #326/#327 are done — so [#325] is unblocked despite the open labels. - Decision (confirmed via
ask_user): inject the wholeGateRunnerrather than only theDecisionReporterthe roadmap originally named. This narrows the handler'ssessionrole to exactly four methods (the three runner roles move to theindex.tswiring) and removes thesession.loggerreach-through — the same LoD smell #322 removed from the runner. Also drops theeventsconstructor param. - Decision: define a flat four-method
GateHandlerSessionrather than pre-splitting a two-methodSessionContextbase. ASessionContextabstraction gets a second consumer only with #329/#331, so introducing it now would be a speculative exportfallowcould flag. - The shared
makeSessioninhandler-fixtures.tsis used only byPermissionGateHandlertests;before-agent-start.test.tsandlifecycle.test.tshave their own localmakeSessionand import onlymakeCtx. So narrowing the shared fixture is safe and does not touch the other handlers. - Cast-removal wrinkle to watch in implementation: the mocks'
resolve/canConfirm/promptPermissiondelegate tocheckPermission/canPrompt/promptand are currently assigned after theas unknown ascast. Without the cast the object literal must satisfy the type at creation; the plan resolves this by defining the delegations inline as closures that read the finalsessionobject at call time, then spreading...overrideslast (replacing theObject.hasOwnguards).external-directory-session-dedup.test.tsis the canary because it drives stateful session-approval through these delegations. - Two vestigial mock members (
getToolPermission,config) exist only to satisfy the concrete class and can be dropped once the type is narrowed. - Broader findings filed as issues (maintainer approved stepping back): #329 extract a
SkillInputGatePipeline(thehandleInputskill-input assembly is still inline, asymmetric withToolCallGatePipeline); #330 relocatecreatePermissionRequestIdoffPermissionSession(it touches zero session state — maintainer noted it should land on the request-creation collaborator, not a free function); #331 narrowAgentPrepHandler+SessionLifecycleHandlerthe same way. - Behavior-preserving constraint kept: the skill-input pre-check stays on raw
checkPermission(no session rules); switching it toresolveis a behavior change deferred to #329. - Roadmap integration (second pass, on review feedback): the three follow-ups were first parked in an ad-hoc "Phase 3 follow-ups" table, which deviated from the roadmap convention (one issue per numbered step + a node in the Mermaid graph). Reworked them into proper Steps and graph nodes.
- Resequencing (third pass, on review feedback): #329 (
SkillInputGatePipeline) introduces a new collaborator thatindex.tsmust construct, so it must land before [#320] (the composition-root reframe) — otherwise [#320] cools theindex.tshotspot only for #329 to re-touch it. Renumbered the Phase 3 tail so reading order matches execution order: Step 12 #329, Step 13 #330, Step 14 #331, Step 15 [#320], Step 16 [#321]; updated the dependency diagram (S12 --> S15), the prose, the Tracks table, and the plan's Non-Goals cross-reference. - Tooling friction:
pi-autoformatre-pads Mermaid blocks and tables after everyWrite/Edit, so batched multi-edit calls against those regions went stale mid-call and failed atomically. Splitting into smaller targeted edits (and using length-preserving replacements for padded table cells) landed them cleanly. Worth remembering for any future edit touching the architecture doc's diagrams or tables.
Stage: Implementation — TDD (2026-06-03T02:10:00Z)
Session summary
Completed all three TDD cycles: (1) introduced GateHandlerSession, added it to PermissionSession's implements list, rewired the handler constructor to accept runner: GateRunner and session: GateHandlerSession, updated all four call sites (index.ts + three test fixtures); (2) dropped the as unknown as PermissionSession casts by defining MockGateHandlerSession — an intersection of all required roles — and rewriting makeSession to use per-field ?? selection with vi.fn<T>() typed mocks; (3) updated architecture.md module-structure listing and marked Phase 3 Step 11 ✅.
Test count was 1807 before and after (behavior-preserving refactor).
Observations
- The plan described the cast-removal approach as "spread
...overrideslast" but this pattern caused TypeScript issues when used with a type annotation on the const (spread ofPartial<T>intoTmakes required fields optional). Resolved by switching to the per-field??selection pattern already established ingate-fixtures.ts(makeGateInputs), which lets TypeScript verify each field individually againstMockGateHandlerSession[K]. - The
resolvedelegation callssession.checkPermission(surface, input, agentName, session.getSessionRuleset())with 4 arguments, butGateHandlerSession.checkPermissionhas only 3 params. Resolved by adding a 4-argcheckPermissionoverride in the inline type ofMockGateHandlerSession(which overrides the 3-arg version fromGateHandlerSessionin the intersection); the handler's 3-arg call sites still compile because the 4th param is optional. vi.fn<Signature>()with the exact method type (e.g.,vi.fn<MockGateHandlerSession["activate"]>()) ensures TypeScript checks the mock against the interface at creation, eliminating the need for any cast.undefined as unknown as ExtensionContextreplaces the oldundefined as neverhack in thecanConfirm/promptPermissiondelegations — cleaner and avoids theneverTDZ issue.- The
external-directory-integration.test.tshad an unusedPromptPermissionDetailsimport after the refactor (the type is now inferred from thevi.fn<T>()generic); removed in the Step 2 commit. - Pre-completion reviewer verdict: WARN — one minor finding: the S11 Mermaid node in
architecture.mdwas missing the ✅ marker carried by the completed S8/S9/S10 nodes. Fixed in a follow-updocs:commit.
Stage: Final Retrospective (2026-06-03T02:35:00Z)
Session summary
Reviewed the full two-stage arc (Planning + TDD) for issue #325.
The TDD session executed all three plan steps cleanly across 90 turns on claude-sonnet-4-6 with zero user corrections, zero rework, and one pre-completion WARN (a missing Mermaid ✅ marker, fixed in the same session).
The one substantive deviation — the plan's prescribed { ...defaults, ...overrides } spread did not typecheck under a precise return annotation — was self-identified and resolved by adopting the existing gate-fixtures.ts per-field ?? pattern.
Observations
What went well
- Thorough pre-implementation reconnaissance before Step 2: turns 34–51 ran ~15 targeted
grepcalls to enumerate everymakeHandler({ session: … })override key across all six handler test files before touching the sharedmakeSessiontype. This confirmed no caller passed the vestigialgetToolPermission/configkeys, so dropping them was provably safe — no rework, no broken test surfaced later. - Incremental verification:
pnpm run check+ package test suite ran after Step 1 (turns 31–32) and again after Step 2 (turns 57–58), withlintafter each. A type regression would have been caught at the step that introduced it, not at the end. - Self-identified plan deviation handled cleanly: the plan's
{ ...defaults, ...overrides }spread approach conflicts with thetestingskill's known mock-typing pitfall. The agent recognized this without being told and pivoted to the per-field?? vi.fn<T>()pattern already established ingate-fixtures.ts(makeGateInputs/makeGateRunner) — a novel win: the codebase's own convention resolved a plan-prescribed dead end.
What caused friction (agent side)
missing-context(planning-side, not TDD) — the plan's Design Overview prescribed defining the delegations inline "then spread...overrideslast," which does not typecheck once the const is annotatedMockGateHandlerSession(spread ofPartial<T>intoTmakes required fields optional). Impact: no rework — the deviation was caught at design-read time and resolved in the first Step 2 write; cost was a few minutes of re-derivation. Thetestingskill already warns the spread "erases mock methods," but it does not name the constructive alternative (per-field??+vi.fn<T>()+ precise return annotation) nor connect it to the cast-removal use case.other(minor) — a transient unusedPromptPermissionDetailsimport lingered inexternal-directory-integration.test.tsafter thevi.fn<T>()generics made the explicit annotation unnecessary. Impact: caught bylintimmediately (turn 59), removed in the same step (turn 62); no rework beyond one edit.
What caused friction (user side)
- None. The session ran end-to-end without user intervention, which is the expected shape for a behavior-preserving refactor with a complete plan. No earlier-context opportunity applies.
Diagnostic details
- Model-performance correlation — all 90 TDD turns ran on
claude-sonnet-4-6, appropriate for mechanical-plus-type-level refactoring. The single subagent dispatch (pre-completion-reviewer, turn 80) ran on its agent-frontmatter default model and produced a thorough 39-tool-use report; no model mismatch. - Escalation-delay tracking — no
rabbit-holefriction; no sequence exceeded 5 consecutive tool calls on the same error. The longest same-purpose run (the turn 34–51 grep sweep) was deliberate reconnaissance, not stuck-state thrashing. - Unused-tool detection — the grep sweep used exact-symbol matching (
makeHandler({,checkPermission), which is the correct tool;colgrepwould not have improved exact-key enumeration. No Explore/Plan dispatch was warranted. - Feedback-loop gap analysis — verification was incremental (check/test after each of Steps 1 and 2, full suite +
fallow dead-code+ lockfile check after Step 3); no end-only verification gap.
Proposed follow-ups
- Refine the
testingskill to name the per-field?? vi.fn<T>()cast-removal pattern and its exception to the "do not annotate the return type" rule (the annotation is correct when callers supply pre-built mocks via overrides, which is what makes the completeness check enforce cast safety). Deferred at the maintainer's direction — recorded here rather than applied inline.
Changes made
- Appended this Final Retrospective stage entry to
packages/pi-permission-system/docs/retro/0325-narrow-permission-gate-handler-roles.md. No prompt orAGENTS.mdedits were made; the one proposedtesting-skill refinement is recorded above as a deferred follow-up per the maintainer's choice.