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

19 KiB
Raw Blame History

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 sessionApproval is 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 (the source: "session" fast path via getRuleset()) and mutated by approve. Phase 6 reaches into the descriptor union and dribbles patterns into it one scalar at a time through deps.approveSessionRule(surface, pattern). That is Ask: the runner is doing the store's bookkeeping for it.
  • The emitDecision payload 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 repeated origin / agentName / matchedPattern ?? null normalization.

This is Phase 2 step 3 of the improvement roadmap in docs/architecture/architecture.md.

Goals

  • Introduce a SessionApproval value object that owns the single-vs-multi-pattern union and exposes behavior: surface, patterns, representativePattern (for the prompt), and toGateApproval() (the { surface; pattern } shape applyPermissionGate expects).
  • Tell the stateful store to record a whole approval: SessionRules.record(approval) loops the patterns internally; the GateRunnerDeps seam becomes recordSessionApproval(approval) instead of the scalar approveSessionRule(surface, pattern).
  • Extract a pure buildDecisionEvent(...) helper so the decision-event payload (and its null-normalization) has one home, used by both emit sites in runGateCheck.
  • As a consequence of the above, runGateCheck shrinks to a thin orchestration function — phase 3's nested ternary becomes descriptor.sessionApproval?.toGateApproval(), phase 6 becomes a single deps.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 use refactor:/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 } sessionApproval seam; the runner adapts to it via SessionApproval.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 fallow still flags runner.ts afterward.
  • 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.md data-flow diagram — runGateCheck remains a single node.

Background

Relevant existing modules:

  • src/handlers/gates/runner.tsrunGateCheck(descriptor, agentName, toolCallId, deps): Promise<GateOutcome>; the orchestrator being thinned.
  • src/handlers/gates/descriptor.tsGateDescriptor.sessionApproval?: { surface; pattern } | { surface; patterns } (the union to replace) and GateRunnerDeps.approveSessionRule(surface, pattern) (the scalar sink to reshape).
  • src/session-rules.tsSessionRules (the stateful store): approve(surface, pattern), getRuleset(), clear(); also exports deriveApprovalPattern. The new SessionApproval value object will live in its own module so both SessionRules and the gates layer can import it without a cycle.
  • src/permission-session.tsPermissionSession.approveSessionRule(surface, pattern) delegates to sessionRules.approve; getSessionRuleset() delegates to getRuleset().
  • src/handlers/permission-gate-handler.ts — builds GateRunnerDeps once (lines 101109), wiring approveSessionRule to this.session.approveSessionRule.
  • src/permission-gate.tsapplyPermissionGate; PermissionGateParams.sessionApproval?: { surface; pattern } and PermissionGateResult echo a single pattern. Unchanged.
  • The five producers that build sessionApproval: tool.ts, path.ts, external-directory.ts, bash-path.ts (single { surface; pattern }), and bash-external-directory.ts (multi { surface; patterns }).
  • src/handlers/gates/helpers.ts — existing pure helpers deriveResolution, deriveDecisionValue; the new buildDecisionEvent belongs here (pure, no deps).

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 noNonNullAssertion bans x!; prefer explicit guards. representativePattern returns string | undefined and 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: replace approveSessionRule(surface, pattern): void with recordSessionApproval(approval: SessionApproval): void.
  • PermissionSession: replace approveSessionRule(surface, pattern) with recordSessionApproval(approval): void { this.sessionRules.record(approval); }.
  • permission-gate-handler.ts: the deps closure becomes recordSessionApproval: (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; representativePattern is the first path's pattern (matches old patterns[0]); record approves all patterns.
  • Single-pattern producers → SessionApproval.single; one pattern recorded.
  • No descriptor.sessionApprovaltoGateApproval() never called, phase-6 guard skips the tell.
  • Empty patterns is unreachable (producers always supply ≥1; bash-external-directory returns a bypass before an empty patterns), but representativePattern/toGateApproval degrade to undefined safely.

Module-Level Changes

  • src/session-approval.ts (new): SessionApproval value object.
  • src/session-rules.ts: import SessionApproval; add record(approval); keep approve(surface, pattern) as the internal primitive.
  • src/permission-session.ts: replace approveSessionRule(surface, pattern) with recordSessionApproval(approval).
  • src/handlers/gates/descriptor.ts: sessionApproval?: SessionApproval; GateRunnerDeps.approveSessionRulerecordSessionApproval(approval).
  • src/handlers/gates/helpers.ts: add buildDecisionEvent (import PermissionDecisionEvent, PermissionDecisionResolution, PermissionCheckResult).
  • src/handlers/gates/runner.ts: phase 3 uses toGateApproval(); phases 2 & 5 use buildDecisionEvent; phase 6 is a single recordSessionApproval tell; the singleSessionApproval ternary and the phase-6 "patterns" in loop are deleted.
  • src/handlers/gates/tool.ts, path.ts, external-directory.ts, bash-path.ts: build SessionApproval.single(surface, pattern).
  • src/handlers/gates/bash-external-directory.ts: build SessionApproval.multiple("external_directory", patterns).
  • src/handlers/permission-gate-handler.ts: deps closure recordSessionApproval wired to this.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 declare approveSessionRule: vi.fn().
  • docs/architecture/architecture.md: mark Phase 2 step 3 done, finding #3, add session-approval.ts to the module tree, refresh refactoring-targets count and runner.ts complexity after re-running fallow 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

  1. New unit tests enabled.
    • test/session-approval.test.ts (new): single/multiple factories, representativePattern (first pattern, undefined when empty), toGateApproval (shape and undefined case).
    • test/session-rules.test.ts: add record(approval) fan-out cases (single pattern → one rule; multi-pattern → one rule per pattern) alongside the kept scalar approve cases.
  2. Tests that change shape (not removed).
    • runner.test.ts: the deps mock field approveSessionRulerecordSessionApproval; the "once per pattern" assertion becomes "called once with a SessionApproval carrying both patterns" (the loop moved into SessionRules, so the runner now makes one call); descriptor fixtures build SessionApproval.single/multiple.
    • permission-session.test.ts: the two delegation tests target recordSessionApproval(approval).
    • The five producer tests: sessionApproval expectations become SessionApproval instances.
    • Handler deps-mock files: rename the approveSessionRule: vi.fn() field; external-directory-session-dedup.test.ts's stateful mock records via record(approval).
  3. Tests that stay as-is.
    • session-rules.test.ts scalar approve cases (the primitive is retained).
    • All runGateCheck behavioral 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.

  1. feat: Add src/session-approval.ts + test/session-approval.test.ts; add SessionRules.record(approval) + its session-rules.test.ts cases. 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.
  2. refactor: The cutover. Change GateDescriptor.sessionApproval to SessionApproval; migrate the five producers to SessionApproval.single/multiple; reshape GateRunnerDeps/PermissionSession (approveSessionRulerecordSessionApproval); rewire the permission-gate-handler.ts closure; update runGateCheck phase 3 (toGateApproval()) and phase 6 (single tell); update runner.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. Run pnpm --filter @gotgenes/pi-permission-system run test and … run check before committing. Commit: refactor: tell SessionRules to record a SessionApproval value object.
  3. feat: Add buildDecisionEvent to helpers.ts (+ a small unit test) and route both runGateCheck emit 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.
  4. docs: Update architecture.md — mark Phase 2 step 3 complete, finding #3, add session-approval.ts to the module tree, refresh metrics after fallow 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 representativePattern diverges from the old patterns[0]. Mitigation: representativePattern is defined as patterns[0]; session-approval.test.ts asserts it, and the multi-pattern runner.test.ts case still verifies all patterns are recorded.
  • Risk: dropping a recorded pattern in the move of the loop into SessionRules.record. Mitigation: record iterates approval.patterns; session-rules.test.ts asserts one rule per pattern, and the end-to-end external-directory-session-dedup.test.ts verifies dedup still works.
  • Risk: step 2 is large and a stale approveSessionRule reference or deps-mock slips through. Mitigation: grep for approveSessionRule reaches zero after step 2; pnpm check fails on any stale reference or mismatched mock shape.
  • Risk: SessionApproval in a new module creates an import cycle (session-rules.ts ↔ producers). Mitigation: session-approval.ts imports nothing from session-rules.ts; the dependency is one-way (session-rules.tssession-approval.ts), and producers import both leaf-ward.
  • Risk: fallow flags SessionApproval members or buildDecisionEvent as dead. Mitigation: representativePattern/toGateApproval are consumed by runGateCheck, patterns/surface by SessionRules.record, the factories by the producers, and buildDecisionEvent by 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 on preCheck/preResolved — deferred. It is value-returning and small; revisit only if fallow health --targets still flags runner.ts above the < 15 target after step 3.