51 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 745 | pi-permission-system: replace the forwarded-request and ui_prompt message with the structured payload |
The cross-boundary swap — the payload replaces message on the wire and the broadcast
Release Recommendation
Release: mid-batch — defer (batch "presentation-contract"); confirm at ship time
Phase 13 of docs/architecture/architecture.md places this issue at Step 3, and the roadmap's Release batches subsection reads: Batch "presentation-contract": Steps 3, 4 (ship together; tail = Step 4; release vehicle = Step 3's feat!: breaking release with the message-replacement migration note).
This issue carries the batch's breaking commits, but Step 4 (#746) is the tail — it retires the last message reader and lands the review-log renderer that bounds what this change's un-capped evidence would otherwise persist.
Releasing here would publish a major bump whose migration note is only half true, so the release-please PR stays unmerged until #746 lands.
Problem Statement
Two consumers still receive a pre-rendered sentence, and each carries its own defect.
The forwarded wire relays the child's prose. The child assembles its message under its configuration, writes it into the request file, and the serving node carries it forward as a single evidence entry — so the parent's own render budget never applies to the child's text. Consistency across local and forwarded asks is therefore not merely unstated; it is unattainable while the payload crossing the wire is a sentence assembled at the child.
The broadcast over-discloses.
permissions:ui_prompt carries the full assembled message, and any loaded extension can observe the bus without the operator having named it.
Every other route to evidence requires that consent — a registered tool-input formatter, or an Authorizer link the operator lists in authorizerChain — which is why ADR 0011 §6 makes the bus the narrowest renderer.
The two preview caps are the third loose end.
toolInputPreviewMaxLength and toolTextSummaryMaxLength bound only the non-bash JSON and search-summary previews, which is why they never bounded the prompt, and keeping them alongside the renderer budgets leaves two layers that both sound like they bound the same thing.
Goals
- The forwarded request carries
payload: PromptPayloadand nomessage; the child serializes it and the serving node renders the child's own facts under the parent's budget. - A forwarded ask renders identically in kind to a local one: the serving node holds the child's real
PromptPayloadKind, so a forwarded bash ask readscommand : …exactly as a local one does. permissions:ui_promptdropsmessageand gainsrequest: PromptRequestFacts— the payload's invariant core, verbatim. The forwarded provenance (forwarding.requesterAgentName/forwarding.requesterSessionId) and the display projection (surface/value/agentName) are retained in full: what narrows is evidence, never correlation (#292, #610).- Version skew is handled per ADR 0011 §9: a request carrying no payload is rendered from the fields it does carry, and a prompt is never presented empty.
toolInputPreviewMaxLengthandtoolTextSummaryMaxLengthstay optional in the schema, their values are ignored, and a config that sets either receives a deprecation notice through the existing config-issue channel.
This change is breaking, on three surfaces:
| Surface | Break |
|---|---|
ForwardedPermissionRequest (on-disk wire) |
message removed; payload added |
PermissionUiPromptEvent (permissions:ui_prompt) |
message removed; request added |
toolInputPreviewMaxLength / toolTextSummaryMaxLength |
accepted but ignored |
Commits carrying a removal are feat!: with a BREAKING CHANGE: footer naming the fields that supersede the removed one; the additive lift-and-shift steps ahead of them are plain feat:.
Non-Goals
- The review log and the agent-facing denial renderer — #746 (Step 4).
renderLegacyMessagesurvives this change;PromptPermissionDetails.messageis still what the review log persists. - Removing the built-in preview truncation (
TOOL_INPUT_PREVIEW_MAX_LENGTH200,TOOL_TEXT_SUMMARY_MAX_LENGTH80). Only the operator-configured values stop taking effect here. Un-capping the payload's tool-input evidence would grow themessagethe review log still persists verbatim, againstdocs/decisions/0010-permission-log-secret-exposure.md; #746 owns the log's own bound and retires the constants once it exists. So the payload's completeness for a non-bash tool ask remains a residual, tracked with #746 and shipping in the same release. - Salvaging a version-skewed child's legacy
messagestring into an evidence entry. The wire type drops the field, and the reader stops reconstructing it: a skewed ask renders fromsurface,value, and the requester provenance, which is never empty. - A complete-view affordance for the
select/inputfallback. #710's plan parked that story here, but nothing in this step creates a keystroke channel for a non-TUI mode; filed as #751. - Rendering
annotations, the annotator registry (ADR 0011 §8), and the evidence-formatter seam. The slot exists and nothing populates it. - Out-of-process forwarding liveness (#721, Step 5).
It also edits
src/authority/approval-escalator.ts, and the roadmap's Track B note says to land the two in sequence rather than concurrently. - Anything about the permission request id — Step 9 (#752) has landed and settled it, including across the forwarding boundary. This step touches no identity code.
- The parent-side terminal decision emit and cross-session prompt/decision correlation — Step 10 (#610), which lands after Step 4 with both halves in place.
- Changing what any gate emits. Every payload builder is untouched; this step moves an existing payload across two boundaries.
Background
Sequencing against the rest of Phase 13
Step 9 (#752) has landed and released (3f8d3fd6), which retires the correlation work this plan originally carried — see "The correlation join" below.
It also already edited src/permission-events.ts and src/authority/approval-escalator.ts, so this plan is written against the post-#752 tree and its line references were re-verified there.
Step 5 (#721) also edits src/authority/approval-escalator.ts and must land before or after this issue, not alongside it.
What Steps 1 and 2 already built
PromptPayload (src/presentation/prompt-payload.ts) is the complete structured description of an ask: a kind discriminant, a request invariant core, complete evidence, and an annotations slot.
PromptPermissionDetails.payload is required, so every ask already carries one (#744).
renderPromptDialog (src/presentation/dialog-renderer.ts) renders it for the inline dialog and the select/input fallback under promptMaxRows plus promptFieldMaxWidth (#710).
The consequence that matters here: the serving node already renders through renderPromptDialog under its own budget, because LocalUserAuthorizer hands details.payload to requestPermissionDecision.
What is missing is not a renderer but the facts — today buildForwardedAskPayload synthesizes a kind: "forwarded" payload whose only evidence entry is the child's sentence.
Once the wire carries the child's payload, "the serving node renders the child's facts under the parent's budget" follows without new render code.
The wire and its tolerant reader
readForwardedPermissionRequest (src/authority/forwarding-io.ts) is a tolerant asX-style parser: it validates a required core, then reconstructs an allowlist of optional fields (asUiPromptSource, asNullableDisplayString, asForwardedSessionApproval, asForwardedAccessIntent), dropping anything malformed.
An added field is silently dropped unless the reader is taught it — so payload needs an asPromptPayload narrowing guard, and the required-core gate must stop demanding typeof parsed.message === "string" or a current child's request is rejected outright.
isPermissionDecisionState lives in permission-dialog.ts, the type's own module, and forwarding-io.ts imports it.
asPromptPayload follows that precedent and lives beside its type in src/presentation/prompt-payload.ts, so a new request fact updates the guard next door rather than in a distant reader.
The broadcast's two vocabularies
PermissionUiPromptEvent carries a display projection: surface is the child's tool name and value the normalized display value, chosen in #292 as "lean by design — not a mirror of the internal review log".
PromptRequestFacts.surface is the gate surface the rule fired on (external_directory, path, a tool name).
buildForwardedAskDetails already keeps the two distinct and documents why; nesting request alongside the flat projection preserves both, and the doc update states the distinction rather than collapsing it.
requestId stays top-level: PromptRequestFacts carries no id, so the correlation key and the facts do not overlap.
Constraints from AGENTS.md and the package skill
- The config field path is
config-schema.ts(with.meta) →pnpm run gen:schema→extension-config.ts→mergeUnifiedConfigs(); a field on the runtime type but not the merge intermediate is silently dropped (the #332 / #347 class). Here the traversal runs backwards: the field leavesPermissionSystemExtensionConfigwhile staying in the schema and the merge, so the deprecation detector can still see an operator's setting. schemas/permissions.schema.jsonis generated; a parity test intest/config-schema.test.tsfails on drift.- Removing a config field entirely would make strict validation reject it fail-closed and empty an operator's policy — hence soft deprecation.
- The forwarding request/response files are mode-restricted but not redacted; the parent reads them to render the ask.
The payload's evidence is the same disclosure class as today's
message, so this is not a widening. - A commit is typed by what a user can observe once it lands; a module no code imports yet is
refactor:. CI gates onpnpm fallow dead-code, soasPromptPayloadlands together with its first consumer rather than as a standalone pure addition.
Design Overview
The wire
export type ForwardedPermissionRequest = {
id: string;
createdAt: number;
requesterSessionId: string;
targetSessionId: string;
requesterAgentName: string;
/**
* The child's complete prompt payload (ADR 0011 §2). Optional for version-skew
* tolerance: an older child omits it, and the serving node renders from the
* display fields it does carry (ADR 0011 §9).
*/
payload?: PromptPayload;
source?: PermissionUiPromptSource;
surface?: string | null;
value?: string | null;
sessionApproval?: ForwardedSessionApproval;
accessIntent?: ForwardedAccessIntent;
};
message: string is gone.
PromptPayload is JSON-safe by construction — every leaf is a string, null, or an array of those, and commandContext is a string-literal union — which is why #744 chose | null over | undefined on the request facts.
permission-forwarding.ts gains one import, #src/presentation/prompt-payload, whose own only import is #src/types.
No cycle: the presentation layer does not import the authority layer.
The correlation join
Settled by #752; nothing to do here.
This plan originally carried a requesterRequestId wire field to join the child's and the serving node's review-log entries, on the measurement that 53 of 57 forwarded_permission.request_created entries named an id appearing on no permission_request.* entry.
#752 closed that gap at the source instead, and better: ParentAuthorizer stopped minting a third id and now writes details.requestId as the forwarded request's id (forwardableRequestId, src/authority/approval-escalator.ts).
So ForwardedPermissionRequest.id is the child's request id, and a second relayed field would name the same value twice.
Verified against the post-#752 tree: requesterRequestId appears nowhere in src/ or test/, and the wire type is otherwise unchanged.
This step therefore adds no identity field, and its TDD order has no correlation cycle.
One residual is worth naming rather than discovering during implementation.
forwardableRequestId falls back to a fresh mint when the inbound id could not safely name a file — the relay-hop guard — and in exactly that case id !== details.requestId, so the join breaks for that one exchange while the forwarded_permission.request_created entry logs the wire id alone.
It is #752's residual, not this step's: the fallback is a filename-safety valve, the ids it rejects are ones no current minter produces, and closing it means logging both ids on that entry rather than changing any contract.
Raised in Open Questions; not folded in silently.
The serving node
buildForwardedAskPayload becomes a two-branch projection rather than a synthesizer:
export function buildForwardedAskPayload(
request: ForwardedPermissionRequest,
): PromptPayload {
const requester = {
agentName: request.requesterAgentName,
forwarded: true,
sessionId: request.requesterSessionId,
};
return request.payload
? { ...request.payload, request: { ...request.payload.request, requester } }
: degradedForwardedPayload(request, requester);
}
The requester is re-stamped because the child built its payload with localRequester(agentName) — forwarded: false, sessionId: null.
The serving node is the only party that knows the ask arrived over the wire, and the request's own requesterAgentName / requesterSessionId are the authoritative provenance (#292); everything else on the payload is the child's fact and passes through untouched.
The degraded branch keeps kind: "forwarded" and builds the request facts from request.surface / request.value with empty evidence.
The "forwarded" kind therefore does not disappear from PromptPayloadKind — it narrows to meaning exactly one thing: this ask arrived without a payload.
Consequences to carry through:
forwardedValueLabel(dialog-renderer.ts) stays, and its comment stops predicting its own dissolution: it now labels the skew render only.renderForwarded(legacy-message.ts) can no longer read a"requested"evidence entry, because the degraded payload has none. It renders the provenance plus the surface/value it does hold.- A payload-bearing forwarded ask no longer reaches
renderForwardedat all — itskindis the child's — so themessagethe serving node's review log persists becomes the local-shaped sentence for that kind. That is a deliberate consequence of "renders identically in kind", and the review log is #746's surface.
The broadcast
export interface PermissionUiPromptEvent {
requestId: string;
source: PermissionUiPromptSource;
/** Normalized display surface (e.g. "bash", "skill"), when known. */
surface: string | null;
/** Normalized display value (command, path, skill name, etc.), when known. */
value: string | null;
agentName: string | null;
/** The ask's invariant core (ADR 0011 §3). No evidence, no annotations. */
request: PromptRequestFacts;
forwarding: ForwardedPromptContext | null;
}
DirectPromptInput.message: string becomes payload: PromptPayload, and buildUiPrompt projects request: input.payload.request.
Both call sites already pass a PromptPermissionDetails, which carries a required payload, so neither LocalUserAuthorizer nor ParentAuthorizer changes beyond compiling.
The consumer's call site, to check the shape reads well:
pi.events.on("permissions:ui_prompt", (raw) => {
const event = raw as PermissionUiPromptEvent;
notify(event.surface, event.value, event.request.matchedPattern);
// e.g. "bash" "git push" "git *"
});
request is nested rather than flattened so the event and the payload share one shape: a fact added to PromptRequestFacts reaches the bus without a second hand-maintained declaration, the same argument that made PromptPermissionDetails.payload required in #744.
Disclosure check against ADR 0011 §6: the bus gains matchedPattern, executedUnit, invokedToolName, commandContext, and the gate surface, and loses the assembled sentence.
matchedPattern already rides permissions:decision; executedUnit is derived from the command, which is already value for a bash ask.
For a write, an edit, or an MCP call the change is a net narrowing — today an incidental preview of up to 200 characters rides message, and after this change nothing from evidence reaches the bus at all.
PromptRequestFacts and PromptRequester become part of the public type surface, so src/service.ts re-exports them and scripts/verify-public-types.sh adds PromptRequestFacts to its symbol list.
The declaration bundle already inlines both (dist/public.d.ts lines 58–85) via PromptPermissionDetails; what is missing is the named export a consumer needs to annotate a variable.
The preview caps
resolveToolPreviewLimits drops its parameter and returns the three built-in constants; ConfigurablePreviewLimits goes with it.
The two fields leave PermissionSystemExtensionConfig and normalizePermissionSystemConfig, so no runtime consumer can read them — the skill's "a declared config field not read at runtime is a maintenance trap" applied deliberately.
They stay in unifiedConfigSchema (strict validation must keep accepting them) and stay in mergeUnifiedConfigs's number-scalar loop, so merged still carries an operator's setting for the detector to see.
The notice follows detectPermissiveBashFallback's precedent exactly — a pure detector over the merged config, whose caller owns pushing onto the issue list:
export function detectDeprecatedPreviewCaps(
config: UnifiedPermissionConfig,
): string | undefined;
loadPermissionConfigs pushes its result onto allIssues alongside the bash-fallback issue, and it surfaces through PolicyLoader.getConfigIssues() → PermissionManager.getConfigIssues() → SessionLifecycleHandler's logger.warn, which is the existing config-issue channel the issue names.
Version skew, in both directions
Skew is only reachable for an out-of-process child (PermissionForwardingTargetSource "env"); an in-process child shares the parent's loaded extension.
| Direction | Behavior |
|---|---|
| New parent, old child | Request has message, no payload. The relaxed required-core gate accepts it; asPromptPayload returns undefined; the degraded branch renders provenance + surface + value. Never empty. |
| Old parent, new child | Request has payload, no message. The old parser's typeof parsed.message !== "string" check rejects it and deletes the file; the child abandons at the forwarding timeout with confirmationUnavailable. Safe direction, slow. |
The second row is unavoidable — ADR 0011 §9 declines to carry both fields indefinitely — so the migration note says to upgrade the parent session first.
Module-Level Changes
Source
| File | Change |
|---|---|
src/authority/permission-forwarding.ts |
ForwardedPermissionRequest: remove message: string, add payload?: PromptPayload; import the payload type |
src/presentation/prompt-payload.ts |
Add asPromptPayload(value: unknown): PromptPayload | undefined — tolerant narrowing over kind, the request facts, evidence, and annotations |
src/authority/forwarding-io.ts |
Drop message from the required-core gate and the reconstruction; add payload: asPromptPayload(parsed.payload) |
src/authority/approval-escalator.ts |
ForwardedRequestFacts.message: string → payload: PromptPayload; authorize relays details.payload; buildForwardedRequest writes it. The requestId field and forwardableRequestId are #752's and are left alone |
src/presentation/forwarded-ask-payload.ts |
Two-branch projection: the child's payload with a re-stamped requester, or the degraded kind: "forwarded" payload; rewrite the module doc, which currently describes the transition as future |
src/presentation/legacy-message.ts |
renderForwarded renders provenance + surface/value instead of the removed "requested" evidence entry |
src/presentation/dialog-renderer.ts |
forwardedValueLabel: comment now scopes it to the skew render rather than predicting its dissolution |
src/permission-events.ts |
PermissionUiPromptEvent: remove message: string, add request: PromptRequestFacts |
src/permission-ui-prompt.ts |
DirectPromptInput.message: string → payload: PromptPayload; buildUiPrompt emits request |
src/service.ts |
Re-export PromptPayload, PromptPayloadKind, PromptRequestFacts, PromptRequester, PromptEvidence, PromptAnnotation |
src/tool-preview-formatter.ts |
resolveToolPreviewLimits() loses its parameter; remove ConfigurablePreviewLimits |
src/permission-session.ts |
getToolPreviewLimits() calls resolveToolPreviewLimits() with no argument |
src/extension-config.ts |
Remove toolInputPreviewMaxLength / toolTextSummaryMaxLength from PermissionSystemExtensionConfig and normalizePermissionSystemConfig |
src/config-schema.ts |
Mark both caps deprecated in their .meta({ description, markdownDescription }) |
src/config-loader.ts |
Add detectDeprecatedPreviewCaps; push its notice onto allIssues |
renderLegacyMessage itself is otherwise untouched, and every payload builder is untouched.
Generated, scripts, and config
| File | Change |
|---|---|
schemas/permissions.schema.json |
Regenerate via pnpm run gen:schema after the .meta edit |
config/config.example.json |
Remove the two deprecated caps |
scripts/verify-public-types.sh |
Add PromptRequestFacts to the required-symbol list |
Tests
| File | Change |
|---|---|
test/helpers/forwarding-fixtures.ts |
writeRequest's default request drops message and gains a payload |
test/helpers/prompt-details-fixtures.ts |
Unchanged — makePromptDetails already defaults payload; makePromptPayload gains the wire-shaped cases the new tests need |
test/authority/forwarding-io.test.ts |
Round-trip: payload written and read back; malformed payload → undefined; legacy message-only request still accepted |
test/authority/approval-escalator.test.ts |
The written request carries payload and no message; #752's id-adoption cases stay green untouched |
test/authority/forwarded-request-server.test.ts |
The escalated ask carries the child's payload with a re-stamped requester; a payload-less request escalates the degraded payload |
test/presentation/legacy-message.test.ts |
Rewrite the forwarded cases against the degraded payload; the eight local-kind cases stay untouched (the #744 byte-identity invariant) |
test/presentation/dialog-renderer.test.ts |
Re-pin the #710 here-string measurement at the new shape (a forwarded ask carrying a child kind: "bash" payload); keep the existing kind: "forwarded" cases as the skew render |
test/permission-ui-prompt.test.ts |
buildUiPrompt emits request, no message; forwarding / surface / value / agentName unchanged |
test/permission-events.test.ts, test/authority/local-user-authorizer.test.ts, test/authority/permission-prompter.test.ts, test/composition-root.test.ts, test/log-redaction.test.ts |
Update event/detail assertions that name message |
test/config-loader.test.ts |
A config setting either cap yields the deprecation notice |
test/tool-preview-formatter.test.ts, test/permission-session.test.ts, test/extension-config.test.ts, test/config-pipeline.test.ts, test/config-store.test.ts, test/handlers/gates/tool-call-gate-pipeline.test.ts, test/handlers/gates/tool.test.ts, test/helpers/gate-fixtures.ts, test/helpers/presentation-fixtures.ts |
Drop config-driven limit expectations; the constants still apply |
test/config-schema.test.ts |
Schema-parity test re-passes after regeneration |
Documentation
| File | Change |
|---|---|
docs/cross-extension-api.md |
Payload-fields table: message row → request row; add a PromptRequestFacts table; rewrite the example, which currently reads event.message; state the display-projection vs gate-facts distinction |
docs/configuration.md |
Mark both caps deprecated in the options table and remove them from the example config block |
docs/migration/0745-prompt-payload-contracts.md |
New: the three breaks, the superseding fields, and the upgrade-the-parent-first ordering |
docs/architecture/architecture.md |
Mark Step 3 ✅ on its heading and its Mermaid node with a Landed: note; flip both message: string metric rows to 0 ✅; rewrite the "Prompt presentation" paragraph at line 388, which asserts the wire, broadcast, and log all still read message and that the two caps still bound the previews; update the module-tree entries for permission-ui-prompt.ts, presentation/forwarded-ask-payload.ts, presentation/legacy-message.ts, and authority/permission-forwarding.ts |
README.md |
No change — its permissions:ui_prompt bullet names the channel, not the payload |
.pi/skills/package-pi-permission-system/SKILL.md |
No change — verified: it names the channel and the mode-restricted-not-redacted request files, neither of which this change alters |
Grep sweeps run at planning time to build this list:
grep -rn "message" src testfor every reader of the removed fields.grep -rln "ui_prompt" docs README.md ../../.pi/skills— the only live docs aredocs/cross-extension-api.mdandREADME.md; every other hit is a historical plan or retro, which is not edited.grep -rn "toolInputPreviewMaxLength\|toolTextSummaryMaxLength" src schemas config docs README.mdfor the cap sweep.grep -n "permission-ui-prompt\|permission-forwarding\|forwarded-ask-payload\|legacy-message" docs/architecture/architecture.mdfor the module-tree entries.
Test Impact Analysis
This is a boundary swap rather than an extraction, so the three questions land differently.
Newly possible tests.
A forwarded ask can now be asserted at the fact level end to end: a child payload written to a request file, read back, and rendered by renderPromptDialog under the serving node's budget, with the assertion on the rendered facts rather than on a relayed sentence.
That test was impossible while the wire carried prose — the only observable was the child's string.
The skew branch also becomes directly testable: a request with a malformed payload and one with none at all are two distinct, assertable renders.
Tests that become redundant.
None are removed.
The kind: "forwarded" dialog-renderer cases look like candidates but are not: they become the skew render's tests, which is a real branch that must keep working.
test/presentation/legacy-message.test.ts's two forwarded cases are rewritten rather than deleted, because the degraded message is still what the review log persists until #746.
Tests that must stay as-is.
The eight local-kind cases in test/presentation/legacy-message.test.ts are the #744 byte-identity proof and must not be touched — if a local ask's message changes here, something leaked across the boundary this change is supposed to be confined to.
test/authority/forwarded-request-server.test.ts's policy-then-escalate, grant-scope, and one-hop-canary cases exercise resolution, not presentation, and are unaffected except where they assert on details.
Invariants at risk
| Invariant | Source | Pinned by |
|---|---|---|
Every ask carries a complete payload; PromptPermissionDetails.payload is required |
#744 Landed: |
The type, plus test/helpers/prompt-details-fixtures.ts |
A local ask's message is byte-identical to the pre-payload assembler output |
#744 Landed: |
The eight local-kind cases in test/presentation/legacy-message.test.ts — untouched |
| A forwarded ask with pathological input renders inside the 24-row default | #710 Landed: (measured 205 rows before, at widths 80/120/160) |
test/presentation/dialog-renderer.test.ts "bounds the reported forwarded here-string ask (#710)" — re-pinned at the new shape |
A forwarded ask's broadcast stays non-degraded: requesterAgentName and requesterSessionId present |
#292, built on by #610 | test/permission-ui-prompt.test.ts, test/permission-events.test.ts |
An Authorizer link sees the child's gate facts but not requesterCwd / principal |
#635 | test/authority/forwarded-request-server.test.ts; toAccessFacts's explicit return type |
| The gate's fail-closed behavior is unchanged | Package invariant | Existing gate suite; this change touches no gate |
The quantitative one is the third, and it is the one the plan must measure rather than argue.
The #710 pin renders a hand-built kind: "forwarded" payload whose evidence is the child's sentence; after this change the same ask arrives as kind: "bash" with the child's real evidence entries, which is a different input to the same budget.
The row budget still bounds evidence and promptFieldMaxWidth still bounds the core, so the prediction is that the render stays at or below 24 rows — but the TDD step asserts it on the new shape before the old test is edited, so the number is measured, not inferred.
#635's boundary deserves an explicit check because the payload now crosses where it did not before.
PromptPayload carries requester.agentName and requester.sessionId, both of which details.forwarding already discloses to a link, and carries neither requesterCwd nor principal.
So the payload's arrival on the ask details is not a widening; the TDD step asserts the absence rather than assuming it.
TDD Order
Lift-and-shift on the wire: the payload arrives alongside message, the serving node switches to it, and only then is message removed.
That keeps each step's blast radius to one contract instead of collapsing the whole wire into one commit.
-
The wire carries the payload (additive). Red:
test/authority/forwarding-io.test.ts— a request written with a payload reads it back; a malformed payload reads backundefined; a request without one is still valid.test/authority/approval-escalator.test.ts— the written request file carriespayload. Green:asPromptPayloadinprompt-payload.ts;payload?: PromptPayloadonForwardedPermissionRequest;forwarding-io.tsreconstructs it;ForwardedRequestFactsgainspayload, andParentAuthorizerwrites both fields.feat(pi-permission-system): carry the prompt payload on the forwarded-request wire -
The serving node renders the child's facts. Red:
test/authority/forwarded-request-server.test.ts— the escalated ask's payload is the child's, withrequesterre-stamped to the request's provenance and the child'skindpreserved; a payload-less request escalates the degradedforwardedpayload.test/presentation/dialog-renderer.test.ts— the #710 here-string measurement at the new shape, asserted before the old case is touched. Green:buildForwardedAskPayload's two branches.feat(pi-permission-system): render a forwarded ask from the child's own payload -
Remove
messagefrom the wire. Every importer of the field breaks at the type level in this commit, so the wire type, the reader, the child's write, the degraded legacy render, and the fixtures move together. Red:test/authority/forwarding-io.test.ts— a legacymessage-only request is accepted and reconstructs no message;test/presentation/legacy-message.test.ts— the rewritten forwarded cases render from surface/value. Green: dropmessagefromForwardedPermissionRequest, fromreadForwardedPermissionRequest's gate and reconstruction, and fromForwardedRequestFacts; rewriterenderForwarded; updateforwardedValueLabel's comment andtest/helpers/forwarding-fixtures.ts.feat(pi-permission-system)!: replace the forwarded-request message with the structured payload -
Narrow the broadcast. Red:
test/permission-ui-prompt.test.ts—buildUiPromptemitsrequestequal to the payload's core and nomessage, withsurface/value/agentName/forwardingunchanged. Green:PermissionUiPromptEvent.message→request;DirectPromptInput.message→payload;service.tsre-exports;scripts/verify-public-types.shsymbol list. Consumer-test updates intest/permission-events.test.ts,test/authority/local-user-authorizer.test.ts,test/authority/permission-prompter.test.ts,test/composition-root.test.ts,test/log-redaction.test.tsride this commit — the field removal breaks them at compile time.feat(pi-permission-system)!: narrow the ui_prompt broadcast to the request facts -
Soft-deprecate the two preview caps. Red:
test/config-loader.test.ts— a config setting either cap yields a deprecation notice throughgetConfigIssues, and setting neither yields none;test/tool-preview-formatter.test.ts— a configured value no longer changes the limit. Green:detectDeprecatedPreviewCaps;resolveToolPreviewLimits()parameterless; the fields leavePermissionSystemExtensionConfig;.metamarked deprecated;pnpm run gen:schema;config/config.example.json.feat(pi-permission-system)!: ignore the deprecated tool-preview caps and notice their use -
Documentation and the roadmap mark.
docs/cross-extension-api.md,docs/configuration.md, the newdocs/migration/0745-prompt-payload-contracts.md, anddocs/architecture/architecture.md(Step 3✅on heading and Mermaid node,Landed:note, both metric rows to0 ✅, the line-388 paragraph, and the four module-tree entries).docs(pi-permission-system): document the payload contracts and mark Phase 13 Step 3 complete
Verification after each step: pnpm --filter @gotgenes/pi-permission-system run check, run lint, run test.
After step 4, also pnpm --filter @gotgenes/pi-permission-system run verify:public-types.
Before the final commit, pnpm fallow dead-code --workspace @gotgenes/pi-permission-system and the metric recomputes:
grep -c "message: string" packages/pi-permission-system/src/authority/permission-forwarding.ts # 1 -> 0
grep -c "message: string" packages/pi-permission-system/src/permission-ui-prompt.ts # 1 -> 0
Baselines measured this session: both are 1.
src/permission-events.ts also holds one message: string and also goes to 0; it is not a roadmap metric row, but it is part of the same removal and the metric would be dishonest without it.
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| An old parent rejects a new child's request and the child burns the 10-minute timeout | Only reachable for an out-of-process child; the migration note says upgrade the parent first, and the abandonment already reports confirmationUnavailable rather than a user denial (#719) |
| The forwarded render regresses past the row budget once real evidence crosses the wire | Step 2 re-pins the #710 measurement at the new shape before the old case is edited, so the number is measured |
asPromptPayload accepts a partially-malformed payload and the serving node renders corrupt facts |
The guard is all-or-nothing, following asForwardedAccessIntent's precedent: any malformed field yields undefined and the degraded render, never a half-payload |
The payload's arrival on the ask details widens what an Authorizer link sees |
The payload carries no requesterCwd and no principal; a test asserts the absence rather than the design assuming it (#635) |
A third-party extension reading event.message breaks silently |
Unavoidable and intended; the migration note names request.value and request.matchedPattern as the superseding fields, and docs/cross-extension-api.md already tells consumers to read defensively |
The payload field is added to the wire type but silently dropped on read |
The tolerant asX reader is treated as a first-class touch point in step 1, with a round-trip test rather than a write-side-only assertion |
Landing concurrently with #721 conflicts in approval-escalator.ts |
The roadmap's Track B note already requires sequencing; this plan restates it and neither issue is in flight |
| Removing the caps' effect grows the review log | Deliberately out of scope — only the configured values stop applying, and the built-in constants still bound the evidence until #746 lands the log's own renderer |
Open Questions
- Whether the degraded skew render should eventually be removed once the version window closes. It is cheap to keep and fails safe, so it stays for now; no issue filed.
- Whether
docs/cross-extension-api.mdshould publish a stability note distinguishing the display projection (surface/value) from the gate facts (request.surface). Planned as prose in the doc update; if consumers conflate them in practice, that becomes a rename discussion, not a doc one. - The
select/inputfallback's complete-view capability, parked here by #710's plan, is filed as #751 and out of scope. - Whether
forwarded_permission.request_createdshould log the requester's id alongside the wire id, closing the join for the one case where #752'sforwardableRequestIdfalls back to a fresh mint. It is #752's residual and needs no contract change; this step keeps today's behavior, and Step 10 (#610) decides it with the full correlation picture.