--- issue: 727 issue_title: "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: 1. A child's chain is empty by construction. `AuthorizerRegistry` is a per-extension-instance object created in `index.ts`, and a child's instance is never filled: a sibling extension in the child resolves `getPermissionsService()` to the *parent's* service, whose registry already owns the name, so `register` throws ([#699]). `resolveConfiguredLinks()` therefore skips every configured name and logs the fail-safe warning on every child ask. 2. The parent's chain does adjudicate the forwarded request. `ForwardedRequestServer.resolveDecision` escalates an `ask` through `AskEscalator`, wired in `index.ts` to the same `AuthorizerSelection` that owns the parent's chain, with the child-fixed access facts projected onto the ask details ([#635]). ADR 0007 §2 already names this shape: `ParentAuthorizer` is "terminal for its node — it forwards up and returns the parent node's `allow | deny`". 3. The missing `model_judge.decision` is not a missing run. `typo-reviewer.ts` writes a review-level record only when a candidate path matches a configured typo pattern; a `pattern-miss` goes 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] (`decidedBy` responder 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, and `authorizer_chain_resolved` supplies the "was it consulted" signal without a second firehose. - Making `AuthorizerRegistry` process-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-activation `hasUI` → `LocalUserAuthorizer` / `isSubagent` → `ParentAuthorizer` / else → `DenyingAuthorizer` dispatch. - `authorizer-selection.ts` — `AuthorizerSelection`, the `AskEscalator`: stores the selected terminal at `activate`, resolves `authorizerChain` to registered links **per ask** (ADR 0007 §4, so a late `permissions:ready` registration is honored), wraps each in the delegation envelope, composes via `composeAuthorizerChain`, and delegates to `PermissionPrompter`. - `authorizer-registry.ts` — `AuthorizerRegistry`, one instance per extension factory invocation, exposed cross-extension as `PermissionsService.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 forwarded `ForwardedAccessIntent` against recorded authority, then escalates an `ask` through the injected `AskEscalator`. Constraints from `AGENTS.md` and the package skill that shape the design: - The `AuthorizerRegistry` is deliberately **not** process-global, unlike `SubagentSessionRegistry` and `ServingSessionRegistry`. 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`, and `README.md` stay aligned when config-visible behavior changes. This change adds no config field, but it does change what `authorizerChain` means on a subagent node, which `docs/configuration.md` documents. ## 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 bare `confirmation_unavailable` deny. - A subagent node whose terminal is `ParentAuthorizer` does **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: ```typescript // 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: ```typescript // src/authority/authorizer-selection.ts (sketch) escalate(details: PromptPermissionDetails): Promise { 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: ```typescript 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 both `authorizer_chain_unregistered_link` (for `missing`) and `authorizer_chain_resolved` with `["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_delegated` and 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_delegated` record. - The cross-module regression — a forwarded request adjudicated by the serving node's registered chain — was previously unreachable because `forwarded-request-server.test.ts` injects 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 1. Extract the `AuthorizerSelection` construction fixtures into `test/helpers/authorizer-fixtures.ts` and migrate `authorizer-selection.test.ts` to 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`. 2. 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 real `AuthorizerSelection` as `ForwardedRequestServer`'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`. 3. Red: `authorizer.test.ts` asserts `adjudicatesLocally` per dispatch arm. Green: add `SelectedAuthority`, change `selectAuthorizer`'s return, update `AuthorizerSelection`'s stored field and `escalate`, and update the four existing `authorizer.test.ts` call 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`. 4. Red: a relaying node with a configured, unregistered chain logs no `authorizer_chain_unregistered_link`, logs one `authorizer_chain_delegated`, composes zero links, and still reaches its terminal. Green: add `linksFor` with the delegation branch. Commit: `fix(pi-permission-system): stop reporting a delegated subagent chain as unregistered links (#727)`. 5. Red: an adjudicating node logs `authorizer_chain_resolved` with the resolved names, and the unregistered warning carries `requestId`; a node with an empty configured chain logs neither. Green: emit the record from `resolveConfiguredLinks` and thread `requestId`. Commit: `feat(pi-permission-system): record which chain links were consulted on each ask (#727)`. 6. Documentation: ADR 0007 §7 plus the `## Status` amendment 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_delegated` names 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_resolved` adds 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 no `authorizerChain`, 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`/`denialReason` and that the terminal was not reached — no ordering or timing assertions. ## Open Questions - The two ID spaces still do not join: `authorizer_chain_delegated` carries the tool-call `requestId` while the adjacent `forwarded_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_resolved` should 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. [#598]: https://github.com/gotgenes/pi-packages/issues/598 [#599]: https://github.com/gotgenes/pi-packages/issues/599 [#626]: https://github.com/gotgenes/pi-packages/issues/626 [#635]: https://github.com/gotgenes/pi-packages/issues/635 [#684]: https://github.com/gotgenes/pi-packages/issues/684 [#699]: https://github.com/gotgenes/pi-packages/issues/699 [#702]: https://github.com/gotgenes/pi-packages/pull/702 [#719]: https://github.com/gotgenes/pi-packages/issues/719 [#721]: https://github.com/gotgenes/pi-packages/issues/721 [#726]: https://github.com/gotgenes/pi-packages/issues/726 [#732]: https://github.com/gotgenes/pi-packages/issues/732