Files
my-pi/pi-permission-system/docs/decisions/0002-path-values-string-boundary.md

86 lines
5.7 KiB
Markdown
Raw Permalink 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.
---
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 14 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