--- status: accepted date: 2026-06-30 --- # 0002 — Keep `path-values` as the manager's string boundary ## Status Accepted. ## Context Phase 7 Steps 1 and 2 ([#502], [#503]) routed every path-shaped surface onto the `AccessPath` value object: the per-tool path gate, the cross-cutting `path` and `external_directory` gates, and the service/RPC policy queries all emit an `access-path` `AccessIntent`. The resolver unwraps that intent via `AccessPath.matchValues()` into a string-based `path-values` intent before the low-level manager evaluates rules. This left the resolver as the **sole producer** of the `path-values` variant. The [#487] vision ("adopt `AccessPath` as the universal internal path representation") listed "collapse the `path-values` variant" as a goal — but the residual variant is not transitional scaffolding. It is the seam between the path-aware resolver and the deliberately string-based manager, so its fate is a design decision, not a mechanical cleanup. ### The three actors The resolve path runs through three collaborators in a strict path-awareness gradient: | Actor | File | `AccessPath`-aware? | Job | | -------- | ---------------------------- | --------------------------------------------- | ----------------------------------------------------------- | | Gate | `src/handlers/gates/*.ts` | Yes — builds it via `normalizer.forPath(...)` | Turn a tool call into an `AccessIntent` | | Resolver | `src/permission-resolver.ts` | Yes — calls `matchValues()` | Compose session rules; unwrap `access-path` → `path-values` | | Manager | `src/permission-manager.ts` | **No** — string-based | Evaluate `(surface, string[])` against the ruleset | ### The type split is load-bearing Two distinct discriminated unions encode the seam, with exactly one converter between them: - `AccessIntent = ToolAccessIntent | AccessPathAccessIntent` — what a gate emits. - `ResolvedAccessIntent = ToolAccessIntent | PathValuesAccessIntent` — what the manager consumes. `PermissionResolver.toResolvedIntent` is the **only** function that converts `access-path` → `path-values`, calling `path.matchValues()` exactly once. Its JSDoc already states the intent: "Tell-Don't-Ask: the resolver asks an `AccessPath` for its `matchValues()`, so the low-level manager never imports the value object." `PermissionManager.check` consumes `ResolvedAccessIntent` and has zero imports from `access-intent/access-path` — the manager's entire path contract is a `string[]` plus a surface name. ## Decision **Formalize the boundary: keep `path-values` as the manager's intentional string seam.** The invariant, stated as a three-part contract: 1. The resolver is the **sole** `matchValues()` unwrap site (`toResolvedIntent`), so the lexical ∪ canonical alias set ([#418]) is derived once, centrally. 2. The manager is **string-based**: `check()` consumes `ResolvedAccessIntent` (`tool | path-values`) and never imports `AccessPath`. 3. Path-awareness flows downward and **stops at the resolver** — the manager is a leaf with no `access-intent/access-path` dependency. To keep the invariant from eroding silently, an ESLint `no-restricted-imports` rule scoped to `permission-manager.ts` forbids importing `access-intent/access-path`, mirroring the existing `process.platform` `no-restricted-syntax` guard ([#510]). Collapsing the boundary would then require an explicit, reviewed lint exception rather than an unremarked import. This decision is non-breaking: no runtime behavior changes, no public type changes, no config changes. ## Consequences - The manager stays a string-matching leaf with a single responsibility — evaluate `(surface, string[])` against a ruleset — and no path semantics. - `matchValues()` keeps a single call site, so the [#418] lexical ∪ canonical alias derivation stays central. - The lint guard pins "the manager never imports `AccessPath`" deterministically, verified in CI via `pnpm run lint`. - Phase 7 Step 5 ([#506]) is the last open step; with Steps 1–4 already shipped, this closes Phase 7. ## Alternatives considered **Collapse the variant — move the `matchValues()` unwrap into the manager.** The manager's `check()` would accept the `access-path` variant directly and call `matchValues()` itself, deleting `PathValuesAccessIntent` / `ResolvedAccessIntent` and importing `AccessPath`. Rejected on three grounds: - **Single responsibility.** The manager evaluates `(surface, string[])` against a ruleset — a complete, testable contract with no path semantics. Collapsing grows the engine a second concern (path representation) it currently delegates away. - **Tell-Don't-Ask wash.** Collapse does not remove the `matchValues()` ask; it relocates the single unwrap one layer deeper, into the busier string-matching engine. - **Dependency direction.** Collapse widens the manager (a leaf) with an `AccessPath` import to save one nominal type (`PathValuesAccessIntent`) and one converter — removing a real seam for a nominal gain. The entire upside of collapse is one fewer named type and one fewer converter; the cost is the manager's lost string-engine invariant and a wider dependency surface. By the "structural reasons before extracting" and ISP heuristics, that is the wrong trade. [#418]: https://github.com/gotgenes/pi-packages/issues/418 [#487]: https://github.com/gotgenes/pi-packages/issues/487 [#502]: https://github.com/gotgenes/pi-packages/issues/502 [#503]: https://github.com/gotgenes/pi-packages/issues/503 [#506]: https://github.com/gotgenes/pi-packages/issues/506 [#510]: https://github.com/gotgenes/pi-packages/issues/510