--- issue: 746 issue_title: "pi-permission-system: agent-facing and review-log renderers over the prompt payload" --- # The agent-facing and review-log renderers over the prompt payload ## Release Recommendation **Release:** ship now — batch "presentation-contract" tail (this issue completes the batch) Phase 13's `Release batches` subsection names Steps 3 and 4 as batch "presentation-contract", with Step 4 as the tail and Step 3's `feat!:` as the release vehicle. Step 3 ([#745]) has landed on `main` unreleased; this issue is the tail, so the release-please PR merges after it. This issue carries breaking commits of its own (the review log's field set and width bound), which join the same major bump. ## Problem Statement Two consumers of a permission ask still read the pre-rendered `message` string rather than the structured payload ADR 0011 §2 introduced, and each is wrong in its own way. The agent-facing denial text interpolates the raw tool input on every path. The same oversized payload that took over the viewport in [#710] is echoed back into the agent's context in full whenever the user denies it — the human's constraint is rows and the agent's is tokens, and one unbounded payload violates both. ADR 0011 §7 states the fix as a rule about content rather than a size cap: the agent renderer identifies the call; it does not reproduce it. The agent authored the tool call, so echoing its input back tells it nothing it did not already have; the new information is the verdict. The review log persists the assembled `message`, so what the log accumulates is a side effect of how a prompt happens to be worded. `docs/decisions/0010-permission-log-secret-exposure.md` bounds what the logs accumulate, and a prompt sentence written verbatim on every ask is not a bound anyone chose. ADR 0011 §6 assigns the log its own configured limits, so its growth becomes an explicit decision. Measured on the operator's live review log (7.07 MB, 9484 entries, 2026-08-16): | Field | Share of the log | Entries | Largest single value | | ------------------ | ---------------- | ------- | -------------------- | | `message` | 21.5% (1.52 MB) | 3904 | 72,784 chars | | `command` | 20.2% (1.49 MB) | 4325 | 72,391 chars | | `toolInputPreview` | 0.1% | — | capped at 1000 today | Command lengths: 1496 entries exceed 200 characters, 607 exceed 400, 188 exceed 1000, 30 exceed 4000. ## Goals - The agent-facing denial renderer reads the `PromptPayload` and never interpolates the bash command or the raw tool input. - The renderer still names the flagged element — the path, MCP target, or skill the rule fired on — so a denial is correctable on the first retry. - The matched pattern, including the wrapper and unparseable-command sentinels, reaches the agent on every verdict, which it does not today for a user denial. - The operator's `deny`-with-reason text reaches the agent on every surface, not only the tool/bash arm. - The review log stops persisting `message` and instead records the payload's request facts. - Every string the review log writes is bounded by a configured `reviewLogFieldMaxWidth`, applied at the single write choke point so no write path can escape it. - `DenialContext` is dissolved into `PromptPayload`: one payload, five renderers, one discriminant. - `renderLegacyMessage` and `src/presentation/legacy-message.ts` are deleted — the last `message` reader is this step's, so the transitional module goes with it. - Breaking: the review log's field set changes and its values acquire a width bound, both observable on upgrade without a user edit. ## Non-Goals - Decision provenance (`decidedBy`) — Phase 13 Step 6, [#726]. It lands after this step so its fields ride the new log renderer rather than the retiring `message` shape; this plan does not add it. - Out-of-process forwarding liveness — Phase 13 Step 5, [#721]. - The annotator and evidence-formatter seams (ADR 0011 §8) — deferred until a downstream consumer exists. - A configurable log destination — PR [#749] proposes file/stdout routing. Orthogonal: it changes where a line goes, not what a line contains. This plan does not touch it. - The skill-input deny path's agent-facing text. `handleInput` returns `{ action: "handled" }` (`src/handlers/permission-gate-handler.ts:104-106`) and discards the formatted reason, so nothing agent-facing is delivered there today. The renderer keeps producing a `skill` arm for uniformity; wiring that string somewhere is out of scope. - Logging `annotations` to the review log. The slot is empty until §8's seam exists, and adding it later is a growth decision of its own. - `src/permission-prompts.ts` (the pre-check reasons for a missing or unregistered tool name). Those are not payload renders — no payload exists at that point — and stay as they are. ## Background ### Where Phase 13 stands Steps 1–3 have landed. Every gate emits a `PromptPayload` (`src/presentation/prompt-payload.ts`), `PromptPermissionDetails.payload` is required, `renderPromptDialog` bounds the human-facing render, and the forwarded wire and `permissions:ui_prompt` broadcast carry facts rather than prose. `message` now has exactly two readers, and both are review-log writes: - `PermissionPrompter.writeReviewEntry` (`src/authority/permission-prompter.ts:152`) writes `message: details.message` on the `waiting` / `approved` / `denied` entries. - Six gate descriptors put `message: askMessage` into `logContext`, which `GateRunner` and `applyPermissionGate` write on the `session_approved` / `auto_approved` / `blocked` entries. `src/presentation/legacy-message.ts` says so at its declaration: "this module goes when the last `message` reader does". `grep -rn "renderLegacyMessage" packages/pi-permission-system/src --include="*.ts" | wc -l` is **17** today. ### The two parallel unions `GateDescriptor` (`src/handlers/gates/descriptor.ts`) carries both `denialContext: DenialContext` (a 7-arm union in `src/denial-messages.ts`) and `promptDetails.payload: PromptPayload` (a 9-arm union). `PromptPayloadKind`'s own declaration says it "mirrors `DenialContext`'s discriminated union, the shape ADR 0011 §7 names as already correct". Every field `DenialContext` holds that the payload lacks is a field §7 forbids the agent renderer from showing: | `DenialContext` field | Payload equivalent | | ---------------------------------------------- | --------------------------------------------------- | | `path.toolName` / `pathValue` | `request.toolName` / `request.value` | | `external_directory.resolvedPath` / `cwd` | evidence `resolves to` / `working directory` | | `bash_external_directory.externalPaths` | evidence `external path` with its `detail` | | `skill_read.readPath` | evidence `read path` | | `tool.check.matchedPattern` / `commandContext` | `request.matchedPattern` / `request.commandContext` | | `bash_path.command` | none — the command §7 forbids echoing | | `tool.input` | none — already unread by any body builder | The one genuine gap is `check.reason`, the operator's `deny`-with-reason string. It is not a payload fact (a deny never prompts, so no human render wants it) and today it renders only on the tool/bash arm. `GateRunner.runDescriptor` holds the resolved `check` at the point it constructs the denial messages, so passing `check.reason` as an argument both closes the gap and generalizes it to every surface. ### How a denial reaches the agent Verified against the sibling Pi checkout at `../pi` (`9d2ec7ffa`). `createFailClosedToolCall` returns `{ block: true, reason }` (`src/handlers/tool-call-boundary.ts:52`). Pi wraps that reason with `createErrorToolResult` (`packages/agent/src/agent-loop.ts:637-641`), and `createToolResultMessage` stamps it with `toolCallId: finalized.toolCall.id` (`agent-loop.ts:779`). In the parallel-tool-call loop (`agent-loop.ts:489-532`) a blocked call is finalized with its own `toolCall` intact, so its result pairs to its own id even when the turn issued several calls. The assistant message carrying every `toolCall` block — including its full arguments — stays in `currentContext.messages` and is sent to the provider through `convertToLlm` (`agent-loop.ts:195, 219-221, 295`). So correlation is structural: the model sees the denial as that call's result, with that call's arguments beside it. The renderer does not have to echo anything for the agent to know which call was refused. What the renderer must still supply is which of the call's *operands* tripped the gate — one bash command can carry several path tokens, and that granularity is below the tool call. ### Constraints from AGENTS.md and the package skill - The new config field must travel `config-schema.ts` → `pnpm run gen:schema` → `extension-config.ts` → `mergeUnifiedConfigs()`'s number-scalar loop, or it is silently dropped before runtime (the #332 / #347 class). - Do not add a log write path that bypasses `writeLine` in `src/logging.ts`. The same reasoning applies to the new width bound: it belongs at that choke point, not at each producer. - Redaction is structural and key-name based (`docs/decisions/0010-permission-log-secret-exposure.md`). A width cap is a quantity bound applied uniformly and must never read a value to decide what to shorten, or it has become redaction by another name — the boundary [#710] drew for the dialog. - `docs/architecture/architecture.md` inline-copies presentation module entries; a module move updates the tree. - The roadmap step's `✅` marks (heading and Mermaid node) plus the `Landed:` note land in the implementation doc-update commit, not at ship time. ## Design Overview ### The agent-facing renderer `src/denial-messages.ts` is replaced by `src/presentation/agent-renderer.ts`, which renders a `PromptPayload` plus the verdict's own reason. ```typescript /** The agent-facing render of a policy deny (ADR 0011 §7). */ export function renderPolicyDenial( payload: PromptPayload, ruleReason: string | null, ): string; /** The agent-facing render of a human's denial at an interactive prompt. */ export function renderUserDenial( payload: PromptPayload, denialReason: string | null, ): string; /** The agent-facing render when no live authority could answer the ask. */ export function renderUnavailableDenial( payload: PromptPayload, denialReason: string | null, ): string; ``` Each returns `${EXTENSION_TAG} ${body}`, and `EXTENSION_TAG` moves here from `denial-messages.ts`. The body is assembled from clauses in a fixed order. Every clause is omitted when its fact is absent, so no arm needs its own sentence template: | Clause | Source | Rendered as | | ---------------- | ---------------------------------------------------- | -------------------------------------------------------- | | verdict | the calling site | `Denied by policy:` / `The user denied this` / `This` | | surface | `request.surface` | `''` | | call | the verdict | `call` for the user and unavailable verdicts | | tool | `request.toolName`, when it differs from the surface | `for tool ''` | | invoked as | `request.invokedToolName` | `(invoked as '')` | | flagged | `flaggedElements(payload)`, minus the command | `for