27 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 727 | pi-permission-system: authorizerChain links are skipped for subagent requests, so configured authorizers never adjudicate them |
One chain per node: settle and record authorizerChain adjudication for subagent asks
Release Recommendation
Release: ship independently
No roadmap step in docs/architecture/architecture.md references this issue, so it carries no Release: batch tag.
The work is a self-contained log-correctness fix plus an observability addition; nothing downstream waits on it.
Problem Statement
Every permission ask raised inside an in-process subagent child logs authorizer_chain_unregistered_link for each name in authorizerChain, immediately before the request is evaluated.
The issue reads that as the configured authorizer never adjudicating a subagent's requests — the requests that arguably need review most, since nobody is watching the subagent.
The measured evidence supports one half of that reading and refutes the other.
| Review-log event | Count (measured, 9167-record log) |
|---|---|
permission_request.waiting |
1426 |
forwarded_permission.request_created |
54 |
forwarded_permission.prompted |
33 |
authorizer_chain_unregistered_link |
43 |
model_judge.decision |
8 |
Three findings, all verified against the source:
- A child's chain is empty by construction.
AuthorizerRegistryis a per-extension-instance object created inindex.ts, and a child's instance is never filled: a sibling extension in the child resolvesgetPermissionsService()to the parent's service, whose registry already owns the name, soregisterthrows (#699).resolveConfiguredLinks()therefore skips every configured name and logs the fail-safe warning on every child ask. - The parent's chain does adjudicate the forwarded request.
ForwardedRequestServer.resolveDecisionescalates anaskthroughAskEscalator, wired inindex.tsto the sameAuthorizerSelectionthat owns the parent's chain, with the child-fixed access facts projected onto the ask details (#635). ADR 0007 §2 already names this shape:ParentAuthorizeris "terminal for its node — it forwards up and returns the parent node'sallow | deny". - The missing
model_judge.decisionis not a missing run.typo-reviewer.tswrites a review-level record only when a candidate path matches a configured typo pattern; apattern-missgoes to the debug log, which is off by default (#626 deliberately scoped it that way).find /is a pattern-miss, so the link ran and deferred silently.
The 43 warnings are two different populations, which is why the fix must not simply delete the event.
Fifteen sit within 2 s of a forwarded_permission.request_created — the child-relay false alarm.
But 2026-08-05 alone contributes 23 warnings against 23 local asks and zero forwarding: a session where the link genuinely was not registered, which is exactly what the event exists to report.
So the reported security consequence does not hold, but three real defects do: a false-alarm warning on every child ask, no positive evidence anywhere in the log that a chain was consulted, and semantics that are undocumented enough that the operator could not distinguish the two populations.
Goals
- Settle and record the adjudication semantics: exactly one node adjudicates an ask, and it is the node whose terminal decides — never a node that relays the ask upward.
- Stop a relaying (subagent) node from reporting its deliberate delegation as a fail-safe skip of unregistered links.
- Record positive evidence per ask of which chain links were consulted, so "did the judge see this request?" is answerable from the review log.
- Pin the parent-side invariant with an end-to-end regression test, so the behavior the issue doubted cannot regress silently.
- Endorse the direction of #699 in the ADR (a child deliberately hosts no links, so a sibling extension should skip registering in one) without implementing it here.
This change is not breaking.
selectAuthorizer and TerminalAuthorizer are internal — the package's public surface is dist/public.d.ts / src/service.ts, which export neither.
Adjudication behavior is unchanged; only review-log records change.
Non-Goals
- Implementing #699 / superseding #702. The registration surface (a public child detector, a typed duplicate-registration error) is that issue's deliverable; this plan touches no registration code and only records that the ADR now blesses the direction.
- Implementing #726 (
decidedByresponder provenance on terminal events). This plan records which links were consulted, not which mechanism decided; the terminal-event provenance shape stays #726's to design. - Changing
pi-permission-model-judge's logging. Recording only pattern-matched asks at review level is #626's deliberate choice, andauthorizer_chain_resolvedsupplies the "was it consulted" signal without a second firehose. - Making
AuthorizerRegistryprocess-global so a child hosts the parent's links. Considered and rejected below. - Widening the delegation envelope's excluded surfaces (#684) or out-of-process forwarding liveness (#721).
- Fixing the global-config-path divergence found while tracing this issue, filed as #732.
Background
The relevant modules, all under packages/pi-permission-system/src/authority/:
authorizer.ts—selectAuthorizer(ctx, deps): TerminalAuthorizer, the once-per-activationhasUI→LocalUserAuthorizer/isSubagent→ParentAuthorizer/ else →DenyingAuthorizerdispatch.authorizer-selection.ts—AuthorizerSelection, theAskEscalator: stores the selected terminal atactivate, resolvesauthorizerChainto registered links per ask (ADR 0007 §4, so a latepermissions:readyregistration is honored), wraps each in the delegation envelope, composes viacomposeAuthorizerChain, and delegates toPermissionPrompter.authorizer-registry.ts—AuthorizerRegistry, one instance per extension factory invocation, exposed cross-extension asPermissionsService.registerAuthorizer.authorizer-chain.ts—composeAuthorizerChain(links, terminal, query, log); zero links returns the terminal instance unchanged (identity).forwarded-request-server.ts— the serving-down half: resolves a forwardedForwardedAccessIntentagainst recorded authority, then escalates anaskthrough the injectedAskEscalator.
Constraints from AGENTS.md and the package skill that shape the design:
- The
AuthorizerRegistryis deliberately not process-global, unlikeSubagentSessionRegistryandServingSessionRegistry. Making it global is a security-relevant change, not a plumbing convenience. - Least privilege: absence of a judge must mean more prompting, never less (ADR 0007 invariant 2).
- Config example, schema,
docs/configuration.md, andREADME.mdstay aligned when config-visible behavior changes. This change adds no config field, but it does change whatauthorizerChainmeans on a subagent node, whichdocs/configuration.mddocuments.
Design Overview
The decision: one chain per node
An ask is adjudicated by exactly one node's chain: the node whose terminal decides it.
- A node with UI (
LocalUserAuthorizer) decides locally, so it runs its chain. - A headless node with no reachable authority (
DenyingAuthorizer) decides locally — by denying — so it runs its chain; a link may still deny with a teaching reason, or allow on a non-excluded surface, which is strictly better than the bareconfirmation_unavailabledeny. - A subagent node whose terminal is
ParentAuthorizerdoes not decide: it relays the ask to a serving node, which resolves it against its own recorded authority and escalates it through its own chain over the same child-fixed facts (#635). Resolving links on the relaying node would adjudicate the same ask twice — two model calls, two latencies — for no additional evidence, and would let a link decide an ask the serving node's policy owns.
Rejected alternative: make AuthorizerRegistry process-global so a child resolves the parent's links.
It converts every deferring ask into two link runs, and lets a link's deny/allow short-circuit before the serving node ever sees the request — a privilege change dressed as a plumbing fix.
The forwarding round trip is not the cost being avoided; the serving node has to resolve the request against its own ruleset regardless.
Rejected alternative: change nothing structural and only downgrade the log event to debug.
That hides the genuine unregistered-link case (23 of the 43 measured records) behind a log that is off by default, which is the visibility the issue objects to.
Threading the decision, not the discriminator
AuthorizerSelection must not re-derive "is this a relaying node?"
from detection.isSubagent(ctx) — that decision already has a home in selectAuthorizer, and re-deriving it would get the subagent-with-UI case wrong (selectAuthorizer tests hasUI first, so such a node decides locally).
So selectAuthorizer returns its product rather than a bare terminal:
// src/authority/authorizer.ts
/** The node's live-authority selection: who decides, and whether this node adjudicates. */
export interface SelectedAuthority {
/** The terminal that decides this node's asks, or relays them upward. */
readonly terminal: TerminalAuthorizer;
/**
* False when the terminal relays the ask to a serving node: that node runs
* its own chain over the same child-fixed facts (#635), so resolving links
* here would adjudicate the ask twice.
*/
readonly adjudicatesLocally: boolean;
}
export function selectAuthorizer(
ctx: ExtensionContext,
deps: AuthorizerSelectionDeps,
): SelectedAuthority;
The single consumer is AuthorizerSelection, and the interaction stays Tell-Don't-Ask at the escalation edge — the caller destructures a value object it was handed, never interrogates the terminal's class:
// src/authority/authorizer-selection.ts (sketch)
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision> {
const authority = this.authority;
if (authority === null) {
return Promise.reject(new Error("escalate called before the session was activated"));
}
const links = this.linksFor(authority, details.requestId);
const chain = composeAuthorizerChain(links, authority.terminal, this.deps.getPermissionQuery(), this.deps.logger);
return this.deps.prompter.prompt(chain, details);
}
linksFor is the one place the delegation branch lives:
private linksFor(authority: SelectedAuthority, requestId: string): Authorizer[] {
const configured = this.deps.getAuthorizerChain();
if (configured.length === 0) {
return [];
}
if (!authority.adjudicatesLocally) {
this.deps.logger.review("authorizer_chain_delegated", { requestId, links: configured });
return [];
}
return this.resolveConfiguredLinks(configured, requestId);
}
With zero configured links nothing is logged and composeAuthorizerChain still returns the terminal instance, so an operator who configures no chain sees no new records at all.
Review-log records
Three records, all keyed by the ask's requestId (always present on PromptPermissionDetails):
| Event | Emitted when | Payload |
|---|---|---|
authorizer_chain_resolved |
an adjudicating node resolved at least one configured name | { requestId, links } — the resolved names, in config order |
authorizer_chain_delegated |
a relaying node has a non-empty configured chain | { requestId, links } — the configured names it deliberately did not run |
authorizer_chain_unregistered_link |
an adjudicating node cannot resolve a configured name | { requestId, name } — gains requestId so the skip is correlatable |
authorizer_chain_resolved is written before the links run: it records consultation, not outcome.
A link's own verdict trail stays the link's responsibility (ADR 0007 §3's injected AuthorizerLog), and the terminal event's "who decided" provenance stays #726's.
Edge cases:
- A mixed chain (
["missing", "present"]) on an adjudicating node emits bothauthorizer_chain_unregistered_link(formissing) andauthorizer_chain_resolvedwith["present"]. - A chain whose every name is unregistered emits only the per-name warnings — there is nothing to record as consulted.
- A relaying node emits
authorizer_chain_delegatedand no per-name warning, because no name was skipped: the whole chain was delegated.
Module-Level Changes
| File | Change |
|---|---|
src/authority/authorizer.ts |
Add the SelectedAuthority interface; selectAuthorizer returns it instead of a bare TerminalAuthorizer (adjudicatesLocally: false only on the ParentAuthorizer arm) |
src/authority/authorizer-selection.ts |
Store SelectedAuthority | null instead of TerminalAuthorizer | null; add linksFor; resolveConfiguredLinks takes the configured names and requestId, adds requestId to the unregistered warning, and emits authorizer_chain_resolved; escalate composes against authority.terminal |
test/helpers/authorizer-fixtures.ts |
New: makeAuthorizerSelectionDeps, makeInvokingPrompter, registerLink, extracted from authorizer-selection.test.ts so a second test file can build a real AuthorizerSelection |
test/authority/authorizer.test.ts |
Four selectAuthorizer(...) call sites read .terminal; new assertions on adjudicatesLocally per arm |
test/authority/authorizer-selection.test.ts |
Migrate to the shared fixtures; update the unregistered-link assertion for requestId; add the relaying-node and authorizer_chain_resolved cases |
test/authority/forwarded-request-server.test.ts |
New describe: a real AuthorizerSelection + real AuthorizerRegistry as the server's escalator, pinning that the serving node's chain adjudicates a forwarded ask |
docs/decisions/0007-model-judge-authorizer-chain-adr.md |
New ### 7. One chain per node under ## Decision; ## Status records the 2026-08-14 amendment; a rejected-alternatives entry for the process-global registry |
docs/architecture/architecture.md |
Module-tree entries for authorizer.ts (returns SelectedAuthority) and authorizer-selection.ts (per-ask resolution, the three chain events, the delegation branch); the live-authority narrative near the selectAuthorizer / composeAuthorizerChain sentence gains the one-chain-per-node rule |
docs/architecture/permission-prompter.md |
The "Relationship to the Authorizer spine" sentence naming selectAuthorizer(ctx, deps)'s return |
docs/configuration.md |
The authorizer-chain section gains a paragraph: where the chain runs when a subagent raises the ask, and which record proves it |
README.md |
One sentence on the authorizerChain paragraph pointing at the subagent semantics |
.pi/skills/package-pi-permission-system/SKILL.md |
The AuthorizerSelection.escalate paragraph — per-ask resolution now branches on the node's chain role, and the three events replace the single authorizer_chain_unregistered_link mention |
Grep verification performed at planning time: selectAuthorizer appears in src/ twice (authorizer.ts, authorizer-selection.ts), in test/ once (authorizer.test.ts), and in narrative prose in docs/architecture/architecture.md, docs/architecture/permission-prompter.md, and docs/decisions/0007-*.md; authorizer_chain_unregistered_link appears in src/authority/authorizer-selection.ts, test/authority/authorizer-selection.test.ts, and docs/architecture/architecture.md.
Historical mentions under docs/architecture/history/, docs/retro/, and docs/plans/ are records of what shipped and are not updated.
No public export changes, so scripts/verify-public-types.sh needs no edit.
Test Impact Analysis
New tests the change enables:
selectAuthorizer's chain role is now an assertable value rather than an inference from the returned class, so the subagent-with-UI case (decides locally despite being a subagent) becomes directly testable.- A relaying node's escalation can be asserted end to end: zero links composed, the configured link never invoked, one
authorizer_chain_delegatedrecord. - The cross-module regression — a forwarded request adjudicated by the serving node's registered chain — was previously unreachable because
forwarded-request-server.test.tsinjects a{ escalate }stub for the escalator, so no test ever wired the real chain owner behind it.
Tests that become redundant: none.
The existing authorizer-selection.test.ts cases (config order, envelope capping, unregistered skip) all still describe an adjudicating node and stay as-is apart from the requestId field.
Tests that must stay: authorizer-chain.test.ts's zero-links identity case, which is now load-bearing for the relaying node (it is how a delegated ask reaches the terminal unchanged), and the forwarded-request-server.test.ts child-fixed-facts and bounded-delegation describes, which pin the evidence the serving node's chain judges on.
Invariants at risk
The change touches the Phase 12 chain surface (#598, #599) and the forwarded-ask edge (#635).
| Invariant | Source | Pinned by |
|---|---|---|
| Zero links composes to the terminal instance (identity) | Phase 12 Step 4 Landed: |
test/authority/authorizer-chain.test.ts — existing |
| Config order, not registration order, fixes chain order | ADR 0007 §4 invariant 1 | authorizer-selection.test.ts "resolves links in config order" — existing |
| Skipping a link is fail-safe: the ask still reaches the terminal | ADR 0007 §4 invariant 2 | authorizer-selection.test.ts unregistered-name case — existing; extended by the new relaying-node case, where zero links still reach the terminal |
A forwarded ask reaches the serving node's Authorizer with the child-fixed gate surface |
#635 | forwarded-request-server.test.ts child-fixed-facts describe — existing; strengthened by the new end-to-end chain test |
| A relaying node's terminal still forwards and abandons an unserved target | #719 | approval-escalator.test.ts — untouched; the relay path only loses its (empty) link resolution |
The one quantitative claim in this plan is record volume, and it is measured, not predicted: at the observed rates the new authorizer_chain_resolved record fires on asks that already write permission_request.waiting (1426 over ~3 months), and authorizer_chain_delegated replaces the 43 existing authorizer_chain_unregistered_link records one for one on relaying nodes, at no more than one per ask.
TDD Order
- Extract the
AuthorizerSelectionconstruction fixtures intotest/helpers/authorizer-fixtures.tsand migrateauthorizer-selection.test.tsto import them. Green throughout — pure test tidying that makes the next step's cross-module test possible. Commit:test(pi-permission-system): extract authorizer-selection fixtures into a shared helper. - Characterization test: a forwarded ask is adjudicated by the serving node's registered chain.
Wire a real
AuthorizerRegistry(one deny-with-reason link) and a realAuthorizerSelectionasForwardedRequestServer's escalator, with a terminal that fails the test if it is reached; assert the written response carries the link's denial. Green on arrival — it pins the invariant the issue doubted before any behavior moves. Commit:test(pi-permission-system): pin serving-node chain adjudication of forwarded asks. - Red:
authorizer.test.tsassertsadjudicatesLocallyper dispatch arm. Green: addSelectedAuthority, changeselectAuthorizer's return, updateAuthorizerSelection's stored field andescalate, and update the four existingauthorizer.test.tscall sites to read.terminal— one commit, since the return-type change breaks every consumer at compile time. Commit:refactor(pi-permission-system): return the node's chain role from selectAuthorizer. - Red: a relaying node with a configured, unregistered chain logs no
authorizer_chain_unregistered_link, logs oneauthorizer_chain_delegated, composes zero links, and still reaches its terminal. Green: addlinksForwith the delegation branch. Commit:fix(pi-permission-system): stop reporting a delegated subagent chain as unregistered links (#727). - Red: an adjudicating node logs
authorizer_chain_resolvedwith the resolved names, and the unregistered warning carriesrequestId; a node with an empty configured chain logs neither. Green: emit the record fromresolveConfiguredLinksand threadrequestId. Commit:feat(pi-permission-system): record which chain links were consulted on each ask (#727). - Documentation: ADR 0007 §7 plus the
## Statusamendment line, the architecture module-tree and narrative entries,permission-prompter.md,docs/configuration.md,README.md, and the package skill. Commit:docs(pi-permission-system): document one-chain-per-node adjudication semantics (#727).
Risks and Mitigations
- Risk: suppressing the warning on a relaying node hides a genuine misconfiguration on that node.
Mitigation:
authorizer_chain_delegatednames the configured links it did not run, so the configuration is still visible in the log; and the names are only resolvable on the node that adjudicates, which now always reports its own resolution. - Risk:
authorizer_chain_resolvedadds volume to a log that already carries 9167 records. Mitigation: it is emitted only when the operator configured a chain and at least one name resolved — for an operator with noauthorizerChain, this change adds zero records. - Risk: the ADR amendment freezes a semantics that #699's fix or a future terminal-replacement link might want to revisit. Mitigation: §7 is scoped to the relaying case and states its reason (the serving node judges the same facts), so a future ADR that gives a child its own decidable authority supersedes it on the record rather than contradicting it silently.
- Risk: the step-2 characterization test wires more real collaborators than the file's existing tests, and could become brittle.
Mitigation: it asserts only the response file's
state/denialReasonand that the terminal was not reached — no ordering or timing assertions.
Open Questions
- The two ID spaces still do not join:
authorizer_chain_delegatedcarries the tool-callrequestIdwhile the adjacentforwarded_permission.*records carry the forwarded request id, so correlating a delegated ask to the serving node that answered it remains a timestamp-adjacency exercise. #726 already names the shared-correlation-ID gap; this plan does not close it. - Whether
authorizer_chain_resolvedshould also record each link's verdict is deferred to #726, which is designing the terminal-event provenance shape. Adding it here would ship a second, overlapping provenance mechanism. - The 2026-08-05 cluster (23 warnings, zero forwarding) is consistent with the model-judge extension not being loaded or configured in that project, which the new records will make unambiguous going forward; #732 is one concrete way that state can arise.