12 KiB
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+sessionfields into a singlegetActiveAgentConfigRules(): Rulesetaccessor. - 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
showhandler issue a single tell (controller.getActiveAgentConfigRules()) instead of chaining through the bag. - Retire the
fallowfalse-positive suppression onPermissionSession.lastKnownActiveAgentNamenow 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
getComposedConfigRulesonPermissionManager— its signature and behavior stay as-is. - No change to the
lastKnownActiveAgentNamegetter itself — it stays onPermissionSession; only the suppression comment and its doc comment change. - No change to any other Phase 5 track (Steps 1–6).
- No change to the
showoutput format, the config summary, or rule-origin display.
Background
Relevant modules:
src/config-modal.ts— defines the package-internalPermissionSystemConfigControllerinterface and theregisterPermissionSystemCommandfactory. ThehandleArgsfunction'sshowbranch performs the reach-through.src/index.ts(≈ line 113) — the composition root constructspermissionManagerandsessionas locals, then passes them into the controller bag viaregisterPermissionSystemCommand(pi, { config, configPath, permissionManager, session }).src/permission-manager.ts(≈ line 195) —getComposedConfigRules(agentName?: string): Rulesetreturns the composed config-layer rules; it always returns aRuleset(neverundefined).src/permission-session.ts(≈ line 153) — thelastKnownActiveAgentNamegetter carries afallow-ignore-next-line unused-class-membersuppression 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
PermissionSystemConfigControllerdrops 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). getComposedConfigRulesalways returns aRuleset, sogetActiveAgentConfigRules()always returns a definedRuleset(possibly empty).summarizeConfigalready handles an empty ruleset viaformatRulesSummaryreturning"", so the existing "omit rule summary when no config rules" behavior is preserved without any optionality.
Edge cases:
- Empty ruleset →
formatRulesSummaryreturns"", summary shows knobs only (unchanged). lastKnownActiveAgentNameisnull→ coalesced toundefined, passed togetComposedConfigRules(unchanged — this logic simply moves from the handler into the closure).
Module-Level Changes
src/config-modal.ts- Replace the
permissionManagerandsessionfields onPermissionSystemConfigControllerwith a singlegetActiveAgentConfigRules(): Rulesetmethod. - Update the
showbranch inhandleArgsto callcontroller.getActiveAgentConfigRules(). - The
Rulesetimport stays (still referenced bygetActiveAgentConfigRulesandformatRulesSummary).
- Replace the
src/index.ts- Change the
registerPermissionSystemCommandcall site to passgetActiveAgentConfigRules: () => permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)in place of thepermissionManagerandsessionproperties.
- Change the
src/permission-session.ts- Remove the
fallow-ignore-next-line unused-class-membersuppression on thelastKnownActiveAgentNamegetter. - Update the preceding comment from "Read by config-modal (
controller.session.lastKnownActiveAgentName)" to note it is read by theindex.tsconfig-modal adapter closure.
- Remove the
test/config-modal.test.ts- Update all four controller literals: replace
permissionManager: { getComposedConfigRules: () => ... }+session: { lastKnownActiveAgentName: null }withgetActiveAgentConfigRules: () => ...(preserving each test's intended ruleset:[] as Rulesetor thecomposedRulesfixture).
- Update all four controller literals: replace
docs/architecture/architecture.md- Append
✓ completeto the Phase 5 Step 7 line (Track D,[#368]). - The metrics table row "
config-modalcontroller reach-throughs" baseline1→ target0is 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).
- Append
Test Impact Analysis
- New tests enabled by the change: none of substance.
The new
getActiveAgentConfigRulesis a wiring closure inindex.ts, not a new extracted module with independently testable logic. The existingconfig-modal.test.tsshow-output tests already exercise the accessor seam (they inject the ruleset directly), so behavior remains covered at the same layer. - 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.
- Tests that must stay as-is:
test/permission-session.test.ts"exposes lastKnownActiveAgentName" genuinely exercises the getter onPermissionSessionand 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).
refactor: collapse config-modal controller reach-through into getActiveAgentConfigRules accessor (#368)- Test surface:
test/config-modal.test.ts— update all four controller literals to thegetActiveAgentConfigRulesshape; 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(): Rulesetinconfig-modal.ts; update theshowbranch to a single tell; move the reach-through into the adapter closure inindex.ts; remove thefallowsuppression and update the doc comment inpermission-session.ts. - Verify:
pnpm --filter @gotgenes/pi-permission-system run check,run lint,run test, andpnpm fallow dead-code(confirminglastKnownActiveAgentNameis no longer reported now that the closure reads it directly).
- Test surface:
docs: mark Phase 5 Step 7 complete in architecture roadmap (#368)- Surface:
docs/architecture/architecture.md— append✓ completeto 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.
- Surface:
Risks and Mitigations
- Risk:
fallowstill flagslastKnownActiveAgentNameafter the change (the retro0341attempt with a named interface did not satisfyfallow). Mitigation: the prior failure was object-literal wiring; this change makessession.lastKnownActiveAgentNamea direct read in a real arrow-function body inindex.ts, whichfallowtraces. The verify step in cycle 1 runspnpm fallow dead-codebefore 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.tsleaves a stalepermissionManager/sessionshape. Mitigation: TypeScript's excess-property checking rejects the stale fields immediately atpnpm run check; all four literals are enumerated in Module-Level Changes. - Risk: silently changing
showoutput when the ruleset is empty. Mitigation:getComposedConfigRulesalways returns aRulesetandformatRulesSummaryalready returns""for an empty config layer; the "omits rule summary" test guards this.
Open Questions
- None blocking.
The
fallowoutcome is the only thing to confirm empirically during cycle 1; the plan carries a documented fallback if the suppression cannot be fully retired.