--- issue: 635 issue_title: "Forwarded accessIntent is dropped before Authorizer Chain escalation" --- # Carry the forwarded access facts through to Authorizer Chain escalation ## Release Recommendation **Release:** ship independently Issue #635 is not a numbered step in `docs/architecture/architecture.md`'s improvement roadmap, and that roadmap carries no `Release:` batch annotations, so there is no batch to hold this behind. It is a self-contained defect fix in the serving-node escalation path. It ships as a **breaking** change (`fix!:`), so it cuts a major release: `23.0.3` → `24.0.0`. ## Problem Statement A subagent child that cannot answer an `ask` forwards the request to its parent (serving) session. Since [#596], the request carries a structured `ForwardedAccessIntent` — the child-fixed access facts (`surface`, `matchValues`, `boundaryValue`) plus requester identity (`requesterCwd`, `principal`). Since [#597], `ForwardedRequestServer.resolveDecision()` resolves that intent against the serving node's own composed ruleset. But when resolution lands on `ask`, `buildForwardedAskDetails()` reconstructs `PromptPermissionDetails` from the request's **display** fields only — `message`, `surface`, `value`, `forwarding`, `sessionApproval` — and drops `request.accessIntent` on the floor. `PromptPermissionDetails` already declares an optional `accessIntent?: ForwardedAccessFacts` field, which every local gate populates; only the serving-node reconstruction leaves it empty. Two consequences follow, both defects against already-accepted design: 1. **An Authorizer Chain link sees no structured evidence for a forwarded ask.** [ADR 0008]'s composition section states that once both tracks land, "a serving node's chain links … review forwarded asks against the child-fixed fact set — honest evidence, not a parent-side re-derivation." Today a link receives display strings and a formatted UI message. It cannot safely parse display text, and it must not reconstruct the child's path from the parent's cwd — the exact re-derivation [ADR 0008] forbids. So a path-aware link has no option but to `defer` every forwarded `write`/`edit`, even when canonical path evidence would let it decide conservatively. 2. **The bounded-delegation checkpoint silently under-applies to forwarded asks.** `delegation-envelope.ts` decides exclusion with `details.accessIntent?.surface ?? details.surface`, preferring the gate-authoritative surface and falling back to the display surface. For a forwarded ask the gate surface is absent, so the checkpoint reads the display surface — the child's **tool name** (`write`), never `path`/`external_directory`. A registered allow-capable link's `allow` on a forwarded `path`-gate ask is therefore honored, where the identical ask made locally in the parent is capped to `defer` ([ADR 0007] §5). Forwarding is currently an escalation path around the operator's own delegation boundary. The same line of code causes both: populating `accessIntent` fixes the evidence gap and closes the checkpoint escape together. ## Goals - Carry the child-fixed access facts (`surface`, `matchValues`, `boundaryValue`) from `request.accessIntent` onto the `PromptPermissionDetails` the serving node escalates. - Keep the disclosure boundary explicit: `requesterCwd` and `principal` do **not** cross onto the prompt details, and neither do raw tool arguments or change bodies. The requester identity a link legitimately needs is already on `details.forwarding`. - Accept, pin, and document the resulting bounded-delegation tightening: a chain link's `allow` on a forwarded `path` / `external_directory` ask is now capped to `defer`. This is a **breaking change** — it ships as `fix(pi-permission-system)!:` with a `BREAKING CHANGE:` footer and a migration note. - Record the cross-issue principle this fix instantiates — *high-fidelity in-process seams, minimal-but-correlatable broadcasts* — so [#610]'s planning session inherits a decided frame rather than re-deliberating it. ## Non-Goals - **[#610] (make UI prompt decisions correlatable in the serving session).** It shares this issue's root cause — the serving node reconstructs a degraded projection of the forwarded request — but it changes the public `permissions:decision` event contract, adds a parent-side emit point, and carries a `pkg:pi-subagents` label. This plan contributes only the shared principle note; the event work stays with [#610]. - **[#620] (allow-capable opaque-bash adjudicator).** [#620] will relax the whole-`path` exclusion to a secret-shaped one, which narrows the tightening this plan lands. That refinement is [#620]'s to make; nothing here changes `DELEGATION_EXCLUDED_SURFACES`. - **The `permissions:ui_prompt` broadcast payload.** `buildUiPrompt` reads only `surface`/`value`/`forwarding` and never touches `accessIntent`, so the forwarded broadcast is byte-identical after this change. `details.surface` is not repointed at the gate surface — that would degrade the [#292] non-degraded-broadcast contract. - **`ServingPolicy.resolve` / recorded-authority resolution.** `resolveDecision`'s `request.accessIntent`-presence gate and its `ask` floor on absence ([ADR 0008] §4) are untouched. - **The multi-surface fact set.** [ADR 0008] records it as an explicitly deferred edge; a multi-surface child decision still floors to `ask`. ## Background Relevant modules, in the order a forwarded ask traverses them: | Module | Role | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/handlers/gates/*.ts` | Every gate populates `promptDetails.accessIntent` via `accessFactsFromPath` / `accessFactsFromValue` (`gates/helpers.ts`). | | `src/authority/approval-escalator.ts` | `ParentAuthorizer.authorize()` reads `details.accessIntent` and `buildForwardedRequest` completes it into a `ForwardedAccessIntent` by stamping `requesterCwd` + `principal`. | | `src/authority/permission-forwarding.ts` | Declares `ForwardedAccessFacts` (the three fields) and `ForwardedAccessIntent extends ForwardedAccessFacts` (plus identity). | | `src/authority/forwarding-io.ts` | `asForwardedAccessIntent` tolerantly narrows the wire field on read. | | `src/authority/forwarded-request-server.ts` | `resolveDecision` resolves the intent; `buildForwardedAskDetails` reconstructs the ask details — **the defect site**. | | `src/authority/authorizer-selection.ts` | `escalate` composes the chain, wrapping each link in `encloseInDelegationEnvelope`. | | `src/authority/delegation-envelope.ts` | The checkpoint; reads `details.accessIntent?.surface ?? details.surface`. | Which gate raises a forwarded ask determines whether the tightening applies at all: | Raising gate | `accessIntent.surface` | Display `surface` | Envelope before | Envelope after | | ------------------------------------- | ---------------------- | ----------------- | ---------------- | -------------- | | `bash` command gate | `bash` | `bash` | not excluded | not excluded | | Per-tool gate (a `write:` rule fired) | `write` | `write` | not excluded | not excluded | | Cross-cutting `path` gate | `path` | `write` | **not excluded** | **excluded** | | `external_directory` gate | `external_directory` | tool name | **not excluded** | **excluded** | Only the last two rows change, and only once an allow-capable link is registered and named in `authorizerChain`. The shipped first-party link (`packages/pi-permission-model-judge`) is deny-first; [ADR 0007] describes the checkpoint as dormant today. Constraints from `AGENTS.md` and the package skill that bear on this plan: - Breaking commits use `!` **after** the scope: `fix(pi-permission-system)!:`. - `docs/architecture/` and `docs/decisions/` are **not** in the package's `files` allowlist, so a link from a shipped doc (`README.md`, `docs/configuration.md`, `docs/migration/*.md`) into either must be an absolute GitHub URL. - Architecture module-tree entries describe current behavior; cite an issue only when the ref encodes an active constraint. - Do not put `Closes #N` in commit messages; use `Refs #635`. ## Design Overview ### The projection `buildForwardedAskDetails` gains one field, produced by an explicitly-typed module-private projection rather than a spread: ```typescript /** * Project the request's `ForwardedAccessIntent` down to the child-fixed access * facts an Authorizer may see. * * The disclosure boundary is deliberate and is why this is a field-by-field * projection, not a spread: `requesterCwd` and `principal` stay off the ask * details. A link that needs requester identity reads `details.forwarding`, * which already carries the agent name and session id. * * The explicit `ForwardedAccessFacts` return type makes the boundary * compile-checked: a field added to `ForwardedAccessFacts` fails `tsc` here * until it is deliberately projected or deliberately withheld. */ function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts { return { surface: intent.surface, matchValues: intent.matchValues, boundaryValue: intent.boundaryValue, }; } ``` and the details builder adds a conditional spread matching the existing `sessionApproval` idiom, so a version-skew request without the field carries no `accessIntent` key at all (not an explicit `undefined`): ```typescript ...(request.accessIntent ? { accessIntent: toAccessFacts(request.accessIntent) } : {}), ``` Absence must stay absence: `delegation-envelope.ts` fail-safes an undetermined surface to *excluded*, and the `?? details.surface` fallback depends on `accessIntent?.surface` being `undefined` rather than a half-populated object. ### Consumer call site A registered chain link's `authorize` is the consumer this exists for. The interaction is Tell-Don't-Ask in the direction that matters — the link is *handed* the facts and *asks the injected query* for the policy; it never reaches back through the details for a collaborator: ```typescript async function authorize(details, query, log) { const facts = details.accessIntent; if (facts?.surface !== "external_directory") return { kind: "defer" }; const verdict = query.checkPermission("external_directory", facts.boundaryValue ?? undefined); log.review("model_judge.reviewed", { requestId: details.requestId, surface: facts.surface }); return verdict.state === "deny" ? { kind: "deny", reason: "outside policy" } : { kind: "defer" }; } ``` `facts.matchValues` and `facts.boundaryValue` are plain strings fixed at the child, honoring the `path-values` string boundary (`docs/decisions/0002-path-values-string-boundary.md`) — the wire never carries an `AccessPath`, and the link never rebuilds one. ### The bounded-delegation consequence No code change in `delegation-envelope.ts`. Its existing `details.accessIntent?.surface ?? details.surface` already prefers the gate-authoritative surface; it simply never had one for a forwarded ask. After this change the forwarded path is treated exactly like the local path, which is what [ADR 0007] §5 already specifies. The checkpoint still only ever *tightens* — it converts `allow` → `defer`, never the reverse — so the invariant [#599] landed is preserved, not merely unbroken. ### The shared principle (the [#610] frame) A new short subsection in `docs/architecture/architecture.md`, under `## The authority model` immediately after `### The recursion` (where the courier hop is already described): > **Reconstruction fidelity at the serving node.** > The courier hop carries facts, not judgment — but what the serving node reconstructs from the forwarded request differs by audience. > An **in-process seam** (the `Authorizer` chain, reached through `PromptPermissionDetails`) receives the full child-fixed fact set, because a chain link is operator-opted-in via `authorizerChain` and must decide from evidence rather than parsed display text. > A **cross-extension broadcast** (`permissions:ui_prompt` / `permissions:decision` on `pi.events`) receives the minimum needed to be correlatable, because any loaded extension can observe it. > Fidelity up, disclosure down: the two directions are the same rule applied to different trust boundaries. > Requester identity (`requesterCwd`, `principal`) crosses to neither — it stays on the wire object, with the ask details carrying only the `forwarding` provenance. This is descriptive of decided architecture ([ADR 0007] §5, [ADR 0008] §2) rather than a new decision, so it belongs in the living architecture doc, not a new ADR. [#610] can cite it for the broadcast half. ## Module-Level Changes ### Source - **`src/authority/forwarded-request-server.ts`** — add the module-private `toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts` helper; add the conditional `accessIntent` spread to `buildForwardedAskDetails`; extend that function's doc comment to name the disclosure boundary and the checkpoint coupling. `ForwardedAccessFacts` joins the existing type import from `#src/authority/permission-forwarding` (`ForwardedAccessIntent` is already imported). - **`src/authority/permission-prompter.ts`** — correct the `PromptPermissionDetails.accessIntent` doc comment. Its current final sentence, "Absent for a serving-node local prompt reconstructed from a forwarded request," becomes false with this change; replace it with the version-skew condition (absent only when the forwarded request carried no intent). Greps run to bound the file list: - `grep -rn "accessIntent" packages/pi-permission-system/src` — 8 gate sites (producers, unchanged), `approval-escalator.ts` (child side, unchanged), `delegation-envelope.ts` (consumer, unchanged), `forwarding-io.ts` (wire read, unchanged), `permission-forwarding.ts` (type, unchanged), plus the two files above. - No export is removed or renamed, so no cross-package or `docs/` symbol sweep is triggered. - `grep -rn "Absent for a serving-node"` — matches `src/authority/permission-prompter.ts` and `dist/public.d.ts`; `dist/` is gitignored and regenerated by `build:types`, so only the source comment is edited. ### Tests - **`test/authority/forwarded-request-server.test.ts`** — the exact-object assertion in "escalates an ask through the AskEscalator with the forwarded provenance details" (currently `expect(escalate).toHaveBeenCalledWith({ … })`) gains the projected `accessIntent`; this is the only exact-match call-site assertion on `escalate` in the suite (`grep -rn "escalate).toHaveBeenCalledWith({" test/` → 1 hit). New cases cover the `path`-surface projection, the exact-key disclosure boundary, version-skew absence, and the bounded-delegation composition. - **`test/helpers/forwarding-fixtures.ts`** — no change; `makeForwardedAccessIntent` already accepts `Partial` overrides and defaults to a `bash` surface with a worktree-shaped `requesterCwd`. ### Docs - **`docs/architecture/architecture.md`** — (a) the `forwarded-request-server.ts` module-tree entry gains a clause that the serving reconstruction projects the request's access facts onto the escalated ask; the clause carries the `#635` ref because the projection is an active constraint (the bounded-delegation checkpoint's exclusion decision depends on it), per the architecture-doc convention. (b) The new `### Reconstruction fidelity at the serving node` subsection under `## The authority model`. The `permission-prompter.ts` entry already reads "carries the child-fixed `accessIntent` facts a forwarded ask relays" and stays accurate. - **`docs/configuration.md`** — the bounded-delegation checkpoint paragraph (in the `Authorizer chain — case-by-case decision links` section) gains one sentence: the cap applies to forwarded subagent asks on the gate surface that raised them, not the tool name displayed. - **`docs/migration/0635-forwarded-ask-delegation-envelope.md`** — new, following the shape of `docs/migration/0644-project-trust-gating.md`: what changed, who is affected (only operators running a third-party *allow*-capable link named in `authorizerChain`), what to do (nothing for the shipped deny-first judge; expect a prompt where a link previously auto-allowed a forwarded path ask), and the forward pointer to [#620]'s secret-shaped refinement. `docs/migration` is in the package's `files` allowlist, so any link it makes into `docs/decisions/` must be an absolute GitHub URL. - **`.pi/skills/package-pi-permission-system/SKILL.md`** — the `AuthorizerSelection.escalate` paragraph's bounded-delegation sentence gains a clause that a forwarded ask carries the child-fixed `accessIntent`, so the checkpoint reads the gate surface for forwarded and local asks alike. This is a reworded-behavior case with no removed symbol, so the skill grep is required by convention. No `README.md` change: its one-sentence chain summary ("caps any link's `allow` on `external_directory`/`path`") stays true and is the right granularity. No config schema, example-config, or `permissions.schema.json` change: no config field is added. ## Test Impact Analysis 1. **Newly enabled tests.** This is a defect fix, not an extraction, so it enables no structurally-new test surface. It does make one assertion newly *meaningful*: that a forwarded `path`-gate ask reaches the chain with `accessIntent.surface === "path"`, which is the observable difference between the two envelope outcomes. The bounded-delegation composition test is new coverage of a real gap — `delegation-envelope.test.ts` covers the envelope over synthetic details, and `forwarded-request-server.test.ts` covers the server over a fake escalator, but nothing composed the two, which is precisely why the forwarded escape went unnoticed. 2. **Redundant tests.** None. No existing test is subsumed; the exact-match escalate assertion is *extended*, not replaced, because pinning the whole details object is exactly what guards the disclosure boundary. 3. **Tests that must stay as-is.** `test/authority/forwarding-io.test.ts`'s `asForwardedAccessIntent` narrowing cases (the wire read is untouched), `forwarded-request-server.test.ts`'s recorded-authority and version-skew cases (`resolveDecision` is untouched), and `composition-root.test.ts`'s forwarded non-degraded-broadcast and grant-scope round-trips (the display projection is untouched). ## Invariants at Risk | Invariant | Origin | Pinned by | Risk and handling | | ---------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | A forwarded ask emits a **non-degraded** `permissions:ui_prompt`: the parent's broadcast carries the child's original `surface`/`value`. | [#292] | `test/composition-root.test.ts`, "forwarded non-degraded broadcast" | Repointing `details.surface` at the gate surface would regress it. Measured, not argued: `buildUiPrompt` (`src/permission-ui-prompt.ts`) reads only `requestId`/`source`/`surface`/`value`/`agentName`/`message`/`forwarding` and never `accessIntent`, so the emitted payload is byte-identical after this change. The plan adds a field and repoints nothing. | | `ServingPolicy` resolution is intent-only; a request without `accessIntent` floors to `ask` without consulting the policy. | [#597], [ADR 0008] §4 | `forwarded-request-server.test.ts`, "floors a request with no fields at all (fully legacy) to escalation without consulting the policy" | `resolveDecision` is not edited. The version-skew test is extended to also assert the escalated details carry **no** `accessIntent` key, so absence stays absence. | | The wire read is tolerant: a malformed `accessIntent` narrows to `undefined` rather than throwing. | [#596] | `test/authority/forwarding-io.test.ts` | Unchanged; the projection runs only on an already-narrowed value, so it can never see a partial object. | | The bounded-delegation checkpoint only ever **tightens** a verdict. | [#599], [ADR 0007] §5 | `test/authority/delegation-envelope.test.ts` | Preserved and strengthened: the change converts one `allow` into a `defer` and creates no path from `defer`/`deny` to `allow`. The new composition test pins it end to end. | | Requester identity (`requesterCwd`, `principal`) never crosses onto the prompt details. | [ADR 0008] §2 disclosure framing; the issue's own explicit ask | *New* — this invariant lives only in prose today | Add the pin: an exact-keys assertion (`Object.keys(details.accessIntent).sort()` equals `["boundaryValue", "matchValues", "surface"]`) plus the compile-checked `ForwardedAccessFacts` return type on `toAccessFacts`. | ## TDD Order 1. **Red → Green: project the child-fixed facts onto the escalated ask, and pin the delegation consequence.** Test surface: `test/authority/forwarded-request-server.test.ts`. Red (four assertions, all failing against current `main`): - Extend the existing exact-object assertion in "escalates an ask through the AskEscalator with the forwarded provenance details" to include `accessIntent: { surface: "bash", matchValues: ["git push"], boundaryValue: null }`. - New: a forwarded request whose `accessIntent` is a `path`-surface intent (multi-alias `matchValues`, non-null `boundaryValue`, and a `requesterCwd`/`principal` distinct from the serving session) escalates with all three fact fields intact **and** with exactly those three keys — the disclosure-boundary pin. - New: a version-skew request with no `accessIntent` escalates with the key absent (`expect(details).not.toHaveProperty("accessIntent")`). - New `describe` composing the two real units: run the details captured from `escalate` through `encloseInDelegationEnvelope` over an allow-returning link, and assert `{ kind: "defer" }` for a forwarded `path`-surface ask and `{ kind: "allow" }` for a forwarded `bash`-surface ask (the scenario-A regression guard). Green: add `toAccessFacts` and the conditional spread in `src/authority/forwarded-request-server.ts`; correct the `PromptPermissionDetails.accessIntent` doc comment in `src/authority/permission-prompter.ts`. Land the user-facing breaking-change docs in the same commit so the `BREAKING CHANGE:` footer points at a note that exists: `docs/migration/0635-forwarded-ask-delegation-envelope.md` and the `docs/configuration.md` sentence. Verify: `pnpm --filter @gotgenes/pi-permission-system run test`, `run check`, `run lint`. Commit: `fix(pi-permission-system)!: carry forwarded access facts to the Authorizer Chain (#635)`, with a `BREAKING CHANGE:` footer describing the checkpoint tightening and pointing at the migration note, plus `Refs #635`. 2. **Docs: architecture module tree, the fidelity principle, and the package skill.** No test cycle — documentation only. Edit `docs/architecture/architecture.md` (the `forwarded-request-server.ts` module-tree clause and the new `### Reconstruction fidelity at the serving node` subsection) and `.pi/skills/package-pi-permission-system/SKILL.md` (the bounded-delegation sentence). Verify: `pnpm exec rumdl check` on the edited files. Commit: `docs(pi-permission-system): record serving-node reconstruction fidelity (#635)`. ## Risks and Mitigations - **Risk: a spread leaks `requesterCwd`/`principal` onto the ask details.** `ForwardedAccessIntent` is structurally assignable to `ForwardedAccessFacts`, so `accessIntent: request.accessIntent` type-checks cleanly while carrying two extra fields at runtime — a silent disclosure widening that `tsc` cannot catch. Mitigation: the field-by-field `toAccessFacts` projection with an explicit `ForwardedAccessFacts` return type, plus the exact-keys test assertion. - **Risk: a future field added to `ForwardedAccessFacts` is silently not projected.** Mitigation: the explicit return type makes the object literal incomplete, so `tsc` fails at `toAccessFacts` until the new field is deliberately projected or deliberately withheld with a comment. - **Risk: an operator running an allow-capable link is surprised by new prompts.** Mitigation: `fix!:` + `BREAKING CHANGE:` footer + a migration note naming exactly who is affected. The blast radius is narrow by construction: it requires a third-party allow-capable link, named in `authorizerChain`, on a forwarded ask raised by the `path` / `external_directory` gate. The shipped first-party judge is deny-first. - **Risk: a major version bump (`23.0.3` → `24.0.0`) for a small change.** Accepted deliberately (operator decision at planning): the tightening changes an authorization outcome, which is the category that most warrants a loud signal. - **Risk: the tightening is later reversed by [#620].** Not a regression — [#620] replaces the whole-surface exclusion with a secret-shaped one *by design*, for local and forwarded asks alike. The migration note names this so an operator reading it understands the trajectory. - **Risk: the new architecture subsection re-inflates the doc.** Mitigation: it is one short paragraph under an existing section, stating current behavior, with no per-issue provenance trail in the module tree. ## Open Questions - Should the fidelity principle eventually graduate from the architecture doc into an ADR? Deferred until [#610] lands: an ADR is warranted if the broadcast half turns out to need real deliberation (e.g. whether `permissions:decision` may carry a value projection at all), and premature if [#610] is a mechanical `requestId` addition. - Should `ForwardedAccessFacts` grow a `requesterCwd` for *display* (letting a prompt or a link show "the child resolved this against `/worktree/issue-42`")? Out of scope; no consumer asks for it, and adding it would widen the disclosure boundary this plan deliberately draws. Revisit only when a concrete consumer exists. [ADR 0007]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0007-model-judge-authorizer-chain-adr.md [ADR 0008]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0008-cross-session-access-intent.md [#292]: https://github.com/gotgenes/pi-packages/issues/292 [#596]: https://github.com/gotgenes/pi-packages/issues/596 [#597]: https://github.com/gotgenes/pi-packages/issues/597 [#599]: https://github.com/gotgenes/pi-packages/issues/599 [#610]: https://github.com/gotgenes/pi-packages/issues/610 [#620]: https://github.com/gotgenes/pi-packages/issues/620