Files
my-pi/pi-permission-system/docs/plans/0316-fold-build-forwarding-deps.md
T

15 KiB
Raw Blame History

issue, issue_title
issue issue_title
316 Fold PermissionPrompter.buildForwardingDeps() into the injected forwarder

Fold PermissionPrompter.buildForwardingDeps() into the injected forwarder

Problem Statement

PermissionPrompter re-synthesizes its own PermissionForwardingDeps bag (buildForwardingDeps()) solely to call confirmPermission(). This is the second independent construction of the same dependency set — index.ts already assembles one for the PermissionForwarder introduced in #315. The prompter's copy diverges subtly (shouldAutoApprove: () => false, a no-op writeDebugLog) and drags along a cluster of eslint-disable @typescript-eslint/unbound-method lines. It is a relay bag the prompter builds only to hand to a free function — anemic design with no owner.

This is step 2 of 3 in the forwarding lift-and-shift (#315 → #316 → #317).

315 has landed: PermissionForwarder exists in src/forwarded-permissions/permission-forwarder.ts with requestApproval() (currently unused by production) and processInbox(), plus a narrow InboxProcessor seam consumed by ForwardingManager

Goals

  • Inject the single PermissionForwarder into PermissionPrompter through a narrow ApprovalRequester interface exposing only requestApproval.
  • Replace the confirmPermission(ctx, …, this.buildForwardingDeps(), …) call with this.deps.forwarder.requestApproval(ctx, …).
  • Delete buildForwardingDeps(), the second PermissionForwardingDeps synthesis, and its eslint-disable unbound-method cluster.
  • Narrow PermissionPrompterDeps by removing the four fields that existed only to feed buildForwardingDeps() (subagentSessionsDir, forwardingDir, registry, requestPermissionDecisionFromUi).
  • Wire index.ts to inject the existing single forwarder into the prompter (no second forwarder, no second bag).
  • Behavior-preserving: this is a refactor:, not a feat:.

Non-Goals

  • Inlining the polling.ts free-function bodies as methods on PermissionForwarder or deleting the PermissionForwardingDeps interface — that is #317 (step 3 of 3).
  • Changing the forwarding wire protocol, request/response file shapes, or the UI dialog flow.
  • Touching ForwardingManager or its InboxProcessor seam (settled in #315).
  • Altering yolo-mode handling — it stays at the prompter level, evaluated before requestApproval is reached.

Background

Relevant modules:

  • src/permission-prompter.tsPermissionPrompter class, PermissionPrompterDeps, and the private buildForwardingDeps() being deleted.
  • src/forwarded-permissions/permission-forwarder.tsPermissionForwarder (already implements InboxProcessor); requestApproval(ctx, message, options?, forwarded?) already exists and delegates to confirmPermission. This is where the new ApprovalRequester seam belongs, mirroring the InboxProcessor convention established in #315.
  • src/index.ts — composition root; constructs the prompter (line ~52), then the forwardingDeps bag and forwarder (lines ~6476), then PermissionSession/ForwardingManager.
  • src/forwarded-permissions/polling.tsconfirmPermission() and the PermissionForwardingDeps interface (untouched this issue).
  • test/permission-prompter.test.ts — currently vi.mocks polling and asserts against mockConfirmPermission; must migrate to an injected forwarder mock.

Constraints from AGENTS.md / package skill:

  • The package is the sole authority for tool policy; this refactor must not alter any allow/deny/ask decision.
  • @typescript-eslint/require-await is enabled for src/; requestApproval already returns the delegated promise, so no async churn.
  • Markdown is enforced by rumdl (pnpm run lint:md), not markdownlint — the MDxxx IDs in conventions are for reference only (per #315 retro).
  • The seam type must be a narrow interface, never the concrete PermissionForwarder — concrete class types leak private fields into the structural checker and force test casts (code-design + design-review guidance, confirmed by #315's InboxProcessor win).

Design Overview

The ApprovalRequester seam

Define a one-method interface alongside InboxProcessor in permission-forwarder.ts and add it to the class's implements clause:

/**
 * Narrow seam describing what `PermissionPrompter` needs from the forwarder:
 * resolve a permission decision for the current context (prompt directly when
 * the session has UI, otherwise forward to the parent).
 */
export interface ApprovalRequester {
  requestApproval(
    ctx: ExtensionContext,
    message: string,
    options?: RequestPermissionOptions,
    forwarded?: ForwardedPromptDisplay,
  ): Promise<PermissionPromptDecision>;
}

export class PermissionForwarder implements InboxProcessor, ApprovalRequester {
  // unchanged
}

requestApproval already has exactly this signature, so the class body is unchanged — only the implements clause and the new interface declaration are added.

Prompter consumption (Tell-Don't-Ask call site)

PermissionPrompter depends on the seam, not the concrete forwarder:

export interface PermissionPrompterDeps {
  getConfig(): PermissionSystemExtensionConfig; // yolo-mode check
  writeReviewLog(event: string, details: Record<string, unknown>): void;
  events: PermissionEventBus; // permissions:ui_prompt broadcast
  forwarder: ApprovalRequester; // resolve the decision (UI or forwarded)
}

Inside prompt(), the confirmPermission(...) call becomes a tell:

const decision = await this.deps.forwarder.requestApproval(
  ctx,
  details.message,
  details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
  { source: uiPrompt.source, surface: uiPrompt.surface, value: uiPrompt.value },
);

The prompter no longer reaches into a bag — it tells the forwarder. PermissionPrompterDeps drops from 7 fields to 4, and every remaining field is read by prompt() directly (passes design-review check 1: every consumer uses every field).

Behavioral nuance: debug logging on the prompter's forwarding path

The deleted buildForwardingDeps() supplied a no-op writeDebugLog and shouldAutoApprove: () => false. The shared forwarder (built in index.ts) supplies the real runtime.writeDebugLog and the real yolo policy.

  • shouldAutoApproveconfirmPermission never calls it (only processForwardedPermissionRequests does, on its own deps), so sharing the real policy is inert on this path. Decision is unchanged.
  • writeDebugLogconfirmPermission's helpers (writeJsonFileAtomic, safeDeleteFile, etc.) pass deps.logger through, so the subagent forwarding path will now emit real debug-level log lines instead of swallowing them.

This is the intended convergence: the #315/#316 plan deferred "trace-level forwarding debug" as an open question, and consolidating onto one forwarder resolves it. The effect is strictly additive debug output on a path that previously logged nothing; no allow/deny/ask decision, review-log entry, or wire message changes. Flagged in Risks below.

Edge cases

  • Yolo-mode short-circuit stays ahead of requestApproval; the forwarder is never consulted when yoloMode is on (existing test coverage preserved).
  • sessionLabel and the display fields (source/surface/value) are relayed unchanged through the new call — the four positional arguments map 1:1 to the old confirmPermission call.

Module-Level Changes

  • src/forwarded-permissions/permission-forwarder.ts
    • Add export interface ApprovalRequester { requestApproval(...) } next to InboxProcessor.
    • Add ApprovalRequester to the PermissionForwarder implements clause.
    • No method-body changes.
  • src/permission-prompter.ts
    • Add forwarder: ApprovalRequester to PermissionPrompterDeps; remove subagentSessionsDir, forwardingDir, registry, requestPermissionDecisionFromUi.
    • Replace the confirmPermission(...) call in prompt() with this.deps.forwarder.requestApproval(...).
    • Delete the private buildForwardingDeps() method.
    • Remove now-unused imports: confirmPermission and PermissionForwardingDeps from ./forwarded-permissions/polling, ForwardedPermissionLogger from ./forwarded-permissions/io, SubagentSessionRegistry, and RequestPermissionOptions if no longer referenced (lint will confirm).
    • Add the ApprovalRequester type import from ./forwarded-permissions/permission-forwarder.
    • Update the PermissionPrompterDeps doc comment ("synthesises the PermissionForwardingDeps it needs internally" is no longer true).
  • src/index.ts
    • Construct forwardingDeps + forwarder before the prompter, then pass forwarder into new PermissionPrompter({ … }).
    • Remove the four dropped fields (subagentSessionsDir, forwardingDir, registry, requestPermissionDecisionFromUi) from the prompter's deps literal — TypeScript excess-property checking rejects them once the interface narrows, so this must land in the same commit.
    • forwardingDeps/forwarder remain (still consumed by ForwardingManager); no second forwarder.
  • test/permission-prompter.test.ts
    • Remove vi.mock("../src/forwarded-permissions/polling") and the hoisted mockConfirmPermission.
    • Add a hoisted mockRequestApproval and inject forwarder: { requestApproval: mockRequestApproval } via makeDeps.
    • Drop subagentSessionsDir/forwardingDir/requestPermissionDecisionFromUi from the makeDeps defaults.
    • Re-point every mockConfirmPermission assertion to mockRequestApproval; the argument matchers shift by one position (the deps bag argument is gone, so the matchers become (ctx, message, options, forwarded)).
    • Reset/seed mockRequestApproval in beforeEach.
  • packages/pi-permission-system/docs/architecture/permission-prompter.md
    • Update the PermissionPrompterDeps interface block (4 fields, add forwarder: ApprovalRequester).
    • Replace the "Relationship to PermissionForwardingDeps" section: the prompter no longer constructs a bag; it depends on the injected ApprovalRequester.
    • Refresh the "Wiring" note to show the forwarder injection.
  • packages/pi-permission-system/docs/architecture/architecture.md
    • Mark Phase 3 Step 3 (#316) with a past-tense outcome and forward reference to #317 (following the #315 status-convention precedent).
    • Update the Track-B roadmap row / Mermaid status node if it tracks per-step completion.

Test Impact Analysis

This is a seam swap, not a new extraction, so the test surface shifts rather than expands.

  1. New tests enabled — the prompter can now be tested against a trivially injected { requestApproval: vi.fn() } with no module mock. This removes the vi.mock("…/polling") indirection and makes the prompter's collaboration with the forwarder explicit and assertable (design-review check 6: mock depth drops, no casts).
  2. Tests simplified — all assertions migrate from mockConfirmPermission (module mock) to mockRequestApproval (injected mock); the deps-bag positional argument disappears, so matchers get simpler. No test is deleted — each still exercises a distinct prompter behavior (yolo short-circuit, waiting/approved/denied logging, UI-prompt emission, sessionLabel/display-field relay, forwarding path).
  3. Tests that stay as-istest/permission-forwarder.test.ts already covers requestApproval's delegation to confirmPermission (the layer being depended upon); it is untouched. test/composition-root.test.ts exercises real wiring and should stay green without edits (verify the forwarder-before-prompter reorder does not perturb it).

TDD Order

  1. Swap the prompter onto the injected ApprovalRequester seam (refactor:)
    • Test surface: test/permission-prompter.test.ts.
    • Red: migrate the suite to inject forwarder: { requestApproval: mockRequestApproval }, drop the polling module mock and the four removed deps, and re-point assertions to mockRequestApproval with the shifted argument positions. The suite fails to compile/run until production changes land.
    • Green: add ApprovalRequester to permission-forwarder.ts (+ implements), narrow PermissionPrompterDeps, replace the confirmPermission call with this.deps.forwarder.requestApproval, delete buildForwardingDeps() and its eslint-disable lines and now-unused imports, and update index.ts to construct the forwarder before the prompter and inject it (removing the four stale fields from the deps literal).
    • This is one atomic commit: narrowing the interface and removing buildForwardingDeps break index.ts (excess properties) and the test (missing forwarder) at the type level simultaneously, so production, wiring, and test migration cannot be separated.
    • Suggested message: refactor: inject forwarder into PermissionPrompter, delete buildForwardingDeps (#316)
  2. Update architecture docs (docs:)
    • Refresh docs/architecture/permission-prompter.md (deps interface, forwarder relationship, wiring) and mark Phase 3 Step 3 in docs/architecture/architecture.md.
    • Suggested message: docs: record forwarder injection into PermissionPrompter (#316)

Run after each step: pnpm --filter @gotgenes/pi-permission-system run check, run lint, run test, then pnpm fallow dead-code before handoff.

Risks and Mitigations

  • Debug-log behavior change on the forwarding path — the prompter's forwarding path gains real writeDebugLog output (was no-op). Mitigation: intended convergence (resolves the deferred debug open question); strictly additive debug-level output, no decision/log/wire change. Documented in Design Overview.
  • Argument-position drift in test assertions — removing the deps-bag positional argument shifts every toHaveBeenCalledWith matcher by one. Mitigation: migrate matchers mechanically and rely on check/test to catch any stale matcher; assert the exact 4-argument shape (ctx, message, options, forwarded).
  • index.ts ordering regression — the forwarder must exist before the prompter literal references it. Mitigation: reorder construction in the same commit; composition-root.test.ts verifies real wiring stays green.
  • Unused-import lint churn — removing buildForwardingDeps orphans several imports. Mitigation: run lint (eslint auto-detects) catches and the implementer prunes them in the same commit.

Open Questions

  • Whether to keep RequestPermissionOptions imported in permission-prompter.ts depends on whether the inline { sessionLabel } literal still references the type after the swap — defer to the type checker during implementation; prune if unused.
  • #317 will dismantle PermissionForwardingDeps and inline the polling.ts bodies as forwarder methods; nothing in this plan should pre-empt that (keep the delegation intact).