Files
my-pi/pi-permission-system/docs/plans/0596-structured-intent-forwarded-wire.md
T

26 KiB
Raw Blame History

issue, issue_title
issue issue_title
596 pi-permission-system: carry the structured access intent onto the forwarded-permission wire

Carry the structured access intent onto the forwarded-permission wire

Release Recommendation

Release: mid-batch — defer (batch "cross-session-intent"); confirm at ship time

This issue is Step 2 of Phase 12 Track A, the middle member of release batch "cross-session-intent" (Steps 1, 2, 3; tail = Step 3, #597). The roadmap step (docs/architecture/architecture.md) tags it Release: batch "cross-session-intent", and Step 2 is not the batch tail. Its plan-execution commits are feat:/test:/docs: on main; the whole batch ships together once #597 lands and cuts the release.

Problem Statement

When an ask-state permission arises in a subagent child with no UI, the extension forwards it up the session tree for a decision. The gate that raised the ask computed a full AccessIntent — for a path-shaped ask, an AccessPath carrying the lexical canonical alias set (matchValues()) and the canonical boundary form (boundaryValue()) — and then discarded it. PromptPermissionDetails and ForwardedPermissionRequest carry only display strings (surface/value), so by the time an ask reaches the serving node the structured facts it needs are unrecoverable.

ADR 0008 (docs/decisions/0008-cross-session-access-intent.md, Step 1) settled the contract: the child owns the facts; the parent owns the judgment. The facts are fixed at the origin child and carried unchanged across every hop; no node re-derives them. This step threads those child-fixed facts from the point of decision (the gate) through the escalation edge (ParentAuthorizer) and onto the forwarded wire as the ForwardedAccessIntent field ADR 0008 specifies. It does not yet make serving consume the field — that is Step 3 (#597). Threading the intent onto the wire is the structural half of dissolving #565 items 23 (path re-interpretation at the wrong node; undefined agent-scope semantics); the serving half lands in Step 3.

Goals

  • Declare the ForwardedAccessIntent wire schema (per ADR 0008 §2) in src/authority/permission-forwarding.ts and add it as an optional field on ForwardedPermissionRequest.
  • Carry the gate-fixed access facts (surface, matchValues, boundaryValue) from every path and non-path gate onto PromptPermissionDetails, so they flow through the runner into the escalation edge.
  • Stamp the requester-identity portion (requesterCwd, principal) at the escalation edge (ParentAuthorizer), where session identity is known, and serialize the complete ForwardedAccessIntent onto the forwarded request.
  • Read the new field tolerantly in src/authority/forwarding-io.ts (version-skew: an older child's request without the field still reads and floors to ask as today).
  • Honor the ADR-0002 string boundary: the wire carries strings only, never an AccessPath instance.
  • This change is non-breaking: it adds an optional field with a tolerant read; no config, schema, default, or observable decision changes on upgrade.

Verify criterion (from the roadmap): grep -c ForwardedAccessIntent src/authority/permission-forwarding.ts goes 0 → ≥ 1.

Non-Goals

  • Serving consumption of the intent — Step 3 (#597) reworks ServingPolicy/forwarded-request-server.ts to resolve the forwarded intent directly and retires the legacy (surface, value) re-derivation. This step leaves servingPolicy in index.ts and forwarded-request-server.ts untouched; serving still re-derives from display strings, and grep -c ForwardedAccessIntent forwarded-request-server.ts stays 0.
  • Removing the hasDisplayFields floor — the display-field escalation floor is Step 3's symptom to dissolve; the display fields (source/surface/value) continue to ride the wire unchanged.
  • Agent-scoped serving evaluationrequesterAgentName graduates to decision-participating only when Step 3 resolves against it; this step carries principal.agentName on the wire but changes no resolution behavior.
  • Multi-surface fact set and multi-hop principal identity — ADR 0008's two explicitly deferred edges; out of scope here.

Background

Relevant existing modules and how they relate:

  • src/access-intent/access-path.ts — the AccessPath value object. matchValues() returns the lexical alias union canonical form (the #418/#486 match set); boundaryValue() returns the canonical (symlink-resolved) form or ""; value() the lexical absolute form. Every path gate already builds one.
  • src/access-intent/access-intent.ts — the gate-emitted AccessIntent union (tool | access-path). The access-path variant holds the AccessPath. ADR-0002 (docs/decisions/0002-path-values-string-boundary.md) keeps the manager string-based: AccessPath never crosses into the manager or the wire; producers convert to strings.
  • Gate factories (src/handlers/gates/) — each builds a pure GateDescriptor. path.ts, external-directory.ts (tool surfaces) and bash-path.ts, bash-external-directory.ts (bash surfaces) hold an AccessPath; tool.ts holds one for the per-tool path-bearing surfaces (accessPath?); skill-input.ts/skill-read.ts are non-path.
  • src/handlers/gates/runner.tsGateRunner.runDescriptor spreads descriptor.promptDetails into this.prompter.escalate({ requestId, ...descriptor.promptDetails, ... }). Anything on promptDetails reaches the escalation edge.
  • src/authority/permission-prompter.tsPromptPermissionDetails is the ask payload. GateDescriptor.promptDetails is Omit<PromptPermissionDetails, "requestId">, so a new optional field on PromptPermissionDetails is automatically available on every descriptor's promptDetails.
  • src/authority/approval-escalator.tsParentAuthorizer.authorize(details) builds a ForwardedPermissionRequest via buildForwardedRequest and writes/polls it. It already computes requesterSessionId (getSessionId(ctx)) and requesterAgentName.
  • src/authority/forwarder-context.tsForwarderContext, the narrow read-interface ExtensionContext satisfies structurally, plus getSessionId(ctx). ExtensionContext.cwd exists (used at permission-gate-handler.ts:73, lifecycle.ts:59), but ForwarderContext does not currently expose it.
  • src/authority/permission-forwarding.ts / forwarding-io.ts — the ForwardedPermissionRequest type and its tolerant reader readForwardedPermissionRequest, which reconstructs an allowlist of known fields with per-field asX narrowers (asUiPromptSource, asNullableDisplayString, asForwardedSessionApproval).

Constraint from AGENTS.md / the package skill applied here:

  • ADR-0002 string boundary — the wire schema carries string[], never AccessPath; each gate converts via matchValues()/boundaryValue() at emit.
  • Architecture-doc convention — module-tree entries describe current behavior; cite an issue only for an active constraint. The roadmap Step 2 heading + Mermaid S2 node get + a Landed: note at implementation completion (not deferred to ship).
  • Tolerant-reader touch point (#558) — readForwardedPermissionRequest reconstructs an allowlist, so a new field is silently dropped unless the reader is extended. That extension is in scope.

Design Overview

The fact / identity split

ADR 0008 groups a forwarded ask into what is being accessed (fixed at the child gate) and who/where is requesting (a property of the requester session). This plan mirrors that split across the two layers that own each half:

  • The gate emits the access factssurface, matchValues, boundaryValue. Only the gate can produce matchValues/boundaryValue (they live on the AccessPath), and they must not be re-derived downstream.
  • The escalation edge stamps the requester identityrequesterCwd (the session cwd, ctx.cwd) and principal (sessionId, agentName). ParentAuthorizer already knows both.

This keeps each gate producing only what it genuinely fixes (no per-gate cwd threading) and localizes principal-stamping to the one edge that owns session identity.

Data shapes

Declared in src/authority/permission-forwarding.ts (strings only — ADR-0002):

/**
 * The child-fixed facts a gate emits: the surface it evaluated and the match
 * set it computed. `principal` and `requesterCwd` are stamped at the
 * escalation edge, so a gate carries only what it alone can produce.
 */
export interface ForwardedAccessFacts {
  /** Gate surface: "path", "external_directory", "bash", a tool name, or a skill name. */
  surface: string;
  /**
   * Child-fixed match set. Path surface: AccessPath.matchValues() (absolute 
   * cwd-relative  canonical). Non-path surface: the already-portable single
   * value as a one-element array. Strings only.
   */
  matchValues: string[];
  /** AccessPath.boundaryValue() for a path surface; null for a non-path surface. */
  boundaryValue: string | null;
}

/** The forwarded-wire access intent (ADR 0008 §2): access facts + requester identity. */
export interface ForwardedAccessIntent extends ForwardedAccessFacts {
  /** Requester cwd, for provenance/disclosure — never for parent re-derivation. */
  requesterCwd: string;
  /** Who is requesting. */
  principal: {
    sessionId: string;
    agentName: string;
  };
}

ForwardedPermissionRequest gains an optional field:

export type ForwardedPermissionRequest = {
  // …existing fields (id, createdAt, requesterSessionId, targetSessionId,
  //   requesterAgentName, message, source?, surface?, value?, sessionApproval?)…
  /**
   * The child-fixed access intent (ADR 0008 §2). Optional for version-skew
   * tolerance: an older child omits it, and the serving node floors to `ask`
   * (Step 3). Present on a current child's request for every gate surface.
   */
  accessIntent?: ForwardedAccessIntent;
};

PromptPermissionDetails (permission-prompter.ts) gains the gate-facts half only:

export interface PromptPermissionDetails {
  // …existing fields…
  /**
   * The child-fixed access facts the raising gate computed. Rides through the
   * runner to the escalation edge, which completes them into a
   * ForwardedAccessIntent (adding requesterCwd + principal). Absent for a
   * serving-node local prompt reconstructed from a forwarded request.
   */
  accessIntent?: ForwardedAccessFacts;
}

Gate emission (call sites)

Each gate sets promptDetails.accessIntent. Because GateDescriptor.promptDetails is Omit<PromptPermissionDetails, "requestId">, no change to the GateDescriptor interface in descriptor.ts is needed — the facts ride on promptDetails, satisfying the issue's "onto the descriptor/details" target.

  • Path surfaces (path.ts, external-directory.ts, bash-path.ts, bash-external-directory.ts): the gate already holds the deciding AccessPath (accessPath / worstEntry.path / the worst uncovered entry's path). Emit:

    accessIntent: {
      surface: /* "path" | "external_directory" */,
      matchValues: accessPath.matchValues(),
      boundaryValue: accessPath.boundaryValue() || null,
    }
    

    For bash-external-directory.ts, select the AccessPath of the uncovered entry whose check === worstCheck (the same entry preCheck came from), mirroring how bash-path.ts finds worstEntry.

  • Per-tool gate (tool.ts, describeToolGate): when accessPath is present (path-bearing surfaces read/write/edit/grep/find/ls) emit the path-facts form with surface: gateSurface; otherwise (bash / MCP / plain tool) emit the single-value form { surface: gateSurface, matchValues: [decision.value], boundaryValue: null }, reusing the already-computed descriptor.decision.value.

  • Skill surfaces (skill-input.ts, skill-read.ts): non-path — { surface: "skill", matchValues: [skillName], boundaryValue: null }.

Escalation-edge completion (ParentAuthorizer)

forwarder-context.ts exposes the session cwd:

export interface ForwarderContext {
  hasUI: boolean;
  ui: PermissionDecisionUi;
  cwd: string; // new — ExtensionContext already provides this
  sessionManager: { /* …unchanged… */ };
}

/** Reads the current session cwd off `ctx`. */
export function getCwd(ctx: ForwarderContext): string {
  return ctx.cwd;
}

ParentAuthorizer.authorize threads details.accessIntent into buildForwardedRequest, which completes it (Tell-Don't-Ask: the edge stamps identity from data it already holds; it never asks the wire object to compute anything):

// ParentAuthorizer.buildForwardedRequest — illustrative
const accessIntent: ForwardedAccessIntent | undefined = facts
  ? {
      ...facts, // surface, matchValues, boundaryValue (child-fixed)
      requesterCwd: getCwd(ctx),
      principal: { sessionId: requesterSessionId, agentName: requesterAgentName },
    }
  : undefined;
return {
  id, createdAt, requesterSessionId, targetSessionId, requesterAgentName, message,
  ...(forwarded ? { source, surface, value } : {}),
  ...(sessionApproval ? { sessionApproval } : {}),
  ...(accessIntent ? { accessIntent } : {}),
};

The full ForwardedAccessIntent is what Step 3 will read off the request (request.accessIntent) and hand to resolver.resolve, using matchValues as-is with no parent-side PathNormalizer re-derivation.

Tolerant read (forwarding-io.ts)

Add an asForwardedAccessIntent(value): ForwardedAccessIntent | undefined narrower alongside the existing asX helpers, accepting only a well-formed shape (string surface, all-string matchValues array, string | null boundaryValue, string requesterCwd, principal with string sessionId/agentName); anything else → undefined. Wire it into readForwardedPermissionRequest's reconstruction block: accessIntent: asForwardedAccessIntent(parsed.accessIntent). Absent or malformed → undefined, which Step 3 floors to ask.

Edge cases

  • Empty boundaryAccessPath.boundaryValue() is "" for a literal-only path (e.g. a relative bash token after a non-literal cd). Emit null for an empty boundary so the wire's boundaryValue: string | null is honest (accessPath.boundaryValue() || null).
  • Multiple external paths — the bash external-directory gate carries only the worst (deciding) path's facts; a multi-path/multi-surface fact set is an ADR-deferred edge and floors to ask at the serving node.
  • unknown requester identitygetSessionId/requesterAgentName already fall back to "unknown"; principal carries those fallbacks verbatim (no new behavior).

Module-Level Changes

  • src/authority/permission-forwarding.ts — add ForwardedAccessFacts and ForwardedAccessIntent interfaces; add optional accessIntent?: ForwardedAccessIntent to ForwardedPermissionRequest. (Satisfies the roadmap grep verify.)
  • src/authority/forwarding-io.ts — add asForwardedAccessIntent narrower; import ForwardedAccessIntent; wire accessIntent into readForwardedPermissionRequest.
  • src/authority/permission-prompter.ts — add optional accessIntent?: ForwardedAccessFacts to PromptPermissionDetails; import ForwardedAccessFacts (module already imports ForwardedSessionApproval from permission-forwarding).
  • src/authority/forwarder-context.ts — add cwd: string to ForwarderContext; add getCwd(ctx) helper.
  • src/authority/approval-escalator.ts — thread details.accessIntent from authorize into waitForForwardedApprovalbuildForwardedRequest; compose and serialize the full ForwardedAccessIntent (requesterCwd via getCwd(ctx), principal from the already-computed requesterSessionId/requesterAgentName).
  • src/handlers/gates/path.ts — set promptDetails.accessIntent from accessPath (surface "path").
  • src/handlers/gates/external-directory.ts — set promptDetails.accessIntent from accessPath (surface "external_directory").
  • src/handlers/gates/bash-path.ts — set promptDetails.accessIntent from worstEntry.path (surface "path").
  • src/handlers/gates/bash-external-directory.ts — select the worst uncovered entry's AccessPath; set promptDetails.accessIntent (surface "external_directory").
  • src/handlers/gates/tool.ts — set promptDetails.accessIntent: path-facts when accessPath present, else [decision.value] single-value form.
  • src/handlers/gates/skill-input.ts, src/handlers/gates/skill-read.ts — set promptDetails.accessIntent to the { surface: "skill", matchValues: [skillName], boundaryValue: null } form.
  • test/helpers/forwarding-fixtures.tsmakeForwarderContext gains a cwd? option with a default (e.g. "/repo"), so fakes built through it satisfy the widened ForwarderContext.
  • Inline ForwarderContext fakes — grep test/ for inline { hasUI, ui, sessionManager } object literals that do not go through makeForwarderContext and add cwd. Candidate files (from the ForwarderContext reference grep): test/authority/forwarding-manager.test.ts, test/authority/forwarded-request-server.test.ts, test/authority/authorizer.test.ts, test/authority/authorizer-selection.test.ts, test/composition-root.test.ts — verify each at implementation time; those using makeForwarderContext need no edit.
  • docs/architecture/architecture.md — mark Phase 12 Step 2 complete: on the Step 2 heading and the Mermaid S2 node, add a Landed: note. Update the module-tree entries that now name the mechanism to describe current behavior: permission-forwarding.ts (carries the ForwardedAccessIntent wire schema), forwarding-io.ts (tolerant read of accessIntent), approval-escalator.ts (ParentAuthorizer stamps requesterCwd/principal and serializes the intent), permission-prompter.ts (PromptPermissionDetails carries the child-fixed access facts), forwarder-context.ts (adds cwd/getCwd). The Forwarded-wire structured intent metric row now reads ≥ 1; note it in the Landed: line but leave the fixed Baseline snapshot column unedited (per the package skill).

Contradiction check: no file appears in both Module-Level Changes and Non-Goals — forwarded-request-server.ts and index.ts's servingPolicy are named in Non-Goals only and are not touched here.

Test Impact Analysis

  1. New unit tests enabled by the structured field:
    • forwarding-io round-trip: a request with a well-formed accessIntent reconstructs it; a malformed one drops to undefined; an absent one reads as undefined (version skew).
    • approval-escalator: buildForwardedRequest stamps principal (sessionId/agentName from the requester) and requesterCwd (from ctx.cwd) onto request.accessIntent; a details without accessIntent omits the field.
    • Per-gate emission: each gate's descriptor carries promptDetails.accessIntent with the expected surface and match set (path facts from the AccessPath for path surfaces; single-value form for bash/MCP/skill/plain-tool). These assert on the pure descriptor (no runner needed).
  2. Existing tests that become redundant: none. The display-field (source/surface/value) and sessionApproval forwarding tests continue to exercise the paths that remain unchanged; nothing is superseded until Step 3 reworks serving.
  3. Existing tests that must stay as-is: the forwarding round-trip and display-field tolerance tests (they pin the display fields still riding the wire), and every gate's existing resolution/descriptor test (the access-path resolve call and preCheck are untouched — the facts are read off the same AccessPath).

Invariants at risk

This step touches surfaces earlier phase steps refactored; each invariant below has a pinning test.

  • Display fields still ride the wire (the #557/#292 non-degraded-broadcast contract) — adding accessIntent must not drop source/surface/value. Pinned by the existing forwarding display-field tests (test/authority/approval-escalator.test.ts, test/authority/permission-forwarding.test.ts). Add an assertion that both display fields and accessIntent are present on the same request.
  • ADR-0002 string boundary — no AccessPath crosses onto the wire. Pinned by the no-restricted-imports lint on permission-manager.ts (untouched) plus a new test asserting request.accessIntent.matchValues are strings and boundaryValue is string | null.
  • Gate resolution unchanged — the access-path intent each gate emits to the resolver, and the resulting preCheck/decision, are unchanged (the facts are a read-only projection of the same AccessPath). Pinned by the existing per-gate resolution tests, which must stay green with no edits to their decision assertions.

TDD Order

  1. test:feat: — wire type + tolerant read. Add ForwardedAccessFacts/ForwardedAccessIntent and the optional ForwardedPermissionRequest.accessIntent field; add asForwardedAccessIntent and wire it into readForwardedPermissionRequest. Test surface: test/authority/permission-forwarding.test.ts (or the forwarding-io test) — round-trip well-formed / malformed / absent. The type and reader land together (the reader references the type). Commit: feat(pi-permission-system): declare ForwardedAccessIntent wire schema with tolerant read (#596).
  2. feat: — escalation-edge serialization + prompt-details facts + context cwd. Add cwd/getCwd to forwarder-context.ts; add accessIntent?: ForwardedAccessFacts to PromptPermissionDetails; thread details.accessIntent through ParentAuthorizer and stamp requesterCwd/principal onto request.accessIntent. Update makeForwarderContext and every inline ForwarderContext fake in the same commit (the widened interface breaks them at compile time). Test surface: test/authority/approval-escalator.test.ts. Commit: feat(pi-permission-system): serialize the child-fixed access intent onto the forwarded request (#596).
  3. feat: — tool-surface path gates emit facts. path.ts + external-directory.ts. Test surface: their gate tests. Commit: feat(pi-permission-system): emit access-intent facts from the tool path gates (#596).
  4. feat: — bash-surface path gates emit facts. bash-path.ts + bash-external-directory.ts (worst-entry AccessPath selection). Commit: feat(pi-permission-system): emit access-intent facts from the bash path gates (#596).
  5. feat: — per-tool gate emits facts. tool.ts (path form when accessPath present; single-value form otherwise). Commit: feat(pi-permission-system): emit access-intent facts from the per-tool gate (#596).
  6. feat: — skill gates emit facts. skill-input.ts + skill-read.ts (single-value skill form). Commit: feat(pi-permission-system): emit access-intent facts from the skill gates (#596).
  7. docs: — mark Step 2 complete + refresh module-tree entries. docs/architecture/architecture.md: heading + S2 node, Landed: note, updated module-tree entries for the touched authority/ modules. Commit: docs(pi-permission-system): mark Phase 12 Step 2 complete (#596).

Each gate step (36) is independently type-safe: adding an optional promptDetails.accessIntent is additive, and the field is not yet consumed for any decision, so no cross-module compile break forces gates into one commit. Step 2's cwd addition is the one interface tightening; its fixture updates ride the same commit.

Risks and Mitigations

  • Silent field drop on read (#558) — the tolerant reader reconstructs an allowlist, so an unwired field never round-trips. Mitigation: Step 1 wires asForwardedAccessIntent and tests the round-trip explicitly.
  • AccessPath leaking onto the wire (ADR-0002 violation) — mitigation: gates convert to strings at emit; a Step-1 test asserts the serialized shape is strings only; the existing permission-manager.ts import lint is unaffected.
  • Interface tightening breaks fakes — widening ForwarderContext with a required cwd breaks inline fakes at compile time. Mitigation: centralize via makeForwarderContext's default and grep test/ for inline constructions in the same commit (the AGENTS.md fixture-grep rule for tightened shared types).
  • Over-reaching into Step 3 — the temptation is to also make serving consume the field. Mitigation: Non-Goals fences forwarded-request-server.ts/servingPolicy; the serving-read metric stays 0 until #597.
  • Empty boundary ambiguity — a literal-only path has boundaryValue() === "". Mitigation: emit null for an empty boundary so the wire distinguishes "no canonical" cleanly.

Open Questions

  • Whether principal should reuse the top-level requesterSessionId/requesterAgentName rather than nest a copy. Resolved for this plan: nest a self-contained principal per ADR 0008 §2, since Step 3 reads intent.principal.agentName and a self-contained fact object is cleaner than reaching across the request; the top-level fields remain for routing/display and backward compatibility. No follow-up needed.
  • No deferred follow-up issues: Step 3 (#597) already exists as the serving-consumption step, and the ADR's two deferred edges are recorded in ADR 0008.