Files
my-pi/pi-permission-system/docs/architecture/history/phase-8-tidy-first-authority-spine.md

18 KiB
Raw Permalink Blame History

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. shouldAutoApprovePermissionState is checked in PermissionPrompter.prompt and again in the forwarded-inbox serve arm; canResolveAskPermissionRequest's yolo arm sits in PromptingGateway.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.requestApproval dispatches hasUI → direct dialog (the future LocalUserAuthorizer), 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), and isSubagentExecutionContext is 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:prompt event-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.ts carries 24 clone groups (305 lines, accelerating churn); permission-forwarder.test.ts carries 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

  1. 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 into test/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 the sessionRule builder now live in test/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

  2. Move yolo into recorded authority: composition-stage askallow rewrite. (#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 the resolvedPermissionsCache key or be applied post-cache), src/rule.ts (RuleOrigin gains "yolo"; update this doc's inline Rule listing), src/handlers/gates/helpers.ts + runner.ts (a yolo-origin allow derives resolution auto_approved, and the runner writes the permission_request.auto_approved review entry so review-log parity holds). Display must not change: getComposedConfigRules / /permission-system show keep showing the configured actions, not the rewrite. Faithful to current behavior: explicit deny is not ask, 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 reporting auto_approved. Landed: rewriteAsksToYolo (pure Ruleset transform in rule.ts) is applied post-cache in PermissionManager.check behind an injected isYoloEnabled reader, wired in index.ts to isYoloModeEnabled(configStore.current()); deriveResolution maps a yolo-origin allow to auto_approved and GateRunner gained a yolo fast-path that writes the permission_request.auto_approved review entry (runner logContext shape, toolCallId not requestId). Skill-reads under yolo resolve to allow via the yolo-aware sanitizer and log policy_allow/origin: "builtin" — an accepted parity nuance (the prompter arm still auto-approves nothing new). The yolo checks on the ask path metric is not yet flipped; the prompter/gateway arms are removed in Step 3. Release: batch "yolo-recorded-authority"

  3. 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; canResolveAskPermissionRequest deleted), src/yolo-mode.ts (dissolved — isYoloModeEnabled and the serve arm's check move next to their config in extension-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 its config dependency; PromptingGateway.canConfirm() is now hasUI isSubagentExecutionContext(...), and canResolveAskPermissionRequest / AskPermissionResolutionOptions are deleted; isYoloModeEnabled moved into extension-config.ts and yolo-mode.ts is deleted; the forwarded-inbox serve arm re-points at isYoloModeEnabled with a comment noting it dissolves in the Phase 9 spine work. Release: batch "yolo-recorded-authority"

  4. 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 fake ForwarderContext into test/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.ts holds createForwardingTempDir (handle + cleanup, with a writeRequest writer), makeForwarderDeps, makeForwarderContext, makeUiDecision, and makeSubagentRegistry; permission-forwarder.test.ts migrated fully (every try/finally temp-dir block gone, makeEvents reused from handler-fixtures) and permission-forwarding.test.ts onto makeSubagentRegistry. forwarding-manager.test.ts was left unchanged — its ExtensionContext-cast ctx, mocked subagent-context, and fake-timer polling do not overlap the harness (per the plan's Non-Goals). Release: independent

  5. Extract a SubagentDetection collaborator; seed src/authority/. (#529) Target: new src/authority/subagent-detection.ts — a class constructed once in index.ts with (subagentSessionsDir, platform, registry), exposing isSubagent(ctx); move src/subagent-context.tssrc/authority/subagent-context.ts (its consumers are all rewired by this step anyway). PromptingGateway, ForwardingManager, and PermissionForwarder drop 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.ts holds SubagentDetection (implements SubagentDetector + RegisteredChildDetector), constructed once in index.ts and shared; it delegates to the pure functions in the moved src/authority/subagent-context.ts, which keep their test file intact. PromptingGateway, ForwardingManager, and PermissionForwarder took the SubagentDetector seam (the forwarder keeps registry for target resolution only), and the scope widened to PermissionServiceLifecycle, which took the RegisteredChildDetector seam and dropped its raw registry field — so all subagent-detection predicates now have one owner. Release: independent

  6. Split PermissionForwarder by 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) and src/authority/forwarded-request-server.ts (ForwardedRequestServer implements InboxProcessorprocessInbox and the per-request serve flow); src/forwarded-permissions/io.tssrc/authority/forwarding-io.ts; the forwarded-permissions/ directory dissolves. Callers are unchanged: PermissionPrompter keeps depending on ApprovalRequester, ForwardingManager on InboxProcessor. 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 three Authorizers branch-by-branch instead of dissecting a god class. Landed: src/authority/forwarder-context.ts holds the shared ForwarderContext read-interface and getSessionId; ApprovalEscalator (escalation-up, ApprovalRequester) and ForwardedRequestServer (serving-down, InboxProcessor) each construct with only their own dependencies — the escalator dropped config/events, the server dropped detection/registry; src/forwarded-permissions/ and test/forwarded-permissions/ both dissolved. Release: independent

  7. Remove the deprecated permissions:rpc:check / permissions:rpc:prompt event-bus channel. (#531) Target: delete src/permission-event-rpc.ts and test/permission-event-rpc.test.ts; remove the deprecated request/reply payload types and channel constants from src/permission-events.ts; unwire from index.ts / PermissionServiceLifecycle; update the cross-extension docs to point exclusively at the Symbol.for() service accessor. Before writing the migration note, verify the named replacement methods on the real PermissionsService type. 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: deleted src/permission-event-rpc.ts and its test; removed the RPC channel constants, request/reply payload types, the shared PermissionsRpcReply envelope, and PERMISSIONS_PROTOCOL_VERSION from src/permission-events.ts; removed the dead rpc_prompt UI-prompt source, buildRpcUiPrompt, and the UI_PROMPT_SOURCES whitelist entry; unwired registration and the two unsub handles from src/index.ts; repointed docs/cross-extension-api.md exclusively at the Symbol.for() service accessor; commented on #309 narrowing its scope to the service path. Release: independent

  8. Split value-guards.ts by 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 0value-guards.ts still reports as a target (19 dependents) because the fan-in came from the retained generic guards (toRecord/getNonEmptyString), not the relocated domain guards. Landed: moved isPermissionState and isDenyWithReason from src/value-guards.ts to src/types.ts, beside PermissionState/DenyWithReason; repointed the three domain-guard consumers (permission-manager.ts, normalize.ts, config-loader.ts) to import from ./types; moved the guard tests from test/value-guards.test.ts into a new test/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 Authorizer interface 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 when processInbox is refactored onto evaluate() 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 into src/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/.