Files
my-pi/pi-permission-system/docs/plans/0746-agent-and-review-log-renderers.md
T

45 KiB
Raw Blame History

issue, issue_title
issue issue_title
746 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 13 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.tspnpm run gen:schemaextension-config.tsmergeUnifiedConfigs()'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.

/** 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 '<surface>'
call the verdict call for the user and unavailable verdicts
tool request.toolName, when it differs from the surface for tool '<toolName>'
invoked as request.invokedToolName (invoked as '<invokedToolName>')
flagged flaggedElements(payload), minus the command for <label> '<value>'
rule request.matchedPattern (rule '<pattern>')
context request.commandContext inside command substitution
boundary evidence working directory : <flagged> outside working directory '<cwd>'
unavailable tail the verdict requires approval, but no interactive UI is available.
rule reason check.reason Reason: <text>.
denial reason the decision's denialReason Reason: <text>.

The flagged element is the one departure from a literal reading of §7's "needs no separate size bound". A path is agent input, so it is capped at promptFieldMaxWidth (default 400) with the same bare-ellipsis marker the dialog uses. The reading this plan settles, and which the module documents at its declaration: identifying the call includes naming which of its operands the rule fired on; reproducing it means echoing the command or the tool-input body, which the renderer never does. This mirrors the reading Step 2 settled for §3 against §5.

Which element is flagged is shared with the dialog renderer rather than restated:

// src/presentation/fact-vocabulary.ts
/** What the ask flags: the value the rule fired on, or the escaping paths. */
export function flaggedElements(payload: PromptPayload): readonly string[];
/** What that element is called, per ask shape ("path", "target", "skill", …). */
export function flaggedElementLabel(payload: PromptPayload): string;
/** Human-readable label for a nested bash execution context. */
export function describeBashCommandContext(
  context: BashCommandContext | null,
): string | undefined;

flaggedElements is dialog-renderer.ts's existing private flaggedTexts, promoted; flaggedElementLabel is its private valueLabel; describeBashCommandContext relocates out of denial-messages.ts, which both renderers already read. The agent renderer drops the flagged clause for kind: "bash" (the flagged element is the command), for kind: "tool" (the value is the tool name an earlier clause already stated), and for kind: "forwarded" (a payload-less relay whose value shape is unknown, and which the agent renderer is never reached with — the child renders its own denial from its own payload).

Worked examples, with the tool call that produced each:

bash({"command": "rm -rf build"})              under bash: {"rm *": "deny"}
  [pi-permission-system] Denied by policy: 'bash' (rule 'rm *').

read({"path": "/etc/passwd"})                  user denies, reason "not that file"
  [pi-permission-system] The user denied this 'path' call for tool 'read' for
  path '/etc/passwd' (rule '/etc/*'). Reason: not that file.

bash({"command": "cp config.yaml /etc/app/config.yaml"})  path: {"/etc/**": "deny"}
  [pi-permission-system] Denied by policy: 'path' for tool 'bash' for path
  '/etc/app/config.yaml' (rule '/etc/**').

bash({"command": "diff /etc/hosts ~/.ssh/known_hosts > /tmp/out"})
  [pi-permission-system] Denied by policy: 'external_directory' for tool 'bash'
  (rule '*'): paths '/etc/hosts', '~/.ssh/known_hosts' are outside working
  directory '/repo'.

bash({"command": "sudo aws s3 rm s3://bucket --recursive"})  user denies
  [pi-permission-system] The user denied this 'bash' call
  (rule '<indirection-bash-wrapper>'). Reason: not with sudo.

bash({"command": "cat <<'EOF' > gen.py\n…72 KB…\nEOF"})     user denies
  [pi-permission-system] The user denied this 'bash' call (rule '*').

The last case is the defect: 72 KB of echoed input becomes one line. The sudo case gains the sentinel, which today's user-denied text drops entirely.

The descriptor's single presentation fact

GateDescriptor loses denialContext and gains payload, which the runner stamps onto the prompt call exactly as it stamps requestId:

export interface GateDescriptor {
  surface: string;
  input: unknown;
  /** The complete structured description of this ask — the one presentation fact. */
  payload: PromptPayload;
  promptDetails: Omit<PromptPermissionDetails, "requestId" | "payload">;
  // …unchanged fields
}

The runner's message construction becomes:

const { payload } = descriptor;
const messages = {
  denyReason: renderPolicyDenial(payload, check.reason ?? null),
  unavailableReason: (decision: PermissionPromptDecision) =>
    renderUnavailableDenial(payload, decision.denialReason ?? null),
  userDeniedReason: (decision: PermissionPromptDecision) =>
    renderUserDenial(payload, decision.denialReason ?? null),
};

and the escalation call gains payload: descriptor.payload beside requestId. This keeps the payload at one hop from the runner and gives it one home on the descriptor, rather than two fields holding the same object.

The review-log renderer

// src/presentation/review-log-renderer.ts
/**
 * The payload facts the review log persists (ADR 0011 §6).
 *
 * Request facts only — no evidence and no annotations, so what the log
 * accumulates does not grow past what `message` already implied.
 */
export function renderReviewLogFacts(
  payload: PromptPayload,
): Record<string, unknown>;

It emits the request facts the log does not already carry, omitting a null:

{
  surface: string;
  matchedPattern?: string;
  executedUnit?: string;
  commandContext?: BashCommandContext;
  invokedToolName?: string;
  forwarded?: true;
  requesterSessionId?: string;
}

toolName, command, path, target, agentName, and toolInputPreview stay where they are — the gates already write them, and duplicating them under a second name would grow the log rather than shrink it. Two of the emitted fields close real gaps: a permission_request.blocked entry records resolution: "policy_denied" today but not which rule denied, and executedUnit (#713's fact) has never reached the log.

Both write sites call it:

// PermissionPrompter.writeReviewEntry
this.deps.logger.review(event, {
  requestId: details.requestId,
  source: details.source,
  agentName: details.agentName,
  ...renderReviewLogFacts(details.payload),
  toolCallId: details.toolCallId ?? null,
  // …the existing structured fields, minus `message`
});

// each gate's descriptor
logContext: {
  source: "tool_call",
  toolCallId: tcc.toolCallId,
  toolName: tcc.toolName,
  ...renderReviewLogFacts(payload),
  ...permissionLogContext,
},

The width bound at the write choke point

writeLine in src/logging.ts is the only place a log line is produced, and it is already the choke point redaction runs at. The width bound goes there, on the review stream only — the debug log is opt-in and exists to be read in full.

// src/log-field-cap.ts
export const DEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH = 1000;

/** The configured review-log field width, falling back to the default. */
export function resolveReviewLogFieldWidth(config: {
  reviewLogFieldMaxWidth?: number;
}): number;

/**
 * Narrow every string in a log-detail record to `maxWidth`, marking a shortened
 * value with a trailing ellipsis.
 *
 * A quantity bound applied uniformly, never a content filter: it does not read
 * a value to decide what to hide, which is what keeps it a cap rather than
 * redaction (ADR 0010). It recurses through plain objects and arrays and
 * touches strings only.
 */
export function capLogFieldWidths(
  details: Record<string, unknown>,
  maxWidth: number,
): Record<string, unknown>;

writeLine applies capLogFieldWidths before redactedJsonStringify, so a sensitive-keyed value is still masked whole regardless of its length — the cap and the mask are independent, and the ADR 0010 boundary is unchanged.

The default 1000 is not a new number: it is today's TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH, which already bounds toolInputPreview. Moving that bound to the writer makes it uniform and lets ToolPreviewFormatterOptions.toolInputLogPreviewMaxLength and its constant go, so the log has one bound rather than one bound plus an unbounded remainder.

Predicted effect on the measured log, computed from the same 7.07 MB file: removing message saves 21.5%, and capping every field at 1000 saves a further 7.1% (all of it from command), for 28.7%. At the same default, 188 of 4325 command entries (4.3%) are shortened. A raised reviewLogFieldMaxWidth restores the full value; there is no unbounded setting, by design.

The config field follows the established path:

// extension-config.ts
/** Max characters of any one value written to the permission review log. Defaults to 1000. */
reviewLogFieldMaxWidth?: number;

Consumer sketch

SessionLoggerDeps.getConfig() already returns the live PermissionSystemExtensionConfig, and createPermissionSystemLogger already holds that closure, so the writer reads the configured width per call without a new collaborator:

const review = (event, details = {}) => {
  const config = options.getConfig();
  if (!config.permissionReviewLog) return undefined;
  return writeLine("review", reviewLogPath, event, details, {
    maxFieldWidth: resolveReviewLogFieldWidth(config),
  });
};

A mid-session config change therefore takes effect on the next line, matching how permissionReviewLog and debugLog already behave.

Module-Level Changes

Added

  • packages/pi-permission-system/src/presentation/agent-renderer.tsEXTENSION_TAG, renderPolicyDenial, renderUserDenial, renderUnavailableDenial.
  • packages/pi-permission-system/src/presentation/fact-vocabulary.tsflaggedElements, flaggedElementLabel, describeBashCommandContext; the render vocabulary both the dialog and the agent renderer read.
  • packages/pi-permission-system/src/presentation/review-log-renderer.tsrenderReviewLogFacts.
  • packages/pi-permission-system/src/log-field-cap.tsDEFAULT_REVIEW_LOG_FIELD_MAX_WIDTH, resolveReviewLogFieldWidth, capLogFieldWidths.
  • packages/pi-permission-system/docs/migration/0746-review-log-fields.md — the breaking-change note.
  • packages/pi-permission-system/test/presentation/agent-renderer.test.ts, test/presentation/fact-vocabulary.test.ts, test/presentation/review-log-renderer.test.ts, test/log-field-cap.test.ts.

Removed

  • packages/pi-permission-system/src/denial-messages.tsDenialContext, formatDenyReason, formatUnavailableReason, formatUserDeniedReason, matchQualifier, resolvesToSuffix. ExternalPathDisclosure relocates to src/presentation/path-ask-payload.ts; EXTENSION_TAG and describeBashCommandContext relocate as above.
  • packages/pi-permission-system/src/presentation/legacy-message.tsrenderLegacyMessage and its private fragment helpers.
  • packages/pi-permission-system/test/denial-messages.test.ts (728 lines) and test/presentation/legacy-message.test.ts (344 lines).
  • PromptPermissionDetails.message (src/authority/permission-prompter.ts).
  • GateDescriptor.denialContext (src/handlers/gates/descriptor.ts).
  • ToolPreviewFormatterOptions.toolInputLogPreviewMaxLength and TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH (src/tool-preview-formatter.ts, src/tool-input-preview.ts).

Changed — source

  • src/handlers/gates/descriptor.tsdenialContextpayload: PromptPayload; promptDetails becomes Omit<PromptPermissionDetails, "requestId" | "payload">.
  • src/handlers/gates/runner.ts — imports the three payload renderers, passes check.reason, stamps payload onto the escalation call beside requestId.
  • src/handlers/gates/tool.ts, path.ts, external-directory.ts, bash-external-directory.ts, bash-path.ts, skill-input.ts, skill-read.ts — build payload once, drop denialContext, drop message from promptDetails and logContext, spread renderReviewLogFacts(payload) into logContext, drop the renderLegacyMessage import.
  • src/authority/permission-prompter.tswriteReviewEntry spreads renderReviewLogFacts(details.payload) in place of message.
  • src/authority/forwarded-request-server.ts — drops the message: renderLegacyMessage(payload) line and its import.
  • src/presentation/dialog-renderer.tsflaggedTexts / valueLabel / describeBashCommandContext come from fact-vocabulary.ts.
  • src/presentation/path-ask-payload.ts — owns ExternalPathDisclosure.
  • src/logging.tswriteLine takes an optional field-width bound; review supplies it, debug does not.
  • src/tool-preview-formatter.tsgetToolInputPreviewForLog and formatGenericToolInputForLog stop truncating; resolveToolPreviewLimits's doc comment loses its "until [#746]" clause.
  • src/config-schema.tsreviewLogFieldMaxWidth with description / markdownDescription.
  • schemas/permissions.schema.json — regenerated by pnpm run gen:schema (never hand-edited).
  • src/extension-config.ts — the field on PermissionSystemExtensionConfig plus its normalizePermissionSystemConfig passthrough; not in DEFAULT_EXTENSION_CONFIG, which deepEqual tests pin.
  • src/config-loader.ts — the field added to the number-scalar loop (line 223).
  • config/config.example.jsonreviewLogFieldMaxWidth: 1000 beside the prompt budgets.

Changed — tests

  • test/handlers/gates/runner.test.ts, test/handlers/external-directory-integration.test.tsEXTENSION_TAG import path and the asserted denial strings.
  • test/bash-external-directory.test.tsExternalPathDisclosure import path; drops its renderLegacyMessage import.
  • test/presentation/tool-ask-payload.test.ts, path-ask-payload.test.ts, skill-ask-payload.test.ts — assertions move from the rendered legacy string to the payload's own fields.
  • test/helpers/presentation-fixtures.ts — drops TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH.
  • test/helpers/gate-fixtures.tsmakeDescriptor / makeDenialDescriptor build a payload rather than a denialContext. makeDenialDescriptor's reason for existing (a caller-supplied DenialContext) goes; confirm whether it collapses into makeDescriptor.
  • test/logging.test.ts, test/session-logger.test.ts — the review-stream width bound, the debug stream's exemption, and cap-before-redaction.
  • test/config-loader.test.ts, test/extension-config.test.ts, test/config-schema.test.ts — the new field survives merge, normalization, and schema parity.
  • Any test constructing a PromptPermissionDetails literal: message is a removed required field, so grep message: across test/ and the test/helpers/ factories — a shared fixture is the common miss.

Changed — docs

  • docs/architecture/architecture.md — the denial-messages.ts and legacy-message.ts module-tree entries replaced by agent-renderer.ts, fact-vocabulary.ts, review-log-renderer.ts; the new log-field-cap.ts entry; the "Prompt presentation" narrative paragraph that ends "The review log is the last message reader, so renderLegacyMessage survives until Step 4 ([#746])"; the toolInputPreviewMaxLength sentence that claims the built-in constants "still bound the evidence the review log persists verbatim"; Phase 13 Step 4's on the heading and its Mermaid node, plus a Landed: note; a new health-metric row (below).
  • docs/configuration.md — the reviewLogFieldMaxWidth row; the permissionReviewLog row's "Records bash command strings verbatim"; line 963's bounded-toolInputPreview bullet; line 969's "the complete bash command string for every bash decision"; line 985's "logged verbatim".
  • docs/troubleshooting.md:54 — "The review log records bash command strings verbatim."
  • README.md — a migration-table row for docs/migration/0746-review-log-fields.md.
  • .pi/skills/package-pi-permission-system/SKILL.md — the "Log writes" section gains the width bound at writeLine and its relationship to redaction; the Debugging section's review-log mining tip (#694) gains the caveat that a command longer than the configured width is stored shortened; the promptMaxRows / promptFieldMaxWidth sentence in Configuration gains reviewLogFieldMaxWidth.

Health metric

Add one row to Phase 13's table, with the baseline measured at this step (the precedent Steps 9 and 10 set for mid-phase rows):

Metric Baseline (2026-08-16) Phase 13 target
Legacy message render sites (renderLegacyMessage in src/) 17 0

Recompute command: grep -rn "renderLegacyMessage" packages/pi-permission-system/src --include="*.ts" | wc -l.

Test Impact Analysis

What the change enables. renderReviewLogFacts and capLogFieldWidths are pure functions over data, so the log's content decision and its width bound each get direct unit tests for the first time — today both are implicit in whatever string an assembler produced. agent-renderer.test.ts can enumerate kind × verdict exhaustively against a payload literal, where denial-messages.test.ts had to build seven differently-shaped context literals.

What becomes redundant. test/presentation/legacy-message.test.ts (344 lines) exists to prove the payload reproduces six retired assemblers byte for byte. That proof was the transition's, and it retires with the string. test/denial-messages.test.ts (728 lines) is replaced wholesale by agent-renderer.test.ts; its coverage map carries over, but almost none of its assertions do, because the rendered text is deliberately different.

What must stay. The three payload-builder suites stay, but their assertion vehicle changes: today they call renderLegacyMessage and match a sentence, and they must instead assert the payload's fields. That is a strictly stronger assertion — a builder test asserting a downstream render can pass while a field it never reads is wrong — and it is the migration that preserves Step 1's "every ask carries a complete payload" guarantee once the legacy oracle is gone. test/presentation/dialog-renderer.test.ts stays untouched in intent; only the fact-vocabulary extraction's imports move under it. test/handlers/gates/runner.test.ts's EXTENSION_TAG assertions stay — they pin that every block reason is attributed to this extension, which no other test does.

Invariants at risk

Invariant Where it was established What pins it
The payload is complete — every fact the six retired assemblers stated is on it Step 1 (#744), proven by the legacy-message suite Migrated payload-builder assertions (per-field), plus the three renderer suites, which between them read every request field and every evidence label. This plan removes the existing proof, so the replacement is the gating deliverable of the deletion step.
The #710 ask renders inside the 24-row default Step 2, re-measured at the Step 3 shape dialog-renderer.test.ts's row-budget cases. The fact-vocabulary extraction moves flaggedTexts and valueLabel out of the module; if the highlight or label behavior changes, those tests fail. Extraction is pure relocation — no signature change beyond commandContext: BashCommandContext | null.
A value bound to a sensitive key name is masked; a secret embedded in a bash command is not docs/decisions/0010-permission-log-secret-exposure.md New logging.test.ts cases: a sensitive-keyed value longer than the cap is masked whole, not truncated-then-masked into a partial secret; a long command is shortened, never masked.
The width bound is a cap, not redaction ADR 0011 §4/§5, and Step 2's reading log-field-cap.test.ts: the cap is applied uniformly by length and never inspects a value's content; identical-length values of different shapes are treated identically.
Every log line goes through writeLine Package skill, "Log writes" The bound lives inside writeLine, so a new write path cannot bypass it without bypassing the writer. No new test needed; the placement is the guarantee.
The review log is minable for real bash commands (#694's method) Package skill, "Debugging" Measured, not argued: at the 1000 default, 188 of 4325 command entries (4.3%) are shortened. Recorded in the skill and the migration note, with reviewLogFieldMaxWidth as the lever.

TDD Order

  1. Extract the shared render vocabulary. Red: test/presentation/fact-vocabulary.test.tsflaggedElements per payload kind (including the bash_external_directory multi-path arm and an empty value), flaggedElementLabel per kind, describeBashCommandContext for each context and for null. Green: new src/presentation/fact-vocabulary.ts; dialog-renderer.ts imports flaggedElements / flaggedElementLabel / describeBashCommandContext from it; denial-messages.ts imports describeBashCommandContext from it and re-exports nothing. Commit: refactor(pi-permission-system): extract the shared payload render vocabulary (#746).

  2. Add the agent renderer beside the old one. Red: test/presentation/agent-renderer.test.ts — every payload kind × the three verdicts, plus the sentinel patterns (<indirection-bash-wrapper>, <opaque-bash-wrapper>, <unparseable-bash-command>), the nested-context clause, the resolved-alias clause, the flagged-element cap at promptFieldMaxWidth, the operator rule reason, and the human denial reason. Green: new src/presentation/agent-renderer.ts. Nothing imports it yet; it takes EXTENSION_TAG from denial-messages.ts for this step. Commit: refactor(pi-permission-system): add the payload-driven agent-facing denial renderer (#746).

  3. Wire it and delete denial-messages.ts. Red: test/handlers/gates/runner.test.ts asserts the runner renders from descriptor.payload and passes check.reason; a new case asserts an operator deny-with-reason renders on a non-tool surface (a path deny), which fails today. Green: GateDescriptor.denialContextpayload; promptDetails narrows to Omit<…, "requestId" | "payload">; the runner stamps payload and calls the three renderers; all seven descriptor builders updated; EXTENSION_TAG moves into agent-renderer.ts; ExternalPathDisclosure moves into path-ask-payload.ts; matchQualifier and resolvesToSuffix become private helpers inside legacy-message.ts (its only remaining callers) and die with it in step 7; denial-messages.ts and test/denial-messages.test.ts deleted; gate-fixtures.ts, bash-external-directory.test.ts, and external-directory-integration.test.ts updated. One step because removing an export breaks every importer at the type level in the same commit. Commit: fix(pi-permission-system): stop echoing tool input in agent-facing denial text (#746).

  4. Add the review-log renderer. Red: test/presentation/review-log-renderer.test.ts — the emitted field set per kind, null facts omitted rather than written as null, forwarded provenance present only for a forwarded payload, no evidence and no annotations in the output. Green: new src/presentation/review-log-renderer.ts. Not wired yet. Commit: refactor(pi-permission-system): add the review-log renderer over the prompt payload (#746).

  5. Bound review-log field width. Red: test/log-field-cap.test.ts (uniform length cap, ellipsis marker, recursion through plain objects and arrays, non-strings untouched); test/logging.test.ts (the review stream caps, the debug stream does not, a sensitive-keyed value is still masked whole); test/config-loader.test.ts / test/extension-config.test.ts / test/config-schema.test.ts (the field survives merge, normalization, and schema parity). Green: src/log-field-cap.ts; config-schema.ts + pnpm run gen:schema; extension-config.ts; the config-loader.ts number-scalar loop; config/config.example.json; writeLine's optional bound; ToolPreviewFormatter and tool-input-preview.ts drop the log-preview cap and its constant; test/helpers/presentation-fixtures.ts follows. Commit: feat(pi-permission-system)!: bound review-log field width with reviewLogFieldMaxWidth (#746), with a BREAKING CHANGE: footer naming the new default and the config lever.

  6. Render the review log from the payload. Red: test/authority/permission-prompter.test.ts (or its current home) asserts the review entry carries surface / matchedPattern / executedUnit and no message; a gate test asserts permission_request.blocked now records the matched rule. Green: PermissionPrompter.writeReviewEntry and all seven logContext builders spread renderReviewLogFacts(payload) and drop message. PromptPermissionDetails.message still exists and is still written — nothing reads it after this commit. Commit: feat(pi-permission-system)!: render the review log from the prompt payload (#746), with a BREAKING CHANGE: footer naming the removed message field, the superseding fields, and docs/migration/0746-review-log-fields.md.

  7. Delete the legacy message. Red: none — the deletion is proven by the suite staying green with the migrated payload-builder assertions from this step. Green: remove PromptPermissionDetails.message, every message: producer in the seven descriptors and forwarded-request-server.ts, src/presentation/legacy-message.ts, and test/presentation/legacy-message.test.ts; migrate tool-ask-payload.test.ts, path-ask-payload.test.ts, and skill-ask-payload.test.ts off the legacy oracle onto direct payload-field assertions; grep message: across test/ for PromptPermissionDetails literals and shared factories. One step because removing a required interface field breaks every constructor at once. Verify: grep -rn "renderLegacyMessage" packages/pi-permission-system/src --include="*.ts" | wc -l is 0. Commit: refactor(pi-permission-system): remove the transitional prompt message string (#746).

  8. Docs and roadmap. Green: the architecture module tree, the prompt-presentation narrative, the Phase 13 Step 4 marks and Landed: note, the new health-metric row; docs/configuration.md; docs/troubleshooting.md; docs/migration/0746-review-log-fields.md; the README.md migration row; the package skill. Commit: docs(pi-permission-system): record the agent and review-log renderers (#746).

Risks and Mitigations

Risk Mitigation
Deleting the legacy-message suite removes the standing proof that the payload is complete. Step 7 migrates the three payload-builder suites to per-field assertions in the same commit as the deletion, and the step is not green until they cover what the legacy assertions did. The renderer suites (dialog, agent, review-log) read every request field between them.
The agent-renderer grammar is new prose replacing 728 lines of assertions, so a kind or verdict can be under-covered. Step 2's suite enumerates kind × verdict exhaustively before anything is wired, including the three synthetic sentinels and the nested-context clause.
A recursive width cap could mangle non-string log values or drop keys. capLogFieldWidths touches strings only and recurses through plain objects and arrays; log-field-cap.test.ts pins numbers, booleans, null, and nested shapes as passed through unchanged.
Capping before redaction could truncate a secret into a partial that then fails key-name masking. Masking replaces the value wholesale by key name, so order does not matter for a masked field; logging.test.ts pins that a sensitive-keyed value longer than the cap is fully masked.
reviewLogFieldMaxWidth silently dropped before runtime (the #332 / #347 class). Step 5 adds it to mergeUnifiedConfigs's number-scalar loop and pins it with a config-loader.test.ts merge case; normalizePermissionSystemConfig reads the typed UnifiedPermissionConfig, so an omission is a compile error.
The ! on two commits produces two changelog breaking entries. Intentional: they are two distinct breaks (a width bound, a removed field). Each footer names its own change and both point at the one migration doc.
A capped command degrades the review-log mining workflow the package skill documents (#694). Measured: 4.3% of command entries are shortened at the default. Recorded in the skill and the migration note, with reviewLogFieldMaxWidth as the lever to restore full values.
The flagged-element clause reintroduces agent input into the denial text, against §7's structural-bound argument. The command and the tool-input body are never rendered — only the path, target, or skill the rule fired on — and that value is capped at promptFieldMaxWidth. The reading is documented at the module declaration and in the roadmap's Landed: note, following the precedent Step 2 set for §3 against §5.

Open Questions

  • Whether ADR 0011 §7 should carry an amendment recording the flagged-element reading, or whether the module declaration plus the roadmap Landed: note suffice. Steps 2 and 3 both settled readings without amending the accepted record; this plan follows that precedent and leaves the question open for a later ADR pass.
  • Whether annotations should ever reach the review log once ADR 0011 §8's annotator seam exists. Deferred until the seam has a consumer; it is a growth decision of its own.
  • Whether makeDenialDescriptor (test/helpers/gate-fixtures.ts) survives once DenialContext is gone, or collapses into makeDescriptor. Settled during step 3 against the actual call sites.
  • Whether permission_request.blocked's newly recorded matchedPattern should also carry the rule's origin scope, which the decision event already emits. Not needed for this step; a candidate for #726's provenance work.