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

12 KiB
Raw Blame History

issue, issue_title
issue issue_title
510 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 (AccessPaths + 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-startsession.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.tsbash-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.