9.9 KiB
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/recordSessionApprovalsare side-effect-only relocations), (2) exported helpers + unit tests (mock-call assertions duplicate the integration suite), and (3) aGateEvaluationcommand 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 isSessionApproval(the{ pattern } | { patterns }union interrogated in both phase 3 and phase 6). Tell-Don't-Ask = tell the store torecord(approval); let the value object own the union. - Key scope decision: this reshapes internal seams (
GateRunnerDeps.approveSessionRule→recordSessionApproval,GateDescriptor.sessionApproval→SessionApproval,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.tsdeliberately kept unchanged — it retains its single{ surface; pattern }seam and the runner adapts viaSessionApproval.toGateApproval(). This contains the blast radius.- Lift-and-shift chosen for the test churn: keep
SessionRules.approve(surface, pattern)as the internal primitive sosession-rules.test.tsis 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
fallowstill flagsrunner.tsafter 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 oldtoHaveProperty("pattern")shape onsessionApproval; updated to?.surface/?.representativePatternaccess which is clearer. - Four
bash-external-directory.test.tssites castdesc.sessionApproval as { patterns: string[] }— the Biome/ESLintnoNonNullAssertion/non-nullable-type-assertion-styleconflict forced an explicitif (!desc.sessionApproval) returnguard (per AGENTS.md resolution). - The
eslint-disablecomment onmatchedPattern ?? nullwas correctly omitted inbuildDecisionEvent— with the narrowedPickparameter type, ESLint no longer firesno-unnecessary-conditionon that line. - Post-review cleanup: the phase-6 guard
gateResult.action === "allow" && hasSessionApprovalhad a redundant term sincehasSessionApprovalalready implies the action check; simplified toif (hasSessionApproval && descriptor.sessionApproval). fallow health --targetsconfirmsrunner.tsis 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 (
grepforapproveSessionRule/sessionApproval/SessionRules.approveacrosssrcandtest) 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 (addingrecord(approval)alongside) meantsession-rules.test.tswas never rewritten — only extended. - The Biome/ESLint
!-vs-asconflict on the fourbash-external-directory.test.tscast sites was recognized as the documentedAGENTS.mdconflict and fixed with the prescribedif (!x) returnguard — the rule worked without user intervention. - Verification ran incrementally (
check/lint/testafter each TDD step, plus a scopedgrep "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 loadcode-designordesign-reviewbefore 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 firstask_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-reviewskill'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 reshapedGateRunnerDeps/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-designand 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 onclaude-sonnet-4-6(appropriate); Ship onopencode-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/testran after each step, and a scopedgrep "error TS"(entries 68–69) bounded the cutover before editing. Norabbit-holesequences (longest same-file run was reading large test files in chunks, not error-thrashing).
Changes made
.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 (againstcode-design) before planning around it..pi/prompts/plan-issue.md("Load skills" section) — reworded thedesign-reviewload 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).