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

110 lines
13 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"
---
# Retro: #341 — Slim PermissionSession to a state/lifecycle owner; unwind the fig-leaf interfaces
## Stage: Planning (2026-06-07T18:39:38Z)
### Session summary
Produced the numbered plan for Phase 4, Step 8 — the final Track C step that slims `PermissionSession` to a state/lifecycle owner.
Confirmed all three prerequisites are CLOSED (Step 1 `#334`, Step 6 `#339`, Step 7 `#340`) and read the session/resolver/runner/handlers/fixtures source plus the `#340` retro to pick up cross-session context.
Surfaced the one genuine design ambiguity via `ask_user`; the user chose Option A (retire the three handler interfaces, depend on the concrete `PermissionSession`, build real instances in tests).
### Observations
- The headline "`GateRunner(session, session, session)` → three different collaborators" was already two-thirds done by Steps 67: the runner is `GateRunner(resolver, session, gateway, reporter)`, so only the recorder role is still the session.
The clean win is moving the recorder to `SessionRules` (rename `record``recordSessionApproval`, `implements SessionApprovalRecorder`); the runner call site is unchanged, only the injected object differs.
- Scope is larger than the issue's 3-file headline implies.
The `#340` retro is explicit that Step 8 also removes the session's transitional query duplicates (`checkPermission`, `getToolPermission`, `getConfigIssues`, `getPolicyCacheStamp`) and rewires `AgentPrepHandler` + `SessionLifecycleHandler` to the resolver.
`getSessionRuleset` is also dead in production (no caller since `#340` — verified by grep) and is removed.
- `PermissionGateHandler` does **not** gain a resolver dependency — its `GateRunner` already owns the resolver; it only needs the session's `activate` / `resolveAgentName`.
Only the two non-gate handlers gain a concrete `PermissionResolver` parameter.
- Option A is a conscious trade-off against the package's "narrow interface, not concrete class" convention.
It is justified because Step 1 made the session/resolver constructible, so tests build real instances (no casts) — the convention's mock-cast smell does not reappear.
`ScopedPermissionResolver`, `ToolCallGateInputs`, `SkillInputGateInputs`, and `SkillPermissionChecker` stay narrow.
- The 104 `makeHandler` call sites only break if its override-bag keys or return shape change — `handler-fixtures.ts` uses its own `MockGateHandlerSession` mock, not the real class, so removing methods from `PermissionSession` does not touch them.
The plan preserves `makeHandler`'s override surface to keep Step 5's blast radius to the fixture file itself.
- The existing `createSession` factory in `permission-session.test.ts` is the real-session fixture to promote into `test/helpers/session-fixtures.ts`; the hand-rolled stateful recorder in `external-directory-session-dedup.test.ts` collapses into a real `SessionRules` + real resolver sharing one ruleset.
- RPC (`permission-event-rpc.ts`) uses `permissionManager.checkPermission` directly and only `session.getRuntimeContext()`; `config-modal.ts` only reads `session.lastKnownActiveAgentName` — neither blocks the query-method removals.
- TDD order is lift-and-shift: promote the fixture, move the recorder, then retire one interface per commit (each deletion + handler retype + consumer-test rewrite folded together), then rebuild the gate-handler fixture, then docs.
## Stage: Implementation — TDD (2026-06-07T20:05:00Z)
### Session summary
Executed all six planned TDD steps plus docs: promoted the real-session fixture to `test/helpers/session-fixtures.ts`, moved the recorder role to `SessionRules` (`record``recordSessionApproval`, `implements SessionApprovalRecorder`), retired `SessionLifecycleSession` / `AgentPrepSession` / `GateHandlerSession` one per commit (rewiring `AgentPrepHandler` and `SessionLifecycleHandler` to a concrete `PermissionResolver`), rebuilt `makeHandler` on real session + resolver + `SessionRules` recorder, and updated architecture + skill docs.
Test count moved 1828 → 1823 (net 5: removed 6 `PermissionSession` delegation tests + 2 recorder/ruleset delegation tests, added 1 `SessionApprovalRecorder` conformance test on `SessionRules`; the remaining delta is the dedup-test rewrite collapsing onto real collaborators).
Pre-completion reviewer: PASS.
### Observations
- The plan held well; the lift-and-shift order kept the suite green at every commit and the predicted "104 `makeHandler` call sites stay put" was correct — only three handler-test assertions needed edits (`session.activate``forwarding.start` in `tool-call`/`input`, and `session.checkPermission``permissionManager.checkPermission` in `input`), because `makeHandler` preserved its override-bag surface (routing `checkPermission` overrides to the fake manager and session-state overrides to `vi.spyOn`).
- Biggest unplanned discovery (surfaced by the user mid-step): after Step 5 removed the last `implements`, `fallow` flagged four `PermissionSession` members (`getActiveSkillEntries` / `getInfrastructureReadDirs` / `getToolPreviewLimits` / `lastKnownActiveAgentName`).
Root cause: `fallow` keys member liveness off `implements` clauses, so the structurally-consumed members went dark when the fig-leaf interfaces left.
Resolved truthfully for the trio by declaring `PermissionSession implements ToolCallGateInputs` (a genuine pipeline-input contract, no import cycle — the pipeline does not import the session); this is now reflected in the plan's design but was not in the original Module-Level Changes.
For `lastKnownActiveAgentName`, a named-interface attempt (`ActiveAgentNameReader`) did **not** satisfy `fallow` — the blind spot is the object-literal wiring in `index.ts` (config-modal receives `session` as an object-literal property, not a traced positional arg), not the missing contract — so it was reverted and a single justified suppression added (verified false positive; `config-modal.ts` reads it in production).
- Plan-completeness gaps caught at the end and fixed: the `skill-prompt-sanitizer.ts` `SkillPermissionChecker` doc comment still named `PermissionSession` (which no longer has `checkPermission`) — corrected to `PermissionResolver`.
- Marked Steps 5 (`#338`) and 7 (`#340`) `✓ complete` in the roadmap — both were CLOSED but unmarked (the user flagged `#338`).
Step 8 (`#341`) stays unmarked until `/ship-issue` per convention.
- Reviewer's one WARN is informational: `PermissionResolver.checkPermission` is intentionally dual-role (ruleset-injecting `resolve` vs. raw `SkillPermissionChecker` pre-filter) — deliberate design carried over from `#340`, no change needed.
- `Edit`-tool friction: the Unicode box-drawing comment banners in `permission-session.ts` and the architecture doc twice defeated `oldText` matching (compounded by `pi-autoformat` reflow); fell back to a Python slice for the two block removals.
Re-reading after autoformat resolved the rest.
## Stage: Final Retrospective (2026-06-07T20:47:46Z)
### Session summary
Shipped `#341` across Planning (`claude-opus-4-8`), TDD (`claude-sonnet-4-6`, with an opus escalation for one design question), and Ship (`deepseek-v4-flash`): `PermissionSession` became a state/lifecycle owner, the recorder role moved to `SessionRules`, the two non-gate handlers were rewired to a concrete `PermissionResolver`, and the three fig-leaf handler interfaces were deleted.
Released as `pi-permission-system-v10.5.1`; behavior-preserving; net test delta 5; pre-completion reviewer returned PASS.
The defining moment was a user "step back" question that converted a `fallow`-suppression band-aid into the truthful `implements ToolCallGateInputs` contract declaration.
### Observations
#### What went well
- The lift-and-shift TDD order held the suite green at every commit, and the planning prediction "the 104 `makeHandler` call sites stay put" was correct — only three handler-test assertions needed edits because `makeHandler` preserved its override-bag surface.
- The model ladder matched task weight at every stage; notably the `sonnet``opus` switch coincided with the user's design question and gave the structural reasoning (`implements` vs. suppress) the right model.
- Incremental verification was disciplined: `pnpm run check` plus a targeted `vitest run` after each step, the full suite before each commit, and `fallow` at the end-of-TDD gate.
- The user's "step back" redirect — a question, not a correction — is the standout: it reframed a band-aid into a truthful design fix (`implements ToolCallGateInputs`) and surfaced a generalizable `fallow` insight worth promoting.
#### What caused friction (agent side)
- `premature-convergence` / `wrong-abstraction` — when `fallow` flagged four `PermissionSession` members after Step 5 removed the last `implements`, the agent (on `sonnet`) reached for four `fallow-ignore` suppressions without first asking "why is `fallow` flagging these?".
The user caught it ("Take a step back.
Why are we having to tell fallow these methods are used?").
Impact: ~4 tool calls of suppression work reverted; the root-cause investigation it triggered would have been needed regardless, so net rework was small but the design-quality delta was large (truthful contract vs. four band-aids).
User-caught.
- `missing-context` — the `fallow` skill was not loaded during TDD (the same gap the `#340` retro noted); loading it is the natural first move when `fallow` flags findings, and its absence reinforced the suppress-first reflex.
The skill did not yet document the `implements`-liveness behavior anyway — hence the proposal below.
Impact: contributed to the premature-suppression reflex; recurring across `#340` and `#341`.
- Edit-tool friction (recurring, ~6 occurrences) — Unicode box-drawing banner comments (`// ── … ──`) in `permission-session.ts`, `handler-fixtures.ts`, and `architecture.md` defeated `oldText` matching (variable-length dash runs compounded by `pi-autoformat` reflow); the reliable workaround was a Python `.find()` slice on a short substring.
Impact: added friction, no rework — each recovered within 12 calls.
#### What caused friction (user side)
- Several `Continue.` nudges during multi-file TDD steps where the agent paused after a tool batch.
Mechanical oversight rather than strategic input; the agent was making steady progress.
Opportunity: batch the remaining edits of a single step more aggressively so a multi-file step does not stall waiting for a nudge (same observation as the `#340` retro — recurring).
- The `#338` `✓ complete` gap: the user had to point out that a prior CLOSED roadmap step was never marked complete in `architecture.md`.
Opportunity: the doc-update step could prompt re-checking sibling roadmap steps' completion marks, not just the current issue's.
### Diagnostic details
- **Model-performance correlation** — clean, no mismatches.
Planning ran on `claude-opus-4-8` (design ambiguity + `ask_user` gate), TDD on `claude-sonnet-4-6` (implementation), the fallow design question escalated `sonnet``opus` (correct — structural-design judgment), Ship on `deepseek-v4-flash` (mechanical git/CI/release), and the `pre-completion-reviewer` subagent returned a thorough PASS.
The escalation landing exactly at the judgment-heavy question is the model ladder working as intended.
- **Escalation-delay tracking** — no long rabbit-hole.
The suppression episode was ~4 tool calls before the user redirected; the post-redirect investigation (root cause → `implements` fix → named-interface attempt → revert → one justified suppression) made steady forward progress rather than repeating a failing approach.
- **Unused-tool detection** — the `fallow` skill was available but not loaded during TDD; this is the second consecutive issue (`#340`, `#341`) where it would have been the right first reach when dead-code findings appeared.
- **Feedback-loop gap analysis** — verification was incremental and effective; `fallow` correctly ran at the end-of-TDD gate per `/tdd-plan`.
The gap was design-foresight (not anticipating that removing the last `implements` would blind `fallow` to structurally-consumed members), not a missing verification run.
### Changes made
1. `.pi/skills/fallow/SKILL.md` — added "Key gotchas" item 6: `fallow` keys class-member liveness off `implements` clauses, so a structurally-consumed member reads as dead once the last `implements` is removed; prefer re-declaring the contract over suppressing.
2. `.pi/prompts/tdd-plan.md` — reframed the end-of-TDD `fallow` step to load the `fallow` skill and prefer declaring a real contract / removing dead exports over suppressing (suppress only verified false positives), replacing the prior "add suppressions for false positives" wording that nudged toward the suppress-first reflex.
3. Recorded (no rule change): the Edit-tool `// ── … ──` banner-matching friction (covered by existing minimal-`oldText` guidance), the `#338` `✓ complete` gap (single-occurrence historical hygiene), and the recurring `Continue.`-nudge batching observation (judged too marginal for a crisp rule in the `#340` retro).