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

264 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 595
issue_title: "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 `getPathPolicyValues``getAbsolutePathPolicyValues` already includes a **cwd-relative alias** (`getCwdRelativePathPolicyValues`) alongside the absolute and canonical forms.
- `src/rule.ts``evaluateAnyValue` 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):
```typescript
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):
```typescript
// 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.
```text
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.
[#418]: https://github.com/gotgenes/pi-packages/issues/418
[#486]: https://github.com/gotgenes/pi-packages/issues/486
[#557]: https://github.com/gotgenes/pi-packages/issues/557
[#565]: https://github.com/gotgenes/pi-packages/issues/565
[#596]: https://github.com/gotgenes/pi-packages/issues/596
[#597]: https://github.com/gotgenes/pi-packages/issues/597