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
DecisionReporterinterface:writeReviewLog(event, details)andemitDecision(event). - Add a
GateDecisionReporterclass that owns theSessionLoggerand the event bus and implements the interface (emitDecisiondelegates toemitDecisionEvent). - 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 inlinewriteReviewLog+emitDecisionmembers) and use it on the runner's three fire sites and the handler's bypass branch. - Route
handleInputthrough the same reporter instance, removing its duplicate reach-through and bus-wrap. - Delete the
writeReviewLog/emitDecisionclosures, the twounbound-methodeslint-disables onthis.session.logger.review, and the handler'semitDecisionEventimport. - Keep the change behavior-preserving.
Non-Goals
- Replacing
GateRunnerDepswith aGateRunnerclass injected with role collaborators — that is #323. - Changing any permission decision, log entry, or decision-event payload (
PermissionDecisionEventshape is untouched). - Touching
emitDecisionEventitself or thepermissions:decisionchannel — the reporter wraps the existing primitive. - Adding a
DecisionReportertoPermissionPrompter,permission-event-rpc, or the forwarder — those carry their own unrelatedwriteReviewLogfields and stay as-is.
Background
src/handlers/permission-gate-handler.tsbuilds theemitDecision/writeReviewLogclosures perhandleToolCall, packs them into theGateRunnerDepsbag, and fires them directly in the bypass branch (runGate).handleInputindependently callsemitDecisionEvent(this.events, {...})and passeswriteLog: this.session.logger.review(its ownunbound-methoddisable) toapplyPermissionGate.src/handlers/gates/descriptor.tsdeclaresinterface GateRunnerDeps extends PermissionResolverwith inlinewriteReviewLog(event, details)andemitDecision(event)members.src/handlers/gates/runner.ts(runGateCheck) firesdeps.writeReviewLog(session-hit path +applyPermissionGate'swriteLog, the latter with anunbound-methoddisable) anddeps.emitDecision(session-hit path + final decision).src/permission-events.tsexportsemitDecisionEvent(events, event)— a try/catch wrapper overevents.emit(PERMISSIONS_DECISION_CHANNEL, event)that swallows listener throws.src/session-logger.tsexposesSessionLogger.review(event, details?);PermissionSessionexposes it as a publicreadonly logger: SessionLogger.src/permission-resolver.tsis the precedent from #319: a narrow role module the gates and runner depend on.src/permission-prompter.tsis 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:
DecisionReporterhas two methods; both consumers (runner, handler bypass/input) use both. No unused surface. - LoD: the gate-time
this.session.logger.reviewreach-through is removed. The runner gains a milddeps.reporter.x()field-then-call on a parameter bag — transitional; #323 makesreportera direct field of theGateRunnerclass. 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.writeReviewLogrequiresdetails: Record<string, unknown>(matching the oldGateRunnerDepsmember);SessionLogger.review'sdetailsis optional, so the required→optional hand-off is sound.handleInputadoption is byte-identical:this.reporter.emitDecision(event)is exactlyemitDecisionEvent(this.events, event), and(e, d) => this.reporter.writeReviewLog(e, d)is exactlythis.session.logger.review(e, d).- One reporter instance per handler is correct:
loggerandeventsare constructor deps, stable for the handler's lifetime, and the reporter holds no mutable state.
Module-Level Changes
src/decision-reporter.ts— new:DecisionReporterinterface +GateDecisionReporterclass.src/handlers/gates/descriptor.ts—GateRunnerDeps: remove the inlinewriteReviewLogandemitDecisionmembers; addreporter: DecisionReporter; import the interface type.src/handlers/gates/runner.ts— fire viadeps.reporter.writeReviewLog(...)/deps.reporter.emitDecision(...)on all four sites; replacewriteLog: deps.writeReviewLog(with itsunbound-methoddisable) with the plain closurewriteLog: (event, details) => deps.reporter.writeReviewLog(event, details).src/handlers/permission-gate-handler.ts— buildthis.reporter = new GateDecisionReporter(session.logger, events)in the constructor; setreporter: this.reporterin the bag; remove theemitDecision/writeReviewLogclosures (and theirunbound-methoddisable); usethis.reporterin the bypass branch; routehandleInputthroughthis.reporter.emitDecision({...})andwriteLog: (e, d) => this.reporter.writeReviewLog(e, d)(removing itsunbound-methoddisable); drop the now-unusedemitDecisionEventimport.test/helpers/gate-fixtures.ts—makeRunnerDeps: replacewriteReviewLog/emitDecisionwithreporter: { writeReviewLog: vi.fn(), emitDecision: vi.fn() }(optionally amakeReporter()helper).test/handlers/gates/runner.test.ts— change the ~13 assertion sites fromdeps.writeReviewLog/deps.emitDecisiontodeps.reporter.writeReviewLog/deps.reporter.emitDecision.test/decision-reporter.test.ts— new: direct unit tests forGateDecisionReporter.docs/architecture/architecture.md— adddecision-reporter.tsto thesrc/file tree (afterpermission-resolver.ts); update thedescriptor.tstree line (GateRunnerDepscarries aDecisionReporter, no longer inlineswriteReviewLog/emitDecision); mark theDecisionReporterportion 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 theS6Mermaid 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
- New tests enabled —
GateDecisionReporteris now unit-testable in isolation, which the anonymous handler closures never were:writeReviewLogdelegates tologger.review(event, details);emitDecisiondelegates toemitDecisionEvent(emitseventonPERMISSIONS_DECISION_CHANNEL); a throwing decision listener does not propagate (inherited fromemitDecisionEvent's try/catch). - Redundant/simplified tests — none become redundant.
runner.test.tskeeps every assertion but reshapes the collaborator handle from two flat mocks to one groupedreportermock that mirrors production structure. - 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'semitDecisionEventtests (the underlying primitive the reporter wraps); every gate descriptor test (unaffected by the runner's collaborator shape).
TDD Order
- Add the
DecisionReporterinterface +GateDecisionReporterclass withtest/decision-reporter.test.ts. Surface:test/decision-reporter.test.ts. Covers:writeReviewLogdelegates tologger.review;emitDecisionemits on the decision channel; a throwing listener does not propagate. No consumers yet — repo stays green. Commit:feat: add DecisionReporter and GateDecisionReporter. - Wire the reporter into the gate runner in one atomic commit (the interface member removal breaks all consumers at the type level): swap
GateRunnerDepsmembers forreporter: DecisionReporter; fire viadeps.reporter.*inrunner.ts(dropping theunbound-methoddisable); buildthis.reporterin the handler constructor, setreporterin the bag, use it in the bypass branch, and remove the handler'semitDecision/writeReviewLogclosures + disable; updatemakeRunnerDeps; reshaperunner.test.tsassertions todeps.reporter.*. Surface:test/handlers/gates/runner.test.ts(+ existing handler tests stay green). Commit:refactor: report gate decisions through DecisionReporter. - Route
handleInputthroughthis.reporter: replaceemitDecisionEvent(this.events, {...})withthis.reporter.emitDecision({...})andwriteLog: this.session.logger.reviewwithwriteLog: (e, d) => this.reporter.writeReviewLog(e, d); remove the remainingunbound-methoddisable and the now-unusedemitDecisionEventimport. Surface: existingtest/handlers/{input,input-events}.test.ts(behavior-preserving — stay green). Commit:refactor: route handleInput review log and decision events through the reporter. - Update
docs/architecture/architecture.md(file tree entry,descriptor.tstree line, Phase 3 Track C row + Step 6 Outcome, new#322step entry, Track C summary,S6Mermaid node) and thread #325 into the decomposition chain (row 6 narrative, Step 6 Outcome, Track C summary,S6node) 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
GateRunnerDepsconsumer in one commit. Mitigation: fold descriptor + runner + handler +gate-fixtures.ts+runner.test.tsinto step 2, exactly as AGENTS.md prescribes for export/member removal. - Behavior drift in
handleInputadoption (step 3). Mitigation: the reporter methods are byte-identical to the inlined calls; rely on the existinginput/input-eventstests 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 aGateRunnerclass wherereporteris a direct field. Track and watch. makeRunnerDepsmock gains one level of nesting (reporter.writeReviewLog). Mitigation: it mirrors production structure and replaces two flat mocks with one grouped mock; amakeReporter()helper keeps call sites tidy.
Open Questions
- The reporter's final home (a
GateRunnerconstructor field) and the deletion ofGateRunnerDepsare 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, andcreatePermissionRequestId) is unresolved and out of scope here — this plan only adds theDecisionReporterrole #325 will consume and names #325 in the roadmap. - Whether
handleInputshould eventually share more of the runner's decision-building (it currently hand-builds itsPermissionDecisionEvent) is out of scope — only the emit/log side effects move to the reporter here.