Files
my-pi/pi-permission-system/docs/plans/0368-config-modal-controller-reach-through.md

12 KiB
Raw Permalink Blame History

issue, issue_title
issue issue_title
368 Remove the `config-modal` controller reach-through

Remove the config-modal controller reach-through

Problem Statement

The show branch of the /permission-system command handler reaches through the controller dependency bag to two strangers in a single expression:

const rules = controller.permissionManager.getComposedConfigRules(
  controller.session.lastKnownActiveAgentName ?? undefined,
);

The command should not know that the active agent name lives on session.lastKnownActiveAgentName and that it must be threaded into permissionManager.getComposedConfigRules. That is a Law-of-Demeter violation: the handler talks to two collaborators it reaches through the controller bag. The same coupling keeps PermissionSession.lastKnownActiveAgentName alive only through object-literal wiring in the composition root, which is why fallow flags the getter as a false-positive unused member (carried today with a suppression).

This is Phase 5 Step 7 (Track D) of the pi-permission-system improvement roadmap. It is independent of all other tracks and touches only config-modal.ts and its composition-root wiring.

Goals

  • Collapse the controller's permissionManager + session fields into a single getActiveAgentConfigRules(): Ruleset accessor.
  • Wire that accessor in the composition root (index.ts) as a thin adapter closure, so the reach-through lives where both collaborators are already in scope.
  • Have the show handler issue a single tell (controller.getActiveAgentConfigRules()) instead of chaining through the bag.
  • Retire the fallow false-positive suppression on PermissionSession.lastKnownActiveAgentName now that it is consumed through a real closure body (a traced read) rather than object-literal wiring.
  • Mark Phase 5 Step 7 complete in docs/architecture/architecture.md.

This change is not breaking: PermissionSystemConfigController is a package-internal type, the wiring is internal to index.ts, and the observable behavior of /permission-system show is unchanged.

Non-Goals

  • No change to getComposedConfigRules on PermissionManager — its signature and behavior stay as-is.
  • No change to the lastKnownActiveAgentName getter itself — it stays on PermissionSession; only the suppression comment and its doc comment change.
  • No change to any other Phase 5 track (Steps 16).
  • No change to the show output format, the config summary, or rule-origin display.

Background

Relevant modules:

  • src/config-modal.ts — defines the package-internal PermissionSystemConfigController interface and the registerPermissionSystemCommand factory. The handleArgs function's show branch performs the reach-through.
  • src/index.ts (≈ line 113) — the composition root constructs permissionManager and session as locals, then passes them into the controller bag via registerPermissionSystemCommand(pi, { config, configPath, permissionManager, session }).
  • src/permission-manager.ts (≈ line 195) — getComposedConfigRules(agentName?: string): Ruleset returns the composed config-layer rules; it always returns a Ruleset (never undefined).
  • src/permission-session.ts (≈ line 153) — the lastKnownActiveAgentName getter carries a fallow-ignore-next-line unused-class-member suppression plus a comment explaining the object-literal-wiring blind spot.

Constraint from the package skill / retro 0341: fallow's blind spot is the object-literal wiring in index.ts — config-modal receives session as an object-literal property, not a traced positional argument, so fallow cannot see the getter being read. Moving the read into a real arrow-function body in index.ts (session.lastKnownActiveAgentName) makes it a directly traced property access, which is exactly the usage fallow can follow. This is what makes retiring the suppression safe.

Constraint from AGENTS.md / package skill: keep schema, example config, loader, and docs aligned — none of those are touched here (no config surface changes), but the architecture roadmap step must be marked complete in the same change.

Design Overview

Replace the two narrow collaborator references on the controller interface with a single value-returning accessor.

Before:

interface PermissionSystemConfigController {
  config: CommandConfigStore;
  configPath: string;
  permissionManager: { getComposedConfigRules(agentName?: string): Ruleset };
  session: { readonly lastKnownActiveAgentName: string | null };
}

After:

interface PermissionSystemConfigController {
  config: CommandConfigStore;
  configPath: string;
  /** Returns the composed config-layer ruleset for the active agent scope. */
  getActiveAgentConfigRules(): Ruleset;
}

The show branch becomes a single tell:

if (normalized === "show") {
  const rules = controller.getActiveAgentConfigRules();
  ctx.ui.notify(
    `permission-system: ${summarizeConfig(controller.config.current(), rules)}`,
    "info",
  );
  return true;
}

Composition-root wiring (index.ts) — the reach-through collapses into a thin adapter closure where both locals are already in scope:

registerPermissionSystemCommand(pi, {
  config: configStore,
  configPath,
  getActiveAgentConfigRules: () =>
    permissionManager.getComposedConfigRules(
      session.lastKnownActiveAgentName ?? undefined,
    ),
});

Design rationale:

  • The accessor returns a value (the Ruleset), so this is a genuine encapsulation of a query, not procedure-splitting — it removes a Law-of-Demeter reach-through and gives the handler one collaborator to tell.
  • Field count on PermissionSystemConfigController drops from 4 to 3; two fields that always travelled together (permissionManager + session, used only to compute one ruleset) collapse into the one query the handler actually needs (ISP).
  • getComposedConfigRules always returns a Ruleset, so getActiveAgentConfigRules() always returns a defined Ruleset (possibly empty). summarizeConfig already handles an empty ruleset via formatRulesSummary returning "", so the existing "omit rule summary when no config rules" behavior is preserved without any optionality.

Edge cases:

  • Empty ruleset → formatRulesSummary returns "", summary shows knobs only (unchanged).
  • lastKnownActiveAgentName is null → coalesced to undefined, passed to getComposedConfigRules (unchanged — this logic simply moves from the handler into the closure).

Module-Level Changes

  • src/config-modal.ts
    • Replace the permissionManager and session fields on PermissionSystemConfigController with a single getActiveAgentConfigRules(): Ruleset method.
    • Update the show branch in handleArgs to call controller.getActiveAgentConfigRules().
    • The Ruleset import stays (still referenced by getActiveAgentConfigRules and formatRulesSummary).
  • src/index.ts
    • Change the registerPermissionSystemCommand call site to pass getActiveAgentConfigRules: () => permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined) in place of the permissionManager and session properties.
  • src/permission-session.ts
    • Remove the fallow-ignore-next-line unused-class-member suppression on the lastKnownActiveAgentName getter.
    • Update the preceding comment from "Read by config-modal (controller.session.lastKnownActiveAgentName)" to note it is read by the index.ts config-modal adapter closure.
  • test/config-modal.test.ts
    • Update all four controller literals: replace permissionManager: { getComposedConfigRules: () => ... } + session: { lastKnownActiveAgentName: null } with getActiveAgentConfigRules: () => ... (preserving each test's intended ruleset: [] as Ruleset or the composedRules fixture).
  • docs/architecture/architecture.md
    • Append ✓ complete to the Phase 5 Step 7 line (Track D, [#368]).
    • The metrics table row "config-modal controller reach-throughs" baseline 1 → target 0 is now met; leave the table as the historical baseline record (no edit needed beyond the step-complete marker, consistent with how prior steps were marked).

Test Impact Analysis

  1. New tests enabled by the change: none of substance. The new getActiveAgentConfigRules is a wiring closure in index.ts, not a new extracted module with independently testable logic. The existing config-modal.test.ts show-output tests already exercise the accessor seam (they inject the ruleset directly), so behavior remains covered at the same layer.
  2. Tests that become redundant: none. The two behavioral show-output tests (rule origins present / rule summary omitted) remain meaningful — they now drive the single accessor instead of the two-field bag.
  3. Tests that must stay as-is: test/permission-session.test.ts "exposes lastKnownActiveAgentName" genuinely exercises the getter on PermissionSession and is unaffected by the controller-interface change.

TDD Order

This is a behavior-preserving refactor whose interface change breaks index.ts and every config-modal.test.ts controller literal at the type level in the same commit, so it lands as one atomic step (per the AGENTS.md rule: removing/replacing interface fields with constructed call sites must update production wiring and consumer tests together).

  1. refactor: collapse config-modal controller reach-through into getActiveAgentConfigRules accessor (#368)
    • Test surface: test/config-modal.test.ts — update all four controller literals to the getActiveAgentConfigRules shape; the existing show-output assertions ("includes rule origins", "omits rule summary") are the behavior-preserving safety net and must continue to pass unchanged.
    • Production: replace the two controller fields with getActiveAgentConfigRules(): Ruleset in config-modal.ts; update the show branch to a single tell; move the reach-through into the adapter closure in index.ts; remove the fallow suppression and update the doc comment in permission-session.ts.
    • Verify: pnpm --filter @gotgenes/pi-permission-system run check, run lint, run test, and pnpm fallow dead-code (confirming lastKnownActiveAgentName is no longer reported now that the closure reads it directly).
  2. docs: mark Phase 5 Step 7 complete in architecture roadmap (#368)
    • Surface: docs/architecture/architecture.md — append ✓ complete to the Step 7 (Track D) line.
    • Commit separately so the doc-only change does not couple to the code commit's review; it touches an excluded path and does not trigger a release.

Risks and Mitigations

  • Risk: fallow still flags lastKnownActiveAgentName after the change (the retro 0341 attempt with a named interface did not satisfy fallow). Mitigation: the prior failure was object-literal wiring; this change makes session.lastKnownActiveAgentName a direct read in a real arrow-function body in index.ts, which fallow traces. The verify step in cycle 1 runs pnpm fallow dead-code before commit — if the getter is still flagged, restore a single justified suppression (with an updated rationale) rather than blocking, and note the residual blind spot in the retro.
  • Risk: a missed controller literal in test/config-modal.test.ts leaves a stale permissionManager/session shape. Mitigation: TypeScript's excess-property checking rejects the stale fields immediately at pnpm run check; all four literals are enumerated in Module-Level Changes.
  • Risk: silently changing show output when the ruleset is empty. Mitigation: getComposedConfigRules always returns a Ruleset and formatRulesSummary already returns "" for an empty config layer; the "omits rule summary" test guards this.

Open Questions

  • None blocking. The fallow outcome is the only thing to confirm empirically during cycle 1; the plan carries a documented fallback if the suppression cannot be fully retired.