24 KiB
Phase 12: Cross-session access intent and the Authorizer chain
Findings (planned 2026-07-15)
Phase 11 closed with the cross-session access-intent spine (principal identity on forwarded asks, path portability across cwds) recorded as the leading Phase 12 candidate, and discovery corroborates it as the phase's cause-level spine.
The cause is a boundary flaw in the escalation edge, named in remaining design work: the gate's structured AccessIntent/AccessPath product dies at the session boundary.
ForwardedPermissionRequest carries a pre-rendered message plus display-only surface/value strings, so the serving node's ServingPolicy.check(surface, value) must re-derive an intent from a bare string through the parent's PathNormalizer and cwd — the path's meaning is re-interpreted at the wrong node (a child in a worktree resolves against a different root), the child's lexical ∪ canonical alias set (the #418/#486 match contract) never crosses the wire, and a request without display fields floors to ask.
Serving is agent-neutral with the semantics explicitly undefined.
Issue #565 items 2–3 name both losses; they were accepted at #557 ship time pending exactly this spine.
The second track is the Authorizer chain (#472): ADR 0007 (docs/decisions/0007-model-judge-authorizer-chain-adr.md) is accepted and explicitly assigns the implementation's decomposition to this planning pass.
The cause is an OCP gap at the live-authority layer: its shape (one terminal Authorizer selected once) cannot seat a non-terminal link that reviews an ask and defers, so a case-by-case judge has no home.
After three consecutive phase deferrals, #472 is scheduled by user decision.
Feasibility probes: @earendil-works/pi-ai exports complete/completeSimple and pi-subagents already depends on it, so the dogfood judge package can invoke a model on the real surface; registerAuthorizer mirrors the existing registerToolAccessExtractor/registerToolInputFormatter service precedent.
Corroboration (fallow + sweeps, 2026-07-15): health 88 (A; deductions are unit size and cooling churn), dead code 0, duplication 0.1% (the one clone group is the documented intentional literalTextOf/resolveNodeText pair).
The repeated-discriminator sweep found no new family — survivors are validation-edge typeof guards, per-node AST dispatch, and presentation dispatch, idiomatic per the taxonomy.
The value-guards.ts refactoring target remains rejected (healthy high-fan-in leaf).
The craftsmanship scout found no concentrated debt: the two fallow "giant function" test flags (program.test.ts, bash-external-directory.test.ts) are false positives (nested describe trees of small behavior-named tests), churn-hotspot test files all use the shared test/helpers/ fixtures cleanly, and the only real finding (a flat ungrouped test run in permission-manager-unified.test.ts) is scattered mechanical trivia deferred to boy-scout tidying.
No directory reorg rides this phase: both tracks land in the existing authority/ domain plus a new package, and the 56-module flat root's next grouping opportunity should ride a phase that rewrites those files.
Health metrics
| Metric | Baseline (2026-07-15) | Phase 12 target |
|---|---|---|
Forwarded-wire structured intent (ForwardedAccessIntent in permission-forwarding.ts) |
0 | ≥ 1 |
Serving reads the forwarded intent (ForwardedAccessIntent in forwarded-request-server.ts) |
0 | ≥ 1 |
registerAuthorizer service surface (service.ts) |
0 | ≥ 1 |
authorizerChain schema sites (config-schema.ts) |
0 | ≥ 1 |
| Model-judge package present | 0 | 1 |
| fallow health score | 88 (A) | ≥ 88 |
| Production duplication | 0.1% | ≤ 0.2% |
| Dead exports | 0 | 0 |
Recompute commands (run from the repo root):
- Forwarded-wire intent:
grep -c ForwardedAccessIntent packages/pi-permission-system/src/authority/permission-forwarding.ts - Serving intent read:
grep -c ForwardedAccessIntent packages/pi-permission-system/src/authority/forwarded-request-server.ts - Service surface:
grep -c registerAuthorizer packages/pi-permission-system/src/service.ts - Schema sites:
grep -c authorizerChain packages/pi-permission-system/src/config-schema.ts - Model-judge package:
ls packages | grep -c pi-permission-model-judge - Health/duplication/dead exports:
pnpm fallow health --score --workspace @gotgenes/pi-permission-system/pnpm fallow dupes --workspace @gotgenes/pi-permission-system/pnpm fallow dead-code --workspace @gotgenes/pi-permission-system
Open-issue sweep dispositions
- #565 — kept open through Phase 12 by decision: Steps 1–3 dissolve its items 2 (agent-scope semantics) and 3 (single-
(surface, value)re-resolution lossiness) structurally; it closes at phase end with a note recording that item 1 (forwarded-prompt fidelity against a real external notification consumer) stays best-effort, since no consumer exists to verify against. Closed at phase end per this disposition (items 2–3 dissolved by Steps 1–3; item 1 recorded best-effort). - #472 — scheduled as Steps 4–6 (Track B) by user decision after three consecutive phase deferrals; ADR 0007 settles the design and this phase implements its deny-first slice.
- #519 — stays open by decision with recorded rationale (not a silent re-defer): it is externally blocked on Pi SDK
UIContextevolution, and theselect/inputfallback keeps frontend-driven flows working meanwhile; it closes or schedules when the SDK ships the capability.
Steps
Step 1: ADR 0008 — forwarded access-intent portability and principal identity (#595) ✅
Cause: the escalation edge has no defined semantics for what a forwarded path means across cwds nor for which agent identity governs serving evaluation — #565 items 2–3 are unanswerable because the questions were never decided, only accepted as failure modes at #557 ship time.
- Smell: Category C (coupling/boundary flaw) — the decision record is the phase deliverable that names the target concept, per the first-principles rule.
- Target:
docs/decisions/0008-cross-session-access-intent.md. Settles: the portable meaning of a path-shaped ask is the match set fixed at the child (the child's lexical ∪ canonicalmatchValues()plus canonicalboundaryValue(), computed where the path was typed — the parent matches its rules against those fixed values and never re-derives them); theForwardedAccessIntentwire schema (surface, match values, boundary value, requester cwd, principal identity) with version-skew tolerance rules (tolerant read,askfloor for legacy requests); and the agent-scope semantics of serving evaluation (whetherrequesterAgentNameparticipates or serving stays deliberately agent-neutral on the base ruleset). - Outcome: the cross-session intent contract is decided in writing before the wire changes; Steps 2–3 implement it rather than deciding it inline.
- Landed:
docs/decisions/0008-cross-session-access-intent.md, structured principle-first — the child owns the facts; the parent owns the judgment — with four derived consequences. Resolved parameters (superseding the speculative framing above): path meaning is fixed at the child (child-fixedmatchValues()∪boundaryValue(), no parent re-derivation); serving is agent-scoped (requesterAgentNameis decision-participating, a strict superset of agent-neutral); version skew is a required field with anaskfloor on absence (not a tolerant dual-path). A composition section situates the record against ADR 0007 (Track A/B orthogonality) without re-deciding it. - Impact 4 / Risk 1 / Priority 20.
Release: batch "cross-session-intent"
Step 2: Carry the structured intent to the escalation edge and onto the forwarded wire (#596) ✅
Cause: the gate computes a full AccessIntent (with the AccessPath alias set) and then discards it — PromptPermissionDetails and ForwardedPermissionRequest carry only display strings, so the intent the parent needs is unrecoverable downstream (the display-field floor in hasDisplayFields is the symptom).
- Smell: Category C (boundary flaw).
- Target:
src/handlers/gates/descriptor.ts+ the path-gate descriptor factories (thread the emitted intent onto the descriptor/details),src/authority/permission-prompter.ts(PromptPermissionDetailscarries the intent),src/authority/approval-escalator.ts(ParentAuthorizerserializes it),src/authority/permission-forwarding.ts(theForwardedAccessIntentfield per ADR 0008),src/authority/forwarding-io.ts(tolerant read). - Outcome: every forwarded ask carries an evaluable intent — path-shaped asks carry the child-fixed alias set and requester cwd; non-path surfaces (bash command, MCP target, skill name) carry their already-portable
(surface, value); an older child's request still reads (version-skew tolerant) and floors toaskas today.grep -c ForwardedAccessIntent src/authority/permission-forwarding.tsgoes 0 → ≥ 1. - Impact 4 / Risk 3 / Priority 12.
- Landed: the wire schema (
ForwardedAccessFacts/ForwardedAccessIntent) lives inpermission-forwarding.ts; each gate emits the child-fixed facts ontoPromptPermissionDetails.accessIntentthrough the sharedaccessFactsFromPath/accessFactsFromValuehelpers (handlers/gates/helpers.ts), sodescriptor.tsneeded no change — the facts ride on the descriptor'spromptDetails.ParentAuthorizercompletes them into aForwardedAccessIntent, stampingrequesterCwd(fromctx.cwd, exposed via the newgetCwd) andprincipal;forwarding-io.tsreads the field tolerantly (absent/malformed →undefined, floored toaskin Step 3). Serving still re-derives from display strings until Step 3, so the forwarded-wire metric now reads ≥ 1 while the serving-read metric stays 0.
Release: batch "cross-session-intent"
Step 3: Serving resolves the forwarded intent at gate parity (#597) ✅
Cause: same cause, consumed at the serving node — ServingPolicy.check(surface, value) re-interprets a child's path string through the parent's PathNormalizer/cwd, so a parent allow that would match the child's alias set can silently miss (and vice versa), and any multi-alias fidelity floors to ask.
- Smell: Category C (boundary flaw).
- Target:
src/authority/forwarded-request-server.ts(ServingPolicybecomes intent-shaped;resolveDecisionresolves the forwarded intent directly, keeping the legacy(surface, value)fallback for version skew),src/index.ts(wiring — the serving closure hands the child's match values toresolver.resolveinstead of rebuilding a path from a bare string viabuildAccessIntentForSurface), agent-scope semantics applied as ADR 0008 decides. - Outcome: the parent's recorded authority governs a child's path ask against the child-fixed alias set — a
/tmp/*allow at the parent matches exactly what the child's own gate would have matched; #565 items 2–3 are structurally dissolved, and #565 closes at phase end with the item-1 best-effort note.grep -c ForwardedAccessIntent src/authority/forwarded-request-server.tsgoes 0 → ≥ 1. - Impact 5 / Risk 2 / Priority 20.
- Landed:
ServingPolicy.resolve(intent: ForwardedAccessIntent)replacescheck(surface, value);resolveDecisiongates onrequest.accessIntentpresence (ADR 0008 §4's sole-resolution-path,ask-floor on absence — the legacy(surface, value)branch was retired outright rather than kept as a dual path, an operator-confirmed deviation from this step's original "keep the legacy fallback" framing). Serving is agent-scoped:buildResolvedIntentFromMatchValues(input-normalizer.ts) builds apath-values/toolResolvedAccessIntentstraight from the wire'smatchValuesandprincipal.agentName, and the widened concretePermissionResolver.resolveaccepts it as a passthrough — noPathNormalizerre-derivation.grep -c ForwardedAccessIntent src/authority/forwarded-request-server.tsreads 4 (0 → ≥ 1, target met). Shippedfeat:(non-breaking, per #557 precedent) since the outcome changes only when the parent holds a per-agent rule for the requesting agent.
Release: batch "cross-session-intent"
✅ Step 4: Authorizer chain infrastructure (#598)
Cause: the live-authority layer's shape (one terminal Authorizer selected once per activation) is closed against non-terminal participants — a link that reviews an ask and defers cannot be seated, which is the structural reason #472 has had no home since Phase 9 built the spine.
- Smell: Category C (OCP at the live-authority layer).
- Target:
src/authority/authorizer.ts(AuthorizerVerdict:allow | deny | defer, withdenycarrying an optional teachingreason), newsrc/authority/authorizer-chain.ts(composeAuthorizerChain— registered non-terminal links, then the context-selected terminal; the terminal-cannot-defer invariant is type-level),src/authority/authorizer-selection.ts(selectAuthorizerbecomes the terminal-selection step; theAskEscalatorsurface is unchanged). - Outcome: refactor-only — behavior is identical with zero registered links, pinned by the existing authorizer-selection tests; the chain seam exists for Step 5 to expose.
- Landed:
Authorizeris now the non-terminal chain link (allow | deny | defer),TerminalAuthorizeris the terminal (cannot defer, type-level), andcomposeAuthorizerChain([], terminal)returns the terminal instance so behavior is byte-identical;AuthorizerSelection.activateroutes through the empty chain. SevencomposeAuthorizerChainunit tests added. - Impact 4 / Risk 3 / Priority 12.
Release: batch "authorizer-chain"
✅ Step 5: registerAuthorizer seam, authorizerChain config, and the enforcement checkpoint (#599)
Cause: same cause, consumed — the chain needs a registration surface and an operator-owned naming step, honoring ADR 0007's invariants: config order (not registration order) fixes the chain order, a missing configured link is skipped fail-safe, and registration alone grants no authority.
- Smell: Category C (OCP), with the config surface following the source-of-truth priority.
- Target:
src/service.ts+src/permissions-service.ts(registerAuthorizer(name, link)with a disposer, mirroringregisterToolAccessExtractor),src/config-schema.ts(anauthorizerChain: string[]field with.metadescriptions) + regeneratedschemas/permissions.schema.json+ carry-through inextension-config.tsandmergeUnifiedConfigs()(the #332/#347 drop class), the enforcement checkpoint in the chain owner (an excluded-surfaceallowdowngrades todefer;external_directoryand secret-shapedpathalways excluded),config/config.example.json,docs/configuration.md,README.md. - Outcome: a downstream extension can offer a named link on
permissions:readyand it decides nothing until the operator names it inauthorizerChain; the checkpoint caps any link's authority;grep -c registerAuthorizer src/service.tsandgrep -c authorizerChain src/config-schema.tsboth go 0 → ≥ 1. The surface ships config-gated; it is vacant only until Step 6 lands (the #267 guard). - Landed:
registerAuthorizer(name, authorize)onPermissionsServicebacked byAuthorizerRegistry;authorizerChain: string[]config carried through the schema,extension-config.ts, andmergeUnifiedConfigs(); a session-scopedPermissionQuery(Step 4's deferred injection) handed to each link viacomposeAuthorizerChain(links, terminal, query);AuthorizerSelectionresolves the chain per ask (config order, fail-safe skip, delegation-envelope wrap) so a link registered in a latepermissions:readyhandler is honored before the first ask. The checkpoint excludes the wholepathsurface (no formal secrets model to key a secret-shaped exclusion on); the secret-shaped refinement, theorigin:"authorizer:model"audit shape, and the allow-capable adjudicator that consumes the query are deferred to #620. Two preparatory refactors (PermissionQueryextraction, array-merge key loop) landed first. - Impact 5 / Risk 2 / Priority 20.
Release: batch "authorizer-chain"
✅ Step 6: Dogfood package — @gotgenes/pi-permission-model-judge (#600)
Cause: the #267 history guard — an inbound registration surface nobody consumes goes vacant; ADR 0007 requires the seam born consumed by a first-party deny-first reviewer, which also exercises the config split (chain policy here, model mechanism there) end to end.
- Smell: Category F (cross-package responsibility placement, done deliberately: this package holds no model-prompt config it does not read).
- Target: new
packages/pi-permission-model-judge/— registers"model-judge"onpermissions:ready; the deny-first typo-path reviewer (verdictsdeny | deferonly in this slice; the allow-capable opaque-bash adjudicator stays deferred per ADR 0007's capability gradient); model calls via@earendil-works/pi-aicomplete(feasibility-probed) with the provider/model/instructions/timeout in its ownconfig.json; full monorepo wiring per AGENTS.md (release-please-config.jsoncomponent +docs/plans/docs/retroexclude-paths,.release-please-manifest.jsonat0.0.0,.pi/settings.jsonload path + npm disable entry, rootREADME.mdpackages table). - Outcome:
registerAuthorizerhas a day-one consumer; an errant typo-pathexternal_directoryask can be auto-denied with a teaching reason when the operator opts in;ls packages | grep -c pi-permission-model-judgegoes 0 → 1. - Landed: new
packages/pi-permission-model-judge/registers"model-judge"onpermissions:ready(from both its ownsession_startand the ready event, idempotently, so either extension-init order completes the registration); the deny-first reviewer gates on theexternal_directorysurface, a configuredtypoPatternsregex pre-filter, then a model confirmation via@earendil-works/pi-aicomplete— verdictsdeny | deferonly, fail-safe todeferon any uncertainty. Its own zod-validatedconfig.json(provider/model/instructions/typoPatterns/timeout) holds the model mechanism; the chain policy stays in pi-permission-system. - Impact 4 / Risk 3 / Priority 12.
Release: independent
Step dependency diagram
flowchart TD
S1["✅ Step 1 (#595): ADR 0008 — forwarded-intent portability + principal identity"] --> S2["✅ Step 2 (#596): structured intent on the forwarded wire"]
S2 --> S3["✅ Step 3 (#597): serving resolves the forwarded intent"]
S4["✅ Step 4 (#598): Authorizer chain infrastructure"] --> S5["✅ Step 5 (#599): registerAuthorizer seam + authorizerChain config"]
S5 --> S6["✅ Step 6 (#600): pi-permission-model-judge dogfood package"]
Parallel tracks
- Track A — cross-session intent spine: Steps 1 → 2 → 3.
- Track B — Authorizer chain: Steps 4 → 5 → 6.
The tracks are independent and can proceed in parallel; both touch src/authority/, but Track A's files (forwarding, serving) and Track B's files (authorizer selection, chain) are disjoint apart from the shared AskEscalator seam, which neither track changes.
Release batches
- Batch "cross-session-intent": Steps 1, 2, 3 (ship together; tail = Step 3).
- Batch "authorizer-chain": Steps 4, 5 (ship together; tail = Step 5).
- Independently releasable: Step 6 (a new package with its own release component; it lands after Step 5).
Completion
All 6 steps are closed: #595, #596, #597, #598, #599, #600.
Follow-on issue #620 (allow-capable opaque-bash adjudicator, ADR 0007's ask-consuming slice 2) was filed during Step 5's landing to track the deferred capability; it remains open and non-gating. #565 (validate serving-is-resolution decisions post-ship, opened as a Phase 9 follow-on) closed at phase end per this phase's open-issue sweep disposition — Steps 1–3 structurally dissolved its items 2–3, and item 1 is recorded best-effort.
Open issues swept and confirmed out of scope during planning, both by decision and non-gating: #472 (ModelTriageAuthorizer — its deny-first slice shipped as Steps 4–6, but the issue stays open pending the allow-capable slice 2, #620), #519 (externally blocked on Pi SDK UIContext evolution).
Delivered vs. predicted metrics
Recomputed at archive time (pnpm fallow health --score --workspace @gotgenes/pi-permission-system / pnpm fallow dupes --workspace @gotgenes/pi-permission-system / pnpm fallow dead-code --workspace @gotgenes/pi-permission-system):
| Metric | Phase 12 target | Delivered |
|---|---|---|
Forwarded-wire structured intent (ForwardedAccessIntent in permission-forwarding.ts) |
≥ 1 | 2 — met |
Serving reads the forwarded intent (ForwardedAccessIntent in forwarded-request-server.ts) |
≥ 1 | 5 — met |
registerAuthorizer service surface (service.ts) |
≥ 1 | 1 — met |
authorizerChain schema sites (config-schema.ts) |
≥ 1 | 1 — met |
| Model-judge package present | 1 | 1 (packages/pi-permission-model-judge/) — met |
| fallow health score | ≥ 88 | 88 (A) — met |
| Production duplication | ≤ 0.2% | 0.1% (34 lines, 1 clone group, the documented intentional literalTextOf/resolveNodeText pair) — met |
| Dead exports | 0 | 0 — met |