13 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 555 | pi-permission-system: introduce the Authorizer spine — interface, three implementations, once-per-session selection |
Retro: #555 — Introduce the Authorizer spine
Stage: Planning (2026-07-07T00:00:00Z)
Session summary
Planned Phase 9 Step 1: introduce the Authorizer interface, its three implementations (LocalUserAuthorizer, ParentAuthorizer, DenyingAuthorizer), and a once-per-activation selectAuthorizer, replacing the three-way hasUI/isSubagent/deny dispatch smeared across PromptingGateway, PermissionPrompter, and ApprovalEscalator.
The direction was fully settled by the architecture doc's authority-model target and the Phase 9 roadmap, and the issue is the operator's own — so no ask_user gate was needed.
The plan lands in two behavior-neutral refactor: steps plus a docs step, filed as packages/pi-permission-system/docs/plans/0555-authorizer-spine.md.
Observations
GatePromptersurvives Step 1 — the runner and its fixtures (gate-fixtures,handler-fixtures,external-directory-fixtures) mock the stable{ canConfirm, prompt }surface, so the blast radius is confined to the ask-path internals +index.tswiring +permission-session.ts/descriptor.ts/session-logger.tsimports + three test files + two fixtures.canConfirm()is dissolved later in #556.- Split via a transitional wrapper — Step 1 has
ParentAuthorizerwrap the intactApprovalEscalator(so all new modules are wired in one commit — nofallow dead-codefailure — while the escalator's forwarding tests stay green); Step 2 folds the escalator in and removes the now-deadhasUI/!isSubagentarms plus theApprovalRequesterseam. This keeps each commit green and reviewable rather than one megacommit. selectAuthorizer(ctx, detection)is roadmap shorthand — the real signature isselectAuthorizer(ctx, deps); the leaf authorizers need construction inputs (events,requestPermissionDecisionFromUi,forwardingDir,registry,logger) beyonddetection.AuthorizerSelectionDepsrelocates the composition inputs the escalator + prompter already receive — not a widening (the escalator shedsrequestPermissionDecisionFromUi, which moves toLocalUserAuthorizer).activateruns per tool call, not once per session — confirmed viapermission-gate-handler.ts. Selecting at eachactivateis behavior-neutral (predicates are session-stable, construction is a cheap allocation); the roadmap's "once per session activation" is honored in spirit, and a memoize-by-ctxoptimization is explicitly out of scope.canConfirmrecomputed transitionally —AuthorizerSelection.activaterecomputeshasUI || isSubagentalongsideselectAuthorizer's own branch, a deliberate short-lived redundancy that keeps the ask path byte-identical until #556 derives confirmability from aDenyingAuthorizermarker.- Invariants pinned — review-log bracketing + yolo single
auto_approved(#526), UI-prompt-event contract (#292), forwarding transport (#530, #398); the plan names the test pinning each and adds a "does-not-emit" assertion forDenyingAuthorizer/ParentAuthorizer. - Doc surface — a dedicated
docs/architecture/permission-prompter.mdexists and needs updating alongside the module-structure tree and the SKILL.md forwarding-test note; the phase-exit metrics table is left until the phase completes (Step 1 does not meet thecanConfirm/role-interface targets).
Stage: Implementation — TDD (2026-07-07T21:35:00Z)
Session summary
Implemented Phase 9 Step 1 in two behavior-neutral TDD cycles plus a docs step, exactly as planned.
Step 1 added Authorizer/selectAuthorizer/LocalUserAuthorizer/DenyingAuthorizer/AuthorizerSelection and moved PermissionPrompter into authority/, with ParentAuthorizer wrapping the intact ApprovalEscalator as the planned transitional seam.
Step 2 folded ApprovalEscalator's forwarding machinery directly into ParentAuthorizer and deleted the dead dispatch arms.
Step 3 updated architecture.md, permission-prompter.md, and SKILL.md.
Test count went from 2272 to 2275 (net +3, after consolidating ~36 old tests across two deleted files into ~39 new tests spread across five files).
Pre-completion reviewer: PASS (one non-blocking WARN, see below).
Observations
- Two genuine deviations from the plan's design pseudocode, both flagged in commit bodies and confirmed sound by the reviewer:
PermissionPrompterApiwas retained, not removed as the plan's Module-Level Changes said — it evolved to the newprompt(authorizer, details)signature and becameAuthorizerSelection's narrow seam onto the concretePermissionPrompterclass. Needed becausePermissionPrompter's privatedepsfield creates a TypeScript nominal brand — a structural{ prompt: vi.fn() }test mock cannot satisfy a concrete-class field type without a cast (the exact trap thecode-designskill documents).ParentAuthorizerDepsdroppeddetectionbeyond what the plan's Design Overview pseudocode showed. Once the escalator's forwarding machinery folded intoParentAuthorizer(Step 2), the only use ofdetectionwas re-derivingisSubagentinsidewaitForForwardedApproval— butselectAuthorizeralready guaranteesisSubagent === truebefore constructing aParentAuthorizer, so the re-check became a hardcodedtruewith an invariant comment, and the dependency dropped entirely.
test/session-start.test.ts's minimalExtensionContextmock surfaced a real timing change —AuthorizerSelection.activateeagerly callsselectAuthorizer(and thusdetection.isSubagent(ctx), which readsctx.sessionManager.getSessionDir()) on every activation, whereas the oldPromptingGateway.canConfirm()was lazy and only evaluated when called. This is the intended behavior change (the roadmap's "predicates evaluated once per session activation" outcome), not a regression, but it required completing a stale test fixture that had never neededgetSessionId/getSessionDirbefore.- The
ParentAuthorizerforwarding round trip had no prior isolated unit test — the pre-#555approval-escalator.test.tsonly covered the two now-deleted dead-arm tests (UI fast path, non-UI/non-subagent); the actual request-write/poll mechanics were only ever exercised viacomposition-root.test.ts's full end-to-end "subagent registry sharing" test. Step 2 added a genuine round-trip test (temp forwarding dir, registered child/parent session pair, hand-written response file) — first pass silently passed for the wrong reason: an incomplete response payload (missingresponderSessionId) failsreadForwardedPermissionResponse's validation and falls through to the same{approved: false, state: "denied"}the deny-path test expected, so the test asserted nothing about the real round trip. Completing the response payload's required fields turned it into a real assertion. - Reviewer WARN (non-blocking) — the plan's Invariants section asked for an explicit "does-not-emit the UI-prompt event" test on
DenyingAuthorizer/ParentAuthorizer. No such runtime assertion was added; instead neither class's deps bag has aneventsfield at all, so non-emission is a compile-time guarantee rather than a tested one. The reviewer judged this a stronger guarantee than the plan asked for and did not block, but futureInvariants at risksections should note when a design choice structurally obsoletes a planned test rather than silently omitting it. - No steps remaining — all three TDD-order steps landed; ready for
/ship-issue.
Stage: Final Retrospective (2026-07-07T22:05:00Z)
Session summary
One continuous session carried #555 from planning through TDD, ship, and this retrospective: a behavior-neutral Phase 9 Step 1 refactor introducing the Authorizer spine.
Execution closely tracked a thorough plan — two refactor: commits plus a docs commit, pre-completion PASS, CI green, issue closed, no release (auto-batched, all commits hidden-type or excluded-path docs).
Friction was minor and self-caught: a false-green forwarding test, a cluster of type-wiring fixups during the big rewire, and a stale test mock surfaced by the intended eager-evaluation timing change.
Observations
What went well
- False-green caught by a paired positive assertion.
Step 2's new
ParentAuthorizerround-trip test first passed the deny path for the wrong reason: a response fixture missingresponderSessionIdfailsreadForwardedPermissionResponse's validation and falls back to the exact{ approved: false, state: "denied" }the deny test asserted. It only surfaced because the approve-path test failed identically — the two tests sharing a fixture shape made the invalid fixture loud on the positive path. Completing the payload turned both into real assertions. - Transitional-wrapper split landed clean.
The plan's two-commit lift-and-shift (Step 1 wraps the intact
ApprovalEscalator; Step 2 folds it in and deletes the dead arms) held exactly as designed — each commit green, nofallow dead-codefailure from unwired modules, no megacommit. tsc/lint as an incremental safety net. Every type-wiring slip during the large rewire (theDenyingAuthorizerzero-arg-vs-interfaceTS2554, theAuthorizerSelectionDepsimport path, theprompterintersection type, the unusedDebugReviewLoggerimport) was caught by apnpm run checkorpnpm run lintrun immediately after the step, not deferred to the end.
What caused friction (agent side)
missing-context— wrote the forwarded-response fixture without first checkingreadForwardedPermissionResponse's required fields (responderSessionId), so the deny-path test false-greened. Impact: two extra test iterations in Step 2 (timeout → registry fix →responderSessionIdfix); no production rework, and the paired positive test caught it before commit.other(mechanical type-wiring during a wide rewire) — four small fixups in Step 1 (DenyingAuthorizersignature, a test import path, a deps intersection type, an unused import). Impact: ~4 quick edits, each caught bytsc/lint within the same step; no rework.missing-context(mild) — theresolvePermissionForwardingTargetSessionIdpath needs either a registry entry or an env var, which the first round-trip fixture supplied via neither, so the request file was never written and the test timed out. Impact: one iteration; fixed by adding amakeSubagentRegistrychild→parent mapping.
What caused friction (user side)
- None.
The plan was detailed enough to run unattended through TDD and ship; no mid-session redirection was needed.
This is the intended payoff of front-loading design into
/plan-issue— the operator's involvement was confirmation, not steering.
Diagnostic details
- Feedback-loop gap analysis — no gap:
pnpm run checkran after each of the two implementation steps (both shared-interface changes), the affected test file ran Red-first before each implementation, and the full package suite plus rootlint/check/fallowran before each push. Verification was incremental, not end-loaded. - Escalation-delay tracking — the Step 2 forwarding test took three iterations (timeout, denied-mismatch,
toMatchObjectfor the live timestamp), but each was a distinct root cause diagnosed in one or two tool calls, never 5+ consecutive calls on the same error. No subagent dispatch warranted. - Model-performance correlation — only one subagent ran, the
pre-completion-reviewer(its ownmodel:frontmatter), on judgment-heavy work (design-deviation review, invariant preservation, doc-staleness grep); appropriate match, no mismatch. - Unused-tool detection — none: no
rabbit-holedeep enough to have wanted an Explore/colgrepdispatch that was skipped.
Follow-up
- None filed.
The reviewer's one WARN (a design choice — no
eventsfield onDenying/Parentdeps — structurally obsoleting the plan's requested "does-not-emit" test) is recorded in the TDD stage and needs no code change. - Next roadmap step: Phase 9 Step 2 (#556, dissolve
canConfirm()), unblocked by this issue.
Changes made
.pi/skills/testing/SKILL.md— added a bullet under § "Test assertions" on the false-green class where a validation/parse step's invalid-input fallback coincides with a negative-path test's expected value (assert the positive path against the same fixture builder, or assert a discriminating field).