Files
my-pi/pi-permission-system/docs/plans/0635-forwarded-access-intent-to-authorizer.md
T

30 KiB

issue, issue_title
issue issue_title
635 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.324.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:

/**
 * 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):

    ...(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:

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 allowdefer, 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<ForwardedAccessIntent> 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.324.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.