mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
271 lines
19 KiB
Markdown
271 lines
19 KiB
Markdown
---
|
||
issue: 287
|
||
issue_title: "Decompose runGateCheck in handlers/gates/runner.ts"
|
||
---
|
||
|
||
# Give the session-approval data behavior and tell the session store to record it
|
||
|
||
## Problem Statement
|
||
|
||
`runGateCheck` in `src/handlers/gates/runner.ts` is flagged by `fallow health --targets` (cognitive complexity 32, Phase 2 finding #3).
|
||
The issue originally proposed extracting three named phase helpers, but that is procedure-splitting: it moves statements into helpers and lowers the metric without improving the design.
|
||
The real smell is that `runGateCheck` does the work that belongs to two collaborators it talks to.
|
||
|
||
- The descriptor's `sessionApproval` is a raw `{ surface; pattern } | { surface; patterns }` union.
|
||
The runner cracks it open **twice** — once in phase 3 with a nested ternary to fish out a representative pattern for the prompt, and again in phase 6 with an `"patterns" in …` branch to loop the patterns into the store.
|
||
That polymorphic union with no behavior is a missing value object.
|
||
- The session store (`SessionRules`, `src/session-rules.ts`) is the genuinely stateful object here — it persists across the whole session, is queried on every check (the `source: "session"` fast path via `getRuleset()`) and mutated by `approve`.
|
||
Phase 6 reaches into the descriptor union and dribbles patterns into it **one scalar at a time** through `deps.approveSessionRule(surface, pattern)`.
|
||
That is Ask: the runner is doing the store's bookkeeping for it.
|
||
- The `emitDecision` payload is constructed in two places (the phase-2 session-hit path and the phase-5 gate-result path) with five of seven fields identical, including the repeated `origin / agentName / matchedPattern ?? null` normalization.
|
||
|
||
This is Phase 2 step 3 of the improvement roadmap in `docs/architecture/architecture.md`.
|
||
|
||
## Goals
|
||
|
||
- Introduce a `SessionApproval` value object that owns the single-vs-multi-pattern union and exposes behavior: `surface`, `patterns`, `representativePattern` (for the prompt), and `toGateApproval()` (the `{ surface; pattern }` shape `applyPermissionGate` expects).
|
||
- Tell the stateful store to record a whole approval: `SessionRules.record(approval)` loops the patterns internally; the `GateRunnerDeps` seam becomes `recordSessionApproval(approval)` instead of the scalar `approveSessionRule(surface, pattern)`.
|
||
- Extract a pure `buildDecisionEvent(...)` helper so the decision-event payload (and its null-normalization) has one home, used by both emit sites in `runGateCheck`.
|
||
- As a **consequence** of the above, `runGateCheck` shrinks to a thin orchestration function — phase 3's nested ternary becomes `descriptor.sessionApproval?.toGateApproval()`, phase 6 becomes a single `deps.recordSessionApproval(...)` tell, and both emits go through the builder.
|
||
The complexity drop is a side effect of better design, not procedure-splitting.
|
||
- Behavior-preserving end-to-end: the same approvals are recorded and the same decision events are emitted.
|
||
- This is a breaking change to internal seams (`GateRunnerDeps`, `GateDescriptor.sessionApproval`, `PermissionSession`, `SessionRules`) — all internal to the package, so no published API changes, but use `refactor:`/`feat:` with a clear body noting the seam reshape.
|
||
|
||
## Non-Goals
|
||
|
||
- No change to permission semantics: resolution order (pre-check → pre-resolved → `checkPermission`), session-hit short-circuit, the deny/ask/allow gate decision, resolution derivation, and which patterns get approved are all frozen.
|
||
- No change to `applyPermissionGate` / `permission-gate.ts` — it keeps its single `{ surface; pattern }` `sessionApproval` seam; the runner adapts to it via `SessionApproval.toGateApproval()`.
|
||
- No extraction of phase 1 (check resolution) into a helper — it is a small inline value-producing branch and splitting it is the procedure-shuffling this plan rejects.
|
||
Listed in Open Questions if `fallow` still flags `runner.ts` afterward.
|
||
- No change to the other Phase 2 targets: `resolvePermissions` ([#286], done), `bash-path-extractor.ts` ([#289]), `stripJsonComments` ([#290]), test-fixture dedup ([#288]).
|
||
- No change to the `v3-architecture.md` data-flow diagram — `runGateCheck` remains a single node.
|
||
|
||
## Background
|
||
|
||
Relevant existing modules:
|
||
|
||
- `src/handlers/gates/runner.ts` — `runGateCheck(descriptor, agentName, toolCallId, deps): Promise<GateOutcome>`; the orchestrator being thinned.
|
||
- `src/handlers/gates/descriptor.ts` — `GateDescriptor.sessionApproval?: { surface; pattern } | { surface; patterns }` (the union to replace) and `GateRunnerDeps.approveSessionRule(surface, pattern)` (the scalar sink to reshape).
|
||
- `src/session-rules.ts` — `SessionRules` (the stateful store): `approve(surface, pattern)`, `getRuleset()`, `clear()`; also exports `deriveApprovalPattern`.
|
||
The new `SessionApproval` value object will live in its own module so both `SessionRules` and the gates layer can import it without a cycle.
|
||
- `src/permission-session.ts` — `PermissionSession.approveSessionRule(surface, pattern)` delegates to `sessionRules.approve`; `getSessionRuleset()` delegates to `getRuleset()`.
|
||
- `src/handlers/permission-gate-handler.ts` — builds `GateRunnerDeps` once (lines 101–109), wiring `approveSessionRule` to `this.session.approveSessionRule`.
|
||
- `src/permission-gate.ts` — `applyPermissionGate`; `PermissionGateParams.sessionApproval?: { surface; pattern }` and `PermissionGateResult` echo a single pattern.
|
||
Unchanged.
|
||
- The five producers that build `sessionApproval`: `tool.ts`, `path.ts`, `external-directory.ts`, `bash-path.ts` (single `{ surface; pattern }`), and `bash-external-directory.ts` (multi `{ surface; patterns }`).
|
||
- `src/handlers/gates/helpers.ts` — existing pure helpers `deriveResolution`, `deriveDecisionValue`; the new `buildDecisionEvent` belongs here (pure, no `deps`).
|
||
|
||
Constraints from AGENTS.md / the package skill that apply:
|
||
|
||
- Enforce permissions deterministically — recording the same patterns and emitting the same events must be preserved.
|
||
- Keep modules focused (one concern per file); import siblings via `#src/` / `#test/` aliases.
|
||
- Every new export needs a consumer — fallow flags speculative re-exports as dead code.
|
||
- Biome `noNonNullAssertion` bans `x!`; prefer explicit guards. `representativePattern` returns `string | undefined` and callers guard rather than assert.
|
||
- ES2024 target — `for...of`, spread, getters available.
|
||
|
||
## Design Overview
|
||
|
||
### New module: `src/session-approval.ts`
|
||
|
||
```typescript
|
||
/** Value object for a session-scoped approval: one surface, one-or-more patterns. */
|
||
export class SessionApproval {
|
||
private constructor(
|
||
readonly surface: string,
|
||
readonly patterns: readonly string[],
|
||
) {}
|
||
|
||
static single(surface: string, pattern: string): SessionApproval {
|
||
return new SessionApproval(surface, [pattern]);
|
||
}
|
||
|
||
static multiple(surface: string, patterns: readonly string[]): SessionApproval {
|
||
return new SessionApproval(surface, [...patterns]);
|
||
}
|
||
|
||
/** Representative pattern for the interactive prompt — the first, if any. */
|
||
get representativePattern(): string | undefined {
|
||
return this.patterns[0];
|
||
}
|
||
|
||
/** Single-pattern shape applyPermissionGate echoes back; undefined when empty. */
|
||
toGateApproval(): { surface: string; pattern: string } | undefined {
|
||
const pattern = this.representativePattern;
|
||
return pattern === undefined ? undefined : { surface: this.surface, pattern };
|
||
}
|
||
}
|
||
```
|
||
|
||
This preserves the old phase-3 behavior exactly: `patterns.length > 0 ? patterns[0] : undefined`.
|
||
|
||
### Stateful store: `SessionRules.record`
|
||
|
||
The store is *told* a whole approval and owns the loop (the bookkeeping that previously leaked into the runner).
|
||
The existing scalar `approve(surface, pattern)` stays as the internal primitive so `session-rules.test.ts` is not rewritten:
|
||
|
||
```typescript
|
||
import { SessionApproval } from "./session-approval";
|
||
|
||
record(approval: SessionApproval): void {
|
||
for (const pattern of approval.patterns) {
|
||
this.approve(approval.surface, pattern);
|
||
}
|
||
}
|
||
```
|
||
|
||
### Reshaped seams
|
||
|
||
- `GateDescriptor.sessionApproval?: SessionApproval`.
|
||
- `GateRunnerDeps`: replace `approveSessionRule(surface, pattern): void` with `recordSessionApproval(approval: SessionApproval): void`.
|
||
- `PermissionSession`: replace `approveSessionRule(surface, pattern)` with `recordSessionApproval(approval): void { this.sessionRules.record(approval); }`.
|
||
- `permission-gate-handler.ts`: the deps closure becomes `recordSessionApproval: (approval) => this.session.recordSessionApproval(approval)`.
|
||
|
||
### Pure builder: `buildDecisionEvent` (in `helpers.ts`)
|
||
|
||
```typescript
|
||
export function buildDecisionEvent(
|
||
decision: { surface: string; value: string },
|
||
check: PermissionCheckResult,
|
||
agentName: string | null,
|
||
result: "allow" | "deny",
|
||
resolution: PermissionDecisionResolution,
|
||
): PermissionDecisionEvent {
|
||
return {
|
||
surface: decision.surface,
|
||
value: decision.value,
|
||
result,
|
||
resolution,
|
||
origin: check.origin ?? null,
|
||
agentName: agentName ?? null,
|
||
matchedPattern: check.matchedPattern ?? null,
|
||
};
|
||
}
|
||
```
|
||
|
||
### Thinned `runGateCheck` (the consequence, not the goal)
|
||
|
||
```typescript
|
||
// phase 1 (inline, unchanged): resolve `check` from preCheck / preResolved / checkPermission
|
||
|
||
// phase 2: session-hit fast path
|
||
if (check.source === "session") {
|
||
deps.writeReviewLog("permission_request.session_approved", { ...descriptor.logContext, agentName, resolution: "session_approved", sessionApprovalPattern: check.matchedPattern });
|
||
deps.emitDecision(buildDecisionEvent(descriptor.decision, check, agentName, "allow", "session_approved"));
|
||
return { action: "allow" };
|
||
}
|
||
|
||
// phase 3: gate — the nested ternary collapses
|
||
const gateResult = await applyPermissionGate({
|
||
state: check.state,
|
||
canConfirm,
|
||
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
|
||
promptForApproval: async () => { /* unchanged; sets autoApproved */ },
|
||
writeLog: deps.writeReviewLog,
|
||
logContext: { ...descriptor.logContext, agentName },
|
||
messages,
|
||
});
|
||
|
||
// phase 4 (unchanged): hasSessionApproval = action === "allow" && gateResult.sessionApproval !== undefined
|
||
|
||
// phase 5: single emit through the builder
|
||
deps.emitDecision(buildDecisionEvent(descriptor.decision, check, agentName,
|
||
gateResult.action === "allow" ? "allow" : "deny",
|
||
deriveResolution(check.state, gateResult.action, hasSessionApproval, canConfirm, autoApproved)));
|
||
|
||
// phase 6: one tell — the union-cracking loop is gone
|
||
if (gateResult.action === "allow" && hasSessionApproval && descriptor.sessionApproval) {
|
||
deps.recordSessionApproval(descriptor.sessionApproval);
|
||
}
|
||
|
||
return gateResult.action === "block" ? { action: "block", reason: gateResult.reason } : { action: "allow" };
|
||
```
|
||
|
||
Tell-Don't-Ask: the runner no longer interrogates the approval union or dribbles patterns; it hands `SessionApproval` to the store.
|
||
Law of Demeter holds — it does not reach through the union's shape.
|
||
ISP: `buildDecisionEvent` takes only `decision`, `check`, `agentName` plus the two varying fields — no unused descriptor fields.
|
||
|
||
### Edge cases (all preserved)
|
||
|
||
- Multi-pattern (`bash-external-directory`) → `SessionApproval.multiple`; `representativePattern` is the first path's pattern (matches old `patterns[0]`); `record` approves all patterns.
|
||
- Single-pattern producers → `SessionApproval.single`; one pattern recorded.
|
||
- No `descriptor.sessionApproval` → `toGateApproval()` never called, phase-6 guard skips the tell.
|
||
- Empty patterns is unreachable (producers always supply ≥1; `bash-external-directory` returns a bypass before an empty `patterns`), but `representativePattern`/`toGateApproval` degrade to `undefined` safely.
|
||
|
||
## Module-Level Changes
|
||
|
||
- `src/session-approval.ts` (new): `SessionApproval` value object.
|
||
- `src/session-rules.ts`: import `SessionApproval`; add `record(approval)`; keep `approve(surface, pattern)` as the internal primitive.
|
||
- `src/permission-session.ts`: replace `approveSessionRule(surface, pattern)` with `recordSessionApproval(approval)`.
|
||
- `src/handlers/gates/descriptor.ts`: `sessionApproval?: SessionApproval`; `GateRunnerDeps.approveSessionRule` → `recordSessionApproval(approval)`.
|
||
- `src/handlers/gates/helpers.ts`: add `buildDecisionEvent` (import `PermissionDecisionEvent`, `PermissionDecisionResolution`, `PermissionCheckResult`).
|
||
- `src/handlers/gates/runner.ts`: phase 3 uses `toGateApproval()`; phases 2 & 5 use `buildDecisionEvent`; phase 6 is a single `recordSessionApproval` tell; the `singleSessionApproval` ternary and the phase-6 `"patterns" in` loop are deleted.
|
||
- `src/handlers/gates/tool.ts`, `path.ts`, `external-directory.ts`, `bash-path.ts`: build `SessionApproval.single(surface, pattern)`.
|
||
- `src/handlers/gates/bash-external-directory.ts`: build `SessionApproval.multiple("external_directory", patterns)`.
|
||
- `src/handlers/permission-gate-handler.ts`: deps closure `recordSessionApproval` wired to `this.session.recordSessionApproval`.
|
||
- Tests (see Test Impact): `test/session-rules.test.ts`, `test/permission-session.test.ts`, `test/handlers/gates/runner.test.ts`, the five producer tests, and the handler deps-mock files (`input.test.ts`, `tool-call.test.ts`, `tool-call-events.test.ts`, `input-events.test.ts`, `external-directory-integration.test.ts`, `external-directory-session-dedup.test.ts`) that declare `approveSessionRule: vi.fn()`.
|
||
- `docs/architecture/architecture.md`: mark Phase 2 step 3 done, ✅ finding #3, add `session-approval.ts` to the module tree, refresh refactoring-targets count and `runner.ts` complexity after re-running `fallow health --targets`.
|
||
- `.pi/skills/package-pi-permission-system/SKILL.md`: no documented symbol is removed — no change.
|
||
|
||
No file in Module-Level Changes is claimed unchanged in Non-Goals (`permission-gate.ts` and `v3-architecture.md` are the only "unchanged" claims, and neither appears above).
|
||
|
||
## Test Impact Analysis
|
||
|
||
1. New unit tests enabled.
|
||
- `test/session-approval.test.ts` (new): `single`/`multiple` factories, `representativePattern` (first pattern, `undefined` when empty), `toGateApproval` (shape and `undefined` case).
|
||
- `test/session-rules.test.ts`: add `record(approval)` fan-out cases (single pattern → one rule; multi-pattern → one rule per pattern) alongside the kept scalar `approve` cases.
|
||
2. Tests that change shape (not removed).
|
||
- `runner.test.ts`: the deps mock field `approveSessionRule` → `recordSessionApproval`; the "once per pattern" assertion becomes "called once with a `SessionApproval` carrying both patterns" (the loop moved into `SessionRules`, so the *runner* now makes one call); descriptor fixtures build `SessionApproval.single/multiple`.
|
||
- `permission-session.test.ts`: the two delegation tests target `recordSessionApproval(approval)`.
|
||
- The five producer tests: `sessionApproval` expectations become `SessionApproval` instances.
|
||
- Handler deps-mock files: rename the `approveSessionRule: vi.fn()` field; `external-directory-session-dedup.test.ts`'s stateful mock records via `record(approval)`.
|
||
3. Tests that stay as-is.
|
||
- `session-rules.test.ts` scalar `approve` cases (the primitive is retained).
|
||
- All `runGateCheck` behavioral cases keep their resolution/emit assertions — they are the behavior-preservation net; only the recording-call shape updates.
|
||
|
||
## TDD Order
|
||
|
||
Lift-and-shift: introduce the value object and store method additively first, then do the type-forced cutover, then dedup the emit.
|
||
|
||
1. `feat:` Add `src/session-approval.ts` + `test/session-approval.test.ts`; add `SessionRules.record(approval)` + its `session-rules.test.ts` cases.
|
||
Purely additive, no consumers yet.
|
||
Red→green within the step (new tests fail until the module/method exist).
|
||
Commit: `feat: add SessionApproval value object and SessionRules.record`.
|
||
2. `refactor:` The cutover.
|
||
Change `GateDescriptor.sessionApproval` to `SessionApproval`; migrate the five producers to `SessionApproval.single/multiple`; reshape `GateRunnerDeps`/`PermissionSession` (`approveSessionRule` → `recordSessionApproval`); rewire the `permission-gate-handler.ts` closure; update `runGateCheck` phase 3 (`toGateApproval()`) and phase 6 (single tell); update `runner.test.ts`, `permission-session.test.ts`, the producer tests, and the handler deps-mocks in the same commit.
|
||
These cannot be split — the descriptor type change and the deps reshape break every producer, the runner, and every deps-mock at the type level simultaneously (excess/missing property errors).
|
||
Green: full suite passes; behavior unchanged.
|
||
Run `pnpm --filter @gotgenes/pi-permission-system run test` and `… run check` before committing.
|
||
Commit: `refactor: tell SessionRules to record a SessionApproval value object`.
|
||
3. `feat:` Add `buildDecisionEvent` to `helpers.ts` (+ a small unit test) and route both `runGateCheck` emit sites through it; delete the duplicated payload construction.
|
||
Independent of step 2's seam reshape (can also land before it).
|
||
Green.
|
||
Commit: `feat: centralize decision-event construction in buildDecisionEvent`.
|
||
4. `docs:` Update `architecture.md` — mark Phase 2 step 3 complete, ✅ finding #3, add `session-approval.ts` to the module tree, refresh metrics after `fallow health --targets`.
|
||
Commit: `docs: mark Phase 2 step 3 complete in permission-system roadmap`.
|
||
|
||
Step 2 is the only large commit; it is type-forced and the producer/test edits are mechanical.
|
||
The `SessionRules` scalar primitive is retained so `session-rules.test.ts` is not rewritten.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- Risk: the multi-pattern `representativePattern` diverges from the old `patterns[0]`.
|
||
Mitigation: `representativePattern` is defined as `patterns[0]`; `session-approval.test.ts` asserts it, and the multi-pattern `runner.test.ts` case still verifies all patterns are recorded.
|
||
- Risk: dropping a recorded pattern in the move of the loop into `SessionRules.record`.
|
||
Mitigation: `record` iterates `approval.patterns`; `session-rules.test.ts` asserts one rule per pattern, and the end-to-end `external-directory-session-dedup.test.ts` verifies dedup still works.
|
||
- Risk: step 2 is large and a stale `approveSessionRule` reference or deps-mock slips through.
|
||
Mitigation: `grep` for `approveSessionRule` reaches zero after step 2; `pnpm check` fails on any stale reference or mismatched mock shape.
|
||
- Risk: `SessionApproval` in a new module creates an import cycle (`session-rules.ts` ↔ producers).
|
||
Mitigation: `session-approval.ts` imports nothing from `session-rules.ts`; the dependency is one-way (`session-rules.ts` → `session-approval.ts`), and producers import both leaf-ward.
|
||
- Risk: fallow flags `SessionApproval` members or `buildDecisionEvent` as dead.
|
||
Mitigation: `representativePattern`/`toGateApproval` are consumed by `runGateCheck`, `patterns`/`surface` by `SessionRules.record`, the factories by the producers, and `buildDecisionEvent` by both emit sites — each has a real consumer.
|
||
|
||
## Open Questions
|
||
|
||
- Whether to also lift phase 1's check resolution onto the descriptor (e.g. `descriptor.resolveCheck(deps)`) so the runner stops branching on `preCheck`/`preResolved` — deferred.
|
||
It is value-returning and small; revisit only if `fallow health --targets` still flags `runner.ts` above the `< 15` target after step 3.
|
||
|
||
[#286]: https://github.com/gotgenes/pi-packages/issues/286
|
||
[#288]: https://github.com/gotgenes/pi-packages/issues/288
|
||
[#289]: https://github.com/gotgenes/pi-packages/issues/289
|
||
[#290]: https://github.com/gotgenes/pi-packages/issues/290
|