Files
my-pi/pi-permission-system/docs/retro/0727-authorizer-chain-subagent-semantics.md

12 KiB

issue, issue_title
issue issue_title
727 pi-permission-system: authorizerChain links are skipped for subagent requests, so configured authorizers never adjudicate them

Retro: #727 — authorizerChain links skipped for subagent requests

Stage: Planning (2026-08-14T04:05:54Z)

Session summary

Traced the reported defect through authorizer-selection.ts, forwarded-request-server.ts, and pi-permission-model-judge's typo-reviewer.ts, then measured the operator's own 9167-record review log to separate the populations behind the 43 authorizer_chain_unregistered_link records. Two of the issue's three claims did not survive: the serving node's chain does adjudicate a forwarded ask, and the missing model_judge.decision is a pattern-miss short-circuit that logs at debug level, not a link that never ran. Produced a plan that settles the semantics (one chain per node, the relaying node delegates), fixes the false-alarm log, adds per-ask consultation records, and pins the parent-side invariant with an end-to-end regression test.

Observations

  • The measured log was decisive and cheap. A node -e scan gave 43 warnings against 1426 local asks, and a per-day breakdown split them into 15 correlated with forwarded_permission.request_created (the child-relay false alarm) and 23 on a single day with zero forwarding (a genuinely unregistered link). That split is the whole reason the fix suppresses the event only on a relaying node instead of deleting or downgrading it — a code-only reading would have gotten this wrong.
  • ask_user answers drove the plan: parent-only semantics codified, one authorizer_chain_delegated record per ask, an authorizer_chain_resolved record naming consulted links, and #699 endorsed in the ADR but implemented separately (PR #702 is open against it).
  • Rejected making AuthorizerRegistry process-global. It would double-adjudicate every deferring ask and let a link short-circuit before the serving node ever sees the request — a privilege change dressed as plumbing.
  • AuthorizerSelection must not re-derive "is this a relaying node?" from detection.isSubagent(ctx): selectAuthorizer tests hasUI first, so a subagent with UI decides locally. Hence selectAuthorizer returns a SelectedAuthority product rather than a bare terminal — the decision keeps one home.
  • Confirmed the change is not breaking: selectAuthorizer and TerminalAuthorizer are absent from dist/public.d.ts; only Authorizer, AuthorizerVerdict, AuthorizerLog, and friends are public.
  • Filed #732 while reading pi-permission-model-judge's config-loader.ts: its defaultAgentDir() hardcodes ~/.pi/agent and ignores PI_CODING_AGENT_DIR, diverging from pi-permission-system's SDK getAgentDir(). That is one concrete way the "link configured but never registered" state arises, which is what the 2026-08-05 cluster looks like.
  • Step 2 of the TDD order is a characterization test that is green on arrival. It is deliberate: the issue's core doubt is an invariant no test currently pins, because forwarded-request-server.test.ts injects a { escalate } stub for the chain owner.

Stage: Implementation — TDD (2026-08-14T04:30:39Z)

Session summary

Executed the plan in eight commits: two preparatory tidy commits from the tidy-first-assessor, the plan's four TDD steps, the docs commit, and one reviewer-driven fixup. selectAuthorizer now returns a SelectedAuthority value object, a relaying subagent node composes no chain links and records authorizer_chain_delegated, and an adjudicating node records authorizer_chain_resolved with the names it consulted. Test count went 2757 → 2769 (+12) in pi-permission-system; check, root lint, test, and fallow dead-code are all green.

Observations

  • The tidy-first-assessor caught a real gap in the plan's step 1: the extraction list (makeDeps, makeInvokingPrompter, register) was not self-contained. makePrompterApi is a hard dependency of makeAuthorizerSelectionDeps's own default and is called directly at four sites, and makeDetection was byte-identical in both test files. It also proposed a second commit — migrating authorizer.test.ts onto the shared fixtures before the return-type change — which kept the compile-breaking step 3 a pure .terminal edit with no fixture untangling folded in. Both landed; neither is in the plan's TDD Order, which is expected for tidy-first commits.
  • Step 2's characterization test was green on arrival, as planned. Non-vacuity was measured, not argued: flipping getAuthorizerChain to [] made it fail with denied_with_reason missing, then the probe was reverted.
  • The relaying-node tests deliberately use the recording prompter rather than the invoking one. Running the real ParentAuthorizer terminal would reach resolvePermissionForwardingTarget, which reads process.env and the filesystem; asserting prompter.prompt was called with expect.any(ParentAuthorizer) proves zero links were composed (with one link the composed value is an anonymous object) without any of that.
  • One deviation from the plan's ordering: the authorizer_chain_resolved tests were drafted alongside the step 4 tests and then pulled back out so the delegation fix and the observability addition stayed separate commits.
  • Pre-completion reviewer: WARN (1 non-blocking finding) — the extracted fixtures typed prompt as ReturnType<typeof vi.fn> rather than Mock<Sig>, a pre-existing pattern carried in verbatim. Fixed in 7d285aed rather than deferred, since the file is new in this change and two test files now import it.

Stage: Final Retrospective (2026-08-14T16:24:54Z)

Session summary

Three stages — planning, TDD, ship — took a bug report whose headline claim was wrong and turned it into a settled design rule plus two observability records, shipped as @gotgenes/pi-permission-system@25.2.0. Nine commits, +12 tests, one follow-up issue filed (#732), zero rework at the commit level. The decisive move happened in the first ten minutes: measuring the operator's own 9167-record review log rather than reasoning from the code alone.

Observations

What went well

  • Log-mining falsified the report, not just quantified it. The package skill frames review-log mining as a way to size a proposed gate change's blast radius (#694). Here the same technique answered a causal question: a per-day breakdown split 43 authorizer_chain_unregistered_link records into 15 adjacent to a forwarded_permission.request_created (the child-relay false alarm) and 23 on one day with zero forwarding (a genuine unregistered link). That split is the entire reason the fix suppresses the event conditionally instead of deleting or downgrading it. A code-only reading would have produced a worse fix that still looked correct.
  • The tidy-first-assessor out-planned the plan. It found the plan's extraction list under-specified (makePrompterApi is a hard dependency of the shared deps factory and is called directly at four sites; makeDetection was byte-identical in both files), and proposed a second commit the plan did not have — migrating authorizer.test.ts onto the shared fixtures before the return-type change. That kept the compile-breaking step a pure .terminal edit. This is the first session where the assessor's output materially improved the decomposition rather than confirming it.
  • The non-vacuity probe on a green-on-arrival test. Step 2's characterization test passed the moment it was written, which is exactly when a test proves nothing. Flipping getAuthorizerChain to [] made it fail, then the probe was reverted — the testing skill's "build the probe to match the guard's exact predicate" rule applied to a characterization test, where it is arguably more necessary than on a guard.
  • The ask_user gate took four questions with no bounce. The pre-ask message carried measured counts and a concrete before/after per option — the practice earlier retros asked for (#635, #678) — and all four answers came back clean on the first attempt.

What caused friction (agent side)

  • instruction-violation (reviewer-caught) — the extracted test/helpers/authorizer-fixtures.ts typed prompt as ReturnType<typeof vi.fn>. The testing skill was loaded at the start of the TDD stage and names this exact anti-pattern ("in Vitest v4 it expands to Mock<Procedure | Constructable>, a union that TypeScript cannot call"). Loading the rule did not help, because the extraction was a copy: the violating text came from the source file, and nothing in the move re-read it as newly-authored code. Caught by the pre-completion-reviewer, not by me and not by the user. Impact: one extra refactor: commit (7d285aed) after the docs commit.
  • scope-drift (self-identified) — drafted the step-5 authorizer_chain_resolved tests in the same Edit as the step-4 delegation tests, then had to remove them to keep the fix: and feat: commits separate. Impact: one wasted Edit cycle, caught before running tests; no commit churn.
  • other — three separate compound commands surfaced as tool errors because a grep existence probe found nothing (the desired answer): the retro-file check, the ADR amendment-convention check, and the AGENTS.md-documented grep -c 'lint/' /tmp/l.log lint-warning count. Impact: a re-read each time to confirm the "error" was the healthy case; no rework.

What caused friction (user side)

  • Nothing material. The only intervention was an accidental interrupt, immediately acknowledged.
  • Opportunity, not friction: three open issues (#699, #726, and this one) plus one third-party PR (#702) all circle the same authorizer-observability surface. Shipping them one at a time means each re-establishes the same context. A single plan spanning the cluster — or an explicit decision to keep them independent — would be cheaper than the third independent ship.

Diagnostic details

  • Model-performance correlation — planning and TDD ran on anthropic/claude-opus-5 (judgment-heavy: diagnosis, design, ADR amendment); ship ran on anthropic/claude-sonnet-5 (mechanical, tool-driven — appropriate). Both subagents (tidy-first-assessor, pre-completion-reviewer) ran on anthropic/claude-sonnet-5 per their frontmatter; both did judgment work and both produced findings the parent had missed. No mismatch found.
  • Escalation-delay tracking — no rabbit-hole friction points; longest run on a single error was one tool call. Lens finds nothing.
  • Unused-tool detectioncolgrep went unused, correctly: every exploration target was a known exact symbol (authorizerChain, registerAuthorizer, selectAuthorizer). No Explore dispatch either — the report supplied named files and a numbered trace, which the /plan-issue prompt explicitly carves out as inline work (#709).
  • Feedback-loop gap analysis — no gap. vitest run <file> ran after every red and every green; pnpm run check ran at each type-touching step; the full suite plus root lint ran before each commit; fallow dead-code ran at the baseline and again before push.

Changes made

  1. .pi/skills/tidy-first/SKILL.md — Step 3 gains a rule that an extraction carries the source's rule violations into a now-shared file, so moved code must be re-read against the governing skill before committing.
  2. .pi/skills/package-pi-permission-system/SKILL.md — Debugging §6 widened: the review log answers diagnostic questions (counting an event per day and against an adjacent event's timestamps), not only blast-radius sizing over bash commands.