12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 319 | Introduce PermissionResolver and remove the session-rule relay from the permission gates |
Retro: #319 — Introduce PermissionResolver and remove the session-rule relay
Stage: Planning (2026-06-02T00:00:00Z)
Session summary
Planned issue #319, but first reframed it.
The original issue proposed replacing the GateRunnerDeps bag with one narrow GateRunnerContext interface; investigation showed that a single interface the session implements wholesale would just re-expose the session ("glomming state"), and that the bag is really a relay plus four genuine roles.
Decomposed the architecture rework into three sequential issues, created the two follow-ups, reframed #319 to the foundational step, then wrote and committed the plan.
Observations
- The decisive evidence:
getSessionRuleset()has no independent use — at all five call sites (the runner and everydescribe*gate plusresolveBashCommandCheck) its result feeds straight into the nextcheckPermission(...)call. SocheckPermission+getSessionRulesetare one operation split into a primitive plus a relay; the fix is a singlePermissionResolver.resolve(surface, input, agentName). - The genuinely missing object is a
DecisionReporterowningwriteReviewLog(currently a Law-of-Demeter reach-through tosession.logger.review) +emitDecision(event bus). This is where the "does the session own the event bus?" question resolves: the reporter owns it, the session never does. - Issue decomposition (user-directed): #319 =
PermissionResolver+ full relay removal across all gates; #322 =DecisionReporterextraction (depends on #319); #323 =GateRunnerclass replacingGateRunnerDeps, adding theGatePrompterrole (depends on #319 and #322). User chose a flat sequence with cross-links over an umbrella epic. - Key behavior-preservation note for implementation:
SessionRules.getRuleset()returns a fresh array copy per call, so folding it intoresolve()re-snapshots per call instead of once per gate. Safe because norecordSessionApprovalruns during descriptor construction — every snapshot within a gate is equal. - Migration sequencing: the handler carries both the resolver and the legacy
checkPermission/getSessionRulesetclosures through the per-gate steps, so the repo stays green between commits; the final runner step deletes the last closures. docs/architecture/architecture.mdstill describes the old single-GateRunnerContextframing (Phase 3 Track C, Step 6, the Mermaid roadmap node, and the smell table) — the plan's final step reframes it into the three-issue decomposition.- The package
SKILL.mddoes not referencegetSessionRulesetorGateRunnerDeps, so no skill update is needed.
Stage: Implementation — TDD (2026-06-02T20:00:00Z)
Session summary
Executed all 7 TDD cycles: introduced PermissionResolver + PermissionSession.resolve (4 new unit tests), migrated the four gate descriptor factories and resolveBashCommandCheck off the (checkPermission, getSessionRuleset) pair, collapsed the runner bag's two members into resolve (GateRunnerDeps extends PermissionResolver), and reframed the architecture doc's Phase 3 Track C roadmap.
Test count went 1759 → 1763 (+4, all from the new resolve unit tests); the relay is gone from every gate.
Pre-completion reviewer returned WARN with two non-blocking findings, both addressed.
Observations
- Deviation from the plan (Step 5): the plan listed only
gate-fixtures.tsplus the five gate test files, but switching the inline tool-gate resolution inhandleToolCalltosession.resolvebroke the handler integration tests whose session mocks lacked aresolvemethod. Fixed by giving three session mocks (sharedmakeSessioninhandler-fixtures.tsplus the two local mocks inexternal-directory-integration.test.tsandexternal-directory-session-dedup.test.ts) a delegatingresolve()that mirrors production (checkPermissionapplyinggetSessionRuleset()). This kept the many integration tests that drive gate outcomes viacheckPermissionworking without rewriting them. The reviewer independently confirmed the delegation is sound and behavior-preserving (the dedup test's rule-doubling is insensitive tofindLast, and that doubling also existed pre-migration). - The delegation guard
if (!Object.hasOwn(overrides, "resolve"))lets a test overrideresolvedirectly when needed while defaulting to the production-mirroring delegation. SessionRules.getRuleset()returns a fresh array copy per call, so folding it intoresolve()re-snapshots per call; confirmed behavior-preserving since norecordSessionApprovalruns during descriptor construction.- Reviewer WARN findings (both fixed before stopping): (1) the package
SKILL.mdgate-fixtures listing omitted the newmakeResolverfactory; (2)permission-gate-handler.tshad two independent references tosession.resolve(theresolverlocal and the bag'sresolvelambda) — the lambda now reusesresolver. - Final state:
pnpm check/lint/test(1763) /fallow dead-codeall green;GateRunnerDepsis down to 6 members, with theDecisionReporter([#322]) andGateRunner([#323]) extractions deferred as planned.
Stage: Final Retrospective (2026-06-02T21:30:00Z)
Session summary
A single continuous session carried #319 through all four stages: planning (which reframed the issue and spawned #322/#323), seven TDD cycles, shipping (CI green, release batched), and this retro.
The headline outcome was a design that started as the issue's prescribed "one narrow GateRunnerContext interface" and, after a user redirect, became a principled three-issue decomposition (relay collapse + DecisionReporter + GateRunner).
Execution was clean: 10 commits, +4 tests, zero rework of committed code, two reviewer WARNs fixed before stopping.
Observations
What went well
- Incremental verification was exemplary and load-bearing: running the affected test file after each Red/Green,
pnpm run checkafter every interface-touching step, and — critically — a proactive handler-integration-test run after the Step 5 inline tool-gate switch caught a plan gap before it reached commit or CI. - The delegating-mock pattern (novel): giving the mock
session.resolvea body that calls the mock's owncheckPermission+getSessionRulesetmirrored production and migrated dozens of integration tests with zero per-test expectation rewrites. - Pre-completion reviewer earned its keep: independently confirmed the delegating-mock was behavior-preserving (the dedup test's rule-doubling is
findLast-insensitive and pre-existed the migration) and surfaced two real WARNs.
What caused friction (agent side)
premature-convergence(planning) — the firstask_useroffered two variants of the prescribedGateRunnerContextapproach (emit-in-session vs. separate event bus) before validating whether a single session-implemented interface was the right abstraction at all. The user redirected with a question — "MaybeGateRunnerContextisn't even helping, if it's just glomming state together" — which catalyzed the relay-collapse +DecisionReporter+ role-decomposition design. Impact: one extra analysis round; net-positive because the redirect produced a materially better design, but the agent should have questioned the prescribed abstraction before asking about its implementation details.missing-context(planning, surfaced in TDD Step 5) — the plan's Module-Level Changes listedgate-fixtures.tsfor test changes but never grepped for the hand-rolledPermissionSessionmocks (handler-fixtures.tsmakeSessionplus local copies inexternal-directory-integration.test.tsandexternal-directory-session-dedup.test.ts). Thetestingskill's mock-grep rule is framed around "adding a field to a shared interface," butPermissionSessionis a class mocked viaas unknown as, so the rule did not obviously apply. Impact: self-identified during TDD via the proactive handler-test run; no rework of committed code, but added three unplanned files to Step 5.other(tooling) — one invalidEditcall usedoldText2/newText2keys (not supported); single retry, trivial.
What caused friction (user side)
- None material.
The user's three interventions — the design redirect, the "rework the architecture and add more issues… make it so" directive, and the batch-release choice — were all strategic-level and well-timed.
The only latent nudge toward friction was the issue body's prescriptive "Define a narrow
GateRunnerContextinterface," which framed a hypothesis as a spec; that is an authoring nuance, not a session fault.
Design follow-up surfaced in the retro
Digging into the Step 5 friction (#missing-context, hand-rolled session mocks) exposed a deeper root cause than "the plan forgot to grep for mocks."
The mocks are as unknown as PermissionSession because PermissionGateHandler's constructor depends on the concrete PermissionSession class (using 12 of its 36 members), and a concrete class with private fields cannot be satisfied structurally without the cast.
That cast is the antipattern: it disables TypeScript's structural check, which is the only reason the missing resolve surfaced at runtime instead of at pnpm run check.
The code-design skill already names the fix — "use a narrow interface type, not the concrete class."
The 12 members decompose by role, and most are already being extracted: resolve/checkPermission → PermissionResolver (#319), recordSessionApproval → SessionApprovalRecorder (#323), canPrompt/prompt → GatePrompter (#323), logger.review → DecisionReporter (#322); the residual cluster (activate, resolveAgentName, config, getInfrastructureDirs, getInfrastructureReadPaths, getActiveSkillEntries, createPermissionRequestId) has no role yet and is the open design question.
A "narrow interface" is therefore not one 12-member facade — it is the handler depending on the small roles, with the residual cluster resolved during planning.
Captured as #325 (depends on #322/#323, to be planned); the as unknown as de-cast falls out as a consequence, restoring compile-time mock-completeness checking.
Diagnostic details
- Model-performance correlation — one subagent dispatched (
pre-completion-reviewer) onanthropic/claude-sonnet-4-6; appropriate for judgment-heavy review (code-design audit, delegation-soundness proof). No mismatch. - Escalation-delay tracking — no rabbit-holes.
The Step 5 mock breakage resolved in ~3 tool calls (diagnose missing
resolve→ fix sharedmakeSession→ fix two local mocks). No 5+ consecutive-call sequences on one error. - Unused-tool detection — the Step 5 gap was greppable at plan time (
as unknown as PermissionSession, localmakeSession); a single grep during planning would have caught it. No subagent was needed. - Feedback-loop gap analysis — verification ran incrementally after every change, not just at the end; the proactive Step 5 handler-test run is the concrete payoff.
Changes made
- Created #325 — "Depend on session role interfaces in
PermissionGateHandler, not the concretePermissionSessionclass" (labelenhancement,pkg:pi-permission-system; depends on #322/#323; needs/plan-issue). This is the real fix for theas unknown as PermissionSessioncasts surfaced by the Step 5 friction. - Rejected two candidate
testingskill edits after picking them apart with the user:- Proposal A (a rule to grep for
as unknown asmocks) — rejected because it would bless the bandaid rather than remove it; the cast is a symptom of consumers depending on the concrete class, addressed by #325. - Proposal B (codify the delegating-mock tactic) — rejected because delegation only works on broad hand-rolled mocks, which are themselves a decoupling smell that #325 removes; not a pattern to hold up as desired.
- Proposal A (a rule to grep for
- No edits to
.pi/skills/testing/SKILL.mdorAGENTS.md; the retro file carries the rationale, and #325 carries the design work.