17 KiB
Phase 9: The Authorizer spine
Phase 9 builds the authority model spine that Phase 8 tidied for: the Authorizer interface and its three implementations, canConfirm() dissolution, serving-as-resolution, human-selectable grant-scope, and the mechanical completion of the authority/ directory migration.
Findings
The cause is first-principles, not tool-sourced: the live-authority path — what happens on ask — has no single owner.
The deontic question "who may decide, and how do we reach them" is answered by an accretion of collaborators: GateRunner asks GatePrompter.canConfirm(), PromptingGateway computes it from hasUI || isSubagent, ApprovalEscalator.requestApproval re-branches on the same predicates per prompt, and PermissionPrompter.prompt reads ctx.hasUI a third time for event emission.
"No authority reachable" is represented twice with different logging (applyPermissionGate's ask + !canConfirm arm and requestApproval's not-a-subagent arm).
The serving side (ForwardedRequestServer.processSingleForwardedRequest) answers escalations with bespoke logic — its own yolo check (the last one outside the composed ruleset) and no evaluate() — so a parent allow/deny rule does not govern a child's escalation.
Fallow corroborates the symptoms: the three largest non-test functions after the composition root are exactly the ask-path modules (runDescriptor 130 lines, processSingleForwardedRequest 117, waitForForwardedApproval 77); dead code is 0 and duplication is 0.4%.
| Metric | Phase 8 exit | Phase 9 target |
|---|---|---|
| Health score | 78 B | ≥ 78 |
| Dead exports / files | 0 | 0 |
Ask-path role interfaces (GatePrompter, PermissionPrompterApi, ApprovalRequester) |
3 | 1 (Authorizer) |
canConfirm occurrences in src/ |
15 across 5 modules | 0 |
hasUI / isSubagent evaluations per ask |
3+ per prompt | once per session |
| Yolo checks outside the composed ruleset | 1 (ForwardedRequestServer) |
0 |
processSingleForwardedRequest |
117 lines | < 60 lines |
Flat src/ root modules |
~67 | ~62 |
Scope decisions from planning: grant-scope selection (resolved direction 4) is included as the tail step; the ModelTriageAuthorizer (#472) is deferred to a later phase with its own decision record — the Step 1 seam is its extension point.
The two production clone groups (58 lines total, unrelated to the spine) score polish-tier (Priority ≤ 10) and are deferred.
Open issues swept and out of scope: #309 (advisory bash-path fidelity), #490 (indirection-wrapper flooring), #520 (win32 backslash-relative bug), #521 (read-only command allowlisting), #519 (SDK UIContext clarification), #23 (upstream-fork per-agent override evaluation).
Steps
-
✅ Introduce the
Authorizerspine: interface, three implementations, once-per-session selection. (#555) Cause: the three-way "who decides" dispatch is buried insideApprovalEscalator.requestApprovaland re-derived per prompt; the fallow signal (waitForForwardedApprovalat 77 lines inside a class that also owns dispatch) is a symptom. Target: newsrc/authority/authorizer.ts(Authorizerinterface —authorize(details): Promise<PermissionPromptDecision>— plusselectAuthorizer(ctx, detection)), newsrc/authority/local-user-authorizer.ts(ownsctx.ui+requestPermissionDecisionFromUi+ direct UI-prompt event emission), newsrc/authority/denying-authorizer.ts(least-privilege deny),src/authority/approval-escalator.ts(sheds itshasUIand not-a-subagent arms; its forwarding machinery becomes theParentAuthorizer),src/prompting-gateway.tsrewritten as the selection owner atsrc/authority/authorizer-selection.ts(context stored atactivate, authorizer selected once per session),src/permission-prompter.ts→src/authority/permission-prompter.ts(keeps review-log bracketing, delegates to the selectedAuthorizer, drops per-callctxthreading). Smell: Category C (missing domain concept; relay chain of 4 role interfaces to reach one dialog). Outcome: thehasUI/isSubagent/deny dispatch exists in exactly one place (selectAuthorizer); predicates evaluated once per session activation; behavior-neutral — existing review-log and decision-event tests pass unchanged. Landed:src/authority/authorizer.ts(Authorizerinterface,AuthorizerSelectionDeps,selectAuthorizer),local-user-authorizer.ts,denying-authorizer.ts, andauthorizer-selection.ts(AuthorizerSelection, thePromptingGatewayrewrite) landed in one commit alongside the movedauthority/permission-prompter.tsand the wiredindex.ts; a second commit foldedApprovalEscalatordirectly intoParentAuthorizer(approval-escalator.ts), removing the transitional wrapper, the deadhasUI/!isSubagentarms, and the now-unusedApprovalRequesterinterface anddetectiondependency.GatePrompter.canConfirm()survives unchanged, as planned — dissolved next in Step 2. Impact 5 / Risk 3 / Priority 15. Release: independent -
✅ Dissolve
canConfirm(): the ask path always escalates. (#556) Cause: "can anyone answer" is a pre-check duplicating the selection knowledge; withDenyingAuthorizer, absent authority is an authorizer that answers, not a boolean smeared across the gateway, gate, and runner. Target: deletesrc/gate-prompter.ts;src/permission-gate.tsdrops thecanConfirmparam (askalways awaitspromptForApproval);src/handlers/gates/runner.tsdrops the pre-check;src/handlers/gates/helpers.tsderivesconfirmation_unavailablefrom a marker on theDenyingAuthorizer's decision (mirroring the existingautoApprovedmarker). Landed:GatePrompterdeleted and replaced by the single-methodAskEscalatorseam (escalate(details),authorizer-selection.ts);permission-gate.ts/runner.ts/helpers.tsshed thecanConfirmplumbing;DenyingAuthorizerdenies with aconfirmationUnavailablemarker andPermissionPromptersurfaces it as the denied entry'sresolution. Smell: Category C (scattered boolean policy) / Category A (parameter dead after Step 1). Outcome:canConfirmoccurrences insrc/drop 15 → 0;runDescriptorsheds the pre-check plumbing. The ask path now escalates uniformly — theDenyingAuthorizeris bracketed like any authorizer — so the unavailable path is recorded as the prompter'swaiting/deniedentries (resolution: confirmation_unavailable, preserved via the marker) rather than a standalone gate-writtenblockedentry; theconfirmation_unavailabledecision event is unchanged. This is a deliberate design decision (uniform escalation over byte-identical review-log shape), so the review log differs from Step 1's target wording. Impact 4 / Risk 2 / Priority 16. Release: independent -
✅ Serving is resolution: rebuild
processInboxonevaluate()+ the serving session'sAuthorizer. (#557) Cause: the serving node answers escalations without consulting its own recorded authority (resolved direction 1), so parent policy cannot govern a child's escalation and yolo needs the bespoke serve-time check. Target:src/authority/forwarded-request-server.ts— inject a policy view + theAskEscalatorseam; a request carrying(surface, value)resolves against the serving node's composed base ruleset (agentNameundefined — the child applied its own per-agent overrides before forwarding;allow, including yolo-rewritten, auto-approves — yolo inheritance for free;denyauto-denies;askor missing fields escalates through the seam); the escalated ask carries its forwarded provenance (requester agent/session, originalsource/surface/value) as data onPromptPermissionDetails, soLocalUserAuthorizeremits the non-degraded forwardedpermissions:ui_promptbroadcast and the server sheds its bespoke emit + dialog path; removeisYoloModeEnabled+ theConfigReaderdep; add the one-hop canary (loud warning when a request arrives from a requester whose registered parent is not the serving session). Smell: Category C (duplicate policy enforcement; single source of truth) / Category A (bespoke yolo arm). Outcome: zero yolo checks outside the composed ruleset;processSingleForwardedRequest< 60 lines; onepermissions:ui_promptemit site (LocalUserAuthorizer); behavior change (ships asfeat:): parentallow/denyrules now govern children's escalations. Invariant (pinned by test): the forwardedpermissions:ui_promptbroadcast stays non-degraded — originalsourceandsurface/valueprojection preserved,forwardingcontext populated — per the #292 contract hardening documented indocs/cross-extension-api.md; rerouting the prompt through theAuthorizermust not regress it. Landed:ForwardedRequestServerresolves each request on the injectedServingPolicy(recorded authority) and escalatesask/field-less requests through theAskEscalatorseam;LocalUserAuthorizerbecame the singlepermissions:ui_promptemit site rendering forwarded provenance fromPromptPermissionDetails(thebuildDirectUiPrompt/buildForwardedUiPromptsplit folded intobuildUiPrompt); the bespoke yolo check +ConfigReaderdep are gone and the one-hop canary warns on a multi-hop/misrouted requester. Design recorded indocs/decisions/0005-serving-authorizer-provenance.md; post-ship validation in #565. Impact 5 / Risk 3 / Priority 15. Release: independent -
✅ Grant-scope selection on forwarded approvals. (#558) Cause: resolved direction 4 — a forwarded "for this session" grant can today land only on the requesting subagent; the human cannot choose the serving scope. Target:
src/permission-forwarding.ts(request carries the child's suggested session pattern),src/authority/approval-escalator.ts(rides the existingsessionApprovalsuggestion along),src/authority/forwarded-request-server.ts(threads the scope choice into the escalated ask's details — after Step 3 the forwarded dialog is shown byLocalUserAuthorizervia the threaded provenance, not server-local prompting),src/authority/local-user-authorizer.ts+src/permission-dialog.ts(scope-aware dialog options — requesting subagent pre-selected as the least-privilege default); a whole-session grant records into the serving node's ownSessionRules. Smell: completes the Category C authority model (feature riding the spine). Outcome: the forwarded dialog offers "this subagent only" (default) vs "whole session"; a whole-session grant suppresses future prompts for the parent and all children (verified by a composition-root round-trip test). Landed: the child rides itsSessionApprovalonPromptPermissionDetails.sessionApproval→ForwardedPermissionRequest.sessionApproval(tolerant read);LocalUserAuthorizeroffers a two-step scope select (buildForwardedScopeLabels) for a forwarded ask carrying a suggestion; a whole-session choice returns the serving-node-internalapproved_for_serving_sessionstate, whichForwardedRequestServer.applyGrantScoperecords into the servingSessionRulesand translates to a plainapproved(child records nothing, re-forwards, auto-approves). Design recorded indocs/decisions/0006-forwarded-grant-scope-selection.md. Impact 3 / Risk 3 / Priority 9. Release: independent -
✅ Complete the
authority/migration. (#559) Cause: Phase 8's forward-looking directory sketch names the elicitation and subagent modules asauthority/residents; Steps 1–4 rewrite most of them into place, and this step moves the mechanical remainder so the domain is closed and files move once. Target:src/permission-dialog.ts,src/permission-forwarding.ts,src/subagent-registry.ts,src/subagent-lifecycle-events.ts,src/forwarding-manager.ts→src/authority/; imports rewritten via the#src/aliases. Smell: Category E (flat directory). Outcome: all escalation/forwarding/subagent modules live undersrc/authority/; flatsrc/root drops ~67 → ~62 modules; no behavior change. Landed: all five modules relocated viagit mv; parent-relative imports rewritten to#src/authority/…aliases (mechanically verified bytsc+ eslint'sno-parent-relative-importsrule); five test files moved intotest/authority/to match the established layout; no logic changes. Impact 2 / Risk 1 / Priority 10. Release: independent
Step dependency diagram
flowchart TD
S1["✅ Step 1 (#555)<br/>Authorizer interface + selection"]
S2["✅ Step 2 (#556)<br/>Dissolve canConfirm"]
S3["✅ Step 3 (#557)<br/>Serving is resolution"]
S4["✅ Step 4 (#558)<br/>Grant-scope selection"]
S5["✅ Step 5 (#559)<br/>Complete authority/ migration"]
S1 --> S2
S1 --> S3
S3 --> S4
S2 --> S5
S4 --> S5
Parallel tracks
- Track A — spine: Step 1 → Step 2.
- Track B — serving: Step 1 → Step 3 → Step 4 (parallel to Track A after Step 1; disjoint files).
- Track C — organization: Step 5, after both tracks land.
Release batches
- No multi-step batch: every step leaves the package consistent on its own.
- Independently releasable: Steps 1, 2, 5 (refactors; hidden changelog type, auto-batch into the next release), Steps 3, 4 (
feat:— each cuts a release on landing).
Completion
All 5 steps are closed: #555, #556, #557, #558, #559.
Follow-on issue #565 (validate serving-is-resolution decisions post-ship) was opened alongside Step 3 to track live validation of the new parent-governs-child-escalation behavior; it is non-gating and remains open for that follow-up observation.
Open issues swept and confirmed out of scope during planning: #309, #490, #520, #521, #519, #23.
The ModelTriageAuthorizer (#472) remains deferred to a later phase with its own decision record.
Delivered vs. predicted metrics
Recomputed at archive time (pnpm fallow:health / pnpm fallow:dupes --workspace @gotgenes/pi-permission-system):
| Metric | Phase 9 target | Delivered |
|---|---|---|
| Health score | ≥ 78 | 78 (B) — met |
| Dead exports / files | 0 | 0.0% / 0.0% — met |
| Ask-path role interfaces | 1 (Authorizer) |
1 (Authorizer, three implementations) — met |
canConfirm occurrences in src/ |
0 | 0 functional occurrences (one explanatory comment) — met |
| Yolo checks outside the composed ruleset | 0 | 0 — met |
processSingleForwardedRequest |
< 60 lines | 39 lines — met |
Flat src/ root modules |
~62 | 62 — met |
| Duplication | (not separately targeted) | 0.2% (58 lines, 2 clone groups, unrelated to the spine; deferred as planned) |