Files
my-pi/pi-permission-system/docs/plans/0595-adr-0008-cross-session-access-intent.md
T

23 KiB
Raw Blame History

issue, issue_title
issue issue_title
595 pi-permission-system: ADR 0008 — forwarded access-intent portability and principal identity

ADR 0008 — forwarded access-intent portability and principal identity

Release Recommendation

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

This issue is Step 1 of Phase 12 Track A, the first member of release batch "cross-session-intent" (Steps 1, 2, 3; tail = Step 3, #597). It is a docs-only ADR — a docs: commit that decides the contract Steps 23 implement — so it does not cut a release on its own and rides the batch tail. The whole batch ships together once #597 lands.

Problem Statement

When an ask-state permission arises in a subagent child with no UI, the extension forwards it up to the parent for a decision. Today that escalation edge loses the gate's structured product: ForwardedPermissionRequest carries a pre-rendered message plus display-only surface/value strings, and the serving node's ServingPolicy.check(surface, value) re-derives an intent from that bare string through the parent's PathNormalizer and cwd. Two failure modes follow, both named in #565 and accepted at #557 ship time pending this spine:

  • Path meaning is re-interpreted at the wrong node. A child in a worktree resolves paths against a different root than the parent, so the child's lexical canonical alias set (the #418/#486 match contract) never crosses the wire — a parent allow can silently miss what the child's own gate would have matched, and vice versa.
  • Agent-scope semantics are undefined. Serving evaluates the base ruleset agent-neutrally; requesterAgentName is display-only with no decided meaning.

These questions are unanswerable in code because they were never decided. The deliverable of this step is therefore the decision record itself: docs/decisions/0008-cross-session-access-intent.md, settling the cross-session access-intent contract before the wire format changes in Steps 23.

Goals

  • Write docs/decisions/0008-cross-session-access-intent.md (status accepted), structured principle-first: one decision — the child owns the facts; the parent owns the judgment — with three derived consequences:
    1. the portable meaning of a path-shaped ask (the facts are fixed at the child; the parent never re-derives);
    2. the ForwardedAccessIntent wire schema (the facts serialized: surface, match values, boundary value, requester cwd, principal identity) and its version handling;
    3. the agent-scope semantics of serving evaluation (the parent's judgment, fully informed by principal identity).
  • Include a composition section situating the decision in the authorization walk (recorded authority → chain links → terminal; the courier carries facts, never judgment), citing docs/decisions/0007-model-judge-authorizer-chain-adr.md by path without re-deciding it.
  • Name the model's explicitly deferred edges (single-surface fact set, multi-hop principal identity) so the ADR marks where the unified model is known-incomplete.
  • Mark Phase 12 Step 1 complete in docs/architecture/architecture.md (heading + Mermaid node + a Landed: note), per the package skill's step-completion convention.
  • Leave all runtime code unchanged — this step decides; Steps 23 (#596, #597) implement.

Non-Goals

  • No code changes: permission-forwarding.ts, forwarded-request-server.ts, forwarding-io.ts, approval-escalator.ts, permission-prompter.ts, and index.ts are untouched by this step (they are Step 2/3 targets).
  • No schema/config changes (config-schema.ts, schemas/permissions.schema.json).
  • No change to the health-metric grep-count rows in the Phase 12 table — those targets (ForwardedAccessIntent counts) are moved by Steps 23, not Step 1.
  • Track B (the Authorizer chain, Steps 46) implementation is out of scope; the ADR's composition section describes the decided chain design (citing docs/decisions/0007-model-judge-authorizer-chain-adr.md by path) and decides nothing new about it.
  • No decision on multi-hop principal identity (originator vs. accumulated chain) or on widening the fact set to multi-surface asks — both are named in the ADR as explicitly deferred edges, not silently omitted.
  • Closing #565 is deferred to Phase 12 end (per the architecture doc's open-issue sweep disposition), not this step.

Background

Relevant existing surfaces (read during planning):

  • src/authority/permission-forwarding.ts — defines ForwardedPermissionRequest (the wire shape: id, createdAt, requesterSessionId, targetSessionId, requesterAgentName, message, optional display source/surface/value, optional sessionApproval) and ForwardedPermissionResponse.
  • src/authority/forwarded-request-server.ts — the serving node: ServingPolicy.check(surface, value) (the narrow recorded-authority seam), AskEscalator escalation on ask, SessionApprovalRecorder for whole-session grants.
  • src/access-intent/access-path.ts — the AccessPath value object: matchValues() (lexical alias union canonical), boundaryValue() (canonical, for containment), value() (lexical, for display). Crucially, matchValues() via getPathPolicyValuesgetAbsolutePathPolicyValues already includes a cwd-relative alias (getCwdRelativePathPolicyValues) alongside the absolute and canonical forms.
  • src/rule.tsevaluateAnyValue matches a ruleset against every alias (last-match-wins across aliases), so a relative config rule and an absolute allowlist coexist without one masking the other.

Existing decision records this ADR builds on:

  • docs/decisions/0005-serving-authorizer-provenance.md — serving a forwarded request is resolution; recorded authority first, escalate ask, provenance rides the ask. Its "Base ruleset (agent-neutral resolution)" section is the exact decision ADR 0008 revises.
  • docs/decisions/0002-path-values-string-boundary.md — the manager stays string-based; AccessPath does not cross into it. The wire schema must carry strings (the match values), not AccessPath instances, consistent with this boundary.
  • docs/decisions/0007-model-judge-authorizer-chain-adr.md — the Authorizer chain (Track B): config-ordered non-terminal links with allow | deny | defer verdicts, a terminal that cannot defer, and the enforcement checkpoint capping link authority. ADR 0008's composition section situates the facts/judgment decision alongside it; the two tracks are orthogonal axes of one structure (plurality of judges within a node vs. fidelity of facts between nodes).

AGENTS.md / skill constraints that apply:

  • ADR numbering is per-package; cite this package's own ADRs by path, not a bare ADR-NNNN token.
  • When the implementation completes a numbered roadmap step, mark it (heading + Mermaid node) in the same doc-update commit — do not defer the marker to ship.
  • One-sentence-per-line; reference GitHub issues with reference-style [#N] links in long-lived docs.

Design Overview

The ADR is structured principle-first: one decision, three derived consequences, a composition section, and named deferred edges. All parameters were confirmed with the operator during planning (agent-scoped serving; child-fixed match set; required field with an ask floor on absence; principle-first restructure with the composition section).

The decision — the child owns the facts; the parent owns the judgment

A forwarded ask separates cleanly into facts (what is being accessed, in every form the origin gate would recognize, and by whom) and judgment (what a policy says about it). The contract: facts are fixed at the origin child and carried unchanged through every hop; judgment is exercised anew at each node against that node's own ruleset; no node ever re-derives facts. The three consequences below are derivations of this principle, not independent parameters — the ADR presents them as such so each is justified by the principle rather than argued locally.

Consequence 1 — the portable meaning of a path-shaped ask is fixed at the child

A path's meaning is the alias set computed where the path was typed (the child), never re-derived at the parent. The child ships the AccessPath's matchValues() (the absolute cwd-relative canonical alias set) and boundaryValue() (canonical) as fixed strings. The parent matches its own ruleset against those fixed values through the ordinary evaluateAnyValue evaluator — it does not rebuild an AccessPath from a bare string through its own PathNormalizer/cwd.

Why this is portable across cwds (the worktree case): because matchValues() already carries a cwd-relative alias, a child in /worktree/issue-42 typing src/foo.ts ships { /worktree/issue-42/src/foo.ts, src/foo.ts, <canonical> }.

  • A relative parent rule (path: { "src/**": allow }) matches the child's src/foo.ts relative alias → the parent's authority stays relevant across worktrees/cwds.
  • An absolute parent rule (/main-checkout/src/**) matches only co-located paths → a different worktree's file is correctly not covered (least privilege).

Canonicalization does not bridge cwds (git worktrees are real directories, not symlinks); the cwd-relative alias is what makes cross-cwd matching work. Recorded consequence: a relative parent allow auto-grants a same-relative path from an unrelated child cwd — consistent with how relative rules already behave locally, and the operator confirmed this is acceptable.

Consequence 2 — the ForwardedAccessIntent wire schema (the facts, serialized)

A new required field on the forwarded request carries the child-fixed facts. Shape (decided here, implemented in Step 2 — the ADR fixes the field names and semantics):

interface ForwardedAccessIntent {
  /** The gate surface the child evaluated (e.g. "path", "external_directory", "bash", the tool name, a skill name, an MCP target). */
  surface: string;
  /**
   * The child-fixed match set. For a path surface: AccessPath.matchValues()
   * (absolute  cwd-relative  canonical), computed at the child. For a
   * non-path surface: the already-portable single value (bash command, MCP
   * target, skill name) as a one-element array. Strings only — the ADR 0002
   * boundary keeps AccessPath out of the wire.
   */
  matchValues: string[];
  /** Canonical boundary form (AccessPath.boundaryValue()) for a path surface; null for non-path surfaces. */
  boundaryValue: string | null;
  /** The requester's cwd, for provenance and prompt disclosure — not for parent re-derivation. */
  requesterCwd: string;
  /** Principal identity: who is requesting. */
  principal: {
    sessionId: string; // already carried today as requesterSessionId
    agentName: string; // graduates from display-only to decision-participating (Consequence 3)
  };
}

Non-path surfaces (bash command pattern, MCP target, skill name) are already portable — they carry their (surface, value) directly as a one-element matchValues with boundaryValue: null. The field is required going forward: it becomes the sole resolution path, and the legacy display-only (surface, value) resolution branch in ServingPolicy is retired in Step 3.

Consequence 3 — agent-scoped serving evaluation (the parent's judgment, fully informed)

requesterAgentName graduates from display-only to decision-participating. The serving node resolves the forwarded intent against its own base ruleset scoped to the requester's agent name (the principal.agentName), applying the parent's per-agent overrides for that agent.

This is not double-application: forwarding up means the child's ruleset already resolved to ask (unresolved); the parent then applies a different ruleset (its own config/project layer). It is a strict superset of agent-neutral serving — with identical parent/child configs the parent also lands on ask and prompts (no regression), and it changes the outcome only when the parent holds per-agent rules for that agent that the child's config lacked. This revises ADR 0005's "agent-neutral resolution" section, which explicitly deferred the semantics to "once principal identity lands."

Serving-node call-site sketch (Step 3 shape, sketched here to validate the contract follows Tell-Don't-Ask — the parent asks the resolver, not the wire object, for a decision):

// Serving node, per forwarded request (Step 3 — illustrative, not built here):
const intent = request.accessIntent; // required field, Consequence 2
const decision = resolver.resolve(
  buildResolvedIntentFromWire(intent),        // match values used as-is; no PathNormalizer re-derivation
  { agentName: intent.principal.agentName },  // Consequence 3 — agent-scoped
);
// allow → auto-approve; deny → auto-deny; ask → escalate through AskEscalator (unchanged).

Consequence 4 — version-skew handling (no facts → no judgment → escalate)

ForwardedAccessIntent is the sole resolution path; the legacy (surface, value) resolution branch is dropped (Step 3). A request that arrives without the field (a rare mid-upgrade skew: a long-running parent reading a newer/older child's request across a pnpm install version bump) floors to ask → prompt — never a hard deny (which would break a legitimate in-flight request) and never a silent grant. Under the principle this is a derivation, not a tolerance hack: missing facts make recorded judgment impossible, so the ask goes straight to live authority. This keeps the ADR 0005 fail-safe direction while shedding the permanent dual-path complexity.

Composition — how the decision sits in the authorization walk

The ADR includes a composition section showing the unified structure the decision fits into. Authorization is a walk up a session tree: at each node an ordered sequence of judges examines the same fixed facts, and the only inter-node operation is the courier move, which carries facts and never judgment.

decide(node, facts):
  verdict = node.rules.resolve(facts, principal)   # recorded authority (deterministic judgment)
  if allow or deny → return verdict
  for link in node.chain:                          # non-terminal judges (Track B, decided in ADR 0007)
    v = link.review(facts)                         #   allow* / deny / defer (* capped by the checkpoint)
    if v ≠ defer → return v
  return node.terminal.authorize(facts)            # terminal:
    LocalUserAuthorizer → human decides            #   terminal judgment
    ParentAuthorizer    → decide(parent, facts)    #   courier — recurse up the tree
    DenyingAuthorizer   → deny                     #   fail-safe

What the section establishes, and its scope guard:

  • ParentAuthorizer occupies the Authorizer slot structurally but is a courier, not a judge — it carries the facts up and exercises no judgment; that is why serving must re-run recorded authority (the ADR 0005 contract) rather than treat arrival at the parent as "needs a human now."
  • Track A (this ADR) and Track B (docs/decisions/0007-model-judge-authorizer-chain-adr.md) are orthogonal axes: fidelity of facts between nodes vs. plurality of judges within a node.
  • Recorded synergy consequence: once both tracks land, a serving node's chain links (e.g. the model judge) review forwarded asks against the child-fixed fact set — honest evidence, not a parent-side re-derivation.
  • Scope guard: the section is descriptive of decided architecture (ADR 0005's serving flow, ADR 0007's chain) and decides nothing new about either; it exists so the tracks are legible as two halves of one picture.

Explicitly deferred edges

The ADR names where the unified model is known-incomplete, so the deferrals are recorded rather than silent:

  • Single-surface fact set (#565 item 3) — a child decision can layer multiple surfaces (an external_directory check over a path), but ForwardedAccessIntent carries one surface + one match set. A multi-surface child decision still floors to ask at the parent (the safe direction); the fact schema may grow additional surfaces later without changing the principle.
  • Multi-hop principal identity — whether a grandchild-through-child forward carries the originator's identity or an accumulated chain is undecided; today forwarding is effectively one hop to the UI-bearing root. Facts-at-origin answers the path question regardless; identity accumulation is deferred until multi-hop forwarding exists.

Module-Level Changes

This is a docs-only step.

  • Add packages/pi-permission-system/docs/decisions/0008-cross-session-access-intent.md — the ADR, following the 0005/0007 format: status: accepted / date frontmatter, # 0008 — …, ## Status, ## Context, ## Decision (the principle as the lead subsection, then the four consequences), a composition subsection (the decide() recursion, the courier observation, the Track A/B orthogonality, the synergy consequence, and the scope guard), ## Rejected alternatives, ## Consequences (including the deferred #565 close, the relative-alias auto-grant consequence, and the two explicitly deferred edges). Reference issues with [#N] reference-style links; cite sibling ADRs by path.
  • Edit packages/pi-permission-system/docs/architecture/architecture.md:
    • Mark Step 1 complete: append to the #### Step 1: ADR 0008 … heading and to the Mermaid S1[…] node label, and add a **Landed:** note under the step recording the ADR path.
    • No other rows change: the Phase 12 health-metric grep-count targets (ForwardedAccessIntent in permission-forwarding.ts / forwarded-request-server.ts) are Step 2/3 outcomes and stay at their baseline here.
    • The docs/decisions/0008-cross-session-access-intent.md path is already named in Step 1's Target and in the findings; verify the reference resolves once the file exists (no new reference-link definition needed for an in-tree relative path).

No src/, test/, schemas/, config/, README, or configuration-doc changes. A grep for the ADR filename and for ForwardedAccessIntent confirms no runtime symbol is introduced or removed by this step.

Test Impact Analysis

None — docs-only. No unit tests are added, changed, or made redundant. The wire-schema and serving-resolution tests land with Steps 2 and 3, which implement the contract this ADR decides.

Invariants at risk

  • ADR 0005's serving-is-resolution contract — ADR 0008 revises one section of it (agent-neutral → agent-scoped) rather than contradicting the whole. The ADR must state explicitly that recorded-authority-first + escalate-ask + provenance-on-the-ask (0005's core) is preserved, and only the agent-scope sub-decision changes. This is prose-only at this step; the behavioral pin lands with Step 3's serving tests.
  • ADR 0002's string boundary — the wire schema carries strings (matchValues: string[]), never AccessPath instances, so the manager stays string-based. The ADR must note this so Step 2 does not serialize an AccessPath onto the wire.

Build Order

Docs-only, so no red→green cycles — a build sequence with docs: commits.

  1. Write the ADR. Author docs/decisions/0008-cross-session-access-intent.md with the four decisions, rejected alternatives, and consequences. Verify with pnpm exec rumdl check packages/pi-permission-system/docs/decisions/0008-cross-session-access-intent.md. Commit: docs(pi-permission-system): add ADR 0008 for cross-session access-intent (#595).
  2. Mark the roadmap step complete. Edit docs/architecture/architecture.md (Step 1 heading , Mermaid node , Landed: note). Verify with pnpm exec rumdl check packages/pi-permission-system/docs/architecture/architecture.md. Commit: docs(pi-permission-system): mark Phase 12 Step 1 complete (#595).

Both commits are docs: (hidden changelog type) and do not cut a release on their own — consistent with the mid-batch deferral. Steps 1 and 2 may be combined into a single docs: commit if preferred at build time; keeping them separate keeps the ADR and the roadmap-mark independently reviewable.

Risks and Mitigations

  • Risk: the ADR over-specifies the wire shape and boxes in Step 2. Mitigation: the schema block fixes field names and semantics (the decision), not the exact TypeScript declaration site or serialization mechanics — Step 2 owns those.
  • Risk: the agent-scoped revision silently contradicts ADR 0005 without cross-linking. Mitigation: the ADR explicitly names the 0005 section it revises and states what 0005 behavior is preserved (Invariants at risk).
  • Risk: marking Step 1 before Steps 23 land makes the roadmap look half-implemented. Mitigation: Step 1's deliverable is the ADR — it is genuinely complete when the file exists; the Landed: note records that Steps 23 implement it, and the batch-tail release marker keeps the shipping story coherent.
  • Risk: the unifying model is over-applied — a concept stretched past where it earned its evidence. Mitigation: the model earned its place by retro-explaining decisions made independently (ADR 0005's serving-is-resolution, ADR 0007's chain) and deriving all three confirmed parameters; the ADR bakes in two guards — the composition section is descriptive-only (decides nothing new), and the deferred-edges section names exactly where the model is known-incomplete.
  • Risk: the composition section drifts into re-deciding ADR 0007. Mitigation: the scope guard is written into the section itself; the pre-completion reviewer checks the ADR introduces no new chain semantics beyond citing docs/decisions/0007-model-judge-authorizer-chain-adr.md.

Open Questions

No open questions block this step — the deliberative parameters (agent scope, path portability, version-skew handling, principle-first structure) were resolved with the operator during planning. Two edges are explicitly deferred and recorded in the ADR rather than left open: the single-surface fact set (#565 item 3 — safe ask floor today, schema may grow) and multi-hop principal identity (undecided until multi-hop forwarding exists). Neither needs a follow-up issue now: the first is already tracked by #565 (open through Phase 12 by roadmap decision), and the second has no implementable surface until multi-hop forwarding is proposed. No other follow-up issues are filed: Steps 2 (#596) and 3 (#597) already exist as the implementation of this contract.