feat: vendor permission system source

This commit is contained in:
云服务部-叶林立
2026-08-19 14:35:19 +08:00
parent 198584daf8
commit 410c50a3e5
809 changed files with 157793 additions and 139 deletions
@@ -0,0 +1,460 @@
---
issue: 744
issue_title: "pi-permission-system: introduce the structured PromptPayload and dissolve the five prompt-assembly sites"
---
# Structured `PromptPayload` and the dissolution of the ask-prompt assembly sites
## Release Recommendation
**Release:** mid-batch — defer (batch "presentation-payload"); confirm at ship time
Phase 13 Step 1 is the first member of the `presentation-payload` batch (Steps 1 and 2); the tail is Step 2 ([#710]), whose `fix:` is the batch's release vehicle.
Every commit in this plan is a hidden changelog type (`refactor:` / `test:` / `docs:`), so this step cuts no release on its own even if the release-please PR is merged.
Leave the release-please PR unmerged until Step 2 lands.
## Problem Statement
Presentation is fused with decision-making.
Each gate renders its facts into an English sentence at the point of decision, and that flat `string` becomes `PromptPermissionDetails.message`, travelling unchanged to the inline TUI dialog, the `select`/`input` fallback, the review log, the `permissions:ui_prompt` broadcast, and the on-disk forwarded request.
Because the payload is a pre-rendered sentence, elision is a property of the payload rather than of a render.
That is the direct cause of the open items [ADR 0011] catalogues: the bash branch has no cap at all, nothing bounds height ([#710]), and a forwarded ask is assembled twice under two different configs, so consistency across local and forwarded asks is structurally unattainable.
ADR 0011 §2 states the rule this step implements: the payload is complete by contract, and elision is a property of a render, never of the payload.
Nothing downstream can be bounded until the payload exists, so this step is the prerequisite for the rest of Phase 13.
### Correction: six assembly sites, not five
The issue body and ADR 0011 §"What the code did before this decision" both enumerate **five** sites.
A grep of `src/` for the shared subject idiom (`Agent '<name>'` / `Current agent`) finds a sixth:
```bash
grep -rn "Current agent" packages/pi-permission-system/src --include="*.ts"
```
The complete inventory of ask-message producers at `main`:
| # | Function | File | Consumers |
| --- | -------------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
| 1 | `formatAskPrompt` (bash / MCP / generic branches) | `src/permission-prompts.ts` | `handlers/gates/tool.ts` |
| 2 | `formatSkillAskPrompt` | `src/permission-prompts.ts` | `handlers/gates/skill-input.ts` |
| 3 | `formatSkillPathAskPrompt` | `src/permission-prompts.ts` | `handlers/gates/skill-read.ts` |
| 4 | `formatExternalDirectoryAskPrompt` | `src/handlers/gates/external-directory-messages.ts` | `handlers/gates/external-directory.ts` |
| 5 | `formatBashExternalDirectoryAskPrompt` | `src/handlers/gates/external-directory-messages.ts` | `handlers/gates/bash-external-directory.ts` |
| 6 | `formatPathAskPrompt` | `src/handlers/gates/path.ts` | `handlers/gates/path.ts`, `handlers/gates/bash-path.ts` |
| 7 | `formatForwardedPermissionPrompt` (module-private) | `src/authority/forwarded-request-server.ts` | the serving node's ask |
Site 6 is the one both the issue and the ADR omit, and it has two consumers.
Seven gate descriptors set `promptDetails.message`: `tool.ts`, `path.ts`, `bash-path.ts`, `external-directory.ts`, `bash-external-directory.ts`, `skill-input.ts`, `skill-read.ts`.
`ToolPreviewFormatter.formatToolInputForPrompt` feeds site 1 and is the per-tool preview the issue counts among the five; it becomes an evidence entry rather than a migrated module (see Non-Goals).
### Correction: `executedUnit` has no existing source
Issue [#713] cites `classifyAndExtractWrapper`, a `payloadText: null` return, and a `STRIPPABLE_WRAPPERS` set.
None of those exist in `src/access-intent/bash/command-enumeration.ts` at `main`.
What exists is `classifyWrapperCommand`, which returns only a `WrapperKind` discriminant — it *flags* a wrapper and never extracts the inner command.
So `timeout 10 grep foo` does **not** currently surface `grep foo`; `PermissionCheckResult.command` carries the whole wrapper text for every wrapper kind, strippable or not.
Populating `request.executedUnit` therefore requires new extraction logic, planned below.
## Goals
- Add `PromptPayload` — the `request` invariant core (ADR 0011 §3), the complete `evidence` list, and the `annotations` slot — as the structured description of a permission ask.
- Seed `src/presentation/` and land the six ask-prompt assemblers there as payload builders, so the modules the spine rewrites reach their final home the first time.
- Derive `message` *from* the payload via a single transitional `renderLegacyMessage(payload)`, so the existing prompt-text tests become the payload's completeness proof.
- Carry the executed unit of a bash wrapper as a payload fact ([#713]'s fact, display-only, never gating).
- Keep behavior byte-identical: every rendered `message` string is unchanged, pinned by the existing suite.
This change is **not** breaking.
It adds optional-then-required internal fields and two new public types; no existing observable behavior, output shape, or default changes.
## Non-Goals
- Any renderer change — the dialog, fallback, log, broadcast, and agent renderers all keep reading `message` (Steps 2, 3, 4: [#710], [#745], [#746]).
- Replacing `message` on the forwarded wire or the `permissions:ui_prompt` broadcast (Step 3, [#745]).
- Writing the payload to the review log — `logContext.message` stays the rendered string, so ADR 0010's log-growth bound is untouched (Step 4, [#746]).
- Migrating `tool-preview-formatter.ts`, `tool-input-prompt-formatters.ts`, or `tool-input-preview.ts` into `src/presentation/` — they also serve `getPermissionLogContext` on the review-log path, which Step 4 owns.
Their prompt output is wrapped as an evidence entry instead.
- Migrating `denial-messages.ts` (Step 4) — it keeps owning `resolvesToSuffix`, `ExternalPathDisclosure`, `matchQualifier`, and `describeBashCommandContext`, which the presentation modules import.
- **Gating** the extracted inner command.
[#713]'s "gateable extraction" option is explicitly declined: the wrapper floor ([#490]) stands unchanged, `executedUnit` is display-only, and no `BashCommand` unit is added or removed.
- The annotator and evidence-formatter registries (ADR 0011 §8) — the `annotations` slot lands empty.
- Soft-deprecating `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` (Step 3, ADR 0011 §5).
- `docs/architecture/v3-architecture.md` — a frozen design-era snapshot, not maintained as current state (established at [#437], [#559]).
## Background
### Modules in play
- `src/handlers/gates/descriptor.ts``GateDescriptor.promptDetails: Omit<PromptPermissionDetails, "requestId">`, the single funnel every gate's prompt facts pass through.
- `src/authority/permission-prompter.ts` — declares `PromptPermissionDetails`, and writes `details.message` to the review log.
- `src/service.ts:39` re-exports `PromptPermissionDetails`, so it is part of the **public** `dist/public.d.ts` bundle, gated by `scripts/verify-public-types.sh`.
- `src/access-intent/bash/command-enumeration.ts``BashCommand` (`text`, `context?`, `wrapperKind?`), `classifyWrapperCommand`, `readWrapperCommand`, `commandUnitText`, `INDIRECTION_WRAPPER_NAMES`, `EXEC_CONDITIONAL_WRAPPERS`, `SHELL_WRAPPER_NAMES`.
`BashCommand`'s doc comment names the type as the stable extension point ([#306] added `context`).
- `src/handlers/gates/bash-command.ts``resolveBashCommandCheck` maps each `BashCommand` to a `PermissionCheckResult` and tags the winner with `commandContext`; the same place can tag `executedUnit`.
- `src/denial-messages.ts` — the established precedent for this change's shape: a structured `DenialContext` discriminated union rendered at the edge, which ADR 0011 §7 explicitly cites approvingly.
### Constraints from AGENTS.md and the package skill
- ADR 0002's string boundary: `permission-manager.ts` must not import `AccessPath`.
The payload carries only strings and enums, so it never crosses that boundary.
- No `process.platform` read inside `src/` outside `index.ts` — the new modules read none.
- `docs/architecture/architecture.md` module-tree entries describe current behavior; cite an issue only when the ref encodes an active constraint.
- The roadmap step must be marked `✅` (heading + Mermaid node) in the implementation doc-update commit, not deferred to ship.
- Health-metric rows naming symbols the phase has not created yet must either use the roadmap's name or be updated in the same commit.
### Measured baselines (2026-08-15)
| Metric | Command | Baseline | Target |
| --------------------------- | ---------------------------------------------------------------------------------------- | -------- | ------ |
| Flat-assembler sites | `grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" \| wc -l` | 4 | 0 |
| `src/presentation/` present | `ls packages/pi-permission-system/src \| grep -c presentation` | 0 | 1 |
Both were run at planning time against `main`; the numbers are measured, not estimated.
### Collision: PR #738
PR [#738] ("Highlight the flagged command, path, or target in TUI permission prompts", opened 2026-08-14) touches `tool.ts`, `path.ts`, `bash-path.ts`, `external-directory.ts`, `bash-external-directory.ts`, `permission-prompter.ts`, `forwarded-request-server.ts`, and `permission-prompt-component.ts` — nearly every file this step rewrites.
It is untriaged and unmentioned in the roadmap's open-issue sweep or in ADR 0011.
Under ADR 0011 highlighting is a **render** concern, exactly as [#716]'s aligned one-fact-per-line intent is.
Disposition (operator-decided at planning): its intent is adopted in Step 2's dialog renderer with authorship credited, and the PR is closed as superseded rather than rebased.
This plan records the disposition in the roadmap; the comment and close happen at ship time.
## Design Overview
### The payload
```typescript
/** Which ask this payload describes; the renderers' dispatch discriminant. */
export type PromptPayloadKind =
| "bash"
| "mcp"
| "tool"
| "path"
| "external_directory"
| "bash_external_directory"
| "skill"
| "skill_read"
| "forwarded";
/**
* One piece of decision evidence. Complete on the payload; each renderer elides
* to fit its own budget (ADR 0011 §2, §4).
*/
export interface PromptEvidence {
readonly label: string;
readonly text: string;
/**
* A secondary fact bound to this entry that a renderer may show alongside or
* elide independently — e.g. a symlink-resolved alias for an external path.
*/
readonly detail: string | null;
}
/** A model-generated advisory; the slot owns its attribution and marking. */
export interface PromptAnnotation {
readonly source: string;
readonly text: string;
}
/** The invariant core: facts no renderer may elide (ADR 0011 §3). */
export interface PromptRequestFacts {
readonly requester: {
readonly agentName: string | null;
readonly forwarded: boolean;
readonly sessionId: string | null;
};
/** The gate surface the rule fired on. */
readonly surface: string;
/** The gated tool name; `null` when the ask is not tool-shaped. */
readonly toolName: string | null;
/** The invoked tool name when a shell alias re-exposes bash (#574); else `null`. */
readonly invokedToolName: string | null;
/** The decision-relevant value — the gate's own decision value. */
readonly value: string;
/** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
readonly matchedPattern: string | null;
/** The offending bash unit's execution context, when nested. */
readonly commandContext: BashCommandContext | null;
/** The unit that will actually run inside a wrapper (#713); `null` when it adds nothing. */
readonly executedUnit: string | null;
}
export interface PromptPayload {
readonly kind: PromptPayloadKind;
readonly request: PromptRequestFacts;
readonly evidence: readonly PromptEvidence[];
readonly annotations: readonly PromptAnnotation[];
}
```
Three deliberate divergences from ADR 0011 §2's illustrative sketch, each recorded here because the ADR assigns the exact types to this issue:
1. **`kind` discriminant.**
The sketch has no discriminant, but nine distinct message shapes must be regenerated, and `(surface, source)` does not separate them — the tool external-directory ask and the bash external-directory ask share surface `external_directory`, and the path gate and the per-tool gate differ only in wording.
A `kind` mirrors `DenialContext`'s existing discriminated union, which ADR 0011 §7 already names as the shape to copy, and it gives every renderer an exhaustive `switch` with a `never` guard instead of a set of string comparisons a new variant sails past.
2. **`matchedPattern: string | null`, not `string | undefined`.**
Step 3 puts the payload on the on-disk forwarded wire, where `undefined` does not survive JSON.
The package already made this call for the same reason — `accessFactsFromPath` maps an empty `boundaryValue()` to `null` "so the wire distinguishes 'no canonical form' cleanly".
Uniform `| null` across the payload keeps Step 3 from re-deciding it field by field.
3. **`commandContext` on the request facts.**
Today's bash prompt renders `matchQualifier(matchedPattern, commandContext)`, e.g. `(matched 'rm *', inside command substitution)`.
The matched rule is invariant-core by ADR 0011 §3.3, and the context is what makes it intelligible — so the raw `BashCommandContext` enum is a request fact and `describeBashCommandContext` stays a render.
Putting the rendered qualifier in `evidence` instead would put a pre-rendered clause back in the payload, which is the thing being removed.
`evidence` is a list, not a record, because ADR 0011 §4 lets a renderer elide entries and order them under its own budget.
### The builders and the transitional renderer
The gate computes its decision value already; the builder receives it rather than re-deriving it, so `request.value` is by construction the same string the decision event carries — and `src/presentation/` never imports from `src/handlers/`.
Consumer call site (`describeToolGate`, ~5 lines, replacing the `formatAskPrompt` call):
```typescript
const decisionValue = deriveDecisionValue(gateSurface, check, toolPath);
const payload = buildToolAskPayload({
check,
agentName: tcc.agentName,
invokedToolName: shell ? tcc.toolName : null,
value: decisionValue,
input: tcc.input,
formatter,
});
const askMessage = renderLegacyMessage(payload);
```
The gate hands over the facts it holds and asks for nothing back but the payload; the message is a render over that payload, not a second assembly.
`renderLegacyMessage` reads only the payload — that is what makes the existing byte-identity tests a completeness proof.
Evidence contributed per kind, and what the legacy render does with it:
| Kind | Evidence entries | Legacy render |
| ------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `bash` | `full command` when it differs from the unit | `(full command: '…')` |
| `mcp` / `tool` | `input` when the preview is non-empty | appended verbatim |
| `path` | none | — |
| `external_directory` | `resolves to` (when the canonical alias differs), `working directory` | `(resolves to '…')` + `outside working directory '…'` |
| `bash_external_directory` | `working directory`, then one `external path` per disclosure with `detail` = canonical alias | the comma-joined path list |
| `skill` | none | — |
| `skill_read` | `read path` | `via '…'` |
| `forwarded` | `requested` = the child's relayed `message` | the three-line prefix + that text |
`renderLegacyMessage` is transitional and deliberately label-coupled to those entries.
It is deleted when the last `message` reader goes — Step 3 for the wire and broadcast, Step 4 for the review log.
The `forwarded` kind is the version-skew reality until Step 3: the child ships a pre-rendered sentence, so the serving node's payload carries it as a single evidence entry.
Step 3 replaces that entry with the child's own payload.
### Executed-unit extraction
New pure module `src/access-intent/bash/executed-unit.ts`, consumed only by `command-enumeration.ts`:
```typescript
/**
* The command that will actually run inside a wrapper unit, or `null` when it
* cannot be established. Display-only: never gated, never a `BashCommand`.
*/
export function extractExecutedUnit(node: TSNode, kind: WrapperKind): string | null;
```
Interaction with its upstream dependency is a read of the same `command` node `classifyWrapperCommand` already inspects, through the same shallow named-child walk `readWrapperCommand` performs — no re-parse, no async, no mutation of the node, and no second traversal of the program.
Algorithm:
- **`opaque-payload`** — take the inline-shell payload argument (the first non-flag argument after the `-c` cluster for a shell; the first argument for `eval`) and strip one layer of matching surrounding quotes.
The payload is an inner *program*, so it is unquoted rather than sliced.
- **`indirection`** — skip the wrapper name, then skip leading `variable_assignment` children and leading option tokens, consuming a following value token for options in a curated per-wrapper value-taking set (`sudo -u`, `xargs -n/-P/-I/-d/-L/-s/-a/-E`, `timeout`'s leading duration and `-s/-k`, `nice -n`, `env -u/-C`, `stdbuf -i/-o/-e`, `watch -n`, `flock`'s leading file/fd and `-w/-E`, `time -o/-f`, `doas -u/-C`).
The executed unit is then `node.text` sliced from the first remaining child's `startIndex`, exactly as `commandUnitText` slices past an assignment prefix — so spacing and quoting are preserved verbatim.
- **exec-conditional (`find`/`fd`)** — slice from the token after the matched exec flag, up to and including a `;`/`+` terminator for `find`, to the end for `fd`.
- **Nesting** — re-apply while the remainder's head basename is itself a wrapper name (`sudo timeout 5 xargs grep foo``grep foo`), capped at a documented depth.
- **Fail to `null`, never to a guess** — an empty remainder, a remainder whose head still begins with `-`, a bare `xargs` (which defaults to `echo`), or an exceeded depth yields `null`.
A curated per-wrapper table matches the package's existing convention (`src/bash-arity.ts` is a curated dictionary; it holds session-approval prefix arity, not flag arity, so it is not reusable here).
Because the fact is display-only and never gates, an imprecise entry is a cosmetic defect and never a permission bypass — and the fail-to-`null` rule keeps it from being a *misleading* one on a decision surface.
Plumbing, one field per hop, following `commandContext`'s existing path:
1. `BashCommand.executedUnit?: string` — set by `makeUnit` when `classifyWrapperCommand` returns a kind and extraction succeeds.
2. `PermissionCheckResult.executedUnit?: string` — tagged onto the winning result in `resolveBashCommandCheck`, alongside the existing `commandContext` tag.
3. `PromptRequestFacts.executedUnit: string | null` — the builder emits `null` when the extracted unit equals `request.value`, so a render shows it only when it adds information.
### `PromptPermissionDetails.payload`
`payload` is added optional, populated at every one of the seven descriptor sites plus the forwarded server, then **tightened to required** in the final cycle.
Required is the point: it makes "every ask has a complete structured payload" a compile-time guarantee rather than a convention, which is exactly Step 1's stated outcome.
`PromptPermissionDetails` is re-exported through `src/service.ts`, so `PromptPayload` and its member types enter `dist/public.d.ts` transitively via the rollup-dts bundle.
An external `Authorizer` link *consumes* details and is unaffected by a widened type; only a constructor breaks, and the only constructors are in this package and its tests.
## Module-Level Changes
### Added
| Path | Contents |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `src/presentation/prompt-payload.ts` | `PromptPayloadKind`, `PromptEvidence`, `PromptAnnotation`, `PromptRequestFacts`, `PromptPayload` |
| `src/presentation/legacy-message.ts` | `renderLegacyMessage(payload)` — exhaustive `switch` on `kind` with a `never` guard; imports `resolvesToSuffix` / `matchQualifier` from `denial-messages.ts` |
| `src/presentation/tool-ask-payload.ts` | `buildToolAskPayload` (bash / MCP / generic branches) |
| `src/presentation/path-ask-payload.ts` | `buildPathAskPayload`, `buildExternalDirectoryAskPayload`, `buildBashExternalDirectoryAskPayload` |
| `src/presentation/skill-ask-payload.ts` | `buildSkillAskPayload`, `buildSkillPathAskPayload` |
| `src/presentation/forwarded-ask-payload.ts` | `buildForwardedAskPayload` |
| `src/access-intent/bash/executed-unit.ts` | `extractExecutedUnit(node, kind)` + the curated per-wrapper value-taking flag table |
| `test/helpers/presentation-fixtures.ts` | `makePermissionCheckResult`, `makeToolPreviewFormatter` (tidy-first prep) |
| `test/helpers/prompt-details-fixtures.ts` | `makePromptDetails`, `makePromptPayload` |
| `test/presentation/*.test.ts` | one file per new `src/presentation/` module |
| `test/access-intent/bash/executed-unit.test.ts` | extraction unit tests |
### Changed
| Path | Change |
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/permission-prompts.ts` | `formatAskPrompt`, `formatSkillAskPrompt`, `formatSkillPathAskPrompt` **removed**; retains `formatMissingToolNameReason` and `formatUnknownToolReason` (agent-facing pre-check text, Step 4's territory) |
| `src/handlers/gates/path.ts` | `formatPathAskPrompt` **removed**; builds the payload, derives `message` |
| `src/handlers/gates/bash-path.ts` | switches to `buildPathAskPayload` (its `formatPathAskPrompt` import dies with the export) |
| `src/handlers/gates/external-directory-messages.ts` | **deleted** — both functions become payload builders |
| `src/handlers/gates/external-directory.ts` | builds the payload, derives `message` |
| `src/handlers/gates/bash-external-directory.ts` | builds the payload, derives `message` |
| `src/handlers/gates/tool.ts` | builds the payload; `decisionValue` computed before the payload |
| `src/handlers/gates/skill-input.ts` | builds the payload, derives `message` |
| `src/handlers/gates/skill-read.ts` | builds the payload, derives `message` |
| `src/authority/forwarded-request-server.ts` | `formatForwardedPermissionPrompt` (module-private) replaced by `buildForwardedAskPayload`; `buildForwardedAskDetails` attaches the payload and still projects only `surface` / `matchValues` / `boundaryValue` off the intent |
| `src/authority/permission-prompter.ts` | `PromptPermissionDetails.payload: PromptPayload` (optional, then required); `writeReviewEntry` unchanged — it keeps logging `details.message` |
| `src/access-intent/bash/command-enumeration.ts` | `BashCommand.executedUnit?: string`; `makeUnit` accepts it; `classifyWrapperCommand`'s call site invokes `extractExecutedUnit` |
| `src/handlers/gates/bash-command.ts` | tags `executedUnit` onto the winning result next to `commandContext` |
| `src/types.ts` | `PermissionCheckResult.executedUnit?: string` |
| `scripts/verify-public-types.sh` | add `PromptPayload` to the required-symbol list |
### Test files touched
- `test/permission-prompts.test.ts` — string assertions for the three removed functions move to `test/presentation/legacy-message.test.ts` and the builder tests; the file shrinks to the two pre-check reason functions.
- `test/handlers/gates/external-directory-messages.test.ts`**deleted**; cases split between `test/presentation/path-ask-payload.test.ts` and `test/presentation/legacy-message.test.ts`.
- `test/handlers/external-directory-integration.test.ts:47` — the `formatExternalDirectoryAskPrompt is a callable function` case dies with the export; drop it.
- `test/denial-messages.test.ts`, `test/tool-preview-formatter.test.ts` — local `PermissionCheckResult` builders and `ToolPreviewFormatter` options literals replaced by the shared fixtures.
- `test/handlers/gates/{tool,path,bash-path,external-directory,skill-input,skill-read}.test.ts` and `test/bash-external-directory.test.ts` — keep asserting `promptDetails.message` byte-for-byte, and gain a `promptDetails.payload` assertion.
- `test/authority/{local-user-authorizer,delegation-envelope,authorizer-selection,permission-prompter,forwarded-request-server,authorizer-chain}.test.ts` — six files construct `PromptPermissionDetails` literals; migrated onto `makePromptDetails` in the cycle that makes `payload` required.
### Documentation
- `docs/architecture/architecture.md`:
- Module tree: add the `src/presentation/` subtree; drop the `external-directory-messages.ts` entry (line ~779); reword the `permission-prompts.ts` entry (line ~818) to pre-check error messages only; add `access-intent/bash/executed-unit.ts`.
- `## Prompt presentation` section: the "Today five sites still assemble a flat `message` string" paragraph is stale on landing — rewrite it to state that the payload exists and the renderers are next, and correct the count to six.
- Phase 13 Findings paragraph (line ~866): correct "five sites" to six and name `formatPathAskPrompt`.
- Step 1 heading and the `S1` Mermaid node marked `✅`, with a `Landed:` note.
- Health metrics: flat-assembler sites 4 → 0, `src/presentation/` present 0 → 1.
- Open-issue sweep dispositions: add the PR [#738] line (intent adopted in Step 2, authorship credited, PR closed as superseded).
- ADR 0011 is **not** edited — its "What the code did before this decision" section is a historical record.
- `docs/architecture/v3-architecture.md` is **not** edited (frozen snapshot).
- `.pi/skills/package-pi-permission-system/SKILL.md` — greped for every removed symbol and for `presentation`; no hits, no edit.
## Test Impact Analysis
**Newly possible.**
The payload builders are pure functions over facts, so each surface's *facts* can be asserted directly (`request.matchedPattern`, `request.executedUnit`, evidence labels) instead of only through an English sentence.
`extractExecutedUnit` becomes unit-testable against a parsed node, which no existing test could reach — today the wrapper's inner command is never computed anywhere.
`renderLegacyMessage` is testable from a hand-built payload with no gate, no resolver, and no formatter.
**Becomes redundant.**
`test/permission-prompts.test.ts`'s ~20 `formatAskPrompt` cases and `test/handlers/gates/external-directory-messages.test.ts` in full are string-shape tests of functions that cease to exist.
They are not deleted but **relocated**: each becomes a `renderLegacyMessage` case (same expected string, payload input) plus, where it pins a fact rather than wording, a builder case.
That relocation is what turns them into the completeness proof.
**Must stay as-is.**
The seven gate descriptor tests asserting `promptDetails.message` — they exercise the whole gate → builder → renderer path end to end and are the byte-identity guard for the whole step.
`test/permission-ui-prompt.test.ts` and the authority tests reading `details.message` stay untouched: Step 1 changes no consumer.
## Invariants at Risk
| Invariant | Source | Pinned by | Action |
| -------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| An aliased shell tool gates on `bash` while the invoked tool name is preserved for display and the log | [#574] | `test/handlers/gates/tool.test.ts` | `invokedToolName` is additive; the legacy render ignores it, so the message is unchanged |
| A wrapper's `allow` is floored to `ask` with the `<opaque-bash-wrapper>` / `<indirection-bash-wrapper>` sentinel | [#481], [#490] | `test/handlers/gates/bash-command.test.ts` | extraction adds a field to `BashCommand`; it must add or remove no unit and change no `wrapperKind` |
| An unparseable command fails closed to `<unparseable-bash-command>` unless an explicit deny covers it | [#712] | `test/handlers/gates/bash-command.test.ts` | the sentinel flows into `request.matchedPattern` unchanged |
| A forwarded ask's details carry `surface` / `matchValues` / `boundaryValue` and **not** `requesterCwd` / `principal` | [#635] | `test/authority/forwarded-request-server.test.ts` | add an explicit assertion that the new `payload` smuggles neither |
| The `permissions:ui_prompt` broadcast's forwarded provenance is non-degraded | [#292], [#610] | `test/permission-ui-prompt.test.ts` | `buildUiPrompt` is untouched in this step |
| The review log's growth is bounded; a complete payload is never persisted verbatim | ADR 0010 | `test/authority/permission-prompter.test.ts` | `writeReviewEntry` keeps logging `details.message`; assert the payload is absent from the entry |
Quantitative invariant: **every rendered `message` is byte-identical**.
The measurement is the existing suite, not an argument — the relocated string assertions run against `renderLegacyMessage`, and the seven descriptor tests run against the full path.
Any deviation is a red test, not a review judgment.
## TDD Order
1. **Tidy-first prep — shared presentation fixtures.**
Extract `makePermissionCheckResult` and `makeToolPreviewFormatter` into `test/helpers/presentation-fixtures.ts`; migrate the six local factories in `test/denial-messages.test.ts`, `test/permission-prompts.test.ts`, and `test/tool-preview-formatter.test.ts`.
Suite stays green throughout.
`test(pi-permission-system): extract shared presentation test fixtures (#744)`
2. **Executed-unit extraction (red → green).**
`test/access-intent/bash/executed-unit.test.ts`: opaque payloads (`bash -c 'rm x'`, `sh -ec "…"`, `eval "…"`), plain indirection (`sudo aws s3 rm`, `sudo -u root aws s3 rm`, `xargs grep foo`, `xargs -0 -n1 grep foo`, `timeout 10 grep foo`, `nice -n 5 make`, `env FOO=bar grep foo`), exec-conditional (`find . -name '*.ts' -exec grep foo {} \;`, `fd -x rm`), nesting (`sudo timeout 5 xargs grep foo`), and the `null` cases (bare `xargs`, unresolvable remainder, depth cap).
Green: `src/access-intent/bash/executed-unit.ts`.
`refactor(pi-permission-system): extract the executed unit of a bash wrapper (#744)`
3. **Carry `executedUnit` to the check result.**
Red in `test/access-intent/bash/command-enumeration.test.ts` (a wrapper unit carries `executedUnit`; unit count and `wrapperKind` unchanged) and `test/handlers/gates/bash-command.test.ts` (the winning wrapper result carries it).
Green: `BashCommand.executedUnit`, `makeUnit`, `PermissionCheckResult.executedUnit`, the `resolveBashCommandCheck` tag.
`refactor(pi-permission-system): carry the wrapper's executed unit on the check result (#744)`
4. **Payload types and the transitional renderer.**
Red: `test/presentation/legacy-message.test.ts` renders all nine kinds from hand-built payloads, asserting the exact strings the current prompt tests assert.
Green: `src/presentation/prompt-payload.ts` and `src/presentation/legacy-message.ts` (exhaustive switch, `never` guard).
No production call site changes yet.
`refactor(pi-permission-system): add PromptPayload and the transitional message renderer (#744)`
5. **Tool / bash / MCP builder; `describeToolGate` migrated.**
Red: `test/presentation/tool-ask-payload.test.ts`.
Green: `src/presentation/tool-ask-payload.ts`; `tool.ts` builds the payload and derives `message`; `formatAskPrompt` removed and `test/permission-prompts.test.ts` migrated in the same commit (removing an export breaks its importers at the type level).
`refactor(pi-permission-system): build the tool ask payload in the presentation domain (#744)`
6. **Path and external-directory builders; four descriptors migrated.**
Red: `test/presentation/path-ask-payload.test.ts`.
Green: `src/presentation/path-ask-payload.ts`; `path.ts`, `bash-path.ts`, `external-directory.ts`, `bash-external-directory.ts` migrated; `formatPathAskPrompt` and `src/handlers/gates/external-directory-messages.ts` removed; `test/handlers/gates/external-directory-messages.test.ts` deleted and the `external-directory-integration.test.ts` callable-function case dropped — all in one commit.
`refactor(pi-permission-system): build the path ask payloads in the presentation domain (#744)`
7. **Skill builders; two descriptors migrated.**
Red: `test/presentation/skill-ask-payload.test.ts`.
Green: `src/presentation/skill-ask-payload.ts`; `skill-input.ts` and `skill-read.ts` migrated; the two skill formatters removed from `permission-prompts.ts` with their tests migrated in the same commit.
`refactor(pi-permission-system): build the skill ask payloads in the presentation domain (#744)`
8. **Forwarded builder; `payload` tightened to required.**
Red: `test/presentation/forwarded-ask-payload.test.ts`, plus a `forwarded-request-server.test.ts` case asserting the payload carries no `requesterCwd` / `principal`, and a `permission-prompter.test.ts` case asserting the review entry still logs `message` and not the payload.
Green: `src/presentation/forwarded-ask-payload.ts`; `forwarded-request-server.ts` migrated; `PromptPermissionDetails.payload` made required; `test/helpers/prompt-details-fixtures.ts` added and the six authority test files migrated onto it; `PromptPayload` added to `scripts/verify-public-types.sh`.
One commit — tightening an optional field to required breaks every constructor at the type level.
`refactor(pi-permission-system): require a complete PromptPayload on every ask (#744)`
9. **Documentation.**
The architecture-doc updates listed above, including the Step 1 `✅` marks and the recomputed health-metric rows.
`docs(pi-permission-system): record the prompt payload seam and mark Phase 13 Step 1 (#744)`
Every commit is a hidden changelog type.
`refactor:` is correct for cycles 28 because no observable behavior changes: the extracted unit is computed but rendered nowhere until Step 2, and the payload's only reader is the renderer that reproduces today's strings.
## Risks and Mitigations
| Risk | Mitigation |
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| The curated wrapper flag table is wrong for some invocation, so `executedUnit` misleads on a decision surface | Fail-to-`null` rather than fail-to-guess; display-only and never gating; nothing renders it until Step 2, so a defect cannot reach a user in this step |
| `renderLegacyMessage`'s label coupling to evidence entries silently drifts | Exhaustive `switch` with a `never` guard, plus the relocated byte-identity suite over every kind; the module is transitional and deleted by Step 4 |
| A relocated string test loses a case during the move | Cycles 57 each move one file's cases and the descriptor tests keep asserting `message` end to end, so a lost case shows as a green-but-thinner suite — check the case count before and after each move |
| Making `payload` required breaks six authority test files at once | Confined to cycle 8, absorbed by a `makePromptDetails` fixture; a new field has no `payload: undefined` literals to hunt |
| PR [#738] conflicts with nearly every file this step rewrites | Disposition decided at planning and recorded in the roadmap; the comment and close are ship-time actions on the PR, and no rebase is attempted |
| The public `.d.ts` grows without the guard noticing | `PromptPayload` added to `scripts/verify-public-types.sh`; `pnpm run verify:public-types` in the pre-completion checks |
| `src/presentation/` importing from `src/handlers/` would invert the layering | The gate passes its already-computed decision value in; no builder imports `deriveDecisionValue` or any `handlers/` module |
## Open Questions
- Whether `permission-prompts.ts` should be renamed once it holds only the two pre-check reason functions.
Deferred: Step 4 moves those to the agent renderer, at which point the file disappears rather than being renamed twice.
- The exact depth cap for nested wrapper extraction.
Chosen at implementation from the test cases; four hops covers every real form surveyed (`sudo timeout … xargs …`).
- Whether `PromptEvidence.detail` earns its keep beyond the external-path disclosure.
Kept for now because that is a real, current need; Step 2's renderer is the place to revisit it.
[#292]: https://github.com/gotgenes/pi-packages/issues/292
[#306]: https://github.com/gotgenes/pi-packages/issues/306
[#437]: https://github.com/gotgenes/pi-packages/issues/437
[#481]: https://github.com/gotgenes/pi-packages/issues/481
[#490]: https://github.com/gotgenes/pi-packages/issues/490
[#559]: https://github.com/gotgenes/pi-packages/issues/559
[#574]: https://github.com/gotgenes/pi-packages/issues/574
[#610]: https://github.com/gotgenes/pi-packages/issues/610
[#635]: https://github.com/gotgenes/pi-packages/issues/635
[#712]: https://github.com/gotgenes/pi-packages/issues/712
[#713]: https://github.com/gotgenes/pi-packages/issues/713
[#716]: https://github.com/gotgenes/pi-packages/pull/716
[#710]: https://github.com/gotgenes/pi-packages/issues/710
[#738]: https://github.com/gotgenes/pi-packages/pull/738
[#745]: https://github.com/gotgenes/pi-packages/issues/745
[#746]: https://github.com/gotgenes/pi-packages/issues/746
[ADR 0011]: ../decisions/0011-prompt-presentation-contract.md