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 -escan gave 43 warnings against 1426 local asks, and a per-day breakdown split them into 15 correlated withforwarded_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_useranswers drove the plan: parent-only semantics codified, oneauthorizer_chain_delegatedrecord per ask, anauthorizer_chain_resolvedrecord naming consulted links, and #699 endorsed in the ADR but implemented separately (PR #702 is open against it).- Rejected making
AuthorizerRegistryprocess-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. AuthorizerSelectionmust not re-derive "is this a relaying node?" fromdetection.isSubagent(ctx):selectAuthorizertestshasUIfirst, so a subagent with UI decides locally. HenceselectAuthorizerreturns aSelectedAuthorityproduct rather than a bare terminal — the decision keeps one home.- Confirmed the change is not breaking:
selectAuthorizerandTerminalAuthorizerare absent fromdist/public.d.ts; onlyAuthorizer,AuthorizerVerdict,AuthorizerLog, and friends are public. - Filed #732 while reading
pi-permission-model-judge'sconfig-loader.ts: itsdefaultAgentDir()hardcodes~/.pi/agentand ignoresPI_CODING_AGENT_DIR, diverging from pi-permission-system's SDKgetAgentDir(). 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.tsinjects 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-assessorcaught a real gap in the plan's step 1: the extraction list (makeDeps,makeInvokingPrompter,register) was not self-contained.makePrompterApiis a hard dependency ofmakeAuthorizerSelectionDeps's own default and is called directly at four sites, andmakeDetectionwas byte-identical in both test files. It also proposed a second commit — migratingauthorizer.test.tsonto the shared fixtures before the return-type change — which kept the compile-breaking step 3 a pure.terminaledit 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
getAuthorizerChainto[]made it fail withdenied_with_reasonmissing, then the probe was reverted. - The relaying-node tests deliberately use the recording prompter rather than the invoking one.
Running the real
ParentAuthorizerterminal would reachresolvePermissionForwardingTarget, which readsprocess.envand the filesystem; assertingprompter.promptwas called withexpect.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_resolvedtests 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
promptasReturnType<typeof vi.fn>rather thanMock<Sig>, a pre-existing pattern carried in verbatim. Fixed in7d285aedrather 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_linkrecords into 15 adjacent to aforwarded_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-assessorout-planned the plan. It found the plan's extraction list under-specified (makePrompterApiis a hard dependency of the shared deps factory and is called directly at four sites;makeDetectionwas byte-identical in both files), and proposed a second commit the plan did not have — migratingauthorizer.test.tsonto the shared fixtures before the return-type change. That kept the compile-breaking step a pure.terminaledit. 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
getAuthorizerChainto[]made it fail, then the probe was reverted — thetestingskill'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_usergate 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 extractedtest/helpers/authorizer-fixtures.tstypedpromptasReturnType<typeof vi.fn>. Thetestingskill was loaded at the start of the TDD stage and names this exact anti-pattern ("in Vitest v4 it expands toMock<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 thepre-completion-reviewer, not by me and not by the user. Impact: one extrarefactor:commit (7d285aed) after the docs commit.scope-drift(self-identified) — drafted the step-5authorizer_chain_resolvedtests in the sameEditas the step-4 delegation tests, then had to remove them to keep thefix:andfeat:commits separate. Impact: one wastedEditcycle, caught before running tests; no commit churn.other— three separate compound commands surfaced as tool errors because agrepexistence probe found nothing (the desired answer): the retro-file check, the ADR amendment-convention check, and the AGENTS.md-documentedgrep -c 'lint/' /tmp/l.loglint-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 onanthropic/claude-sonnet-5(mechanical, tool-driven — appropriate). Both subagents (tidy-first-assessor,pre-completion-reviewer) ran onanthropic/claude-sonnet-5per their frontmatter; both did judgment work and both produced findings the parent had missed. No mismatch found. - Escalation-delay tracking — no
rabbit-holefriction points; longest run on a single error was one tool call. Lens finds nothing. - Unused-tool detection —
colgrepwent unused, correctly: every exploration target was a known exact symbol (authorizerChain,registerAuthorizer,selectAuthorizer). NoExploredispatch either — the report supplied named files and a numbered trace, which the/plan-issueprompt 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 checkran at each type-touching step; the full suite plus rootlintran before each commit;fallow dead-coderan at the baseline and again before push.
Changes made
.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..pi/skills/package-pi-permission-system/SKILL.md— Debugging §6 widened: the review log answers diagnostic questions (counting aneventper day and against an adjacent event's timestamps), not only blast-radius sizing over bash commands.