19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 287 | Decompose runGateCheck in handlers/gates/runner.ts |
Give the session-approval data behavior and tell the session store to record it
Problem Statement
runGateCheck in src/handlers/gates/runner.ts is flagged by fallow health --targets (cognitive complexity 32, Phase 2 finding #3).
The issue originally proposed extracting three named phase helpers, but that is procedure-splitting: it moves statements into helpers and lowers the metric without improving the design.
The real smell is that runGateCheck does the work that belongs to two collaborators it talks to.
- The descriptor's
sessionApprovalis a raw{ surface; pattern } | { surface; patterns }union. The runner cracks it open twice — once in phase 3 with a nested ternary to fish out a representative pattern for the prompt, and again in phase 6 with an"patterns" in …branch to loop the patterns into the store. That polymorphic union with no behavior is a missing value object. - The session store (
SessionRules,src/session-rules.ts) is the genuinely stateful object here — it persists across the whole session, is queried on every check (thesource: "session"fast path viagetRuleset()) and mutated byapprove. Phase 6 reaches into the descriptor union and dribbles patterns into it one scalar at a time throughdeps.approveSessionRule(surface, pattern). That is Ask: the runner is doing the store's bookkeeping for it. - The
emitDecisionpayload is constructed in two places (the phase-2 session-hit path and the phase-5 gate-result path) with five of seven fields identical, including the repeatedorigin / agentName / matchedPattern ?? nullnormalization.
This is Phase 2 step 3 of the improvement roadmap in docs/architecture/architecture.md.
Goals
- Introduce a
SessionApprovalvalue object that owns the single-vs-multi-pattern union and exposes behavior:surface,patterns,representativePattern(for the prompt), andtoGateApproval()(the{ surface; pattern }shapeapplyPermissionGateexpects). - Tell the stateful store to record a whole approval:
SessionRules.record(approval)loops the patterns internally; theGateRunnerDepsseam becomesrecordSessionApproval(approval)instead of the scalarapproveSessionRule(surface, pattern). - Extract a pure
buildDecisionEvent(...)helper so the decision-event payload (and its null-normalization) has one home, used by both emit sites inrunGateCheck. - As a consequence of the above,
runGateCheckshrinks to a thin orchestration function — phase 3's nested ternary becomesdescriptor.sessionApproval?.toGateApproval(), phase 6 becomes a singledeps.recordSessionApproval(...)tell, and both emits go through the builder. The complexity drop is a side effect of better design, not procedure-splitting. - Behavior-preserving end-to-end: the same approvals are recorded and the same decision events are emitted.
- This is a breaking change to internal seams (
GateRunnerDeps,GateDescriptor.sessionApproval,PermissionSession,SessionRules) — all internal to the package, so no published API changes, but userefactor:/feat:with a clear body noting the seam reshape.
Non-Goals
- No change to permission semantics: resolution order (pre-check → pre-resolved →
checkPermission), session-hit short-circuit, the deny/ask/allow gate decision, resolution derivation, and which patterns get approved are all frozen. - No change to
applyPermissionGate/permission-gate.ts— it keeps its single{ surface; pattern }sessionApprovalseam; the runner adapts to it viaSessionApproval.toGateApproval(). - No extraction of phase 1 (check resolution) into a helper — it is a small inline value-producing branch and splitting it is the procedure-shuffling this plan rejects.
Listed in Open Questions if
fallowstill flagsrunner.tsafterward. - No change to the other Phase 2 targets:
resolvePermissions(#286, done),bash-path-extractor.ts(#289),stripJsonComments(#290), test-fixture dedup (#288). - No change to the
v3-architecture.mddata-flow diagram —runGateCheckremains a single node.
Background
Relevant existing modules:
src/handlers/gates/runner.ts—runGateCheck(descriptor, agentName, toolCallId, deps): Promise<GateOutcome>; the orchestrator being thinned.src/handlers/gates/descriptor.ts—GateDescriptor.sessionApproval?: { surface; pattern } | { surface; patterns }(the union to replace) andGateRunnerDeps.approveSessionRule(surface, pattern)(the scalar sink to reshape).src/session-rules.ts—SessionRules(the stateful store):approve(surface, pattern),getRuleset(),clear(); also exportsderiveApprovalPattern. The newSessionApprovalvalue object will live in its own module so bothSessionRulesand the gates layer can import it without a cycle.src/permission-session.ts—PermissionSession.approveSessionRule(surface, pattern)delegates tosessionRules.approve;getSessionRuleset()delegates togetRuleset().src/handlers/permission-gate-handler.ts— buildsGateRunnerDepsonce (lines 101–109), wiringapproveSessionRuletothis.session.approveSessionRule.src/permission-gate.ts—applyPermissionGate;PermissionGateParams.sessionApproval?: { surface; pattern }andPermissionGateResultecho a single pattern. Unchanged.- The five producers that build
sessionApproval:tool.ts,path.ts,external-directory.ts,bash-path.ts(single{ surface; pattern }), andbash-external-directory.ts(multi{ surface; patterns }). src/handlers/gates/helpers.ts— existing pure helpersderiveResolution,deriveDecisionValue; the newbuildDecisionEventbelongs here (pure, nodeps).
Constraints from AGENTS.md / the package skill that apply:
- Enforce permissions deterministically — recording the same patterns and emitting the same events must be preserved.
- Keep modules focused (one concern per file); import siblings via
#src//#test/aliases. - Every new export needs a consumer — fallow flags speculative re-exports as dead code.
- Biome
noNonNullAssertionbansx!; prefer explicit guards.representativePatternreturnsstring | undefinedand callers guard rather than assert. - ES2024 target —
for...of, spread, getters available.
Design Overview
New module: src/session-approval.ts
/** Value object for a session-scoped approval: one surface, one-or-more patterns. */
export class SessionApproval {
private constructor(
readonly surface: string,
readonly patterns: readonly string[],
) {}
static single(surface: string, pattern: string): SessionApproval {
return new SessionApproval(surface, [pattern]);
}
static multiple(surface: string, patterns: readonly string[]): SessionApproval {
return new SessionApproval(surface, [...patterns]);
}
/** Representative pattern for the interactive prompt — the first, if any. */
get representativePattern(): string | undefined {
return this.patterns[0];
}
/** Single-pattern shape applyPermissionGate echoes back; undefined when empty. */
toGateApproval(): { surface: string; pattern: string } | undefined {
const pattern = this.representativePattern;
return pattern === undefined ? undefined : { surface: this.surface, pattern };
}
}
This preserves the old phase-3 behavior exactly: patterns.length > 0 ? patterns[0] : undefined.
Stateful store: SessionRules.record
The store is told a whole approval and owns the loop (the bookkeeping that previously leaked into the runner).
The existing scalar approve(surface, pattern) stays as the internal primitive so session-rules.test.ts is not rewritten:
import { SessionApproval } from "./session-approval";
record(approval: SessionApproval): void {
for (const pattern of approval.patterns) {
this.approve(approval.surface, pattern);
}
}
Reshaped seams
GateDescriptor.sessionApproval?: SessionApproval.GateRunnerDeps: replaceapproveSessionRule(surface, pattern): voidwithrecordSessionApproval(approval: SessionApproval): void.PermissionSession: replaceapproveSessionRule(surface, pattern)withrecordSessionApproval(approval): void { this.sessionRules.record(approval); }.permission-gate-handler.ts: the deps closure becomesrecordSessionApproval: (approval) => this.session.recordSessionApproval(approval).
Pure builder: buildDecisionEvent (in helpers.ts)
export function buildDecisionEvent(
decision: { surface: string; value: string },
check: PermissionCheckResult,
agentName: string | null,
result: "allow" | "deny",
resolution: PermissionDecisionResolution,
): PermissionDecisionEvent {
return {
surface: decision.surface,
value: decision.value,
result,
resolution,
origin: check.origin ?? null,
agentName: agentName ?? null,
matchedPattern: check.matchedPattern ?? null,
};
}
Thinned runGateCheck (the consequence, not the goal)
// phase 1 (inline, unchanged): resolve `check` from preCheck / preResolved / checkPermission
// phase 2: session-hit fast path
if (check.source === "session") {
deps.writeReviewLog("permission_request.session_approved", { ...descriptor.logContext, agentName, resolution: "session_approved", sessionApprovalPattern: check.matchedPattern });
deps.emitDecision(buildDecisionEvent(descriptor.decision, check, agentName, "allow", "session_approved"));
return { action: "allow" };
}
// phase 3: gate — the nested ternary collapses
const gateResult = await applyPermissionGate({
state: check.state,
canConfirm,
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
promptForApproval: async () => { /* unchanged; sets autoApproved */ },
writeLog: deps.writeReviewLog,
logContext: { ...descriptor.logContext, agentName },
messages,
});
// phase 4 (unchanged): hasSessionApproval = action === "allow" && gateResult.sessionApproval !== undefined
// phase 5: single emit through the builder
deps.emitDecision(buildDecisionEvent(descriptor.decision, check, agentName,
gateResult.action === "allow" ? "allow" : "deny",
deriveResolution(check.state, gateResult.action, hasSessionApproval, canConfirm, autoApproved)));
// phase 6: one tell — the union-cracking loop is gone
if (gateResult.action === "allow" && hasSessionApproval && descriptor.sessionApproval) {
deps.recordSessionApproval(descriptor.sessionApproval);
}
return gateResult.action === "block" ? { action: "block", reason: gateResult.reason } : { action: "allow" };
Tell-Don't-Ask: the runner no longer interrogates the approval union or dribbles patterns; it hands SessionApproval to the store.
Law of Demeter holds — it does not reach through the union's shape.
ISP: buildDecisionEvent takes only decision, check, agentName plus the two varying fields — no unused descriptor fields.
Edge cases (all preserved)
- Multi-pattern (
bash-external-directory) →SessionApproval.multiple;representativePatternis the first path's pattern (matches oldpatterns[0]);recordapproves all patterns. - Single-pattern producers →
SessionApproval.single; one pattern recorded. - No
descriptor.sessionApproval→toGateApproval()never called, phase-6 guard skips the tell. - Empty patterns is unreachable (producers always supply ≥1;
bash-external-directoryreturns a bypass before an emptypatterns), butrepresentativePattern/toGateApprovaldegrade toundefinedsafely.
Module-Level Changes
src/session-approval.ts(new):SessionApprovalvalue object.src/session-rules.ts: importSessionApproval; addrecord(approval); keepapprove(surface, pattern)as the internal primitive.src/permission-session.ts: replaceapproveSessionRule(surface, pattern)withrecordSessionApproval(approval).src/handlers/gates/descriptor.ts:sessionApproval?: SessionApproval;GateRunnerDeps.approveSessionRule→recordSessionApproval(approval).src/handlers/gates/helpers.ts: addbuildDecisionEvent(importPermissionDecisionEvent,PermissionDecisionResolution,PermissionCheckResult).src/handlers/gates/runner.ts: phase 3 usestoGateApproval(); phases 2 & 5 usebuildDecisionEvent; phase 6 is a singlerecordSessionApprovaltell; thesingleSessionApprovalternary and the phase-6"patterns" inloop are deleted.src/handlers/gates/tool.ts,path.ts,external-directory.ts,bash-path.ts: buildSessionApproval.single(surface, pattern).src/handlers/gates/bash-external-directory.ts: buildSessionApproval.multiple("external_directory", patterns).src/handlers/permission-gate-handler.ts: deps closurerecordSessionApprovalwired tothis.session.recordSessionApproval.- Tests (see Test Impact):
test/session-rules.test.ts,test/permission-session.test.ts,test/handlers/gates/runner.test.ts, the five producer tests, and the handler deps-mock files (input.test.ts,tool-call.test.ts,tool-call-events.test.ts,input-events.test.ts,external-directory-integration.test.ts,external-directory-session-dedup.test.ts) that declareapproveSessionRule: vi.fn(). docs/architecture/architecture.md: mark Phase 2 step 3 done, ✅ finding #3, addsession-approval.tsto the module tree, refresh refactoring-targets count andrunner.tscomplexity after re-runningfallow health --targets..pi/skills/package-pi-permission-system/SKILL.md: no documented symbol is removed — no change.
No file in Module-Level Changes is claimed unchanged in Non-Goals (permission-gate.ts and v3-architecture.md are the only "unchanged" claims, and neither appears above).
Test Impact Analysis
- New unit tests enabled.
test/session-approval.test.ts(new):single/multiplefactories,representativePattern(first pattern,undefinedwhen empty),toGateApproval(shape andundefinedcase).test/session-rules.test.ts: addrecord(approval)fan-out cases (single pattern → one rule; multi-pattern → one rule per pattern) alongside the kept scalarapprovecases.
- Tests that change shape (not removed).
runner.test.ts: the deps mock fieldapproveSessionRule→recordSessionApproval; the "once per pattern" assertion becomes "called once with aSessionApprovalcarrying both patterns" (the loop moved intoSessionRules, so the runner now makes one call); descriptor fixtures buildSessionApproval.single/multiple.permission-session.test.ts: the two delegation tests targetrecordSessionApproval(approval).- The five producer tests:
sessionApprovalexpectations becomeSessionApprovalinstances. - Handler deps-mock files: rename the
approveSessionRule: vi.fn()field;external-directory-session-dedup.test.ts's stateful mock records viarecord(approval).
- Tests that stay as-is.
session-rules.test.tsscalarapprovecases (the primitive is retained).- All
runGateCheckbehavioral cases keep their resolution/emit assertions — they are the behavior-preservation net; only the recording-call shape updates.
TDD Order
Lift-and-shift: introduce the value object and store method additively first, then do the type-forced cutover, then dedup the emit.
feat:Addsrc/session-approval.ts+test/session-approval.test.ts; addSessionRules.record(approval)+ itssession-rules.test.tscases. Purely additive, no consumers yet. Red→green within the step (new tests fail until the module/method exist). Commit:feat: add SessionApproval value object and SessionRules.record.refactor:The cutover. ChangeGateDescriptor.sessionApprovaltoSessionApproval; migrate the five producers toSessionApproval.single/multiple; reshapeGateRunnerDeps/PermissionSession(approveSessionRule→recordSessionApproval); rewire thepermission-gate-handler.tsclosure; updaterunGateCheckphase 3 (toGateApproval()) and phase 6 (single tell); updaterunner.test.ts,permission-session.test.ts, the producer tests, and the handler deps-mocks in the same commit. These cannot be split — the descriptor type change and the deps reshape break every producer, the runner, and every deps-mock at the type level simultaneously (excess/missing property errors). Green: full suite passes; behavior unchanged. Runpnpm --filter @gotgenes/pi-permission-system run testand… run checkbefore committing. Commit:refactor: tell SessionRules to record a SessionApproval value object.feat:AddbuildDecisionEventtohelpers.ts(+ a small unit test) and route bothrunGateCheckemit sites through it; delete the duplicated payload construction. Independent of step 2's seam reshape (can also land before it). Green. Commit:feat: centralize decision-event construction in buildDecisionEvent.docs:Updatearchitecture.md— mark Phase 2 step 3 complete, ✅ finding #3, addsession-approval.tsto the module tree, refresh metrics afterfallow health --targets. Commit:docs: mark Phase 2 step 3 complete in permission-system roadmap.
Step 2 is the only large commit; it is type-forced and the producer/test edits are mechanical.
The SessionRules scalar primitive is retained so session-rules.test.ts is not rewritten.
Risks and Mitigations
- Risk: the multi-pattern
representativePatterndiverges from the oldpatterns[0]. Mitigation:representativePatternis defined aspatterns[0];session-approval.test.tsasserts it, and the multi-patternrunner.test.tscase still verifies all patterns are recorded. - Risk: dropping a recorded pattern in the move of the loop into
SessionRules.record. Mitigation:recorditeratesapproval.patterns;session-rules.test.tsasserts one rule per pattern, and the end-to-endexternal-directory-session-dedup.test.tsverifies dedup still works. - Risk: step 2 is large and a stale
approveSessionRulereference or deps-mock slips through. Mitigation:grepforapproveSessionRulereaches zero after step 2;pnpm checkfails on any stale reference or mismatched mock shape. - Risk:
SessionApprovalin a new module creates an import cycle (session-rules.ts↔ producers). Mitigation:session-approval.tsimports nothing fromsession-rules.ts; the dependency is one-way (session-rules.ts→session-approval.ts), and producers import both leaf-ward. - Risk: fallow flags
SessionApprovalmembers orbuildDecisionEventas dead. Mitigation:representativePattern/toGateApprovalare consumed byrunGateCheck,patterns/surfacebySessionRules.record, the factories by the producers, andbuildDecisionEventby both emit sites — each has a real consumer.
Open Questions
- Whether to also lift phase 1's check resolution onto the descriptor (e.g.
descriptor.resolveCheck(deps)) so the runner stops branching onpreCheck/preResolved— deferred. It is value-returning and small; revisit only iffallow health --targetsstill flagsrunner.tsabove the< 15target after step 3.