Files
my-pi/pi-permission-system/docs/retro/0287-decompose-run-gate-check.md

9.9 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
287 Thin runGateCheck via a SessionApproval value object and SessionRules.record

Retro: #287 — Thin runGateCheck via a SessionApproval value object and SessionRules.record

Stage: Planning (2026-05-31T00:00:00Z)

Session summary

Planned the decomposition of runGateCheck in src/handlers/gates/runner.ts. The plan rejects the issue's original "extract three phase helpers" approach as procedure-splitting and instead targets the real design smells: a behaviorless sessionApproval union, the runner doing the session store's bookkeeping scalar-by-scalar, and duplicated decision-event construction. The committed plan introduces a SessionApproval value object, a SessionRules.record(approval) tell that absorbs the per-pattern loop, and a pure buildDecisionEvent helper; runGateCheck thins as a consequence. Issue #287 was amended (title + body) to match this framing.

Observations

  • The user drove a Socratic redesign across several rounds, rejecting in turn: (1) the three free helpers (emitSessionHit/recordSessionApprovals are side-effect-only relocations), (2) exported helpers + unit tests (mock-call assertions duplicate the integration suite), and (3) a GateEvaluation command object ("two methods and one is a constructor — a function in a class trenchcoat"; the per-call evaluation is transient, not stateful).
  • The converged insight: the genuinely stateful object is SessionRules (lives for the session, queried + mutated), and the missing value object is SessionApproval (the { pattern } | { patterns } union interrogated in both phase 3 and phase 6). Tell-Don't-Ask = tell the store to record(approval); let the value object own the union.
  • Key scope decision: this reshapes internal seams (GateRunnerDeps.approveSessionRulerecordSessionApproval, GateDescriptor.sessionApprovalSessionApproval, PermissionSession, SessionRules) and all five gate producers + ~8 deps-mock test files. Wider than the issue's original "internal decomposition," so the issue was amended rather than silently exceeded.
  • applyPermissionGate / permission-gate.ts deliberately kept unchanged — it retains its single { surface; pattern } seam and the runner adapts via SessionApproval.toGateApproval(). This contains the blast radius.
  • Lift-and-shift chosen for the test churn: keep SessionRules.approve(surface, pattern) as the internal primitive so session-rules.test.ts is not rewritten; the type-forced cutover (descriptor type + deps reshape) is one mechanical commit because TypeScript breaks every producer, the runner, and every deps-mock simultaneously.
  • The original first draft of the plan (the rejected three-helper version) was overwritten in place before commit, so only the converged plan is in history.
  • Deferred to Open Questions: lifting phase-1 check resolution onto the descriptor — revisit only if fallow still flags runner.ts after step 3.

Stage: Implementation — TDD (2026-05-31T02:00:00Z)

Session summary

Completed all four TDD steps: (1) added SessionApproval value object and SessionRules.record; (2) executed the type-forced cutover reshaping GateDescriptor.sessionApproval, GateRunnerDeps.recordSessionApproval, PermissionSession, five gate producers, and ~10 test files; (3) added buildDecisionEvent to helpers.ts and routed both runner.ts emit sites through it; (4) updated architecture.md. Test count went from 1553 → 1571 (+18 new tests across session-approval.test.ts, session-rules.test.ts, and helpers.test.ts). Pre-completion reviewer: PASS.

Observations

  • The plan's blast-radius estimate was accurate: the type-forced cutover (step 2) touched 5 producers + ~10 test files but was fully mechanical — no logic changes, just rename and constructor swap.
  • Three producer tests (external-directory.test.ts, path.test.ts, tool.test.ts) had assertions using the old toHaveProperty("pattern") shape on sessionApproval; updated to ?.surface / ?.representativePattern access which is clearer.
  • Four bash-external-directory.test.ts sites cast desc.sessionApproval as { patterns: string[] } — the Biome/ESLint noNonNullAssertion / non-nullable-type-assertion-style conflict forced an explicit if (!desc.sessionApproval) return guard (per AGENTS.md resolution).
  • The eslint-disable comment on matchedPattern ?? null was correctly omitted in buildDecisionEvent — with the narrowed Pick parameter type, ESLint no longer fires no-unnecessary-condition on that line.
  • Post-review cleanup: the phase-6 guard gateResult.action === "allow" && hasSessionApproval had a redundant term since hasSessionApproval already implies the action check; simplified to if (hasSessionApproval && descriptor.sessionApproval).
  • fallow health --targets confirms runner.ts is no longer in the refactoring targets list; 4 → 3 targets remaining.

Stage: Final Retrospective (2026-05-31T03:00:00Z)

Session summary

Shipped #287 end-to-end across planning, TDD, and ship stages: a SessionApproval value object, SessionRules.record(approval), and a buildDecisionEvent helper that together thinned runGateCheck and dropped runner.ts from the refactoring-target list (4 → 3). Released as pi-permission-system-v8.2.0; +18 tests (1553 → 1571); pre-completion reviewer PASS. The defining event was a planning-stage design correction: the agent first planned the issue's literal "extract three helpers" before the user's four Socratic questions surfaced that it was procedure-splitting.

Observations

What went well

  • The type-forced cutover (TDD step 2) touched ~17 files in a single commit and compiled/passed essentially first try, because the planning stage had mapped every call site (grep for approveSessionRule / sessionApproval / SessionRules.approve across src and test) before writing the plan. Thorough call-site mapping during planning is what made a 17-file reshape mechanical rather than iterative.
  • The lift-and-shift decision to keep SessionRules.approve(surface, pattern) as an internal primitive (adding record(approval) alongside) meant session-rules.test.ts was never rewritten — only extended.
  • The Biome/ESLint !-vs-as conflict on the four bash-external-directory.test.ts cast sites was recognized as the documented AGENTS.md conflict and fixed with the prescribed if (!x) return guard — the rule worked without user intervention.
  • Verification ran incrementally (check / lint / test after each TDD step, plus a scoped grep "error TS" to bound the cutover), not just at the end.

What caused friction (agent side)

  • instruction-violation (user-caught) — the planning stage did not load code-design or design-review before evaluating the issue's proposed approach, despite the plan-issue prompt's "Load skills" section listing both. It planned the issue's literal "extract three helpers," wrote the full plan to disk, and only the user's four Socratic interventions ("they have side effects?" → "pushing dirt around, what's the missing collaborator?" → "where's the stateful object?" → "a function in a class trenchcoat") surfaced that the decomposition was procedure-splitting. Impact: first plan draft discarded and rewritten; issue #287 amended (title + body); four rounds of planning-conversation rework — but entirely pre-code, so zero implementation churn.
  • wrong-abstraction — within the wrong frame, the first ask_user (entry 10) asked about helper visibility (export vs. private) before establishing whether the helpers should exist at all. Impact: one wasted decision-gate round; folded into the larger redesign above.
  • The design-review skill's load trigger ("if the plan adds fields to shared interfaces or touches wiring between layers") is chicken-and-egg: the first (wrong) plan touched no wiring, so the condition could not fire; only the correct design reshaped GateRunnerDeps / PermissionSession / SessionRules. The trigger gates on a plan property that only becomes true after the design judgment that needs the skill.

What caused friction (user side)

  • The user carried the entire design correction through four rounds of Socratic questioning. This worked well and the converged design is genuinely better, but it was the user doing the design thinking the planning stage is meant to do. Opportunity: the same outcome is reachable agent-side by loading code-design and testing the issue's proposed decomposition against its Law-of-Demeter / Tell-Don't-Ask heuristics before writing the plan.

Diagnostic details

  • Model-performance correlation — Planning ran on claude-opus-4-8 (appropriate for the judgment-heavy redesign); TDD on claude-sonnet-4-6 (appropriate); Ship on opencode-go/deepseek-v4-flash (mechanical git/CI/release steps — appropriate low-cost match, executed cleanly). No quality mismatch: the design judgment that faltered was on the high-capability model, so the miss was a skill-loading gap, not a model-capability gap.
  • Feedback-loop gap analysis — No gaps; check/lint/test ran after each step, and a scoped grep "error TS" (entries 6869) bounded the cutover before editing. No rabbit-hole sequences (longest same-file run was reading large test files in chunks, not error-thrashing).

Changes made

  1. .pi/prompts/plan-issue.md ("Decide" section) — added a "treat the issue's Proposed change as a hypothesis, not a spec" rule that names the procedure-splitting anti-pattern and requires verifying each prescribed extraction returns a value, owns state, or gives behavior to data (against code-design) before planning around it.
  2. .pi/prompts/plan-issue.md ("Load skills" section) — reworded the design-review load trigger to fire for any refactor/extraction/shared-interface/layer-wiring change judged from the issue, not from a plan that already shows wiring changes (fixes the chicken-and-egg trigger).