11 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 556 | pi-permission-system: dissolve canConfirm() — the ask path always escalates to the Authorizer |
Retro: #556 — dissolve canConfirm() — the ask path always escalates to the Authorizer
Stage: Planning (2026-07-08T00:00:00Z)
Session summary
Planned Phase 9 Step 2: dissolve canConfirm() (15 occurrences across 5 src/ modules → 0) by deleting gate-prompter.ts, making the ask path always escalate to the selected Authorizer, and driving confirmation_unavailable from a confirmationUnavailable?: true decision marker (mirroring the existing autoApproved marker).
Two design forks were resolved interactively with the operator and drove the Goals and Design Overview.
Plan committed as 0556-dissolve-canconfirm.md; TDD order is 3 steps (additive marker → atomic dissolve → docs).
Observations
- Byte-identical constraint relaxed by the operator.
The issue mandated "blocked-when-unavailable review entries byte-identical to today's," which conflicts with routing
DenyingAuthorizerthroughPermissionPrompter(it writeswaitingbefore consulting the authorizer, so the marker — known only post-authorize— cannot suppress the bracketing without reordering). The operator explicitly deprioritized byte-identical output in favor of a "clear, coherent state," which unlocked uniform escalation:DenyingAuthorizerflows throughPermissionPrompterlike every authorizer, with zero special-casing. This is cleaner than the two alternatives (selection-bypass branch, or awaiting-reorder inPermissionPrompter). - Signal preserved, not lost.
Chosen sub-decision: surface the marker as
resolution: confirmation_unavailableinPermissionPrompter'sdeniedreview entry (one marker-read), so the "no authority reachable" diagnostic survives in the review log as well as the decision event. DenyingAuthorizeris the headless-root case, not the subagent case.selectAuthorizerpicks it only when!hasUI && !isSubagent; a subagent with a live parent selectsParentAuthorizer. Today theDenyingAuthorizerask path is unreachable at runtime (canConfirmshort-circuits); this step makes it reachable.- The gate role collapses to a single-method seam.
GatePrompter(canConfirm+prompt) becomesAskEscalator(escalate), co-located withAuthorizerSelectioninauthorizer-selection.ts. Renamedprompt→escalatefor coherence with the target model; the internalPermissionPrompter.promptdelegation keeps its name (reads asescalate()callingprompt()). - Deliberate roadmap-Outcome correction flagged.
The
architecture.mdStep 2 Outcome currently claims "byte-identical" — Step 3 must correct it to the uniform-escalation reality so a later reviewer does not read the old wording as a regressed invariant. - Step 2 is necessarily atomic (~10 test files).
Deleting the
GatePrompterexport + removingcanConfirmfromPermissionGateParams+ changingderiveResolution's signature all breakrunner.tsand its tests at the type level together. Step 1 (additive marker) shrinks Step 2; the fan-out edits are single-token retypes (GatePrompter["prompt"]→AskEscalator["escalate"]). - Grep surface enumerated.
Only
authorizer-selection.tsandrunner.tsimportGatePrompterinsrc/;index.tsandpermission-session.tsare untouched (they holdAuthorizerSelectionby value / viaAuthorizerSelectionLifecycle). Docs to update:architecture.md,permission-prompter.md, package SKILL fixture note.
Stage: Implementation — TDD (2026-07-08T13:32:00Z)
Session summary
Executed all three planned TDD cycles: (1) additive confirmationUnavailable marker on PermissionPromptDecision + DenyingAuthorizer + PermissionPrompter preserve-signal; (2) the atomic dissolve — deleted gate-prompter.ts, introduced the single-method AskEscalator seam (escalate), stripped canConfirm from permission-gate.ts/runner.ts/helpers.ts/authorizer-selection.ts, and migrated ~11 test files + 3 fixtures off the two-method { canConfirm, prompt } mock; (3) docs — marked Phase 9 Step 2 ✅ (heading + Mermaid node), corrected the roadmap Outcome to the uniform-escalation reality, updated permission-prompter.md and the package SKILL.
Test count: 2287 → 2280 in pi-permission-system (net -7); full suite, tsc, root lint, and fallow dead-code all green.
Observations
canConfirmfully dissolved to 0 insrc/— the only surviving mentions are two historical references in theauthorizer-selection.tsdoc comment (explaining whatAskEscalatorreplaced).- Method renamed
prompt→escalateon the gate seam. Deviated slightly toward more churn than a bareprompt-keeping rename, but the per-fileprompter.promptsites were disjoint (AskEscalator mocks vsPermissionPrompterApimocks live in different files), so per-filesedwas unambiguous.PermissionPrompter.prompt(authorizer, details)keeps its name —AuthorizerSelection.escalate()now reads as callingPermissionPrompter.prompt(), a clean disambiguation. - Deviation 1 (test removed): dropped the
external-directory-integration.test.tstest "writes review-log entry with confirmation_unavailable when no UI". Under uniform escalation with an injected fakeAskEscalator, the gate no longer writes a standaloneblockedentry and the fake bypassesPermissionPrompter, so the integration layer cannot assert that entry; the behavior moved to the newpermission-prompter.test.tsunit test (Step 1). Reviewer confirmed this is legitimately redundant, not lost coverage. - Deviation 2 (files not enumerated): also migrated
external-directory-session-dedup.test.tsandexternal-directory-integration.test.tsas consumers ofexternal-directory-fixtures.ts(whose exported prompter type changedGatePrompter→AskEscalator). A plan file-list gap, not an implementation gap —tscwould have caught a miss. Also updatedmakeDedupWiring/makeDeduplicatingHandlersignatures in the fixture (the plan named the fixture but not these two helpers). - Review-log behavior change is intentional.
The unavailable path is now
waiting+denied(resolution: confirmation_unavailable, preserved via the marker) instead of a singleblocked/confirmation_unavailableentry; thepermissions:decisionbroadcast is unchanged. Recorded in the corrected roadmap Outcome so it is not later read as a regression. - Pre-completion reviewer: PASS.
Mermaid (4 charts parse), dead code (fallow zero), cross-step invariants (all four Step-1 invariants verified intact: waiting-before-consult ordering, no UI-event from
DenyingAuthorizer, yolo singleauto_approved,confirmation_unavailabledecision event now marker-driven). No warnings.
Stage: Final Retrospective (2026-07-08T14:05:00Z)
Session summary
Phase 9 Step 2 shipped end-to-end across planning, TDD, and ship stages: canConfirm() dissolved (15 → 0 in src/), GatePrompter replaced by the single-method AskEscalator seam, and the ask path now uniformly escalates to the selected Authorizer.
Three refactor:/docs: commits landed on main with green CI; the work is a non-releasing batch (auto-batches into the next feat:/fix:), so no release-please PR was cut.
The session was clean — no rework, no user corrections; all friction was self-corrected tool slips caught immediately by existing feedback loops.
Observations
What went well
- The planning
ask_usercaught a self-conflicting constraint in the issue itself, not just an ambiguity. Tracing the code before planning revealed the issue's "blocked-when-unavailable review entries byte-identical to today's" requirement conflicts with routingDenyingAuthorizerthroughPermissionPrompter(which writeswaitingbefore it can read the post-authorizemarker). Surfacing that tension let the operator relax the byte-identical constraint, which unlocked the simplest design (uniform escalation) instead of a bypass branch or awaiting-reorder. The win was reading the code first so the fork was framed concretely, not abstractly. - Tidy-first additive marker step shrank the atomic commit as designed.
Step 1 (add
confirmationUnavailabletoPermissionPromptDecision+DenyingAuthorizer+PermissionPrompter) was production-behavior-neutral and unit-testable on its own, so Step 2's unavoidable ~11-file fan-out landed against a smaller diff. - Exemplary incremental verification.
vitestper file after each red/green,pnpm run checkimmediately after the shared-interface change in Step 2 (which caught three residualGatePromptertype refs inexternal-directory-fixtures.ts), then full suite + root lint +fallowafter the last step. Every type-level break surfaced within one commit, never at the end.
What caused friction (agent side)
missing-context(planning) — the plan's grep-surface note scopedGatePrompter-importer enumeration tosrc/and listed the test fixtures generically, missing threeGatePromptertype-annotation sites insideexternal-directory-fixtures.ts(makeDedupWiring/makeDeduplicatingHandlerhelper signatures) and the two fixture-consumer test files. Impact: low —pnpm run checkflagged all three during Step 2 and they folded into the same commit; no rework, no extra commit. The existing/plan-issueguidance already says to grepsrc/andtest/for a removed symbol; this was an execution scoping slip, not a missing rule.other(tool slip) — emitted a non-existentoldText2property on theEdittool twice while editingarchitecture.md. Impact: negligible — the tool rejected the call, re-issued correctly; no file left in a bad state.other(environment) — used BSD-unsupported\bword boundary in asedrename; matched nothing. Impact: negligible — a follow-upgrepshowed zero replacements, switched to aprompter.prompt)literal immediately.
What caused friction (user side)
- None. The operator's two planning answers (relax byte-identical; preserve the signal in the review entry) were decisive and correctly scoped, and were the pivot that produced the cleanest design. No mid-implementation intervention was needed.
Diagnostic details
- Model-performance correlation — one subagent dispatch (the
pre-completion-reviewer) ran onanthropic/claude-sonnet-5, a strong reasoning model appropriate for the judgment-heavy invariant/deviation review; no mismatch. - Escalation-delay / unused-tool / feedback-loop lenses — nothing notable: no error ran past one tool call, no rabbit-hole warranted a subagent, and verification ran incrementally (see "Exemplary incremental verification" above), not just at the end.
Changes made
packages/pi-permission-system/docs/retro/0556-dissolve-canconfirm.md— added this Final Retrospective stage entry. NoAGENTS.mdor prompt changes: the session's only friction was self-corrected tool slips already caught by existing feedback loops, and the one planningmissing-contextslip is already covered by/plan-issue's existing "grepsrc/andtest/" guidance.