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

5.7 KiB
Raw Blame History

status, date
status date
accepted 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-pathpath-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-pathpath-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.