Files
my-pi/pi-permission-system/docs/retro/0510-thread-path-normalizer-seam.md
T

121 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 510
issue_title: "Thread an injected platform/path-semantics seam through the bash path pipeline"
---
# Retro: #510 — Thread an injected platform/path-semantics seam through the bash path pipeline
## Stage: Planning (2026-06-28T00:00:00Z)
### Session summary
Planned the refactor that completes the half-built platform seam in the bash path pipeline.
The operator's `ask_user` answers reshaped the design away from the issue's literal "thread a `PathSemantics`/`NodeJS.Platform` knob" framing toward a single injected collaborator — `PathNormalizer` — constructed at the edge with both `platform` and `cwd` baked in, handed raw tokens, and returning the prepared values (`AccessPath`s + routing answers) the gates expect ("prepare the data before evaluation, not during evaluation").
Confirmed a behavior-preserving `refactor:` that batches into [#508]'s `fix:` release, full enforcement scope (lint guard + `rule.ts`/`subagent-context.ts` cleanup), and a 10-step lift-and-shift TDD order.
### Observations
- **Design steer (via `ask_user`).**
Rejected both "bare `NodeJS.Platform`" and a passive `PathSemantics` value bag.
The operator's framing: hand paths to a collaborator that *owns* platform + cwd and figures out the normalized forms.
Result: `PathNormalizer` (name chosen over `AccessPathFactory`/`PathInterpreter`), single collaborator owning both `AccessPath` construction and routing (`isAbsolute`/`resolveBase`/`joinBase`/`isWithinDirectory`/`isOutsideWorkingDirectory`).
- **Two distinct edges.**
`process.platform` is process-global → read once in `index.ts` (factory body), injected into `PermissionManager` (for `rule.ts`) and `PermissionSession`.
`cwd` is session-scoped → not available in the factory body; arrives at `session_start` via `resetForNewSession(ctx)`, so `PathNormalizer` is built there and exposed via `getPathNormalizer()` on the existing `ToolCallGateInputs` seam.
- **`cwd` source change is the main risk.**
Today the pipeline reads `ctx.cwd` per tool call; baking it into the session normalizer assumes per-session stability.
Holds for Pi (a session is bound to one project dir), and `resetForNewSession` rebinds on every `/new`/`/resume`/`/fork`.
Flagged with a composition-root regression test.
- **Behavior-preserving by construction.**
Every converted interior op already used host `node:path`/`process.platform`, so the host-default result is identical; the only POSIX-hard-coded drift (`isRelativeCandidate`'s `startsWith("/")`) is deliberately left as-is and deferred to [#508], keeping this a pure `refactor:` with no observable POSIX change.
`canonicalizePath`'s new `win32` split is a no-op on POSIX and a latent fix validated by injected-`win32` unit tests.
- **Release framing.**
Not in the Phase 7 roadmap (the issue proposes it as a new step); marker is "ship independently" with the rationale that a `refactor:` does not cut its own release — it auto-batches into [#508]'s `fix:`.
Avoided the formal batch-tail marker since there is no named roadmap batch.
- **Scope boundary vs. [#505].**
`PathNormalizer` is a facade over the platform-parameterized `path-utils`/`AccessPath`, not a relocation; the Phase 7 [#505] path-utils dissolution can later move internals behind it without re-touching the seam.
No new follow-up issue filed (the deferred `isRelativeCandidate` conversion already lives in [#508]).
- **Testability payoff.**
The whole point is exercising Windows behavior on a POSIX CI by injecting a `win32` `PathNormalizer` — no `vi.mock("node:path")`.
## Stage: Implementation — TDD (2026-06-28T18:35:00Z)
### Session summary
Completed the 10-step plan: steps 16 (leaf-normalizer platform flavor, `AccessPath` platform option, the `PathNormalizer` collaborator, session-edge construction, the `BashPathResolver` rename, and the gate migration) landed in a prior session; this session executed steps 710 (inject `platform` into `rule.ts` evaluation, into `subagent-context.ts` detection, the `process.platform` ESLint guard + removal of all interior defaults, and the architecture/SKILL docs).
The test count rose from 2183 to 2189 (+6: the `rule.ts` and `subagent-context.ts` win32/posix injection assertions).
Final state: all 10 `#510` commits green on `pnpm run check` / `pnpm run lint` / 2189 tests / `pnpm fallow dead-code`; pre-completion reviewer returned **PASS**.
### Observations
- **`required` vs defaulted platform param (operator decision via `ask_user`).**
Chose required params (no `= process.platform` default) on `rule.ts`, `subagent-context.ts`, and every `path-utils`/`canonicalize-path` leaf — fully `tsc`-enforced threading — over a lower-churn posix-literal default.
`PermissionManager`'s constructor option `platform?` keeps an internal `?? "linux"` default (its only production caller, `index.ts`, passes `hostPlatform`), which contained the manager-construction test churn.
- **`evaluate` param reorder.**
`evaluate`'s optional `defaultAction` blocked a required trailing `platform`, so the signature became `evaluate(surface, pattern, rules, platform, defaultAction?)`.
Migrated ~84 `rule.ts`/`synthesize`/`session-rules` test call sites with a paren-balancing Python script (append `"linux"` for 3-arg calls, insert before `"deny"` for `defaultAction` calls, drop the old `undefined,` slot for the win32 calls).
- **Step-9 blast radius exceeded the plan (operator-confirmed deviation).**
The plan named only `isPathWithinDirectory`/`isPiInfrastructureRead`, but the package-wide lint guard forbids the *text* `process.platform`, so **all** leaf defaults had to go.
That forced threading `platform` to ~6 production sites the plan did not enumerate: `input-normalizer` (via `manager.platform`), the `tool`/`skill-read`/`external-directory` gates (via a new `ToolCallGateInputs.getPlatform()` off the session), and `skill-prompt-sanitizer` (via `before-agent-start``session.getPlatform()`).
Test churn (~93 path-utils call sites + `AccessPath.forPath({ cwd })` object injection) was automated with a second paren-balancing script.
- **Lint-guard sanity check gotcha.**
Verifying the guard fires (temporarily adding an interior `process.platform`) used `git checkout` to revert — which also reverted the *uncommitted* step-9 change to `canonicalize-path.ts`.
Caught it (`grep "= process.platform"`) and re-applied before committing.
- **`getPlatform()` alongside `getPathNormalizer()`.**
Two session accessors on `ToolCallGateInputs`: the normalizer for the bash pipeline, and the bare `platform` for the rule/gate sites that call raw `path-utils`/`isPiInfrastructureRead` rather than an `AccessPath` op.
The reviewer confirmed the split is correct (path-interpretation vs. rule case-folding are distinct concerns).
- **Pre-completion reviewer: PASS** — all deterministic checks green, all cross-step invariants ([#418], [#393], [#308], [#382], [#478]) preserved, all 4 Mermaid diagrams validated, no dead code, docs forward/reverse complete.
No WARN findings.
- **Doc note.** `architecture.md` recorded the seam as "### Related: PathNormalizer platform seam ([#510])" under Phase 7 (a precursor refactor, not one of the five Phase 7 steps), updated the `cwd-projection.ts``bash-path-resolver.ts` rename, the `BashProgram.parse` signature, the `evaluate()` pseudo-code, and added a `path-normalizer.ts` module entry.
## Stage: Final Retrospective (2026-06-28T20:30:00Z)
### Session summary
Shipped [#510] as `pi-permission-system` v17.1.0 (the lone `feat:``add PathNormalizer collaborator` — promoted the otherwise-`refactor:` batch into a minor release, exactly as the plan predicted).
During the post-implementation review the operator's question "what besides `PathNormalizer` references the platform-taking utilities?"
surfaced residual `getPlatform()` threading the seam had left behind, which was documented in `architecture.md` and filed as follow-up [#511].
The dominant arc-wide theme: the operator's `required`-param choice generated large mechanical test churn, and the step-9 lint-guard blast radius repeatedly exceeded the plan's stated scope.
### Observations
#### What went well
- **Paren-balancing migration scripts.**
Two disposable Python scripts (top-level-arg splitter + balanced-paren insertion) migrated ~177 call sites across `rule.ts`/`synthesize`/`session-rules` (step 7) and the path-utils/`AccessPath.forPath` consumers (step 9) — a novel, effective answer to a required-param signature change with noise-arg churn that hand-editing would have made error-prone.
- **Operator Q&A surfaced real debt.**
The "what else references these utilities?"
question caught the residual `getPlatform()` straggler sites (infra-read containment, skill-prompt sanitization) the implementation left un-folded; it was mapped to the Phase 7 roadmap and tracked as [#511] rather than left implicit.
A clean bidirectional win.
- **Clean ship.**
The release-please PR's `UNSTABLE`-no-checks state was the expected `GITHUB_TOKEN` case; the documented `gh pr merge --rebase` fallback worked, and v17.1.0 landed by rebase with the baseline auto-advanced.
#### What caused friction (agent side)
- `missing-context` (planning) — step 9 underscoped the lint-guard blast radius: the plan named only `isPathWithinDirectory`/`isPiInfrastructureRead`, but a `no-restricted-syntax` guard bans the *text* `process.platform` everywhere, so **all** seven leaf defaults had to go in one atomic commit, making each param required and cascading to ~6 unplanned production callers (`input-normalizer`, the `tool`/`skill-read`/`external-directory` gates, `skill-prompt-sanitizer`) plus ~93 test edits.
Impact: self-identified (caught by `tsc`), no wrong-direction rework, but two mid-TDD `ask_user` rounds, a new `getPlatform()` session accessor, and a second migration script.
- `premature-convergence` — the first param-style `ask_user` ("required vs posix-default") was framed around `rule.ts` before the path-utils blast radius was scoped, so the same underlying decision re-opened for a second `ask_user` at step 9.
Impact: two ask rounds for one decision; the second could have been folded in had the full caller set been enumerated before the first.
- `other` (tooling hazard) — the lint-guard sanity check (temporarily adding an interior `process.platform` to confirm the guard fires) used `git checkout` to revert, which also reverted the *uncommitted* step-9 change to `canonicalize-path.ts`.
Impact: caught immediately via `grep "= process.platform"` and re-applied; ~3 tool calls, no rework.
#### What caused friction (user side)
- Opportunity, not criticism: the `required`-vs-`defaulted` preference was asked twice across the TDD session.
Presenting the full blast radius (path-utils leaves + ~177 test edits) in the *first* question would have made it a single decision boundary — but that gap was the agent's scoping, not the operator's.
### Diagnostic details
- **Unused-tool (planning).**
A `grep -rn "process.platform"` plus a caller-enumeration pass at plan time would have surfaced the true blast radius (all seven leaf functions + ~6 production callers) instead of the representative two the plan named.
This is the root of the step-9 deviation.
- **Feedback-loop.**
Verification cadence was healthy: `tsc` after each interface-changing step, `vitest` per affected file, full suite + lint + `fallow` at TDD end, and lint + `fallow` pre-push.
No end-only-verification gap.
- **Model-performance.**
The only subagent (the `pre-completion-reviewer`, dispatched in the TDD session) ran fresh-context on judgment-heavy review work — an appropriate match; no mismatch.
### Changes made
1. `.pi/skills/testing/SKILL.md` — added a TDD-planning rule (after the "parameter that flows through callback chains" rule) that a plan adding a lint guard forbidding a global read bans the *text* everywhere (including `= process.platform` default params), so every default must be removed in the guard's commit and every occurrence + caller enumerated at plan time.