多个扩展各自整段改写系统提示词并按加载顺序接力,任一扩展定死提示词后 其余只能做字符串手术,且各上游的工具说明与组合包实际工具格局冲突。 - 新增 extensions/system-prompt.ts 作为唯一出口(加载列表最后),按 config/prompt/(preamble.md、tools.jsonc、routing.jsonc、sections.jsonc) 组合开场白、工具简介/规则与 <tool_routing> 分区;替代原封面与 tool-routing 扩展,/dump-system-prompt 输出最终提示词。 - pi-chrome、pi-ssh、pi-hermes-memory 改为写命名分区,pi-rtk-optimizer 改写 promptGuidelines,pi-permission-system 直接过滤结构化 skills, 均不再返回 systemPrompt;pi-chrome 未授权时不再每轮冻结提示词。 - 新增 prompt-guard 代理加载第三方扩展(Hashline、FFF、LSP、 context-mode):拦截直接改写提示词并按目录覆盖工具说明;context-mode 去掉与组合包路由冲突的锚点段,保留记忆与恢复快照。 - 消除冲突:bash 简介不再建议 grep/find;Hashline edit 不再称可用 grep 锚点、不再介绍已关闭的 replace_text;context-mode 读写/网页路由提示。 - 守门测试:工具目录覆盖率、禁用用法检查、context-mode 上游原文锁定; AGENTS.md 新增「系统提示词单一所有权」。 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
82 KiB
Architecture
This document describes the internal design of the permission system, informed by OpenCode's permission model.
Design principles
- Unified rule model - one
Ruletype, one evaluation function, all surfaces. - Pure evaluation - permission decisions are pure functions of (surface, pattern, rules). IO stays at the edges.
- Session approvals are just more rules - no separate matching engine, no separate pre-check.
- MCP stays special - multi-name target derivation is pre-processing, not a special evaluation path.
- Defaults are rules - the universal default (
permission["*"]) is synthesized as a low-priority rule in the array. No side-channel fallbacks. - Flat config format - the flat
permission: { ... }object where each key is a surface. The config IS the ruleset in human-friendly form. - Preserve the two-phase model - tool filtering (before_agent_start) and invocation gating (tool_call) remain separate.
- Ask = cache miss - "ask" is the absence of a matching rule. The human is the oracle. Their decision is a rule. Persistence determines lifetime (once / session / config).
- Single-agent core, multi-agent by extension - Pi is single-agent by deliberate design; the notion of multiple named agents is introduced entirely by external extensions (pi-subagents, pi-agent-router, some MasuRii packages), not by Pi itself.
Per-agent
permission:frontmatter is therefore an extension bridge layered on this single-agent core, not a core responsibility. The package learns the active agent from a generic<active_agent>signal (a system-prompt tag or anactive_agentsession entry), never from a hard dependency on any one multi-agent extension, so the bridge works with any tool that emits the signal.
Core data model
Rule
/**
* Provenance of a rule - which source contributed it.
*
* Config scopes: "global", "project", "agent", "project-agent".
* Synthesized: "builtin" (universal default / evaluate() fallback),
* "baseline" (conditional MCP metadata auto-allow).
* Runtime: "session" (session approvals).
* Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
* "fail-closed" (composition-stage allow→ask floor when an
* invalid non-global config scope is detected).
*/
type RuleOrigin =
| "global"
| "project"
| "agent"
| "project-agent"
| "builtin"
| "baseline"
| "session"
| "yolo"
| "fail-closed";
interface Rule {
/** The permission surface: "bash", "edit", "mcp", "skill", "path", etc. */
surface: string;
/** The match pattern: a command glob, tool name, file path, skill name, or "*". */
pattern: string;
/** The decision. */
action: PermissionState;
/** Custom denial reason for deny rules (optional). */
reason?: string;
/**
* Origin layer - used to derive PermissionCheckResult.source after evaluation.
* Not used by evaluate(); purely informational metadata.
*/
layer?: "default" | "baseline" | "config" | "session";
/** Which source contributed this rule. */
origin: RuleOrigin;
}
Every config entry, default policy, session approval, and agent override normalizes into Rule[].
Ruleset
type Ruleset = Rule[];
A Ruleset keeps declaration order for deterministic tie-breaking, but declaration order is not the primary precedence rule.
For all matching rules on the requested surface, evaluate() chooses the most specific pattern:
- exact match;
- more literal characters;
- fewer
*wildcards; - longer pattern;
- later declaration only for a complete tie.
The synthesized universal * is therefore a fallback. A specific deny cannot be shadowed by a later broad allow.
Evaluate
evaluate() filters to matching surface/value candidates, ranks them with the specificity comparator, and returns the highest-ranked rule. If none match, it returns the built-in ask fallback.
Decision composition
PermissionManager does not load an ordinary project policy. It composes these authorities:
- global-only Hard Guards (first and final for matching
deny); - session approvals;
- active agent restrictions from global/project agent Markdown;
- the single global ordinary policy;
- built-in
ask.
Rules inside one authority use pattern specificity. Results from independent gates (path, per-tool, Bash) compose most-restrictive-wins. AutoReview is not a ruleset layer: it only receives a completed ordinary-policy ask.
Architecture overview
flowchart TD
HG["Global hard-guards.jsonc"] --> Intent["Resolved access intent"]
GP["Global policy.jsonc"] --> PM["PermissionManager"]
AF["Optional active-agent frontmatter"] --> PM
SR["SessionRules"] --> PM
Intent --> Pipeline["skill -> path -> bash -> tool gates"]
Pipeline --> PM
PM -->|allow| Allow["execute"]
PM -->|deny| Deny["block"]
PM -->|ask| Chain["authorizerChain"]
Chain -->|allow/deny| Result["resolve"]
Chain -->|defer or exhausted| Human["human prompt"]
Project-level policy.jsonc is intentionally absent. Project agent Markdown may restrict one active agent, but it is not a second ordinary configuration scope.
Tool Search alone owns getActiveTools() / setActiveTools(). Permission System does not participate in tool visibility; it receives only calls Pi actually invokes.
Config format
{
"permission": {
"*": "ask",
"read": "allow",
"bash": { "*": "allow", "git *": "allow", "npm *": "allow", "rm *": "deny" },
"mcp": { "*": "ask", "exa:*": "allow" },
"skill": { "*": "ask", "librarian": "allow" },
"path": { "*": "allow", "*.env": "deny" },
}
}
Each top-level key in permission is a surface name.
A string value is shorthand for { "*": action } (surface-level catch-all).
An object value maps patterns to actions.
permission["*"] is the universal fallback.
Normalization to Rule[]
normalizeFlatConfig (src/normalize.ts) flattens each permission entry into Rules: a string value expands to a single surface catch-all ({ surface, pattern: "*", action }), and an object value expands each pattern → action pair to one Rule.
MCP pre-processing
MCP is the one surface that requires pre-processing before evaluation.
The multi-name target derivation stays, but it feeds candidate values into evaluate() rather than a separate code path:
flowchart LR
Input["MCP tool call input"] --> Derive["createMcpTargets(input)"]
Derive --> Candidates["[exa_search, exa:search, exa, search, mcp_call]"]
Candidates --> Loop{"For each candidate"}
Loop --> Eval["evaluate('mcp', candidate, rules)"]
Eval --> Found{"Explicit match?"}
Found -->|Yes| Return["Return rule"]
Found -->|No| Next["Next candidate"]
Next --> Loop
Loop -->|Exhausted| Fallback["evaluate('mcp', '*', rules)<br/>(hits synthesized default)"]
The priority ordering of candidates is preserved.
The evaluation function is unchanged - MCP just calls it multiple times with different values.
MCP target derivation helpers live in src/access-intent/mcp-targets.ts.
Input normalization for all surfaces lives in src/access-intent/input-normalizer.ts.
Path-bearing tool normalization
Per-tool path patterns are emitted as path-values intents. Relative inputs may carry lexical, cwd-normalized absolute, and canonical aliases so one configured pattern can protect the same file across equivalent spellings.
There is no outside-CWD classifier and no external_directory intent. Paths outside the working directory use the same path, per-tool, and Bash policy evaluation as paths inside it.
Tools such as SSH that carry remote paths register an explicit extractor so local path normalization is not inferred from input.path.
Session approvals: the cache-miss model
Session rules are stored as Ruleset and are generalized to all surfaces.
evaluate() is a lookup against cached decisions.
When no rule matches (or the matching rule says "ask"), the system has a cache miss - it needs the human oracle to produce a decision.
The human's response is simultaneously:
- The answer for this request (allow or deny).
- A rule that can be cached for future lookups.
The dialog determines persistence - where the rule lives:
evaluate(surface, value, composedRules)
│
├── match.action = "allow" → proceed (cache hit)
├── match.action = "deny" → block (cache hit)
│
└── match.action = "ask" → cache miss, query oracle
│
▼
Dialog: "[surface] wants to [value]"
│
├── "Yes" → allow this request (no persistence)
├── "Yes, for session" → allow + store in session layer
│ (future lookups hit without asking)
├── "No" → deny this request (no persistence)
└── (future: "Always") → allow + store in config layer (disk)
Pattern suggestions
When prompting, each surface suggests a pattern for the "for session" option. The pattern determines what class of future requests auto-approve:
| Surface | Input value | Suggested session pattern | Mechanism |
|---|---|---|---|
| bash | git checkout main |
git checkout * |
Arity table |
| bash | npm run dev |
npm run dev |
Arity table |
| tool (read/write/etc.) | tool surface itself | * (all uses of that tool) |
Tool-level |
| mcp | exa:search |
exa:* |
Server-level wildcard |
| skill | librarian |
librarian |
Exact name |
The suggestion is shown in the dialog text so the user sees what they're approving:
● Allow once
● Allow "git checkout *" for this session
● Deny
Implementation
sequenceDiagram
participant User
participant Gate as Elicitor (ask-path)
participant Eval as evaluate()
participant Session as Session Rules (Ruleset)
Gate->>Eval: evaluate("bash", "git status", composedRules)
Eval-->>Gate: { action: "ask" } (cache miss)
Gate->>User: "Allow 'git status'? [Once / Session: 'git status*' / Deny]"
User-->>Gate: "Session"
Gate->>Session: append { surface: "bash", pattern: "git status*", action: "allow" }
Note over Gate,Session: Next similar call - cache hit
Gate->>Eval: evaluate("bash", "git status --short", composedRules incl. session)
Eval-->>Gate: { action: "allow" } (matched session rule)
Note over Gate: No prompt needed
Prompt presentation
What a prompt must show, what a renderer may elide, and what bounds its size are settled by ADR 0011. The contract in one line: the payload is complete, and elision is a property of a render, never of the payload.
A gate emits structured facts rather than a sentence.
The payload's request group — requester and forwarded-ness, tool name and invoked tool name, gate surface and matched rule, the decision-relevant value, and for bash the unit that will actually run — is never elided by any renderer.
evidence is complete on the payload and elided to fit each renderer's budget, with the elision marked but uncounted; an operator must still be able to reach the complete information while the decision is pending.
The dialog is bounded by a row budget plus a per-field width cap, the review log by its own configured limits, and the permissions:ui_prompt broadcast receives the request facts only — the narrowest renderer, because the bus is the one channel an extension observes without the operator having named it.
Denial text is a fifth render of the same facts under one extra rule: it identifies the call rather than reproducing it, since the agent already holds its own tool input.
The payload exists, and the human-facing renderers are bounded.
Every gate emits a PromptPayload (src/presentation/), and PromptPermissionDetails requires one, so the six former assembly sites are gone.
renderPromptDialog renders it for the inline dialog and the select/input fallback under promptMaxRows plus promptFieldMaxWidth, and Ctrl+O expands the dialog to the complete request.
The cap applies to the request facts too: never elided means never omitted — a long one is shortened, marked, and reachable in full rather than dropped.
Without that reading a bounded render is unreachable, since the decision-relevant value is itself the pathological field in the reported case (#710).
A fact an adjacent line already states is not repeated — a bash ask's gate surface is its tool name, and a path ask's is the word its value line is labelled with — so the render spends a line only where it adds something.
That is a redundancy rule, not an elision: the fact is still on screen, which is what §3 requires.
The two cross-boundary contracts now carry facts rather than prose.
The forwarded-request wire carries the child's PromptPayload, so the serving node renders the child's own facts under the parent's budget — a forwarded bash ask reads command : … exactly as a local one does, and kind: "forwarded" narrows to meaning one thing: this ask arrived without a payload.
permissions:ui_prompt carries request, the payload's invariant core, and no evidence at all, which makes the bus the narrowest renderer (ADR 0011 §6): any loaded extension observes it without the operator having named that extension.
toolInputPreviewMaxLength and toolTextSummaryMaxLength are deprecated and ignored, superseded by the renderer budgets.
The last two consumers are renderers too, so the flat message string is gone.
The agent-facing text identifies a refused call rather than reproducing it (§7): it names the surface, the tool, the rule with its nested context, the flagged path or target or skill, and the operator's or human's reason — never the bash command, which is the payload that took over the viewport in #710 and the agent's context window on every denial.
The flagged element is agent input, so it is capped rather than structurally bounded; naming it is what makes a denial correctable, since which of a call's operands a rule fired on is below tool-call granularity and the agent cannot recover it from its own arguments.
The review log persists the payload's request facts rather than the prompt sentence — stamped by GateRunner beside the request id, so no gate can forget them — and every string it writes is narrowed to reviewLogFieldMaxWidth.
That bound lives in writeLine beside the key-name mask, which makes the log's growth a decision the operator makes rather than a consequence of how long a command happened to be.
ADR 0011 records what each dependent item becomes under the contract.
Two-phase checking
Phase 1: Tool filtering (before_agent_start)
shouldExposeTool (src/handlers/before-agent-start.ts) calls evaluate(toolName, "*", rules) and exposes the tool unless the surface-level result is deny — "is this tool denied regardless of specific input?"
Phase 2: Invocation gating (tool_call)
The gate pipeline (src/handlers/gates/) normalizes the input to (surface, value), evaluates it against the composed ruleset, and acts on the result: allow proceeds, deny blocks, and ask elicits from the session's Authorizer — a persisted "session" decision appends a Rule to sessionRules so the next similar call is a cache hit.
Same evaluate(), same ruleset.
The only surface-specific logic is input normalization (what surface and value to look up) and pattern suggestion (what glob to offer for "session" approval).
checkPermission() uses a single evaluate path: normalizeInput() → evaluateFirst() → deriveSource() → single result object.
Subagent detection and permission forwarding
When ask-state permissions arise in a headless subagent child process, the extension forwards the dialog to the parent session rather than silently denying.
This requires two detections:
- Is the current process a subagent?
isSubagentExecutionContext()insrc/authority/subagent-context.ts.
- What is the parent session ID?
resolvePermissionForwardingTargetSessionId()insrc/authority/permission-forwarding.ts.
Known extension env var inventory
| Extension | Child-process env vars | Parent-session env var |
|---|---|---|
| pi-agent-router (original) | PI_IS_SUBAGENT, PI_SUBAGENT_SESSION_ID, PI_AGENT_ROUTER_SUBAGENT |
PI_AGENT_ROUTER_PARENT_SESSION_ID |
| nicobailon/pi-subagents | PI_SUBAGENT_CHILD, PI_SUBAGENT_RUN_ID, PI_SUBAGENT_CHILD_AGENT, PI_SUBAGENT_DEPTH |
none set (see #98) |
| tintinweb/pi-subagents | none - runs fully in-process via createAgentSession(); the maintained my-pi fork uses the registry lifecycle |
n/a |
| HazAT/pi-interactive-subagents | PI_SUBAGENT_NAME, PI_SUBAGENT_ID, PI_SUBAGENT_SESSION, PI_SUBAGENT_ACTIVITY_FILE |
none set (see #98) |
Detection (isSubagentExecutionContext)
isSubagentExecutionContext() checks three sources in priority order:
- Explicit registry - a compatible in-process spawner (
@gotgenes/pi-subagentsor the maintainedmy-piTintinweb fork) emitssubagents:child:session-createdbeforebindExtensions(); the permission system's subscriber writes the entry intoSubagentSessionRegistrysynchronously. The registry (keyed by child session id) is checked first. Each concurrent sibling child of the same parent receives a unique session id fromsessionManager.newSession(), so siblings occupy distinct keys - one sibling'sdisposedevent cannot evict another's entry (fixes #298). The registry is a process-global singleton (viagetSubagentSessionRegistry(), backed byglobalThis+Symbol.for()) because each session'sResourceLoadercreates its ownpi.eventsbus: the parent's instance registers the child over the parent bus, while the child's separate jiti instance reads the same global store to detect itself and resolve its forwarding target. - Env vars (
SUBAGENT_ENV_HINT_KEYS) - returnstruewhen any key is set to a non-empty, non-whitespace value. Used by process-based subagent extensions. - Filesystem path - session-directory path-based fallback (child session dir is nested under
subagentSessionsDir).
Parent-session resolution (resolvePermissionForwardingTargetSessionId)
resolvePermissionForwardingTargetSessionId() checks two sources in priority order:
- Explicit registry - if the caller provides a
sessionIdandregistry, the registry entry'sparentSessionIdis returned when present. Used by in-process subagent extensions. - Env vars (
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) - iterates candidates and returns the first non-empty, non-"unknown"value. Used by process-based subagent extensions.
Neither nicobailon nor HazAT sets a parent-session env var today, so forwarding still fails for those extensions with an explicit log message pointing to #98. Adding a new env var candidate when an extension adopts the convention is a one-line change to the array.
In-process case (resolved)
In-process subagent extensions call createAgentSession() directly - no child process is spawned and no env vars are ever set.
@gotgenes/pi-subagents and the maintained my-pi Tintinweb fork publish subagents:child:session-created before bindExtensions() and subagents:child:disposed after the child closes; src/authority/subagent-lifecycle-events.ts subscribes and writes/removes the entry in SubagentSessionRegistry synchronously.
The registry is process-global (see getSubagentSessionRegistry() in src/authority/subagent-registry.ts) so the child's separate jiti instance reads the same store as the parent.
See src/authority/subagent-registry.ts and Subagent Integration for details.
External convention guide
A permission frontmatter convention guide documents how upstream subagent extensions can adopt the permission: frontmatter key as a shared convention.
This is a documentation-only proposal - no code dependency is required.
The guide covers the two-layer model, flat format reference, composition examples, and the optional event bus runtime integration.
Cross-extension service accessor
The primary cross-extension API is a Symbol.for()-backed service object on globalThis.
Pi's extension loader creates a fresh jiti instance per extension with moduleCache: false, isolating module-scoped state.
Symbol.for() and globalThis are process-global by spec, so they survive this isolation.
The extension publishes a PermissionsService object via publishPermissionsService() at session_start, gated so an in-process subagent child does not clobber the parent's service (#302).
Other extensions retrieve it with getPermissionsService() from import("@gotgenes/pi-permission-system").
The package.json exports field's default condition points to src/service.ts, which contains the interface, the accessor functions, and the Symbol.for() key - no extension machinery.
The types condition instead resolves to a bundled dist/public.d.ts (built by rollup-plugin-dts from rollup.dts.config.mjs, published via prepack) so a downstream consumer's tsc never follows the raw #src/* module graph - only the default condition (the jiti runtime) reads src/ directly (#592).
The PermissionsService interface exposes five methods:
checkPermission(surface, value?, agentName?)- full policy query.getToolPermission(toolName, agentName?)- tool-level permission state (allow/deny/ask) for pre-filtering.registerToolInputFormatter(toolName, formatter)- register a custom ask-prompt preview for a tool name; returns a disposer (#283).registerToolAccessExtractor(toolName, extractor)- declare the local filesystem path a non-conventional tool accesses so the cross-cuttingpathgate sees it, or explicitly suppress local-path inference for remote tools; returns a disposer.registerAuthorizer(name, authorize)- register a named live-authority chain link (allow | deny | defer); it decides nothing until the operator names it inauthorizerChain, thenallow/denyis final for any ordinary-policyask, includingpath; returns a disposer.
permissions:decision and permissions:ui_prompt broadcasts remain on the event bus - fire-and-forget observation is the right abstraction for those channels (#531 removed the event-bus RPC channel; the service accessor is now the sole cross-extension policy/prompt surface).
The authority model
This section records the organizing concept the package is built around — the spine the elicitation, forwarding, and yolo machinery collapse into — plus the still-open directions that extend it.
It is current state, not a target: the Authorizer interface, its three implementations, once-per-activation selection, canConfirm()'s dissolution, serving-as-resolution, human-selectable grant-scope, and the authority/ directory migration all shipped in Phase 9 (see history/phase-9-authorizer-spine.md for why the spine is the correct model of the @gotgenes/pi-subagents integration — the anonymous cross-session-authority recursion behind the #296/#298/#302 bug history — and not merely an internal tidy).
Of the "beyond the target" extension points below, the model-triage Authorizer chain is now implemented (Phase 12; ADR 0007), and its named-link registration subsumes the pluggable escalation seam; the deny-first slice is dogfooded by packages/pi-permission-model-judge, and the allow-capable opaque-bash adjudicator (#620) remains the sole open Track B slice.
A non-deterministic access-intent classifier remains aspirational.
The spine
Every action resolves against an authority — an entity empowered to permit or forbid it. The only questions are which authority and how we reach it.
This sharpens principle 8.
That principle calls the human "the oracle," borrowing the computer-science term for a black box consulted for an answer the system cannot compute.
But a permission decision is not epistemic (who knows the answer); it is deontic (who has the right to decide).
If a bystander happened to know what the user wanted, their saying "allow" would authorize nothing.
What makes a decision binding is authority, not knowledge — so the organizing concept is authority, and the entity that holds it is an Authorizer.
The human is merely the Authorizer at the interactive root; another agent can hold the role equally well.
Authority lives in three places
- Recorded authority — the ruleset.
Config (durable, on disk), session rules (this session), and synthesized defaults/baseline are all prior rulings.
evaluate()is "consult recorded authority": anallowordenymeans recorded authority is sufficient, and the decision is final. - Live authority — reached only on
ask, when recorded authority is silent. An entity empowered to rule now, reached through one of three channels (below). - Absent authority — nothing recorded, nothing reachable. Least privilege applies: no authority means the action is unauthorized, so it is denied.
The three are one thing at different lifetimes. A live ruling, once persisted, becomes recorded authority — principle 8's "their decision is a rule." The "for this session" dialog option writes a session rule; a future "always" writes config.
The Authorizer role
On ask, the gate escalates to one Authorizer, selected once per session from context, and is told the decision.
LocalUserAuthorizer— the session has UI; prompt the human here.ParentAuthorizer— the session is a subagent; escalate up the tree to the parent's authority.DenyingAuthorizer— no authority is reachable; deny (least privilege).
There is no "can anyone answer" pre-check.
canConfirm() — today a boolean smeared across the gateway, prompter, and forwarder — dissolves: every Authorizer answers, the DenyingAuthorizer by denying.
The three context predicates (hasUI, isSubagent, yolo) are evaluated once, at selection, instead of repeatedly down the prompt path.
evaluate(action, recorded authority)
├─ allow / deny ------------------> decided (recorded authority sufficient)
└─ ask (recorded authority silent)
└─ escalate to the session's Authorizer
├─ LocalUserAuthorizer -> prompt the human here
├─ ParentAuthorizer -> forward up the tree, await the parent's ruling
└─ DenyingAuthorizer -> deny (no authority reachable)
|
(a persisted ruling becomes recorded authority)
The recursion
Authority is delegated down the session tree: the human drives the root, which spawns subagents that hold no inherent authority to approve a novel action.
So an ask a subagent cannot answer escalates up to where authority resides.
Permission-system instances form a tree mirroring the session tree, and ParentAuthorizer is the edge that routes a child's escalation toward the human at the root.
This is the same recursion pi-subagents describes (a subagent is a child Pi), viewed from the permission system's side: the package is itself one of the hooks on that child, and it recurses by forwarding.
Reconstruction fidelity at the serving node
The courier hop carries facts, not judgment — but what the serving node reconstructs from a forwarded request differs by audience, and the two directions are the same rule applied to different trust boundaries.
An in-process seam — the Authorizer chain, reached through PromptPermissionDetails — receives the full child-fixed fact set.
A chain link is operator-opted-in via authorizerChain and must decide from evidence, not from parsed display text or a parent-side re-derivation of the child's path (ADR 0008 forbids the latter outright).
The same child-fixed facts let the serving node's configured authorizer decide a forwarded ask without re-deriving its path or trust context.
A cross-extension broadcast — permissions:ui_prompt / permissions:decision on pi.events — receives the minimum needed to stay correlatable, because any loaded extension can observe it.
Maximum fidelity to the decider; minimum disclosure to the observer.
Requester identity (requesterCwd, principal) crosses to neither: it is the serving node's own resolution input (ADR 0008 §3) and stays on the wire object, with the ask details carrying only the forwarding provenance.
yolo is recorded authority
yolo is not a channel and not a live concern — it is a standing authorization, and it belongs in the ruleset, not in the prompt path.
It is a composition-stage rewrite: when enabled, every ask action in the composed ruleset is rewritten to allow, tagged origin: "yolo" so the review log still distinguishes a yolo grant from a policy allow.
const effective = yolo
? composed.map((r) => (r.action === "ask" ? { ...r, action: "allow", origin: "yolo" } : r))
: composed;
This is faithful to current behavior exactly: explicit deny rules are not ask, so they pass through untouched — yolo suppresses prompts but preserves hard denies.
It honors principle 5 (defaults are rules; no side-channel fallbacks): evaluate() runs pure over the rewritten ruleset, and the prompt path loses all yolo knowledge (shouldAutoApprovePermissionState and canResolveAskPermissionRequest's yolo arm dissolve).
The ruleset is the whole story for asks the ruleset produces.
An ask synthesized after resolution is not one: the bash wrapper floor (#481, #490) and the fail-closed <unparseable-bash-command> sentinel (#452) are properties of a parsed command unit, not of a pattern, so there is no rule for the rewrite to touch and they reached the prompter under yolo (#712).
The reconciliation has exactly one home — resolveYoloGrant at GateRunner's auto-approve fast path, the single choke point every gate passes through before escalating — so the contract "an ask never reaches PermissionPrompter under yolo" holds structurally for whatever floor is added next.
It is the same deny-preserving shape as the rewrite: a deny is not an ask, so it matches neither arm.
A future "disable everything" mode — overriding denies too — would require a separate, deliberately named bypass. Appending a broad allow cannot override a more-specific deny under specificity-based evaluation.
It is not built, and it would be requested by name, never conflated with yolo.
Fail-closed on an invalid non-global scope
The mirror image of the yolo rewrite.
When a non-global config scope (project, agent, or project-agent) is present but fails to load or validate, the loader marks it invalid (ScopeConfig.invalid) instead of silently substituting an empty scope.
At composition the manager floors every allow in the composed ruleset to ask, tagged origin: "fail-closed", so a permissive rule inherited from a lower-precedence scope cannot remain effective behind a higher scope that was meant to tighten it (#646).
const effective =
failClosedScopes.length > 0
? composed.map((r) => (r.action === "allow" ? { ...r, action: "ask", origin: "fail-closed" } : r))
: composed;
Like yolo it is deny-preserving (only allow is touched) and applied at composition, so the display surfaces (getComposedConfigRules, getToolPermission) reflect the clamp too.
Global is excluded — it is the lowest precedence, so nothing more permissive is inherited when it fails.
The two overlays stack in order: fail-closed floors allow→ask first, then yolo (if enabled) rewrites ask→allow, so an explicit yolo opt-in still wins.
Discriminating delegation: the built-in model Authorizer
Nothing constrains an Authorizer to be deterministic. LocalUserAuthorizer is already a non-deterministic oracle — the human — and the determinism principle governs recorded authority (evaluate()), never the live-authority layer.
This package now owns the default model reviewer as the named auto-review authorizer. It is still downstream of deterministic policy: only an ordinary ask enters the authorizer chain, and a hard-guard deny never reaches it. The reviewer returns allow, deny, or defer; only defer falls through to the terminal human authority. Its transcript boundary admits direct user messages and recognized non-cancelled structured answers, never assistant/tool/compaction content.
The composition root registers the authorizer on the same LocalPermissionsService instance it publishes. installBuiltinAutoReview() returns lifecycle methods instead of registering a second set of Pi session handlers. The root's single session_start and session_shutdown handlers coordinate service publication and reviewer state, and turn_start resets the denial circuit breaker.
The review model is configuration-only. resolveReviewModel() reads autoReview.provider / autoReview.model and looks the pair up in the live registry; it neither defaults the pair nor synthesizes a stand-in. An unconfigured, unknown-provider, or unpublished-model pair resolves to a failure category, which the reviewer records and turns into defer, so the ask reaches the human prompt instead of being decided by an unintended model.
A separate HardGuardPolicyLoader supplies a global-only manager to PermissionResolver. Resolution is deliberately asymmetric:
hardGuardManager.check(intent, no session rules)
deny -> final
allow -> continue
ordinaryManager.check(intent, session rules)
allow/deny -> final
ask -> authorizer chain -> human terminal fallback
Thus hard-file allow means “not blocked by hard guards,” not “approved overall.” Ordinary policy, per-agent restrictions, session grants, yolo mode, and model authority cannot loosen a hard deny.
The public authorizer registry remains available for other named authorities. Configuration order determines chain order, unregistered names are skipped fail-safe, and registration alone grants no authority.
Authorizers are named capabilities registered on permissions:ready; the integrated auto-review authorizer uses the same chain contract as third-party links. Once configured, a link's allow or deny is final for any ordinary-policy ask, including path; defer advances to the next link or terminal.
One configured path bypasses the chain entirely: protectedWrites in the hard-guard file marks write targets as human-only. The protected-write gate produces its ask last in the tool-call pipeline (a hard deny still blocks first); the descriptor's humanOnly marker skips the runner's session-approval and yolo fast paths, and AuthorizerSelection composes it with zero links so no link — including auto-review — is consulted. A LocalUserAuthorizer terminal prompts without a "for this session" option; any other terminal (DenyingAuthorizer, a ParentAuthorizer relay) denies without prompting. Reads never reach the gate.
This is the principled successor to the per-command argument-position work deferred from #509.
The bash path projection surfaces a bare token that names a real file (#645) and deliberately accepts a fail-safe false positive (grep id_rsa secrets.txt prompts when an id_rsa file happens to exist); that false positive lives on the ask-producing side of evaluate(), and the model link dismisses it on the ask-consuming side without hard-coding per-command file-argument tables.
This split is the layering principle of ADR 0009: the deterministic layer biases toward surfacing because over-suppression is unrecoverable, and the judge absorbs the surplus.
The two compose cleanly because a promoted token emits the same structured descriptor a prefixed path does, so a link needs no promotion-specific knowledge.
Dogfooded: a first-party monorepo package (packages/pi-permission-model-judge) implements the deny-first typo-path reviewer against the real seam, so registerAuthorizer is born consumed (the #267 vacant-surface guard).
Resolved direction
The deterministic core accepts a closed ResolvedAccessIntent union emitted by the gate pipeline. One resolver answers those intents; adding a gate does not add a side-channel policy method.
The current classifier is deterministic. Bash parsing and registered tool extractors may emit path-values; there is no outside-CWD intent. Model judgment is downstream and receives only completed ask decisions through the authorizer chain.
This preserves the core property that the same policy and resolved intent produce the same deterministic decision.
Beyond the target: a pluggable escalation seam
The registration seam this section anticipated is now designed: ADR 0007 settles the Authorizer chain and its named-link registration (registerAuthorizer), with the model judge as its first consumer.
What remains a more distant direction — a candidate extension point, not planned work — is applying that same seam to replace the terminal (a delegation framework other than pi-subagents, a chat-approval bot, or a remote review surface as the authority) and refactoring the built-in subagent integration to register through it.
The #261/#267 inversion made pi-subagents pure — it publishes its child lifecycle and knows nothing about consumers (ADR-0002) — but the purity is one-sided: this package is the integration owner.
It knows pi-subagents' event channel names (subagent-lifecycle-events.ts), hardcodes an env-hint inventory of known third-party subagent extensions (SUBAGENT_ENV_HINT_KEYS), and bakes in a session-directory heuristic.
Supporting a new delegation framework — or something that is not a subagent extension at all, such as a chat-approval bot or a remote review surface — means editing this package.
The subagent machinery decomposes into three roles a seam would name and separate:
- Detection — is this session a delegated context?
This is an Authorizer-selection predicate; #529's
SubagentDetectiongives it one owner. - Target resolution — where does authority live for this session; which node serves the escalation (
resolvePermissionForwardingTargetSessionIdtoday). - Transport — how an
asktravels to that authority and the ruling returns (the file-based request/response polling today; #530's escalation-up role,ParentAuthorizersince #555).
A registered provider is exactly a selection predicate plus a ParentAuthorizer-shaped transport: "when my predicate matches this session and recorded authority is silent, escalate through me."
The Authorizer spine is therefore the seam — this direction is the spine's registration story, not a mechanism beside it.
Two shapes, the second generalizing the first:
- A bridge extension — a third package subscribes to pi-subagents' lifecycle and registers with this package's public seam, leaving both cores pure. A dedicated glue extension knowing both ends is the sanctioned complement of the rule against outbound bridges from a core.
- A dogfooded provider seam — this package defines the registration API and implements its own built-in pi-subagents integration through it, the way
registerToolAccessExtractor/registerToolInputFormatteralready let extensions plug the gates; third parties register on equal terms and the zero-config default survives.
A history guard: this re-introduces an inbound registration surface of the kind #267 retired.
It differs in kind — consumer-agnostic, documented for third parties, and consumed by the built-in provider itself, so it cannot go vacant the way the two-method registerSubagentSession RPC did.
Any design must honor the standing constraints: registration lands synchronously before bindExtensions(); cross-session visibility rides globalThis + Symbol.for() (the #296 bus-split lesson); a provider is live authority only and never touches evaluate(); and a session no provider claims selects DenyingAuthorizer — least privilege, unchanged.
It sequences after the Phase 9 spine and warrants its own decision record.
Naming
The concept and the code role take two grammatical forms of one root, each for what it correctly denotes:
authority(mass noun) — the right to decide; used for the concept ("recorded authority," "where authority lives").Authorizer(count noun) — the entity that holds it; used for the interface and its implementations.
Authorizer is domain-idiomatic: AWS Lambda "authorizers" and OAuth's authorization server return allow/deny, so the term already denotes an entity that can refuse.
Module structure
src/
├── index.ts Composition root and Pi event registration
├── config-loader.ts Global ordinary policy loading
├── policy-loader.ts Global policy + active-agent frontmatter gateway
├── hard-guard-loader.ts Independent global-only hard-guard + protectedWrites loading
├── rule.ts Pattern matching and specificity ranking
├── permission-manager.ts Unified intent evaluation
├── access-intent/ Resolved intent types and resolver
├── handlers/
│ ├── before-agent-start.ts Session warmup + denied-skill filtering (structured skills array, prompt-text fallback); no active-tool control
│ └── gates/ skill -> path -> bash -> tool pipelines
├── authority/
│ ├── authorizer-selection.ts Configured authorizer resolution
│ └── authorizer-chain.ts Named authorizer ordering and terminal fallback
├── auto-review/ Integrated reviewer
├── service.ts Cross-extension permission service
└── types.ts Shared domain types
Tool visibility is deliberately absent from this tree's responsibilities. It belongs to Tool Search.
Improvement roadmap — Phase 13: The prompt-presentation seam
Findings (planned 2026-08-15)
The declared candidate is ADR 0011 (the prompt-presentation contract), whose Staging section assigns its decomposition to this planning pass.
The cause is a structural fusion of presentation with decision-making, recorded in the Prompt presentation section above: six sites assemble a flat prompt message string (formatAskPrompt's three branches, the skill prompts, the external-directory prompts, formatPathAskPrompt, the per-tool previews, and the parent-side forwarded prefix) that travels unchanged to every consumer — the inline dialog, the select/input fallback, the review log, the permissions:ui_prompt broadcast, and the forwarded wire.
Because the payload is a pre-rendered sentence, elision is a payload property rather than a render property: the bash branch has no cap, nothing bounds height (#710), a forwarded ask is assembled twice under two configs, and every denial path echoes unbounded input into the agent's context.
The phase implements the contract's staged first step — the complete payload and the renderer seam — so #710 is fixed by construction and #713 becomes a conformance requirement of the payload's invariant core rather than a separate enhancement.
Corroboration (fallow + sweeps, 2026-08-15): health 88 (A; deductions are unit size and coupling), dead code 0, duplication 0.2% (the documented intentional literalTextOf/resolveNodeText pair plus one new 16-line internal clone in token-collection.ts).
The repeated-discriminator sweep found no new family — survivors are validation-edge typeof guards, per-node AST dispatch, and boundary translation, idiomatic per the taxonomy.
The value-guards.ts refactoring target remains rejected (healthy high-fan-in leaf).
The craftsmanship scout re-refuted all three fallow giant-test flags (nested describe trees of small behavior-named tests, unchanged since Phase 12) and found one concentrated cluster: six duplicated local test factories (PermissionCheckResult builders and ToolPreviewFormatter options literals) across denial-messages.test.ts, permission-prompts.test.ts, and tool-preview-formatter.test.ts — exactly the presentation test files the spine rewrites, so the extraction rides Step 1 as a tidy-first prep commit.
Directory check: the spine rewrites the ~8 cohesive presentation modules at the flat src/ root, so per the recorded reorg convention this phase seeds src/presentation/ and the touched modules reach their final home the first time.
Open-issue sweep dispositions (user-decided):
- #710 — adopted as Step 2 (the bounded local renderers are its fix by construction); closed with it.
- PR #738 (highlight the flagged element in TUI prompts) — swept in during Step 1 planning, having been filed the day before this phase was scoped. Highlighting is a render concern under ADR 0011, so its intent is adopted in Step 2's dialog renderer with authorship credited, and the PR closes as superseded rather than being rebased — the same disposition #716 received. Both were adopted and credited when Step 2 landed.
- #713 — its inner-command fact enters the payload in Step 1 and becomes visible in every render in Step 2 (the
runsline); it closed with Step 2. - #721 / #735 — adopted as Step 5: out-of-process forwarding liveness; #735's scenario 1 (dead parent) is resolved by it, while scenario 2 (a parent whose turn is occupied) stays with the #722 diagnosis, which remains open and out of scope.
- #726 — adopted as Step 6 (decision provenance).
- #732 — adopted as Step 7 (model-judge
agentDirfix). - #655 — adopted as Step 8 (
deriveApprovalPatternflavor injection). - #620 — deferred with recorded rationale: one phase old, non-gating, the
registerAuthorizerseam it consumes exists, and the phase's capacity goes to the presentation spine; #698 and #706 express user demand for the same capability and fold into it when it is scheduled. - #519 — kept open with recorded rationale (not a silent re-defer): still externally blocked on Pi SDK
UIContextevolution; it closes or schedules when the SDK ships the capability. - #639 — deferred to a later phase: first sweep since filing, and its policy-model design budget does not fit alongside the presentation spine.
- #742 — swept out of scope this phase by composition decision (first explicit sweep); it is the last member of the #306/#741 nested-command bypass family and is a strong candidate for the next phase's spine or an independent step.
- #610 — adopted mid-phase as Step 10, after a #745 planning question surfaced the gap underneath it. It was originally swept out as a feature issue; the sweep read the symptom (a parent-side consumer with no terminal signal) without reading the cause. Tracing it found three id conventions and no id at all on any non-prompting path, so the local foundation split out as Step 9 (#752) and this issue narrowed to the cross-session half it actually reported.
- #752 — filed mid-phase as Step 9, the foundation Step 10 needs.
- Feature issues #736, #720, #691, #688, #687, #686, #680, #658, #609, #604, #603, #699 — out of scope for a structural phase; #654 and #648 become downstream packages over the annotator and evidence-formatter seams per ADR 0011 §8, which are themselves deferred until the payload exists.
Trajectory: Phase 12's maximum step priority was 20; this phase's is 20 (Step 1). No decline, so the regular rotation continues.
Health metrics
| Metric | Baseline (2026-08-15) | Phase 13 target |
|---|---|---|
Flat-assembler sites (formatAskPrompt references in src/) |
4 | 0 ✅ |
Forwarded-wire message: string field (permission-forwarding.ts) |
1 | 0 ✅ |
Broadcast message: string field (permission-ui-prompt.ts) |
1 | 0 ✅ |
src/presentation/ domain directory present |
0 | 1 ✅ |
Legacy message render sites (renderLegacyMessage in src/) |
17 | 0 ✅ |
Forwarding-liveness module present (authority/forwarding-liveness.ts) |
0 | 1 ✅ |
decidedBy provenance sites in src/ |
0 | ≥ 1 ✅ |
Request-id mint sites in src/ |
2 | 1 ✅ |
requestId fields in permission-events.ts (ui_prompt + decision) |
1 | 2 ✅ |
Model-judge resolves agentDir via getAgentDir (config-loader.ts) |
0 | ≥ 1 |
Ambient node:path import in session-rules.ts |
1 | 0 |
| fallow health score | 88 (A) | ≥ 88 |
| Production duplication | 0.2% | ≤ 0.2% |
| Dead exports | 0 | 0 |
Recompute commands (run from the repo root):
- Flat-assembler sites:
grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -l - Wire message field:
grep -c "message: string" packages/pi-permission-system/src/authority/permission-forwarding.ts - Broadcast message field:
grep -c "message: string" packages/pi-permission-system/src/permission-ui-prompt.ts - Presentation directory:
ls packages/pi-permission-system/src | grep -c presentation - Legacy message sites:
grep -rn "renderLegacyMessage" packages/pi-permission-system/src --include="*.ts" | wc -l - Liveness module:
ls packages/pi-permission-system/src/authority | grep -c "forwarding-liveness" - Provenance sites:
grep -rn "decidedBy" packages/pi-permission-system/src | wc -l - Id mint sites:
grep -rnE "Math\.random\(\)\.toString\(36\)|randomUUID\(\)" packages/pi-permission-system/src --include="*.ts" | wc -l - Event request ids:
grep -c "requestId" packages/pi-permission-system/src/permission-events.ts - Model-judge agentDir:
grep -c "getAgentDir" packages/pi-permission-model-judge/src/config-loader.ts - Ambient path import:
grep -c "node:path" packages/pi-permission-system/src/session-rules.ts - 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
The presentation-directory, liveness-module, decidedBy, and getAgentDir rows grep for names the phase has not created yet; the step that creates each (Steps 1, 5, 6, 7 respectively) must either use the roadmap's name or update the metric row in the same commit.
The two request-id rows were added mid-phase with Steps 9 and 10, and the legacy-message row with Step 4, so their baselines are measured at that point rather than at the phase-open snapshot.
Steps
✅ Step 1: PromptPayload and its builders — the assembly sites become one payload (#744)
Cause: presentation is fused with decision-making — each gate renders its facts into a sentence at the point of decision, so no consumer downstream can render under its own budget; the flat message string is the fusion made concrete.
- Smell: Category C (coupling/boundary flaw — the payload/render boundary does not exist).
- Target: new
src/presentation/prompt-payload.ts(thePromptPayloadtype per ADR 0011 §2 —requestinvariant core,evidence,annotationsslot — plus builders);permission-prompts.ts,handlers/gates/external-directory-messages.ts, and the skill-prompt formatting migrate intosrc/presentation/as payload builders; the gate descriptors emit the payload alongside the facts they already compute;PromptPermissionDetailscarries it;messageis derived from the payload during the transition (lift-and-shift, no consumer changes yet). The payload'srequest.executedUnitcarries the inner command of an unstrippable wrapper — #713's fact, entering here. Tidy-first prep commit: extract the scout's duplicated fixtures (makePermissionCheckResult, a sharedToolPreviewFormatterfactory) intotest/helpers/and migrate the three presentation test files. - Outcome: every ask has a complete structured payload;
grep -rn "formatAskPrompt" packages/pi-permission-system/src --include="*.ts" | wc -lgoes 4 → 0;ls packages/pi-permission-system/src | grep -c presentationgoes 0 → 1; behavior is unchanged (the derivedmessageis byte-compatible or near-compatible, pinned by existing tests). - Landed: both metrics hit their targets, and
messageis byte-identical — every former assembler's string assertion now runs againstrenderLegacyMessage, which reads the payload alone, so the suite is the proof the payload is complete.PromptPermissionDetails.payloadis required, making "every ask carries a complete payload" a compile-time guarantee rather than a convention. Planning found a sixth assembler the issue and ADR 0011 both omit —formatPathAskPrompt, with two consumers — and found that #713's fact had no source at all:classifyWrapperCommandonly flagged a wrapper, so the newwrapper-analysis.tsresolves what one actually runs. Three departures from ADR 0011 §2's illustrative type are documented at their declarations: akinddiscriminant,| nullover| undefined(the payload goes on the JSON wire in Step 3), andcommandContextas a request fact. - Impact 5 / Risk 2 / Priority 20.
Release: batch "presentation-payload"
✅ Step 2: Bounded local renderers — the dialog and fallback render the payload under a budget (#710)
Cause: same cause, consumed at the human's decision surface — with no renderer layer, the dialog shows whatever the assembler produced, so a subagent's oversized tool input takes over the parent's viewport and the operator decides blind or scrolls away the transcript.
- Smell: Category C, with the user-visible symptom filed as the #710 bug.
- Target: new
src/presentation/dialog-renderer.tsrendering the payload for the inline TUI dialog and theselect/inputfallback under a row budget plus a per-field width cap (ADR 0011 §5), with marked elision and a reachable complete view (§4); #716's aligned one-fact-per-line rendering intent adopted here; the invariant core (§3) — includingexecutedUnit— always visible, which closes #713; the row-budget config field follows the establishedconfig-schema.ts→extension-config.ts→mergeUnifiedConfigs()path (the #332/#347 drop class) withpnpm run gen:schema. - Outcome: a forwarded ask with pathological input renders within the budget with the complete view reachable; #710 and #713 close; the local prompt path no longer reads
details.message. - Landed: the reported ask — a 200-line here-string, measured at 202 rows locally and 205 forwarded, identically at widths 80/120/160 — renders inside the 24-row default with its request facts intact.
Planning settled the reading that makes that possible: §3's "never elided" means never omitted, so the field cap applies to the core and §5's own here-string rationale is coherent with it.
The row budget therefore bounds evidence and the field cap bounds the core, with an entry admitted whole or dropped.
Ctrl+Ogained the dialog's own expansion alongside its host forward (#642) rather than a second binding, and the hint names it only when the render dropped something. PR #738's highlight target is derived from the payload rather than carried as aPromptPermissionDetailsfield, so it cannot drift from the rendered text. - Impact 5 / Risk 3 / Priority 15.
Release: batch "presentation-payload"
✅ Step 3: The cross-boundary swap — payload replaces message on the wire and the broadcast (#745)
Cause: same cause at the two cross-boundary contracts — the forwarded wire relays the child's prose (assembled under the child's config) and the broadcast ships the full sentence to any unconsented observer, so consistency across local and forwarded asks is structurally unattainable and the bus over-discloses.
- Smell: Category C (boundary flaw), with the ADR 0011 §6 broadcast narrowing as the disclosure fix.
- Target:
src/authority/permission-forwarding.ts(the request carries the payload,messageremoved),src/authority/approval-escalator.ts(child serializes it),src/authority/forwarded-request-server.ts(serving renders the child's facts under the parent's budget; a version-skewed request without a payload renders from whatever fields it carries, never empty — ADR 0011 §9),src/permission-ui-prompt.ts(broadcast narrowed to therequestfacts; forwarded provenance retained in full), soft-deprecation oftoolInputPreviewMaxLength/toolTextSummaryMaxLengthvia the config-issue channel (§5). Breaking:feat!:with a migration note naming the payload fields that supersedemessageon both contracts. - Outcome:
grep -c "message: string"goes 1 → 0 in bothpermission-forwarding.tsandpermission-ui-prompt.ts; a forwarded ask renders identically in kind to a local one; the bus discloses request facts and verdicts only. - Impact 4 / Risk 3 / Priority 12.
- Landed: both metric rows are
0;asPromptPayload(an all-or-nothing tolerant guard beside its type) admits the payload through the wire'sasXreader, and the required-core gate no longer demandsmessage, so an older child's request is served from its display fields rather than rejected.docs/migration/0745-prompt-payload-contracts.mdnames the superseding fields and the upgrade-the-parent-first ordering. The #710 row-budget invariant was re-measured at the new shape — the reported here-string ask arriving askind: "bash"with the child's real evidence — and stays inside the 24-row default.
Release: batch "presentation-contract"
✅ Step 4: The agent-facing and review-log renderers (#746)
Cause: the same unbounded payload that took over the viewport is echoed verbatim into the agent's context on every denial (the human's constraint is rows; the agent's is tokens), and the review log persists prompt wording as a side effect of assembly rather than as a configured render.
- Smell: Category C, plus the log-growth concern of
docs/decisions/0010-permission-log-secret-exposure.md. - Target:
denial-messages.tsmigrates tosrc/presentation/agent-renderer.tsunder ADR 0011 §7 — the agent renderer identifies the call (surface, matched pattern, verdict, the human's typed reason) and never reproduces its input; the review-log write path (permission-prompter.ts/session-logger.ts) renders the payload under its existing configured limits instead of persistingmessage. - Outcome: denial text is structurally bounded (no raw-command interpolation on any denial path); the review log's growth is a configured decision; key-name redaction unchanged.
- Impact 3 / Risk 2 / Priority 12.
- Landed:
grep -rn "renderLegacyMessage" srcgoes 17 → 0 — the review log was the lastmessagereader, solegacy-message.tswent with it andPromptPermissionDetails.messageis gone. Planning settled the reading §7 leaves open: identifying a call includes naming which of its operands the rule fired on, while reproducing it means echoing the command or the tool-input body, which no render does. The flagged element is therefore rendered under a field cap rather than structurally excluded — the departure is deliberate, because correlation is already structural (Pi returns a block reason as that call's own tool result, stamped with itstoolCallId, with the call's arguments retained in context) and what the agent cannot recover is sub-call granularity.DenialContextdissolved intoPromptPayload: every field it uniquely held is one §7 forbids rendering, and the operator's deny-with-reason text passes from the resolved check as an argument, which also makes it reach the agent on every surface rather than tool and bash alone. Measured on a live 7.07 MB review log: droppingmessage(21.5%) and capping every field at the newreviewLogFieldMaxWidth(a further 7.1%, all of itcommand) removes 28.7%, shortening 4.3% of command entries. The bound went towriteLinerather than each renderer, and the log facts toGateRunnerrather than each of the seven gates, on the same reasoning: a producer cannot forget what it never supplies.
Release: batch "presentation-contract"
✅ Step 5: Out-of-process forwarding liveness (#721, fixes #735 scenario 1)
Cause: the forwarding timeout conflates "a human is deliberating" with "nobody is home" — for an out-of-process child (which shares no globalThis with its parent) the 10-minute PERMISSION_FORWARDING_TIMEOUT_MS is the only signal, so every ask forwarded to a dead parent burns the full timeout and reports a denial the user never made.
The in-process serving registry (#719) already made the two distinguishable for in-process children; the filesystem channel lacks the equivalent.
- Smell: Category C (lifecycle/boundary flaw at the cross-process edge).
- Target: new
src/authority/forwarding-liveness.ts(a filesystem liveness signal — #721 names two candidate mechanisms, claim artifact or serving heartbeat;/plan-issuepicks on ergonomics),src/authority/forwarding-manager.ts(serving node maintains the signal),src/authority/approval-escalator.ts(child fast-fails on absent/stale liveness after a short grace, with a path-namingdenialReasonandconfirmationUnavailable, matching the in-process judgement's safe direction). - Outcome: a child forwarding to a target no live session is draining abandons in seconds instead of 600, resolving #735 scenario 1; scenario 2 stays with #722;
ls packages/pi-permission-system/src/authority | grep -c "forwarding-liveness"goes 0 → 1. - Impact 4 / Risk 3 / Priority 12.
- Landed: the metric is 1, and the mechanism choice went to the heartbeat on a constraint the issue's framing did not carry:
processInboxdrains serially, awaiting each escalation, so a per-request claim would leave a second request unclaimed for as long as a human deliberates on the first — and claiming the whole batch up front degrades the artifact to "the loop saw you" while adding a third file to the tree whose removal ordering produced #398. The records therefore live besidesessions/, never inside it, which is what keeps that ordering untouched. The load-bearing detail is where the re-announcement sits:ForwardingManager's tick refreshes ahead of its processing guard, because a parent holdingprocessInboxopen for a deliberating human is serving throughout, and refreshing behind the guard would let its record decay exactly when it is most demonstrably alive. The two-channel dispatch went intoForwardingLivenessJudgerather thanParentAuthorizer, so the poll loop asks one question about a target and the in-process and out-of-process rules cannot drift. Absence of a record counts as unserved (user decision at the clarification gate), which is what resolves the reported case — a cleanly exited parent leaves nothing behind — at the cost of an upgrade-ordering requirement now documented indocs/subagent-integration.md. This deliberately reverses #719's rule that anenv-resolved target is never fast-failed; a dead pid is judged immediately, while a merely stale record still waits out the staleness window, and no new config field was added.
Release: independent
✅ Step 6: Decision provenance — decidedBy on permission decisions (#726)
Cause: the decision path knows what decided (human prompt, session approval, config rule, authorizer link, auto-allow, timeout) and discards it before the log write, so an audit cannot distinguish a human approval from an auto-approval — the decision-provenance principle: record what decided and on what basis, not only the outcome.
- Smell: Category C (a fact established at the decision point dies before its consumer).
- Target: a
decidedBydiscriminated union threaded from the decision sites (GateRunner's fast paths,PermissionPrompter, theAuthorizerchain,ForwardedRequestServer) into the review-log entries and the forwarded response; lands after Step 4 so the provenance fields ride the new log renderer rather than the retiringmessageshape. - Outcome: every terminal
permission_request.*/forwarded_permission.*entry names its decider with enough detail to reconstruct the decision;grep -rn "decidedBy" packages/pi-permission-system/src | wc -lgoes 0 → ≥ 1. - Impact 3 / Risk 1 / Priority 15.
- Landed: the metric is 37, and the scope narrowed by user decision during planning:
PermissionDecisionEventis not touched, because the bus channel's consumers are not yet known and it is the narrowest renderer under ADR 0011 §6. Two of the issue's three asks were already answered — #752 made the request id shared across the forwarding hop, and there is no/permissionshistory view to surface into. Each variant is self-contained (it repeats its own surface, pattern, origin, link name, or reason) rather than leaning on a sibling log column: that duplicatessurfaceand the pattern locally, and it is the only shape that survives onto the response file, which has no such columns. Theforwardedvariant is recursive, so the requesting side records which session answered and what within it decided as two facts — flattening would make a remote decision read as local. Its guard is depth-bounded because the value is read off disk. Attribution is stamped at the site that decides, never derived: the mode dispatcher names the human's surface (the dialog model and the fallback return anUnattributedDecision, the shapeGateBypass.decisionalready used for the request id), the chain names the link at the point its loop breaks, and both absent-authority paths reuse the string they already report to the model so the two cannot drift.decidedByis required onPermissionPromptDecisionandGateBypass, making completeness a compile-time guarantee rather than a convention. Measured on a live 7.44 MB review log: 1432 terminal prompted decisions carried no decider, and the record adds 95–134 bytes to 5777 decision-bearing lines — a 7.4% worst case, against the 28.7% #746 removed.
Release: independent
Step 7: Model-judge honors PI_CODING_AGENT_DIR (#732)
Cause: pi-permission-model-judge recomputes the global config scope from a hardcoded ~/.pi/agent instead of the SDK's getAgentDir(), so the two packages disagree about where the global scope lives whenever PI_CODING_AGENT_DIR is set — and the configured judge silently never registers, indistinguishable in the review log from "not installed".
- Smell: Category F (cross-package divergence on a single source of truth).
- Target:
packages/pi-permission-model-judge/src/config-loader.tsresolvesagentDirviagetAgentDir()from@earendil-works/pi-coding-agent, as pi-permission-system does. - Outcome: both packages read the global scope from the same directory;
grep -c "getAgentDir" packages/pi-permission-model-judge/src/config-loader.tsgoes 0 → ≥ 1; ships as afix:in the model-judge component. - Impact 3 / Risk 1 / Priority 15.
Release: independent
Step 8: deriveApprovalPattern takes the injected PathFlavor (#655)
Cause: deriveApprovalPattern (session-rules.ts) reads node:path's ambient dirname/sep, bypassing the injected PathFlavor that owns every other platform decision — the one surviving violation of the #562/#510 invariant, producing mixed-separator patterns on a real Windows host and untestable win32 behavior on POSIX CI.
- Smell: Category C (ambient platform read; decide-once violation).
- Target:
src/session-rules.ts— derive the pattern through the flavor'simpl/separator, threading the flavor from the call sites that already hold aPathNormalizer. - Outcome:
grep -c "node:path" packages/pi-permission-system/src/session-rules.tsgoes 1 → 0; a win32 unit test can pin the derived pattern;refactor:(hidden type — cuts no release on its own). - Impact 2 / Risk 1 / Priority 10.
Release: independent
✅ Step 9: A minted request id, carried on every decision (#752)
Cause: there is no permission request id — there are three conventions, and none covers a request that never prompts.
The tool-call gates borrow the SDK's toolCallId, the skill-input gate mints its own, and the escalation edge mints a third that discards the one it was handed; the id attaches inside promptForApproval, so session-approved, yolo, infrastructure-auto-allowed and policy-blocked resolutions carry no id at all, and PermissionDecisionEvent carries none ever.
- Smell: Category C (a fact established at request creation dies before its consumers), the same shape as Step 6.
- Target: a single mint at the top of
GateRunner.runshared by the bypass and descriptor branches; the id carried on the non-prompting review-log writes and added toPermissionDecisionEvent;GateBypass.decisionbecomes anOmit<PermissionDecisionEvent, "requestId">so a gate keeps emitting only what it knows;createSkillInputRequestIddeleted.toolCallIdkeeps flowing untouched — it is the join back to the Pi transcript, and a distinct fact from the request id. - Outcome: every permission request is correlatable from creation regardless of how it resolves; the mint-site count goes 2 → 1;
grep -c "requestId" packages/pi-permission-system/src/permission-events.tsgoes 1 → 2. Additive for consumers, so it needs no major bump of its own. - Landed: both metrics hit their targets, and three of the issue's own shape claims did not survive the code.
The non-prompting writes are four, not three —
policy_deniedis written byapplyPermissionGatefrom the log context the runner hands it, so injecting the id there covers it. Exactly oneGateBypasscarries adecision(the infrastructure-read bypass), not three; the other two carry only alog. Andrun's third parameter is deleted, not narrowed:requestId: toolCallIdwas its only reader. The forwarding edge stopped minting its third id and adopts the one it is handed, so a forwarded ask carries one id from the child's gate to the human's decision — which supersedes Step 3's plannedrequesterRequestIdwire field. Two decisions beyond the issue:randomUUIDover the package's<ts>-<rand>-<pid>convention (Node has no UUIDv7 —randomUUID({ version: 7 })silently returns a v4), and a filename-safety guard on the adopted id, since adoption is what first lets an inbound id name an outbound file. The gate-error boundary's missingpermissions:decisionwas found here and filed as #753. - Impact 3 / Risk 1 / Priority 15.
Release: independent
Step 10: Cross-session prompt/decision correlation (#610)
Cause: a forwarded ask's prompt is emitted by the parent and its terminal decision by the child, on a different event bus for an out-of-process child — so a parent-side consumer that marks an agent blocked on permissions:ui_prompt has no public signal to clear it and can stay blocked forever.
Measured on the review log: 53 of 57 forwarded_permission.request_created entries carry an id appearing on no permission_request.* entry, the child's ask and the request the parent serves joined by nothing but a one-millisecond timestamp gap.
- Smell: Category C (boundary flaw — a lifecycle observable on one side of the forwarding edge and not the other).
- Target:
src/authority/forwarded-request-server.tsemits a parent-sidepermissions:decisionafter the serving session's human decision, reusing the request id its ownpermissions:ui_promptcarried;ForwardedPermissionRequest.idis the child's originatingrequestId(Step 9), so it already joins the two sides' log entries. Silent policy resolutions stay silent — no prompt, no terminal event, unchanged. - Outcome: a direct prompt and its decision share one id; a forwarded prompt and its parent-side decision share one id on one bus; concurrent equivalent prompts stay independently correlatable.
- Impact 3 / Risk 2 / Priority 12.
Release: independent
Step dependency diagram
flowchart TD
S1["✅ Step 1 (#744): PromptPayload + builders"] --> S2["✅ Step 2 (#710): bounded local renderers"]
S2 --> S3["✅ Step 3 (#745): cross-boundary swap (feat!)"]
S2 --> S4["✅ Step 4 (#746): agent + review-log renderers"]
S4 --> S6["✅ Step 6: decidedBy provenance (#726)"]
S9["✅ Step 9 (#752): minted request id"] --> S3
S9 --> S10["Step 10 (#610): cross-session correlation"]
S3 --> S10
S4 --> S10
S5["✅ Step 5: forwarding liveness (#721)"]
S7["Step 7: model-judge agentDir (#732)"]
S8["Step 8: deriveApprovalPattern flavor (#655)"]
Parallel tracks
- Track A — prompt-presentation spine: Steps 1 → 2 → {3, 4}.
- Track B — forwarding liveness: Step 5 (touches
authority/forwarding files only; disjoint from Track A apart fromapproval-escalator.ts, which Track A's Step 3 also edits — land Step 5 before or after Step 3, not concurrently). - Track C — decision provenance: Step 6, after Step 4.
- Track D — independent fixes: Steps 7 and 8, any time.
- Track E — request identity: Step 9, then Step 10 after Step 4.
Step 9 was disjoint from Track A apart from
permission-events.ts, which Step 3 also edits (different interfaces in the same file) — it landed before Step 3, and its id adoption retired therequesterRequestIdwire field Step 3 had planned, so Step 10 finds both halves in place. Step 10 and Step 6 both enrich the review-log write path; land them in sequence.
The step numbers are discovery order, not execution order: Steps 9 and 10 were added mid-phase, and Step 9 runs before Step 3. The diagram above is the authority on sequencing.
Release batches
- Batch "presentation-payload": Steps 1, 2 (ship together; tail = Step 2; release vehicle = Step 2's
fix:for #710 — Step 1 is a hiddenrefactor:). - Batch "presentation-contract": Steps 3, 4 (ship together; tail = Step 4; release vehicle = Step 3's
feat!:breaking release with themessage-replacement migration note). - Independently releasable: Step 5 (
fix:), Step 6 (feat:), Step 7 (fix:, model-judge component), Step 8 (refactor:— hidden type, batches into the next release), Step 9 (feat:), Step 10 (feat:).
Refactoring history
The architecture above is the product of twelve completed improvement phases.
Each phase's findings, numbered plan, dependency diagram, and health metrics are preserved in a per-phase history file under history/.
| Phase | Theme | History |
|---|---|---|
| 1 | Preview formatter extension seam | phase-1-preview-formatter-seam.md |
| 2 | Complexity and duplication paydown | phase-2-complexity-duplication.md |
| 3 | State-owning collaborators | phase-3-collaborator-encapsulation.md |
| 4 | Constructibility and god-object decomposition | phase-4-constructibility.md |
| 5 | Tell-Don't-Ask and decoupling sweep | phase-5-tell-dont-ask-sweep.md |
| 6 | Access-intent extraction | phase-6-access-intent-extraction.md |
| 7 | AccessPath as the universal path representation | phase-7-accesspath-universal-representation.md |
| 8 | Tidy first for the authority spine | phase-8-tidy-first-authority-spine.md |
| 9 | The Authorizer spine | phase-9-authorizer-spine.md |
| 10 | Decide-once dispatch and bash-surface hardening | phase-10-decide-once-dispatch-bash-surface-hardening.md |
| 11 | Shell-tool aliasing and elicitation UX | phase-11-shell-tool-aliasing-elicitation-ux.md |
| 12 | Cross-session access intent and the Authorizer chain | phase-12-cross-session-intent-authorizer-chain.md |