18 KiB
Phase 8: Tidy first for the authority spine
The authority model is the declared target: an Authorizer role selected once per session, yolo as recorded authority, and PermissionForwarder split by direction of authority flow.
Phase 8 does not build the spine.
It makes the spine change easy — Kent Beck's "make the change that makes the change easy, then make the easy change" — by landing the preparatory refactorings the discovery trace found between GateRunner and the UI/file transport.
The spine itself (the Authorizer interface and its three implementations, canConfirm() dissolution, serving-as-resolution, grant-scope selection) is Phase 9, and the case-by-case model judge requested in #472 rides on that spine as the ModelTriageAuthorizer, not on this phase.
Findings
Health score 76 (B); no dead code; average cyclomatic complexity 1.4; maintainability 91.1.
The score deductions are large test arrow functions and test-tree duplication — production functions are small, so the remaining debt is structural, not syntactic.
The trace from GateRunner down to the UI dialog and forwarding files confirmed the elicitation thicket exactly as the target section describes it, plus the friction that would make the spine diff large:
- yolo is smeared across the ask path.
shouldAutoApprovePermissionStateis checked inPermissionPrompter.promptand again in the forwarded-inbox serve arm;canResolveAskPermissionRequest's yolo arm sits inPromptingGateway.canConfirm(). Three modules know about yolo on the decision path; the target says the ruleset should be the only one. - The three
Authorizers already exist as anonymous branches.PermissionForwarder.requestApprovaldispatches hasUI → direct dialog (the futureLocalUserAuthorizer), not-a-subagent → deny (DenyingAuthorizer), else → forward (ParentAuthorizer) — inside a 591-LOC class that also owns the opposite-direction serving role (processInbox). - Subagent detection is threaded as a dep triple.
(
subagentSessionsDir,platform,registry) is threaded into three constructors (PromptingGateway,PermissionForwarder,ForwardingManager), andisSubagentExecutionContextis re-evaluated up to three times per ask; the spine's "selected once per session" needs one owner for this predicate. - A third elicitation path.
The deprecated
permissions:rpc:promptevent-bus handler is a parallel prompt path (own hasUI check, own review-log entry, own UI-prompt event) the spine would otherwise have to adapt. - The test scaffolding the spine will rewrite is duplicated.
permission-manager-unified.test.tscarries 24 clone groups (305 lines, accelerating churn);permission-forwarder.test.tscarries 6 groups including a 43-line clone ×2.
| Metric | Phase 7 close | Target after Phase 8 |
|---|---|---|
| Health score | 76 (B) | ≥ 76 (B) |
| yolo checks on the ask path | 3 (prompter, gateway, serve arm) | ✅ 1 (composition-stage rewrite) + serve arm (dissolves with the spine) |
canConfirm() predicates |
hasUI ∨ isSubagent ∨ yolo | ✅ hasUI ∨ isSubagent (selection-ready) |
| Elicitation paths the spine must adapt | 3 (gate prompt, forwarded inbox, RPC prompt) | ✅ 2 (gate prompt, forwarded inbox) |
PermissionForwarder roles per class |
2 (escalation + serving, 591 LOC) | ✅ 1 each (two classes under src/authority/) |
| Subagent-detection dep-triple constructors | 3 | ✅ 1 (SubagentDetection) |
| fallow refactoring targets | 1 (value-guards.ts) |
1 (value-guards.ts; fan-in from toRecord/getNonEmptyString persists) |
| Duplication | 6.7% (3,129 lines) | ≤ 5.5% |
Steps
-
✅ Extract shared fixtures from
permission-manager-unified.test.ts. (#525) Target:test/permission-manager-unified.test.ts(3,714 LOC, 24 clone groups / 305 duplicated lines, accelerating churn) — extract the repeated config-harness blocks intotest/helpers/manager-harness.ts(or a sibling fixture module). No production change; tidies the ground Step 2's manager tests land on. Smell: Category D (test duplication). Outcome: the file's clone groups drop to near zero; test-tree duplication falls measurably. Landed: the seven config-harness factories plus thesessionRulebuilder now live intest/helpers/manager-harness.ts; the test file drops from 3,745 to 3,481 LOC with one intentional act/assert clone remaining (agent-frontmatter, kept per the plan's Non-Goals). Release: independent -
✅ Move yolo into recorded authority: composition-stage
ask→allowrewrite. (#526) Target:src/permission-manager.ts(apply the rewrite over the composed ruleset at check time, keyed off an injected yolo reader; yolo state must join theresolvedPermissionsCachekey or be applied post-cache),src/rule.ts(RuleOrigingains"yolo"; update this doc's inlineRulelisting),src/handlers/gates/helpers.ts+runner.ts(a yolo-originallowderives resolutionauto_approved, and the runner writes thepermission_request.auto_approvedreview entry so review-log parity holds). Display must not change:getComposedConfigRules//permission-system showkeep showing the configured actions, not the rewrite. Faithful to current behavior: explicitdenyis notask, so yolo suppresses prompts but preserves hard denies (see yolo is recorded authority). Smell: Category C (policy smeared across the prompt path). Outcome:evaluate()is the only yolo decision point; the prompter and gateway yolo arms become unreachable; review log and decision events keep reportingauto_approved. Landed:rewriteAsksToYolo(pureRulesettransform inrule.ts) is applied post-cache inPermissionManager.checkbehind an injectedisYoloEnabledreader, wired inindex.tstoisYoloModeEnabled(configStore.current());deriveResolutionmaps a yolo-originallowtoauto_approvedandGateRunnergained a yolo fast-path that writes thepermission_request.auto_approvedreview entry (runnerlogContextshape,toolCallIdnotrequestId). Skill-reads under yolo resolve toallowvia the yolo-aware sanitizer and logpolicy_allow/origin: "builtin"— an accepted parity nuance (the prompter arm still auto-approves nothing new). Theyolo checks on the ask pathmetric is not yet flipped; the prompter/gateway arms are removed in Step 3. Release: batch "yolo-recorded-authority" -
✅ Delete the dead yolo arms from the prompt path; dissolve
yolo-mode.ts. (#527) Target:src/permission-prompter.ts(drop the auto-approve arm),src/prompting-gateway.ts(canConfirm()= hasUI ∨ isSubagent;canResolveAskPermissionRequestdeleted),src/yolo-mode.ts(dissolved —isYoloModeEnabledand the serve arm's check move next to their config inextension-config.ts). The forwarded-inbox serve arm keeps its yolo check for now — it dissolves when serving becomes resolution (Phase 9), and is documented as such. Smell: Category A (dead code after Step 2). Outcome: no yolo knowledge on the prompt path;canConfirm()is reduced to the two Authorizer-selection predicates. Landed:PermissionPrompter.prompt()dropped the auto-approve arm and itsconfigdependency;PromptingGateway.canConfirm()is nowhasUI ∨ isSubagentExecutionContext(...), andcanResolveAskPermissionRequest/AskPermissionResolutionOptionsare deleted;isYoloModeEnabledmoved intoextension-config.tsandyolo-mode.tsis deleted; the forwarded-inbox serve arm re-points atisYoloModeEnabledwith a comment noting it dissolves in the Phase 9 spine work. Release: batch "yolo-recorded-authority" -
✅ Extract a shared forwarded-permission test harness. (#528) Target:
test/permission-forwarder.test.ts(43-line clone ×2 plus 6 groups / 110 lines),test/forwarding-manager.test.ts,test/permission-forwarding.test.ts— extract request/response builders, temp forwarding-dir setup, and a fakeForwarderContextintotest/helpers/forwarding-fixtures.ts. Smell: Category D (test duplication). Outcome: forwarder-family clone groups drop to near zero; Step 6 migrates its per-class tests onto the harness instead of copying scaffolding again. Landed:test/helpers/forwarding-fixtures.tsholdscreateForwardingTempDir(handle +cleanup, with awriteRequestwriter),makeForwarderDeps,makeForwarderContext,makeUiDecision, andmakeSubagentRegistry;permission-forwarder.test.tsmigrated fully (everytry/finallytemp-dir block gone,makeEventsreused fromhandler-fixtures) andpermission-forwarding.test.tsontomakeSubagentRegistry.forwarding-manager.test.tswas left unchanged — itsExtensionContext-cast ctx, mockedsubagent-context, and fake-timer polling do not overlap the harness (per the plan's Non-Goals). Release: independent -
✅ Extract a
SubagentDetectioncollaborator; seedsrc/authority/. (#529) Target: newsrc/authority/subagent-detection.ts— a class constructed once inindex.tswith (subagentSessionsDir,platform,registry), exposingisSubagent(ctx); movesrc/subagent-context.ts→src/authority/subagent-context.ts(its consumers are all rewired by this step anyway).PromptingGateway,ForwardingManager, andPermissionForwarderdrop the threaded dep triple and take the collaborator. Smell: Category C (dep triple threaded through three constructors) + Category E (seeds the authority domain directory). Outcome: one construction site for subagent detection — the input the Phase 9 Authorizer selection consumes;src/authority/exists. Landed:src/authority/subagent-detection.tsholdsSubagentDetection(implementsSubagentDetector+RegisteredChildDetector), constructed once inindex.tsand shared; it delegates to the pure functions in the movedsrc/authority/subagent-context.ts, which keep their test file intact.PromptingGateway,ForwardingManager, andPermissionForwardertook theSubagentDetectorseam (the forwarder keepsregistryfor target resolution only), and the scope widened toPermissionServiceLifecycle, which took theRegisteredChildDetectorseam and dropped its raw registry field — so all subagent-detection predicates now have one owner. Release: independent -
✅ Split
PermissionForwarderby direction of authority flow. (#530) Target:src/forwarded-permissions/permission-forwarder.ts(591 LOC, both roles) →src/authority/approval-escalator.ts(ApprovalEscalator implements ApprovalRequester— keeps the three-way dispatch with each branch a named method, plus the request-write/poll machinery) andsrc/authority/forwarded-request-server.ts(ForwardedRequestServer implements InboxProcessor—processInboxand the per-request serve flow);src/forwarded-permissions/io.ts→src/authority/forwarding-io.ts; theforwarded-permissions/directory dissolves. Callers are unchanged:PermissionPrompterkeeps depending onApprovalRequester,ForwardingManageronInboxProcessor. Smell: Category B/C (dual-role class; the target's declared split). Outcome: each class constructs with only its own dependencies; Phase 9 turns the escalator's three named branches into the threeAuthorizers branch-by-branch instead of dissecting a god class. Landed:src/authority/forwarder-context.tsholds the sharedForwarderContextread-interface andgetSessionId;ApprovalEscalator(escalation-up,ApprovalRequester) andForwardedRequestServer(serving-down,InboxProcessor) each construct with only their own dependencies — the escalator droppedconfig/events, the server droppeddetection/registry;src/forwarded-permissions/andtest/forwarded-permissions/both dissolved. Release: independent -
✅ Remove the deprecated
permissions:rpc:check/permissions:rpc:promptevent-bus channel. (#531) Target: deletesrc/permission-event-rpc.tsandtest/permission-event-rpc.test.ts; remove the deprecated request/reply payload types and channel constants fromsrc/permission-events.ts; unwire fromindex.ts/PermissionServiceLifecycle; update the cross-extension docs to point exclusively at theSymbol.for()service accessor. Before writing the migration note, verify the named replacement methods on the realPermissionsServicetype. Narrows #309 to the service path only — leave a comment on that issue. Smell: Category A (deprecated subsystem) / Category F (duplicate cross-extension surface). Outcome: one cross-extension policy/prompt surface; the spine adapts two elicitation paths instead of three; breaking for event-bus RPC consumers. Landed: deletedsrc/permission-event-rpc.tsand its test; removed the RPC channel constants, request/reply payload types, the sharedPermissionsRpcReplyenvelope, andPERMISSIONS_PROTOCOL_VERSIONfromsrc/permission-events.ts; removed the deadrpc_promptUI-prompt source,buildRpcUiPrompt, and theUI_PROMPT_SOURCESwhitelist entry; unwired registration and the two unsub handles fromsrc/index.ts; repointeddocs/cross-extension-api.mdexclusively at theSymbol.for()service accessor; commented on #309 narrowing its scope to the service path. Release: independent -
✅ Split
value-guards.tsby cohesion. (#532) Target:src/value-guards.ts: keep the generic parsing guards (toRecord,getNonEmptyString); move the domain guards (isPermissionState,isDenyWithReason) next to the types they guard (src/types.ts). Note: #547 already removed the config-only guards (normalizeOptionalStringArray,normalizeOptionalPositiveInt) when zod took over config validation, shrinking this target. Smell: Category B (high-impact file) / Category E (mixed cohesion). Outcome: domain guards co-located with their types; fallow refactoring targets did not clear to 0 —value-guards.tsstill reports as a target (19 dependents) because the fan-in came from the retained generic guards (toRecord/getNonEmptyString), not the relocated domain guards. Landed: movedisPermissionStateandisDenyWithReasonfromsrc/value-guards.tstosrc/types.ts, besidePermissionState/DenyWithReason; repointed the three domain-guard consumers (permission-manager.ts,normalize.ts,config-loader.ts) to import from./types; moved the guard tests fromtest/value-guards.test.tsinto a newtest/types.test.ts. Release: independent
Step dependency diagram
flowchart TD
S1["✅ Step 1 (#525)<br/>Manager-unified test fixtures"]
S2["✅ Step 2 (#526)<br/>yolo into the composed ruleset"]
S3["✅ Step 3 (#527)<br/>Delete dead yolo arms"]
S4["✅ Step 4 (#528)<br/>Forwarding test harness"]
S5["✅ Step 5 (#529)<br/>SubagentDetection + seed authority/"]
S6["✅ Step 6 (#530)<br/>Split PermissionForwarder by direction"]
S7["✅ Step 7 (#531)<br/>Remove deprecated event-bus RPC<br/>(breaking)"]
S8["✅ Step 8 (#532)<br/>Split value-guards.ts"]
S1 --> S2
S2 --> S3
S4 --> S6
S5 --> S6
Parallel tracks
- Track A — yolo becomes recorded authority: Steps 1 → 2 → 3.
- Track B — escalation machinery: Steps 4 and 5 in parallel, then Step 6.
- Track C — cross-extension surface reduction: Step 7, independent.
- Track D — health: Step 8, independent.
Release batches
- Batch "yolo-recorded-authority": Steps 2, 3 (ship together; tail = Step 3). Step 2 relocates the yolo decision with observable review-log/decision-event field changes and Step 3 is its cleanup.
- Independently releasable: Steps 1, 4 (test-only; hidden changelog type), Steps 5, 6, 8 (refactors; auto-batch into the next release), Step 7 (breaking — ships as its own major-bump release).
Non-goals
- The spine itself.
The
Authorizerinterface and its three implementations,canConfirm()dissolution, serving-as-resolution, the one-hop canary, grant-scope selection, and yolo inheritance are Phase 9 — this phase only removes the friction in their way. - The serve-arm yolo check.
It survives Phase 8 (one isolated
if) and dissolves whenprocessInboxis refactored ontoevaluate()plus Authorizer selection. - Principal identity and cross-session path portability. Still the deferred access-intent design work; the forwarded request keeps carrying display fields, not a re-evaluable intent, until then.
- A big-bang
src/reorganization. Only the files Phase 8 already rewrites move intosrc/authority/; see the directory sketch below.
Directory sketch (forward-looking)
Phase 8 seeds src/authority/ with the modules it rewrites: subagent-detection.ts and subagent-context.ts (Step 5), then approval-escalator.ts, forwarded-request-server.ts, and forwarding-io.ts (Step 6).
The remaining elicitation modules (gate-prompter.ts, prompting-gateway.ts, permission-prompter.ts, permission-dialog.ts, permission-forwarding.ts, subagent-registry.ts) migrate in Phase 9 as the spine rewrites them into the Authorizer interface and its implementations — there is no peer subagent/ domain, because the subagent machinery is the cross-session edge of authority/.