---
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<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:

```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
