12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 503 | pi-permission-system: migrate the service/RPC path queries onto AccessPath (Phase 7 Step 2) |
Retro: #503 — Migrate the service/RPC path queries onto AccessPath (Phase 7 Step 2)
Stage: Planning (2026-06-29T15:29:08Z)
Session summary
Planned Phase 7 Step 2: route the Symbol.for() service (LocalPermissionsService.checkPermission) and the deprecated event-bus RPC (permissions:rpc:check) path queries through AccessPath so external policy queries for path / external_directory / path-bearing surfaces match the lexical aliases ∪ canonical set the gates do (the [#486] / [#502] parity).
The design routes both consumers through the resolver (resolve(intent)) rather than manager.check, so the resolver becomes the sole path-values producer — the premise Step 5 ([#506]) decides the boundary against.
Produced a three-step plan (two breaking feat!: migrations — service then RPC — plus docs) at docs/plans/0503-service-rpc-access-path.md.
Observations
- Routing through the resolver is roadmap-blessed, not just cleaner.
The manager's
checkaccepts onlyResolvedAccessIntent(tool | path-values) and never importsAccessPath. Having the service/RPC build apath-valuesintent themselves would make them a secondpath-valuesproducer, contradicting Step 5's ([#506]) explicit premise. Emittingaccess-pathtoresolver.resolve(the single unwrap site) is the intended design and a clean 1:1 substitution for today'smanager.check(intent, sessionRules.getRuleset())— the resolver subsumes the droppedSessionRulesdependency. - Discovered a latent gap the migration fixes for free.
buildInputForSurfaceonly wires the value intoexternal_directory(returns{ path }); forpathand the path-bearing tools it returns the catch-all{}, so a query likecheckPermission("read", "/p")today drops the path and evaluates["*"](asserted bytest/service.test.ts). Building anAccessPathfor the wholePATH_SURFACESset fixes this drop as a natural consequence — folded into the breaking surface, not deferred. - Two breaking
feat!:commits, each independently green. Step 1 migrates the service (moves theresolverconst up inindex.ts, leaves the RPC on its old deps); Step 2 migrates the RPC (reuses the moved-up resolver). Splitting avoids a single oversized commit while keeping each compilable — the constructor/deps changes each have a single production call site (index.ts). The helperbuildAccessIntentForSurfacelands in Step 1 with the service as its first consumer, sopnpm fallow dead-codenever sees it unused. #502was the template. Loading the [#502] plan/retro gave theaccess-pathintent shape, thenode:fsrealpathSyncmock convention, and the [#502] lesson that a type-only parameter change can yield a hollow red under esbuild — flagged so Step 1/2's reds exercise the new behavior (canonical match), not just the new signature. Also carried forward the [#502] caution to runfallow dead-codefor a stale suppression.buildInputForSurfacestays exported — it is thetool-branch input builder insidebuildAccessIntentForSurfaceand is imported bytest/service.test.ts; itsexternal_directorybranch becomes test-only but is not dead (still exported + imported).- Skipped the
ask_usergate: operator-authored issue, unambiguous and roadmap-blessed proposal; the only design nuance (resolver-injection vs. a localized swap) is settled by Step 5's premise, not a genuine open choice. - Release: Step 2 of batch "symlink-resistant-path-matching" (tail = Step 3, [#504]); mid-batch → defer.
The breaking
feat!:commits land onmainand auto-batch; the major-bump release cuts when Step 3 lands.
Stage: Implementation — TDD (2026-06-29T11:55:00Z)
Session summary
Implemented all three planned TDD steps plus an unplanned cleanup: the breaking feat!: service migration (Step 1), the breaking feat!: RPC migration (Step 2), the docs: roadmap/API updates (Step 3), and a refactor: un-exporting buildInputForSurface.
Test suite went 2215 → 2222 (+7); pnpm run check, root pnpm run lint, full pnpm run test, and pnpm fallow dead-code all green.
Pre-completion reviewer returned PASS with no warnings.
Observations
- The design matched the plan exactly — routing through the resolver was the load-bearing decision.
Both consumers emit an
access-pathintent toresolver.resolve(never buildingpath-valuesthemselves), so the resolver stays the solepath-valuesproducer (the [#506] premise). The service collaborators narrowed cleanly from(manager, sessionRules, …)to(resolver, session, …)— the resolver subsumes the session-ruleset composition, so it was a 1:1 substitution plus the per-callgetPathNormalizer()fetch. - Two deviations, both follow-the-evidence cleanups:
buildInputForSurfacewas made module-private (the plan said keep it exported). Oncetest/service.test.ts's adapter block was rewritten to drive the realLocalPermissionsService, the export had no remaining external consumer.pnpm fallow dead-codepassed either way (internal caller present), but un-exporting is the honest surface — landed as a separaterefactor:commit.- The
service.test.ts"service adapter delegation" describe (a hand-rolledbuildInputForSurfaceadapter simulating the oldindex.tswiring) was renamed to "service round-trip through the global slot" and rewritten to exercise the real class, deleting the staleread → {}assertion that documented the latent value-drop bug.
- The latent gap is real and now fixed end-to-end.
buildInputForSurfacereturned{}for thepathand path-bearing surfaces, so those service/RPC queries collapsed to["*"]and dropped the supplied path — onlyexternal_directoryever worked. Added a composition-root end-to-end test (#503) proving apath-surface service query now resolves against a deny rule on the supplied path; this distinguishes new behavior from old without needing a symlink (a pure value-passing proof). PermissionRpcDeps.sessionwidening cascaded to the prompt tests. AddinggetPathNormalizerto the narrowsessionview broke the prompt-RPC tests' inlinesession: { getRuntimeContext }overrides attsctime (not at runtime — esbuild skips types). Resolved by extracting amakeSession(ctx)helper so all overrides carry both methods; caught only bypnpm run check, a reminder to run it after a shared-interface change.- ESLint auto-fixes fired twice on commit (stripping unnecessary
!non-null assertions onmock.calls[0]![0]and a redundant return-type cast) — the pre-commit hook modified files and aborted the commit; re-staging and re-committing cleared it both times. - Pre-completion reviewer: PASS — no warnings; verified the resolver-routing invariant, the
✅Step 2 markers (heading +S2Mermaid node), conventional-commit/BREAKING-CHANGE correctness, and the two deviations as sound.
Stage: Final Retrospective (2026-06-29T17:01:27Z)
Session summary
Shipped Phase 7 Step 2 across plan → TDD → a user-prompted guideline re-review → ship in one continuous session: two breaking feat!: migrations (service then RPC), a docs: roadmap/API update, and a refactor: un-export, plus two test: conformance fixes surfaced by the re-review.
The operator deferred the release (mid-batch, batch "symlink-resistant-path-matching", tail = Step 3 [#504]); commits landed on main, CI passed, and the issue stays open until the batch tail ships.
A clean run with no rework — the only friction was minor tool-usage slips and three testing-skill rules under-applied during test authoring, all caught before ship.
Observations
What went well
- The [#502] template carried the whole batch.
Reusing the [#502] plan/retro, the already-migrated
path.ts/path.test.ts, and thenode:fsrealpathSyncmock convention made [#503] a near-mechanical parallel — the design matched the plan exactly, every invariant at risk was predicted, and the pre-completion reviewer returned PASS first try. This is the second batch member to ship cleanly off the same template (cross-stage pattern). - Release coordination handshake worked as intended.
The plan's
**Release:** mid-batch — defermarker drove a single up-frontask_userat ship time; the operator confirmed defer, and steps 5–6 (close + release-please merge) were skipped cleanly with the issue left open. - Incremental verification held the line.
Each TDD step ran its affected test file (red→green),
pnpm run checkran immediately after the interface changes, and the full suite + root lint +fallow dead-coderan after the last step — no end-of-session surprise.pnpm run checkwas the only gate that caught thePermissionRpcDeps.sessionwidening break (the prompt tests passed under esbuild), validating the "run check after a shared-interface change" rule.
What caused friction (agent side)
instruction-violation(self/tool-caught) — authored the new tests against three testing-skill rules that were loaded but not applied:mock.calls[0]![0]with a!(the skill says usetoHaveBeenCalledWith/ drop the!),ReturnType<typeof vi.fn<…>>instead ofMock<Sig>, and a missingbeforeEachrealpathSyncreset in the RPC suite. The!was stripped by the ESLint pre-commit hook (aborting two commits, re-staged); the other two passed the pre-completion reviewer and were caught only by the operator's "do they meet our guidelines?" prompt. Impact: two ESLint commit re-tries plus two follow-uptest:commits (dc79ed9b,5b5e2553) — no behavior rework.other(Edit-tool misuse) — twice packed two replacements into oneedits[]object viaoldText2/newText2keys; the tool rejected with "must not have additional properties." Impact: two rejected tool calls, immediately re-issued as separate array entries — no rework.other(path slip) — once issued aReadwith a doubled absolute path (…/pi-packages/packages/pi-permission-system/packages/pi-permission-system/…), denied by the permission gate. Impact: one denied call, corrected immediately.
What caused friction (user side)
- The operator's mid-flight "take one more review of the code changes — do they meet our guidelines?"
was a high-value strategic intervention, not mechanical oversight: it surfaced two testing-skill conformance gaps the pre-completion reviewer's PASS had missed.
Framed as opportunity: the reviewer's design lens (2d) loads the
code-designskill forsrc/files but has no symmetric lens that loads thetestingskill fortest/files, so test-code convention drift currently relies on a manual prompt to catch.
Diagnostic details
- Model-performance correlation — the only subagent dispatch was the
pre-completion-revieweronanthropic/claude-sonnet-4-6(per itsmodel:frontmatter), appropriate for judgment-heavy review. Its PASS was correct within its checklist; the two missed items are outside its current coverage (no test-conformance lens), a checklist-scope gap rather than a model mismatch. - Escalation-delay tracking — no
rabbit-hole; no error or approach occupied more than two consecutive tool calls before resolving. - Unused-tool detection — no
missing-contextgaps warranted an Explore/colgrepdispatch; the [#502] template and direct source reads supplied the needed context. - Feedback-loop gap analysis — verification was incremental, not end-loaded: per-file red→green,
pnpm run checkright after the RPC interface change (the gate that caught thesession-widening break), and the full suite/lint/fallow after the final step.
Changes made
.pi/agents/pre-completion-reviewer.md— broadened section 2d ("Code design review") applicability tosrc/ortest/files, and added atesting-skill spot-check for changedtest/files (mock fields typedMock<Sig>notReturnType<typeof vi.fn<…>>; module-scopevi.fn()stubs reset inbeforeEach; mock-call assertions viatoHaveBeenCalledWithnotmock.calls[0]![0]), reported as WARN; updated the output-format SKIP line and added a sample test-conformance WARN.packages/pi-permission-system/docs/retro/0503-service-rpc-access-path.md— this Final Retrospective stage entry.