Files
my-pi/pi-permission-system/docs/plans/0322-extract-decision-reporter.md

18 KiB

issue, issue_title
issue issue_title
322 Extract a DecisionReporter for permission gate review-log and decision events

Extract a DecisionReporter for permission gate review-log and decision events

Problem Statement

In PermissionGateHandler.handleToolCall, two of the GateRunnerDeps closures report a gate's outcome and always travel together:

const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
  emitDecisionEvent(this.events, e);
// eslint-disable-next-line @typescript-eslint/unbound-method
const writeReviewLog = this.session.logger.review;

writeReviewLog reaches through the session to logger.review — a Law-of-Demeter violation that also forces the unbound-method disable. emitDecision wraps the event bus. Together they form one cohesive role — "report the permission outcome to the review log and the decision channel" — that has no home object today. The runner fires both on the session-hit path, the final decision path, and (via applyPermissionGate's writeLog) the prompt path; the handler's bypass branch fires them too, and handleInput repeats the same reach-through and bus-wrap for the /skill: path.

This is the second step of the gate-runner collaborator rework: #319 (landed) collapsed the checkPermission + getSessionRuleset relay into PermissionResolver; this issue extracts the reporter role; #323 replaces GateRunnerDeps with a GateRunner class injected with the role collaborators (resolver, recorder, prompter, reporter); and #325 — the phase capstone — retypes PermissionGateHandler against the resulting narrow role interfaces (PermissionResolver, DecisionReporter, GatePrompter, SessionApprovalRecorder) instead of the concrete PermissionSession, dropping the as unknown as PermissionSession test casts. The DecisionReporter interface this plan introduces is one of the four roles #325 consumes, so the architecture doc must thread #325 into the same decomposition chain even though its residual-cluster decomposition is still nebulous.

Goals

  • Define a narrow DecisionReporter interface: writeReviewLog(event, details) and emitDecision(event).
  • Add a GateDecisionReporter class that owns the SessionLogger and the event bus and implements the interface (emitDecision delegates to emitDecisionEvent).
  • Build it once in PermissionGateHandler's constructor from the session's logger and the event bus.
  • Carry the reporter as a single named role in GateRunnerDeps (replacing the inline writeReviewLog + emitDecision members) and use it on the runner's three fire sites and the handler's bypass branch.
  • Route handleInput through the same reporter instance, removing its duplicate reach-through and bus-wrap.
  • Delete the writeReviewLog/emitDecision closures, the two unbound-method eslint-disables on this.session.logger.review, and the handler's emitDecisionEvent import.
  • Keep the change behavior-preserving.

Non-Goals

  • Replacing GateRunnerDeps with a GateRunner class injected with role collaborators — that is #323.
  • Changing any permission decision, log entry, or decision-event payload (PermissionDecisionEvent shape is untouched).
  • Touching emitDecisionEvent itself or the permissions:decision channel — the reporter wraps the existing primitive.
  • Adding a DecisionReporter to PermissionPrompter, permission-event-rpc, or the forwarder — those carry their own unrelated writeReviewLog fields and stay as-is.

Background

  • src/handlers/permission-gate-handler.ts builds the emitDecision/writeReviewLog closures per handleToolCall, packs them into the GateRunnerDeps bag, and fires them directly in the bypass branch (runGate). handleInput independently calls emitDecisionEvent(this.events, {...}) and passes writeLog: this.session.logger.review (its own unbound-method disable) to applyPermissionGate.
  • src/handlers/gates/descriptor.ts declares interface GateRunnerDeps extends PermissionResolver with inline writeReviewLog(event, details) and emitDecision(event) members.
  • src/handlers/gates/runner.ts (runGateCheck) fires deps.writeReviewLog (session-hit path + applyPermissionGate's writeLog, the latter with an unbound-method disable) and deps.emitDecision (session-hit path + final decision).
  • src/permission-events.ts exports emitDecisionEvent(events, event) — a try/catch wrapper over events.emit(PERMISSIONS_DECISION_CHANNEL, event) that swallows listener throws.
  • src/session-logger.ts exposes SessionLogger.review(event, details?); PermissionSession exposes it as a public readonly logger: SessionLogger.
  • src/permission-resolver.ts is the precedent from #319: a narrow role module the gates and runner depend on. src/permission-prompter.ts is the precedent for co-locating a role interface and its implementing class in one module.

Constraint from AGENTS.md / code-design: keep the new module a pure role (no Pi SDK imports); when a shared interface references a collaborator, type it as the narrow interface, not the concrete class. Removing the two inline members from GateRunnerDeps breaks every consumer at the type level in one commit — the descriptor, runner, handler, fixture, and runner test must move together (see TDD Order).

Design Overview

One new role module co-locating the interface and its implementation, mirroring permission-prompter.ts:

// src/decision-reporter.ts
import {
  emitDecisionEvent,
  type PermissionDecisionEvent,
  type PermissionEventBus,
} from "./permission-events";
import type { SessionLogger } from "./session-logger";

/**
 * Reports a permission gate's outcome to the review log and the decision
 * channel. Groups the two side effects that always travel together.
 */
export interface DecisionReporter {
  writeReviewLog(event: string, details: Record<string, unknown>): void;
  emitDecision(event: PermissionDecisionEvent): void;
}

/**
 * Owns the SessionLogger and the event bus; answers "who owns the event bus"
 * — the reporter does, not the session.
 */
export class GateDecisionReporter implements DecisionReporter {
  constructor(
    private readonly logger: SessionLogger,
    private readonly events: PermissionEventBus,
  ) {}

  writeReviewLog(event: string, details: Record<string, unknown>): void {
    this.logger.review(event, details);
  }

  emitDecision(event: PermissionDecisionEvent): void {
    emitDecisionEvent(this.events, event);
  }
}

The handler builds it once in the constructor and exposes it as a DecisionReporter:

private readonly reporter: DecisionReporter;
constructor(
  private readonly session: PermissionSession,
  private readonly events: PermissionEventBus,
  private readonly toolRegistry: ToolRegistry,
  private readonly customFormatters?: ToolInputFormatterLookup,
) {
  this.reporter = new GateDecisionReporter(session.logger, events);
}

session.logger is read once at construction to pass the logger as a dependency — not invoked two hops deep at gate time, so the gate-time reach-through is gone.

GateRunnerDeps carries the reporter as one named role instead of two inline methods:

export interface GateRunnerDeps extends PermissionResolver {
  recordSessionApproval(approval: SessionApproval): void;
  reporter: DecisionReporter;
  canConfirm(): boolean;
  promptPermission(
    details: PromptPermissionDetails,
  ): Promise<PermissionPromptDecision>;
}

The runner fires through the reporter (deps.reporter.writeReviewLog(...), deps.reporter.emitDecision(...)); the one callback hand-off to applyPermissionGate becomes a plain closure, dropping the unbound-method disable:

writeLog: (event, details) => deps.reporter.writeReviewLog(event, details),

Why the bag, not a 5th runner parameter

runGateCheck(descriptor, agentName, toolCallId, deps) already separates stable collaborators (deps) from per-call "extemporaneous data" (descriptor, agentName, toolCallId). The reporter is a stable collaborator, so it joins the bag alongside resolve, recordSessionApproval, canConfirm, and promptPermission rather than becoming a fifth positional parameter. This is the deliberate intermediate: that the runner now juggles four stable role collaborators plus three per-call arguments is exactly the signal that it wants to be a class constructed with its roles — which is #323's GateRunner, where reporter becomes a constructor field (this.reporter.writeReviewLog) and deps dissolves entirely.

Design-review notes

  • Width: DecisionReporter has two methods; both consumers (runner, handler bypass/input) use both. No unused surface.
  • LoD: the gate-time this.session.logger.review reach-through is removed. The runner gains a mild deps.reporter.x() field-then-call on a parameter bag — transitional; #323 makes reporter a direct field of the GateRunner class. Track and watch, resolved by #323.
  • Intermediate abstraction: this extraction groups two cohesive side effects, reducing GateRunnerDeps' inline members by 2 (replaced by one field).

Edge cases

  • DecisionReporter.writeReviewLog requires details: Record<string, unknown> (matching the old GateRunnerDeps member); SessionLogger.review's details is optional, so the required→optional hand-off is sound.
  • handleInput adoption is byte-identical: this.reporter.emitDecision(event) is exactly emitDecisionEvent(this.events, event), and (e, d) => this.reporter.writeReviewLog(e, d) is exactly this.session.logger.review(e, d).
  • One reporter instance per handler is correct: logger and events are constructor deps, stable for the handler's lifetime, and the reporter holds no mutable state.

Module-Level Changes

  • src/decision-reporter.tsnew: DecisionReporter interface + GateDecisionReporter class.
  • src/handlers/gates/descriptor.tsGateRunnerDeps: remove the inline writeReviewLog and emitDecision members; add reporter: DecisionReporter; import the interface type.
  • src/handlers/gates/runner.ts — fire via deps.reporter.writeReviewLog(...) / deps.reporter.emitDecision(...) on all four sites; replace writeLog: deps.writeReviewLog (with its unbound-method disable) with the plain closure writeLog: (event, details) => deps.reporter.writeReviewLog(event, details).
  • src/handlers/permission-gate-handler.ts — build this.reporter = new GateDecisionReporter(session.logger, events) in the constructor; set reporter: this.reporter in the bag; remove the emitDecision/writeReviewLog closures (and their unbound-method disable); use this.reporter in the bypass branch; route handleInput through this.reporter.emitDecision({...}) and writeLog: (e, d) => this.reporter.writeReviewLog(e, d) (removing its unbound-method disable); drop the now-unused emitDecisionEvent import.
  • test/helpers/gate-fixtures.tsmakeRunnerDeps: replace writeReviewLog/emitDecision with reporter: { writeReviewLog: vi.fn(), emitDecision: vi.fn() } (optionally a makeReporter() helper).
  • test/handlers/gates/runner.test.ts — change the ~13 assertion sites from deps.writeReviewLog/deps.emitDecision to deps.reporter.writeReviewLog/deps.reporter.emitDecision.
  • test/decision-reporter.test.tsnew: direct unit tests for GateDecisionReporter.
  • docs/architecture/architecture.md — add decision-reporter.ts to the src/ file tree (after permission-resolver.ts); update the descriptor.ts tree line (GateRunnerDeps carries a DecisionReporter, no longer inlines writeReviewLog/emitDecision); mark the DecisionReporter portion of the Phase 3 Track C row (line ~788) and the Step 6 Outcome prose ; add a ✅ Extract DecisionReporter (#322) entry to the numbered improvement steps; update the Track C summary row. Extend the gate-runner decomposition chain to name #325 as the capstone everywhere it currently stops at #323 — the row 6 narrative, the Step 6 Outcome prose, the Track C summary, and the S6 Mermaid node (… → GateRunner (#323) → PermissionGateHandler role-interface retyping (#325)) — and add the missing [#325] link-reference definition.

No public export is removed or renamed: GateRunnerDeps stays exported (member swap only), emitDecisionEvent/SessionLogger.review remain. A grep confirms GateRunnerDeps["writeReviewLog"] / ["emitDecision"] are referenced only in permission-gate-handler.ts, descriptor.ts, runner.ts, and gate-fixtures.ts; the writeReviewLog fields on PermissionPrompterDeps, permission-event-rpc, the forwarder, and io.ts belong to separate interfaces and are out of scope. No package-*/SKILL.md references these members.

test/handlers/{tool-call,tool-call-events,input,input-events}.test.ts need no changes: they drive the handler through the real event bus (getDecisionEvents reads events.emit on the permissions:decision channel) and the session.logger.review mock, both of which the reporter routes through identically.

Test Impact Analysis

  1. New tests enabled — GateDecisionReporter is now unit-testable in isolation, which the anonymous handler closures never were: writeReviewLog delegates to logger.review(event, details); emitDecision delegates to emitDecisionEvent (emits event on PERMISSIONS_DECISION_CHANNEL); a throwing decision listener does not propagate (inherited from emitDecisionEvent's try/catch).
  2. Redundant/simplified tests — none become redundant. runner.test.ts keeps every assertion but reshapes the collaborator handle from two flat mocks to one grouped reporter mock that mirrors production structure.
  3. Tests that stay as-is — the four handler integration test files (they exercise the full gate/input paths through the real bus + logger mock); permission-events.test.ts's emitDecisionEvent tests (the underlying primitive the reporter wraps); every gate descriptor test (unaffected by the runner's collaborator shape).

TDD Order

  1. Add the DecisionReporter interface + GateDecisionReporter class with test/decision-reporter.test.ts. Surface: test/decision-reporter.test.ts. Covers: writeReviewLog delegates to logger.review; emitDecision emits on the decision channel; a throwing listener does not propagate. No consumers yet — repo stays green. Commit: feat: add DecisionReporter and GateDecisionReporter.
  2. Wire the reporter into the gate runner in one atomic commit (the interface member removal breaks all consumers at the type level): swap GateRunnerDeps members for reporter: DecisionReporter; fire via deps.reporter.* in runner.ts (dropping the unbound-method disable); build this.reporter in the handler constructor, set reporter in the bag, use it in the bypass branch, and remove the handler's emitDecision/writeReviewLog closures + disable; update makeRunnerDeps; reshape runner.test.ts assertions to deps.reporter.*. Surface: test/handlers/gates/runner.test.ts (+ existing handler tests stay green). Commit: refactor: report gate decisions through DecisionReporter.
  3. Route handleInput through this.reporter: replace emitDecisionEvent(this.events, {...}) with this.reporter.emitDecision({...}) and writeLog: this.session.logger.review with writeLog: (e, d) => this.reporter.writeReviewLog(e, d); remove the remaining unbound-method disable and the now-unused emitDecisionEvent import. Surface: existing test/handlers/{input,input-events}.test.ts (behavior-preserving — stay green). Commit: refactor: route handleInput review log and decision events through the reporter.
  4. Update docs/architecture/architecture.md (file tree entry, descriptor.ts tree line, Phase 3 Track C row + Step 6 Outcome, new #322 step entry, Track C summary, S6 Mermaid node) and thread #325 into the decomposition chain (row 6 narrative, Step 6 Outcome, Track C summary, S6 node) plus add its [#325] link reference. Commit: docs: record DecisionReporter extraction and the #325 capstone in the architecture roadmap.

Steps 1 and 3 are independently green; step 2 is the single mandated atomic commit where the GateRunnerDeps member swap ripples to the descriptor, runner, handler, fixture, and runner test together.

Risks and Mitigations

  • Interface member removal ripples to every GateRunnerDeps consumer in one commit. Mitigation: fold descriptor + runner + handler + gate-fixtures.ts + runner.test.ts into step 2, exactly as AGENTS.md prescribes for export/member removal.
  • Behavior drift in handleInput adoption (step 3). Mitigation: the reporter methods are byte-identical to the inlined calls; rely on the existing input/input-events tests staying green, and add no payload changes.
  • Mild deps.reporter.x() LoD reach introduced in the runner. Mitigation: transitional only; #323 dissolves the bag into a GateRunner class where reporter is a direct field. Track and watch.
  • makeRunnerDeps mock gains one level of nesting (reporter.writeReviewLog). Mitigation: it mirrors production structure and replaces two flat mocks with one grouped mock; a makeReporter() helper keeps call sites tidy.

Open Questions

  • The reporter's final home (a GateRunner constructor field) and the deletion of GateRunnerDeps are deferred to #323; this plan leaves the reporter inside the bag.
  • #325's residual-cluster decomposition (which narrow roles absorb activate, resolveAgentName, config, the infrastructure-path getters, getActiveSkillEntries, and createPermissionRequestId) is unresolved and out of scope here — this plan only adds the DecisionReporter role #325 will consume and names #325 in the roadmap.
  • Whether handleInput should eventually share more of the runner's decision-building (it currently hand-builds its PermissionDecisionEvent) is out of scope — only the emit/log side effects move to the reporter here.