23 KiB
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 2–3 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
allowcan 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;
requesterAgentNameis 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 2–3.
Goals
- Write
docs/decisions/0008-cross-session-access-intent.md(statusaccepted), structured principle-first: one decision — the child owns the facts; the parent owns the judgment — with three derived consequences:- the portable meaning of a path-shaped ask (the facts are fixed at the child; the parent never re-derives);
- the
ForwardedAccessIntentwire schema (the facts serialized: surface, match values, boundary value, requester cwd, principal identity) and its version handling; - 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.mdby 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✅+ aLanded:note), per the package skill's step-completion convention. - Leave all runtime code unchanged — this step decides; Steps 2–3 (#596, #597) implement.
Non-Goals
- No code changes:
permission-forwarding.ts,forwarded-request-server.ts,forwarding-io.ts,approval-escalator.ts,permission-prompter.ts, andindex.tsare 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 (
ForwardedAccessIntentcounts) are moved by Steps 2–3, not Step 1. - Track B (the Authorizer chain, Steps 4–6) implementation is out of scope; the ADR's composition section describes the decided chain design (citing
docs/decisions/0007-model-judge-authorizer-chain-adr.mdby 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— definesForwardedPermissionRequest(the wire shape:id,createdAt,requesterSessionId,targetSessionId,requesterAgentName,message, optional displaysource/surface/value, optionalsessionApproval) andForwardedPermissionResponse.src/authority/forwarded-request-server.ts— the serving node:ServingPolicy.check(surface, value)(the narrow recorded-authority seam),AskEscalatorescalation onask,SessionApprovalRecorderfor whole-session grants.src/access-intent/access-path.ts— theAccessPathvalue object:matchValues()(lexical alias union ∪ canonical),boundaryValue()(canonical, for containment),value()(lexical, for display). Crucially,matchValues()viagetPathPolicyValues→getAbsolutePathPolicyValuesalready includes a cwd-relative alias (getCwdRelativePathPolicyValues) alongside the absolute and canonical forms.src/rule.ts—evaluateAnyValuematches 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, escalateask, 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;AccessPathdoes not cross into it. The wire schema must carry strings (the match values), notAccessPathinstances, consistent with this boundary.docs/decisions/0007-model-judge-authorizer-chain-adr.md— the Authorizer chain (Track B): config-ordered non-terminal links withallow | deny | deferverdicts, 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-NNNNtoken. - 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'ssrc/foo.tsrelative 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:
ParentAuthorizeroccupies theAuthorizerslot 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_directorycheck over apath), butForwardedAccessIntentcarries one surface + one match set. A multi-surface child decision still floors toaskat 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/datefrontmatter,# 0008 — …,## Status,## Context,## Decision(the principle as the lead subsection, then the four consequences), a composition subsection (thedecide()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 MermaidS1[…]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 (
ForwardedAccessIntentinpermission-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.mdpath 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).
- Mark Step 1 complete: append
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[]), neverAccessPathinstances, so the manager stays string-based. The ADR must note this so Step 2 does not serialize anAccessPathonto the wire.
Build Order
Docs-only, so no red→green cycles — a build sequence with docs: commits.
- Write the ADR.
Author
docs/decisions/0008-cross-session-access-intent.mdwith the four decisions, rejected alternatives, and consequences. Verify withpnpm 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). - Mark the roadmap step complete.
Edit
docs/architecture/architecture.md(Step 1 heading✅, Mermaid node✅,Landed:note). Verify withpnpm 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 2–3 land makes the roadmap look half-implemented. Mitigation: Step 1's deliverable is the ADR — it is genuinely complete when the file exists; theLanded:note records that Steps 2–3 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.