Files
my-pi/pi-permission-system/docs/retro/0319-introduce-permission-resolver.md

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 every describe* gate plus resolveBashCommandCheck) its result feeds straight into the next checkPermission(...) call. So checkPermission + getSessionRuleset are one operation split into a primitive plus a relay; the fix is a single PermissionResolver.resolve(surface, input, agentName).
  • The genuinely missing object is a DecisionReporter owning writeReviewLog (currently a Law-of-Demeter reach-through to session.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 = DecisionReporter extraction (depends on #319); #323 = GateRunner class replacing GateRunnerDeps, adding the GatePrompter role (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 into resolve() re-snapshots per call instead of once per gate. Safe because no recordSessionApproval runs during descriptor construction — every snapshot within a gate is equal.
  • Migration sequencing: the handler carries both the resolver and the legacy checkPermission/getSessionRuleset closures through the per-gate steps, so the repo stays green between commits; the final runner step deletes the last closures.
  • docs/architecture/architecture.md still describes the old single-GateRunnerContext framing (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.md does not reference getSessionRuleset or GateRunnerDeps, 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.ts plus the five gate test files, but switching the inline tool-gate resolution in handleToolCall to session.resolve broke the handler integration tests whose session mocks lacked a resolve method. Fixed by giving three session mocks (shared makeSession in handler-fixtures.ts plus the two local mocks in external-directory-integration.test.ts and external-directory-session-dedup.test.ts) a delegating resolve() that mirrors production (checkPermission applying getSessionRuleset()). This kept the many integration tests that drive gate outcomes via checkPermission working without rewriting them. The reviewer independently confirmed the delegation is sound and behavior-preserving (the dedup test's rule-doubling is insensitive to findLast, and that doubling also existed pre-migration).
  • The delegation guard if (!Object.hasOwn(overrides, "resolve")) lets a test override resolve directly when needed while defaulting to the production-mirroring delegation.
  • SessionRules.getRuleset() returns a fresh array copy per call, so folding it into resolve() re-snapshots per call; confirmed behavior-preserving since no recordSessionApproval runs during descriptor construction.
  • Reviewer WARN findings (both fixed before stopping): (1) the package SKILL.md gate-fixtures listing omitted the new makeResolver factory; (2) permission-gate-handler.ts had two independent references to session.resolve (the resolver local and the bag's resolve lambda) — the lambda now reuses resolver.
  • Final state: pnpm check / lint / test (1763) / fallow dead-code all green; GateRunnerDeps is down to 6 members, with the DecisionReporter ([#322]) and GateRunner ([#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 check after 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.resolve a body that calls the mock's own checkPermission + getSessionRuleset mirrored 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)

  1. premature-convergence (planning) — the first ask_user offered two variants of the prescribed GateRunnerContext approach (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 — "Maybe GateRunnerContext isn'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.
  2. missing-context (planning, surfaced in TDD Step 5) — the plan's Module-Level Changes listed gate-fixtures.ts for test changes but never grepped for the hand-rolled PermissionSession mocks (handler-fixtures.ts makeSession plus local copies in external-directory-integration.test.ts and external-directory-session-dedup.test.ts). The testing skill's mock-grep rule is framed around "adding a field to a shared interface," but PermissionSession is a class mocked via as 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.
  3. other (tooling) — one invalid Edit call used oldText2/newText2 keys (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 GateRunnerContext interface," 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/checkPermissionPermissionResolver (#319), recordSessionApprovalSessionApprovalRecorder (#323), canPrompt/promptGatePrompter (#323), logger.reviewDecisionReporter (#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) on anthropic/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 shared makeSession → 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, local makeSession); 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

  1. Created #325 — "Depend on session role interfaces in PermissionGateHandler, not the concrete PermissionSession class" (label enhancement, pkg:pi-permission-system; depends on #322/#323; needs /plan-issue). This is the real fix for the as unknown as PermissionSession casts surfaced by the Step 5 friction.
  2. Rejected two candidate testing skill edits after picking them apart with the user:
    • Proposal A (a rule to grep for as unknown as mocks) — 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.
  3. No edits to .pi/skills/testing/SKILL.md or AGENTS.md; the retro file carries the rationale, and #325 carries the design work.