# Phase 10: Decide-once dispatch and bash-surface hardening

Phase 10 cleared the two repeated-discriminator families filed as planning input against the [target architecture](../architecture.md#the-authority-model) — tool-kind dispatch and the win32 path flavor — plus scheduled bash-surface work (advisory decomposition parity, indirection-wrapper flooring) and a documentation recipe.

## Findings (planned 2026-07-10)

Phase 9 completed the declared [authority model](../architecture.md#the-authority-model) target, so Phase 10 planning started from the doc's one remaining first-principles gap: the cross-session access intent ([remaining design work](../architecture.md#remaining-design-work) — principal identity and path portability across cwds).
Deep-tracing corroborated that gap as live code, not theory: `ForwardedPermissionRequest` is a stringly `(surface?, value?)` payload, the serving node's `ServingPolicy` normalizes a child's forwarded value against the **parent's** `PathNormalizer`/cwd, and pi-subagents' `WorkspaceProvider` seam makes cross-cwd children real.
The owner chose to defer that spine to a later phase (it remains the leading Phase 11 candidate) and focus Phase 10 on the two repeated-discriminator families filed as planning input ([#561], [#562]) plus scheduled bash-surface work ([#309], [#490]) and a docs recipe ([#521]).

Both discriminator families are cause-level Category C coupling flaws traced to the decide-once principle (OCP), not fallow findings — fallow is structurally blind to scattered one-line comparisons; the repeated-discriminator grep sweep found them.
[#562] additionally carries the must-agree security property: a leaf that misses the win32 case/separator fold is a silent permission bypass (the [#382]/[#508] class).

### Health metrics

| Metric                                                               | Baseline (2026-07-10)               | Phase 10 target                                                                                                                                                                 |
| -------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tool-kind discriminator sites (`src/`)                               | 21                                  | ≤ 4, all in `access-intent/tool-kind.ts` (met: 2, Step 2)                                                                                                                       |
| `platform === "win32"` sites (`src/`)                                | 13                                  | 1 (the `PathFlavor` construction) (met: 1, Step 3)                                                                                                                              |
| win32 match-fold derivations (`caseInsensitive` occurrences, `src/`) | 6                                   | ≤ 2 (met: 1 derivation — the sole `PathFlavor` literal; the grep reports 4, the other 3 being the intrinsic `WildcardMatchOptions` definition in `wildcard-matcher.ts`, Step 3) |
| Advisory bash fidelity                                               | whole-string match                  | decomposed parity with the gate (test-gated)                                                                                                                                    |
| Indirection-wrapper coverage                                         | inline-shell wrappers only ([#481]) | all listed wrappers floored to `ask` (`<indirection-bash-wrapper>`)                                                                                                             |
| Flat `src/` root modules                                             | 62                                  | ≤ 59 (`src/path/` seeded) (met: 59, Step 3)                                                                                                                                     |
| fallow health score                                                  | 88 (A)                              | ≥ 88                                                                                                                                                                            |
| Production duplication                                               | 0.2%                                | ≤ 0.2%                                                                                                                                                                          |

Recompute commands (run from the repo root):

- Tool-kind sites: `grep -rE 'toolName === "(bash|mcp)"|source === "mcp"' packages/pi-permission-system/src --include="*.ts" | wc -l`
- win32 sites: `grep -r 'platform === "win32"' packages/pi-permission-system/src --include="*.ts" | wc -l`
- Match-fold derivations: `grep -r 'caseInsensitive' packages/pi-permission-system/src --include="*.ts" | wc -l`
- Root modules: `ls packages/pi-permission-system/src | grep -c '\.ts$'`
- Health/duplication: `pnpm fallow health --score --workspace @gotgenes/pi-permission-system` / `pnpm fallow dupes --workspace @gotgenes/pi-permission-system`

### Open-issue sweep dispositions

- [#23] — closed as resolved-by-events (2026-07-10): the "no real-world consumers" premise is stale — `@gotgenes/pi-subagents` emits `<active_agent name="…"/>` in child system prompts, so the per-agent override system is live.
- [#561] — superseded by Steps 1–2 below; closed at issue-filing time with a pointer to the step issues.
- [#562], [#309], [#490], [#521] — scheduled as Steps 3–6 below.
- [#564] — mislabeled for this package: its targets are `packages/pi-github-tools/src/lib/ci.ts`; the `pkg:pi-permission-system` label was removed.
- [#519] — explicit deferral (recorded, not a silent sweep): blocked on Pi SDK UIContext surface evolution; revisit when the SDK exposes a custom-UI approval channel.
- [#472] — remains deferred by name; the Phase 9 spine is its extension point, and it warrants its own decision record before scheduling.
- [#565] — stays open as the non-gating post-ship observation of [#557].

## Steps

### ✅ Step 1: Introduce a tool-kind classification decided once at the normalize boundary ([#568])

**Cause:** the extraction question — "what does this invocation access: a bash command, an MCP target, or a path?"
— is a domain decision re-derived by silent string comparison at every consumer instead of decided once where the invocation enters the system ([#561]).
The 21 grep sites are the symptom; the cause is the missing dispatch point.

- **Smell:** Category C (repeated discriminator / OCP).
- **Target:** new `src/access-intent/tool-kind.ts` (the classification and its per-kind extraction product); migrate the extraction consumers: `input-normalizer.ts`, `tool-input-path.ts`, `handlers/gates/tool.ts`, `handlers/gates/tool-call-gate-pipeline.ts`, `permission-manager.ts`.
  Constraint: `permission-manager.ts` stays string-based per `docs/decisions/0002-path-values-string-boundary.md`; the classification value is plain data, safe to consume there.
- **Outcome:** extraction-family `toolName === "bash"`/`"mcp"` sites drop to 0 outside `access-intent/tool-kind.ts`; total family sites 21 → ≤ 12 (presentation family remains until Step 2).
- **Landed:** `access-intent/tool-kind.ts` (`ToolKind` + `classifyToolKind`) added; the five extraction consumers migrated onto it; total family 21 → 12 (all remaining sites are the presentation family Step 2 clears, plus one docstring inside `tool-kind.ts`). `getToolPermission`'s dead per-kind branches collapsed to a single `evaluate(…, "*", …)` in the same pass.
- **Impact 4 / Risk 2 / Priority 16.**

Release: batch "tool-kind-dispatch"

### ✅ Step 2: Move the presentation family onto the tool-kind product ([#569])

**Cause:** the same discriminator on the presentation side — prompt, preview, denial-message, and decision-value projections each re-decide the kind per formatter, including a private `isMcpCheck()` helper that two sibling call sites re-derive instead of sharing.

- **Smell:** Category C (repeated discriminator / OCP).
- **Target:** `tool-preview-formatter.ts`, `permission-prompts.ts`, `denial-messages.ts` (delete `isMcpCheck`), `handlers/gates/helpers.ts` (`deriveDecisionValue`).
- **Outcome:** total family sites 21 → ≤ 4, all inside `access-intent/tool-kind.ts` (the recompute command above hits the target).
- **Landed:** `isMcpCheck` promoted from a private `denial-messages.ts` helper to a shared export in `access-intent/tool-kind.ts` (keeps the `source === "mcp"` disjunct that `classifyToolKind` cannot express); the four presentation consumers (`denial-messages`, `permission-prompts`, `tool-preview-formatter`, `deriveDecisionValue`) migrated onto `classifyToolKind`/`isMcpCheck`, with the `&& target` guard hoisted to the call sites that display it.
  Recompute 12 → 2 (both inside `tool-kind.ts`: the docstring and the `isMcpCheck` disjunct); `deriveDecisionValue` became an exhaustive `switch`; suite +4 (`isMcpCheck` unit tests).
- **Impact 3 / Risk 1 / Priority 15.**

Release: batch "tool-kind-dispatch"

### ✅ Step 3: `PathFlavor` — pass the resolved win32 capability, not the raw platform ([#562])

**Cause:** the win32 path-interpretation mapping (path impl, case fold, separator fold, match options) is re-derived from a raw `platform: NodeJS.Platform` parameter at 13 sites; connascence of algorithm where one divergent leaf is a silent permission bypass (the [#382]/[#508] class).
The [#505]/[#510] seam fixed *where the platform is read* but threaded the raw discriminator instead of the resolved product.

- **Smell:** Category C (thread decisions, not discriminators).
- **Target:** new `src/path/path-flavor.ts`; relocate the co-rewritten leaves `path-containment.ts`, `canonicalize-path.ts`, `pi-infrastructure-read.ts` into `src/path/` (tidy-first: touched files reach their final home); edit `path-normalizer.ts`, `access-intent/path-normalization.ts`, `authority/subagent-context.ts`, `rule.ts` (`pathMatchOptions`), `permission-manager.ts`, and `index.ts` (single `PathFlavor` construction from the one `process.platform` read).
- **Outcome:** `platform === "win32"` sites 13 → 1; `caseInsensitive` derivations 6 → ≤ 2; flat `src/` root 62 → 59.
- **Landed:** `src/path/path-flavor.ts` added — `PathFlavor` is a behavioral collaborator (the platform's path *language*: `impl`, `matchOptions`, `fold`, `comparable`, `isWithin`, `hasPathSeparator`, `bashTokenShape`) resolved by `pathFlavorForPlatform` into one of two cached singletons holding the package's only `=== "win32"` comparison.
  `path-containment.ts` / `canonicalize-path.ts` / `pi-infrastructure-read.ts` relocated into `src/path/` (flat `src/` root 62 → 59); the standalone `isPathWithinDirectory` was dissolved onto `PathFlavor.isWithin` and `PathNormalizer.usesWindowsSeparators()` onto `PathFlavor.hasPathSeparator`.
  The flavor is injected once from `index.ts` into `PermissionManager` / `PermissionSession` (→ `PathNormalizer`) / `SubagentDetection`; `PathNormalizer` dropped both `!== "win32"` bash-token guards via `bashTokenShape` and now holds no platform discriminator.
  `platform === "win32"` code comparisons 13 → 1; the win32 match-options literal derivation 2 → 1; suite +16 (`path-flavor.test.ts`), net −5 across removed `usesWindowsSeparators`/duplicate classifier tests.
  Follow-up [#571] filed for the deferred `subagent-context` containment unification.
- **Impact 4 / Risk 2 / Priority 16.**

Release: independent

### ✅ Step 4: Advisory bash decomposition parity ([#309])

**Cause:** two answers to one question — the advisory `PermissionsService.checkPermission("bash", …)` matches the whole string while the gate decomposes, because the sync-by-contract service cannot await tree-sitter init; decision fidelity depends on which door you ask at.
Feasibility probe passed: `TSParser.parse` is synchronous once initialized (`BashProgram.parse` is async only for `await getParser()`), and the async `before_agent_start` hook precedes any tool call, so a warm-then-sync path exists.

- **Smell:** Category C (dual fidelity at a public boundary).
- **Target:** `access-intent/bash/parser.ts` (warm-up + sync accessor), `handlers/before-agent-start.ts` (warm-up trigger), `permissions-service.ts` / `input-normalizer.ts` (route bash advisory queries through `BashProgram.commands()` + `resolveBashCommandCheck`); the cold-start fallback stays whole-string.
- **Outcome:** an advisory chained-command query returns the gate's decomposed decision (test-gated); the public-semantics strengthening is noted in the release notes.
- **Impact 2 / Risk 3 / Priority 6** — scheduled by owner decision (2026-07-10) despite the low score; no external consumer exercises bash advisory queries yet.

Release: independent

### ✅ Step 5: Floor indirection wrappers ([#490])

**Cause:** enforcement authority is evadable through a wrapper — `sudo <cmd>` / `env VAR=x <cmd>` make the gated inner command an argument, so the rule that should decide never matches (least-privilege flaw, the [#481] class).
Direction confirmed 2026-07-12, superseding the earlier 2026-07-10 re-target proposal: floor **all** listed wrappers to `ask` like the opaque wrappers, rather than re-targeting prefix wrappers at the inner command — re-targeting would need a per-wrapper option-arity table whose errors silently under-match (a bypass), while the uniform floor needs none.

- **Smell:** Category C (boundary flaw in bash command enumeration).
- **Target:** `access-intent/bash/command-enumeration.ts` (the `wrapperKind` discriminant + `INDIRECTION_WRAPPER_NAMES` / `EXEC_CONDITIONAL_WRAPPERS` tables), `handlers/gates/bash-command.ts` (the `WRAPPER_SENTINEL` map), `docs/configuration.md` / `README.md` / this file / the package skill (+ tests).
- **Outcome:** `sudo`/`env`/`xargs`/`time`/`nohup`/`timeout`/`nice` and `find`/`fd` (with an exec flag) cannot ride a permissive allow — their `allow` is floored to `ask` with the `<indirection-bash-wrapper>` sentinel (test-gated); a bare `find`/`fd` search is unaffected.
- **Landed:** #481's `BashCommand.opaque?: boolean` generalized to a `wrapperKind?: "opaque-payload" | "indirection"` discriminant (byte-identical `<opaque-bash-wrapper>` sentinel preserved); `isOpaqueWrapperCommand` → `classifyWrapperCommand` gains `INDIRECTION_WRAPPER_NAMES` (always-invoke) and `EXEC_CONDITIONAL_WRAPPERS` (`find`/`fd` exec-flag-gated); the floor maps the kind to its sentinel via `WRAPPER_SENTINEL`.
  Follow-up [#575] filed to survey other exec-capable CLI rewrites.
- **Impact 4 / Risk 3 / Priority 12.**

Release: independent

### ✅ Step 6: Read-only bash allowlist recipe ([#521])

**Cause:** none (documentation) — answers a standing user question with a config pattern instead of new runtime mechanism, per the package's mechanism-is-forever preference.

- **Smell:** n/a (documentation).
- **Target:** `docs/configuration.md` (a "read-only command allowlist" recipe enumerating read-only commands as bash allow rules); close [#521] on ship.
- **Outcome:** documented recipe; issue closed.
- **Landed:** added the "Read-Only Bash Command Allowlist" recipe to `docs/configuration.md`'s Common Recipes — a conservative curated allowlist (file inspection, listing/metadata, search, comparison/hashing, system info, and enumerated `git` read subcommands) paired with `write`/`edit` deny and a `path` deny block.
  The prose documents the four safety nets that keep it safe (redirect gating on the `path` surface, the `find`/`fd` exec-flag floor, chain most-restrictive, and the wrapper floors), directly answering [#521]'s `find *` + `-exec` + chains question; `echo`/`printf`/`tee`/`sort`/`sed`/`awk` are omitted with a stated rationale.
- **Impact 2 / Risk 1 / Priority 10.**

Release: independent

## Step dependency diagram

```mermaid
flowchart TD
    S1["✅ Step 1 - Tool-kind classification decided once (#568)"] --> S2["✅ Step 2 - Presentation family onto the tool-kind product (#569)"]
    S1 -.->|"soft ordering — shared input-normalizer.ts churn"| S4["✅ Step 4 - Advisory bash decomposition parity (#309)"]
    S3["✅ Step 3 - PathFlavor + src/path/ domain (#562)"]
    S5["✅ Step 5 - Indirection-wrapper floor (#490)"]
    S6["✅ Step 6 - Read-only allowlist recipe (#521)"]
```

## Parallel tracks

- **Track A — tool-kind dispatch:** Steps 1 → 2.
- **Track B — win32 flavor:** Step 3.
- **Track C — bash surface:** Steps 4 and 5 (independent of each other; Step 4 prefers landing after Step 1 to avoid `input-normalizer.ts` churn, a soft ordering only).
- **Track D — docs:** Step 6.

## Release batches

- **Batch "tool-kind-dispatch":** Steps 1, 2 (ship together; tail = Step 2).
- Independently releasable: Steps 3, 4, 5, 6.

Release-type note: Steps 1–3 are `refactor:` (hidden changelog type — they batch into the next release rather than cutting one); Step 4 and Step 5 are behavior changes (`feat:`/`fix:`) that cut releases; Step 6 is an unhidden `docs:` change.

## Completion

All 6 steps are closed: [#568], [#569], [#562], [#309], [#490], [#521].
Follow-on issues filed during the phase: [#571] (unify `subagent-context` containment onto `PathFlavor.isWithin`) and [#575] (survey other exec-capable CLI rewrites for indirection-wrapper flooring); both remain open and non-gating.
Open issues swept and confirmed out of scope during planning: [#561] (superseded by Steps 1–2), [#564] (mislabeled for this package), [#519] (deferred — SDK `UIContext` evolution), [#472] (deferred — `ModelTriageAuthorizer`), [#565] (stays open — non-gating Phase 9 post-ship observation), [#23] (closed as resolved-by-events).

### Delivered vs. predicted metrics

Recomputed at archive time (`pnpm fallow health --score --workspace @gotgenes/pi-permission-system` / `pnpm fallow dupes --workspace @gotgenes/pi-permission-system`):

| Metric                                                               | Phase 10 target                              | Delivered                                                                                                                                               |
| -------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tool-kind discriminator sites (`src/`)                               | ≤ 4, all in `access-intent/tool-kind.ts`     | 2 — met                                                                                                                                                 |
| `platform === "win32"` sites (`src/`)                                | 1 (the `PathFlavor` construction)            | 1 — met                                                                                                                                                 |
| win32 match-fold derivations (`caseInsensitive` occurrences, `src/`) | ≤ 2                                          | 4 total occurrences (1 `PathFlavor` derivation + 3 intrinsic to `WildcardMatchOptions`) — met (target scoped to the derivation, not the raw grep count) |
| Advisory bash fidelity                                               | decomposed parity with the gate (test-gated) | delivered, test-gated — met                                                                                                                             |
| Indirection-wrapper coverage                                         | all listed wrappers floored to `ask`         | delivered — met                                                                                                                                         |
| Flat `src/` root modules                                             | ≤ 59 (`src/path/` seeded)                    | 59 at Step 3 landing; 60 at phase close (`bash-advisory-check.ts` added by Step 4) — met at Step 3, expected drift after                                |
| fallow health score                                                  | ≥ 88                                         | 88 (A) — met                                                                                                                                            |
| Production duplication                                               | ≤ 0.2%                                       | 0.2% (58 lines, 2 clone groups) — met                                                                                                                   |

[#23]: https://github.com/gotgenes/pi-packages/issues/23
[#309]: https://github.com/gotgenes/pi-packages/issues/309
[#382]: https://github.com/gotgenes/pi-packages/issues/382
[#472]: https://github.com/gotgenes/pi-packages/issues/472
[#481]: https://github.com/gotgenes/pi-packages/issues/481
[#490]: https://github.com/gotgenes/pi-packages/issues/490
[#505]: https://github.com/gotgenes/pi-packages/issues/505
[#508]: https://github.com/gotgenes/pi-packages/issues/508
[#510]: https://github.com/gotgenes/pi-packages/issues/510
[#519]: https://github.com/gotgenes/pi-packages/issues/519
[#521]: https://github.com/gotgenes/pi-packages/issues/521
[#557]: https://github.com/gotgenes/pi-packages/issues/557
[#561]: https://github.com/gotgenes/pi-packages/issues/561
[#562]: https://github.com/gotgenes/pi-packages/issues/562
[#564]: https://github.com/gotgenes/pi-packages/issues/564
[#565]: https://github.com/gotgenes/pi-packages/issues/565
[#568]: https://github.com/gotgenes/pi-packages/issues/568
[#569]: https://github.com/gotgenes/pi-packages/issues/569
[#571]: https://github.com/gotgenes/pi-packages/issues/571
[#575]: https://github.com/gotgenes/pi-packages/issues/575
