Files

327 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 556
issue_title: "pi-permission-system: dissolve canConfirm() — the ask path always escalates to the Authorizer"
---
# Dissolve `canConfirm()` — the ask path always escalates to the `Authorizer`
## Release Recommendation
**Release:** ship independently
Phase 9 Step 2 is tagged `Release: independent` in the architecture roadmap (`architecture.md` line 884).
This lands as a `refactor:` (hidden changelog type), so it does not cut a release on its own — it lands on `main` and auto-batches into the next `feat:`/`fix:`/unhidden-`docs:` release.
It is not a member of any release batch, so there is nothing to defer or coordinate at ship time.
## Problem Statement
"Can anyone answer this ask?"
is currently a boolean pre-check smeared across five modules.
`GateRunner` asks `GatePrompter.canConfirm()` before prompting; `applyPermissionGate` re-branches on the same boolean to short-circuit `ask` into a block; `deriveResolution` reads it a third time to distinguish `user_denied` from `confirmation_unavailable`; and `AuthorizerSelection` recomputes `hasUI || isSubagent` at `activate` to feed it (`canConfirm` appears 15 times across 5 modules in `src/`).
Phase 9 Step 1 ([#555]) already introduced the `Authorizer` role and its three implementations, including `DenyingAuthorizer` — the least-privilege authority for a session with no reachable authority.
With that in place, "absent authority" is no longer a boolean to smear: it is an `Authorizer` that answers, by denying.
This step dissolves `canConfirm()`: the `ask` path always escalates to the session's selected `Authorizer`, and the `DenyingAuthorizer`'s deny answer carries a marker that drives the `confirmation_unavailable` resolution.
## Goals
- Delete `src/gate-prompter.ts` (the two-method `GatePrompter` role interface).
- `src/permission-gate.ts`: drop the `canConfirm` param; the `ask` branch always awaits `promptForApproval()`.
- `src/handlers/gates/runner.ts`: drop the `this.prompter.canConfirm()` pre-check; the gate role collapses to a single-method escalation seam.
- `src/handlers/gates/helpers.ts`: `deriveResolution` derives `confirmation_unavailable` from a marker on the decision (mirroring the existing `autoApproved` marker) instead of a `canConfirm` boolean.
- `canConfirm` occurrences in `src/` drop 15 → 0.
## Non-Goals
- Rebuilding the serving side (`ForwardedRequestServer`) onto `evaluate()` + a serving `Authorizer` — that is Phase 9 Step 3 ([#557]), a disjoint Track-B step.
- The `ModelTriageAuthorizer` decorator ([#472]) — deferred to a later phase with its own decision record.
- Moving yolo into the ruleset — already landed ([#526], [#527]); untouched here.
- Any change to the cross-extension `permissions:decision` broadcast shape — the `confirmation_unavailable` resolution value is preserved.
## Background
Relevant modules (all in `packages/pi-permission-system/`):
- `src/gate-prompter.ts` — the `GatePrompter` interface: `canConfirm(): boolean` + `prompt(details): Promise<PermissionPromptDecision>`.
`GateRunner` depends on it; `AuthorizerSelection` implements it.
- `src/authority/authorizer-selection.ts``AuthorizerSelection` (the Step 1 rewrite of `PromptingGateway`): selects the `Authorizer` once per activation via `selectAuthorizer`, and today also recomputes `this.confirmable = ctx.hasUI || detection.isSubagent(ctx)` transitionally to keep `canConfirm()` byte-identical until this step.
- `src/authority/authorizer.ts``selectAuthorizer(ctx, deps)`: `hasUI``LocalUserAuthorizer`; `isSubagent``ParentAuthorizer`; else → `DenyingAuthorizer`.
- `src/authority/denying-authorizer.ts``DenyingAuthorizer.authorize()` returns `{ approved: false, state: "denied" }`.
- `src/authority/permission-prompter.ts``PermissionPrompter.prompt(authorizer, details)`: brackets the ask with `permission_request.waiting``authorizer.authorize(details)``permission_request.approved`/`denied`.
- `src/permission-gate.ts``applyPermissionGate(params)`: pure deny/ask/allow decision function; today the `ask` branch short-circuits to a block when `!canConfirm`.
- `src/handlers/gates/runner.ts``GateRunner.runDescriptor`: computes `canConfirm`, builds messages, calls `applyPermissionGate`, emits the decision event.
- `src/handlers/gates/helpers.ts``deriveResolution(state, action, hasSession, canConfirm, autoApproved)`.
- `src/permission-dialog.ts``PermissionPromptDecision`, which already carries the `autoApproved?: true` marker precedent.
Key fact about when `DenyingAuthorizer` is selected: only when **`!ctx.hasUI` AND `!detection.isSubagent(ctx)`** — a no-UI, non-subagent (headless-root) session.
A subagent with a live parent registration selects `ParentAuthorizer` (authority is reachable up the tree), and a UI session selects `LocalUserAuthorizer`.
So `DenyingAuthorizer` is the genuinely-unreachable case: an automated/CI/hook-driven `pi` run with no interactive UI and no parent to escalate to, where an `ask` has no one to answer it — least privilege denies it.
Today this path is **unreachable at runtime** because `canConfirm()` (= `false` here) short-circuits before the prompt; this step makes it reachable.
Constraint from AGENTS.md / the package skill: `docs/architecture/architecture.md` names internal symbols in narrative prose and a module tree, and `.pi/skills/package-pi-permission-system/SKILL.md` documents the test fixtures by their interface types — both must be updated when `GatePrompter` is removed.
The package convention is to mark the completed roadmap step (`✅` on the heading and the Mermaid node) in the implementation doc-update commit, not a deferred ship commit.
## Design Overview
### Decision: uniform escalation (no special-casing)
The `ask` path becomes uniform: it always escalates to the selected `Authorizer` through `PermissionPrompter`, with no pre-check and no bypass branch.
The `DenyingAuthorizer` flows through `PermissionPrompter` exactly like `LocalUserAuthorizer` and `ParentAuthorizer``PermissionPrompter` is **not** changed to special-case it.
This is the purest expression of the target model ("every `Authorizer` answers; the `DenyingAuthorizer` by denying") and dissolves `canConfirm` to zero occurrences with zero replacement predicates.
Consequence for the review log (accepted — see the Decide gate below): the unavailable path is no longer recorded as a single `permission_request.blocked` / `confirmation_unavailable` entry.
It is now recorded as `permission_request.waiting` + `permission_request.denied`, identical in shape to a user denial — except the `denied` entry's `resolution` is preserved as `confirmation_unavailable` (see "Preserve the signal" below).
The standalone gate-written `blocked` / `confirmation_unavailable` review entry is removed; the gate still writes `blocked` / `policy_denied` for the `deny` **state** (unchanged).
The cross-extension `permissions:decision` broadcast is unchanged: it still emits `resolution: "confirmation_unavailable"` for this path, derived from the marker.
### The marker
`PermissionPromptDecision` gains a `confirmationUnavailable?: true` marker, mirroring the existing `autoApproved?: true`:
```typescript
export type PermissionPromptDecision = {
approved: boolean;
state: PermissionDecisionState;
denialReason?: string;
/** True when yolo mode auto-approved this decision (consumed by deriveResolution). */
autoApproved?: true;
/**
* True when no live authority was reachable and the DenyingAuthorizer denied
* this ask. Consumed by deriveResolution (decision event) and by the gate
* (block reason) and PermissionPrompter (review-entry resolution).
*/
confirmationUnavailable?: true;
};
```
`DenyingAuthorizer.authorize()` sets it:
```typescript
authorize(): Promise<PermissionPromptDecision> {
return Promise.resolve({
approved: false,
state: "denied",
confirmationUnavailable: true,
});
}
```
### Preserve the signal in the review entry
`PermissionPrompter.prompt` writes the outcome entry with `resolution: decision.state` today.
Change the denied branch to surface the marker so the "nobody could answer" diagnostic survives in the review log, not just the decision event:
```typescript
this.writeReviewEntry(decision.approved ? "..." : "permission_request.denied", {
...details,
resolution: decision.confirmationUnavailable
? "confirmation_unavailable"
: decision.state,
denialReason: decision.denialReason,
});
```
A normal denial (no marker) is unchanged (`resolution: decision.state`).
### The gate role collapses to a single-method escalation seam
Deleting `GatePrompter` removes the two-method interface `GateRunner` held.
The gate no longer needs `canConfirm()`; it needs only to escalate an ask and receive a decision.
Introduce a single-method role interface, co-located with its implementer in `authorizer-selection.ts` (parallel to the existing `AuthorizerSelectionLifecycle`):
```typescript
/** The ask-escalation seam GateRunner depends on: escalate to the session's Authorizer. */
export interface AskEscalator {
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
}
```
`AuthorizerSelection` implements it (renaming its public `prompt(details)``escalate(details)`; the internal delegation to `PermissionPrompter.prompt(authorizer, details)` keeps its name, which now reads as `escalate()` calling `prompt()` — clear disambiguation).
It drops `implements GatePrompter`, the `canConfirm()` method, the `confirmable` field, and the `this.confirmable = …` computation in `activate`/`deactivate`.
`selectAuthorizer` already encodes the liveness decision in *which* authorizer it returns, so no separate predicate remains.
`GateRunner`'s consumer call site (Tell-Don't-Ask, single method — no reach-through):
```typescript
// runner.runDescriptor — no canConfirm pre-check; always escalate.
let confirmationUnavailable = false;
const gateResult = await applyPermissionGate({
state: check.state,
sessionApproval: descriptor.sessionApproval?.toGateApproval(),
promptForApproval: async () => {
const decision = await this.prompter.escalate({
requestId: toolCallId,
...descriptor.promptDetails,
});
autoApproved = decision.autoApproved === true;
confirmationUnavailable = decision.confirmationUnavailable === true;
return decision;
},
writeLog: (event, details) => this.reporter.writeReviewLog(event, details),
logContext: { ...descriptor.logContext, agentName },
messages,
});
// … emitDecision(… deriveResolution(check.state, gateResult.action,
// hasSessionApproval, confirmationUnavailable, autoApproved))
```
### The gate: always prompt; block reason keyed off the marker
`applyPermissionGate` drops the `canConfirm` param and the `!canConfirm` short-circuit.
The `ask` branch always prompts; on a denial it picks the block reason from the marker:
```typescript
if (state === "ask") {
const decision = await promptForApproval();
if (!decision.approved) {
return {
action: "block",
reason: decision.confirmationUnavailable
? messages.unavailableReason
: messages.userDeniedReason(decision),
};
}
if (decision.state === "approved_for_session" && params.sessionApproval) {
return { action: "allow", sessionApproval: params.sessionApproval };
}
}
```
`writeLog` and `messages` (all three reasons: `denyReason`, `unavailableReason`, `userDeniedReason`) are still needed — `writeLog` for the `deny`-state `blocked` / `policy_denied` entry, `messages.unavailableReason` for the marker-driven block reason.
### `deriveResolution`: marker replaces `canConfirm`
```typescript
export function deriveResolution(
state: "allow" | "deny" | "ask",
action: "allow" | "block",
hasSession: boolean,
confirmationUnavailable: boolean,
autoApproved = false,
): PermissionDecisionResolution {
if (state === "allow") return autoApproved ? "auto_approved" : "policy_allow";
if (state === "deny") return "policy_deny";
// state === "ask"
if (action === "allow") {
if (autoApproved) return "auto_approved";
return hasSession ? "user_approved_for_session" : "user_approved";
}
return confirmationUnavailable ? "confirmation_unavailable" : "user_denied";
}
```
Only the 4th parameter's meaning flips (`canConfirm``confirmationUnavailable`) and the final ternary inverts; the positional shape is otherwise unchanged, so the runner call site updates in place.
## Module-Level Changes
### `src/` (all in Step 2 unless noted)
- `src/permission-dialog.ts`**Step 1**: add `confirmationUnavailable?: true` to `PermissionPromptDecision`.
- `src/authority/denying-authorizer.ts`**Step 1**: return `{ approved: false, state: "denied", confirmationUnavailable: true }`; update the class doc comment.
- `src/authority/permission-prompter.ts`**Step 1**: denied-entry `resolution` surfaces the marker.
- `src/gate-prompter.ts`**DELETE**.
- `src/authority/authorizer-selection.ts` — introduce `AskEscalator`; `AuthorizerSelection implements AskEscalator, AuthorizerSelectionLifecycle`; remove `canConfirm()`, the `confirmable` field, and its `activate`/`deactivate` assignments; rename `prompt``escalate`; drop the `GatePrompter` import; update the class/method doc comments.
- `src/handlers/gates/runner.ts` — remove the `const canConfirm = this.prompter.canConfirm()` line; change the `prompter` field type `GatePrompter``AskEscalator`; call `this.prompter.escalate(...)`; capture `confirmationUnavailable` in the `promptForApproval` closure; drop `canConfirm` from the `applyPermissionGate` call; pass `confirmationUnavailable` to `deriveResolution`; update the `GatePrompter` import to `AskEscalator`.
- `src/permission-gate.ts` — remove `canConfirm` from `PermissionGateParams`; rewrite the `ask` branch (always prompt; block reason via the marker); remove the `!canConfirm` block-and-log arm; update the `promptForApproval` doc comment (drop "and canConfirm is true").
- `src/handlers/gates/helpers.ts``deriveResolution`: 4th param `canConfirm``confirmationUnavailable`; invert the final ternary; update the JSDoc `@param`.
`index.ts` is **unchanged**: it constructs `AuthorizerSelection` and passes it to `GateRunner` by value; `AuthorizerSelection` still satisfies the (now single-method) role, so only the static type flows differently.
`permission-session.ts` is **unchanged**: it depends on `AuthorizerSelectionLifecycle` (activate/deactivate), not `GatePrompter`.
Grep confirmation (no other `GatePrompter`/`canConfirm` consumers in `src/`): the only `src/` importers of `GatePrompter` are `authorizer-selection.ts` and `runner.ts`; the only `canConfirm` `src/` sites are `authorizer-selection.ts`, `gate-prompter.ts`, `permission-gate.ts`, `runner.ts`, `helpers.ts` — all listed above.
### Docs (Step 3 — same commit that marks the roadmap step complete)
- `docs/architecture/architecture.md`:
- Module tree (line ~739): remove the `gate-prompter.ts` entry.
- `runner.ts` tree entry (line ~766): `GatePrompter (AuthorizerSelection, #555)``AskEscalator (AuthorizerSelection)`.
- `authorizer-selection.ts` tree entry (line ~824): "context-owning `GatePrompter` implementation … delegates `prompt(details)`" → `AskEscalator` / `escalate(details)`.
- Target-model narrative (lines ~497, ~554, ~580): note `canConfirm()` is now dissolved (was "survives as a transitional predicate").
- Roadmap Step 2 (lines ~882886): `✅` on the heading; correct the **Outcome** line — the unavailable path is now `waiting` + `denied` (`resolution: confirmation_unavailable`), the standalone `blocked` entry is removed, and the decision event keeps `confirmation_unavailable`**not** "byte-identical to today" (design decision: uniform escalation over log-shape preservation).
- Mermaid node `S2` (line ~919): `✅ Step 2 (#556)`.
- Leave the phase-exit metrics table (lines ~859860) as-is — it is a target table (Phase-8-exit vs Phase-9-target), not a running tally, and Phase 9 is not complete (Steps 3, 4 remain), matching the #555 precedent.
- `docs/architecture/permission-prompter.md` (lines ~7374): `authorizerSelection implements GatePrompter … canConfirm()/prompt(details) role` → implements `AskEscalator`; `GateRunner` calls `this.prompter.escalate(details)`; note step 2's `DenyingAuthorizer` is now reachable and denies with the `confirmationUnavailable` marker (and that the denied review entry surfaces `resolution: confirmation_unavailable`).
- `.pi/skills/package-pi-permission-system/SKILL.md` (line ~145): the `handler-fixtures.ts` description `prompter: GatePrompter``prompter: AskEscalator`.
Historical records (`docs/plans/*`, `docs/retro/*`, `docs/architecture/history/*`) are not edited — they are point-in-time records.
## Test Impact Analysis
New unit tests enabled:
1. `DenyingAuthorizer` returns the `confirmationUnavailable` marker (`test/authority/denying-authorizer.test.ts`).
2. `PermissionPrompter` surfaces the marker as `resolution: confirmation_unavailable` in the denied entry while leaving a plain denial as `resolution: denied` (`test/authority/permission-prompter.test.ts`).
3. Uniform escalation is now reachable end-to-end: a `DenyingAuthorizer`-style decision drives `waiting` + `denied` review entries, a `confirmation_unavailable` decision event, and an `unavailable`-reason block — asserted via the runner and external-directory integration suites through a marker-returning `escalate` stub.
Redundant / simplified tests:
- `test/authority/authorizer-selection.test.ts` — the entire `describe("canConfirm", …)` block (lines ~88126, ~199209) is removed (the method is gone); update the file header comment.
- `test/permission-gate.test.ts` — the `ask branch — unavailable` cases keyed on `canConfirm: false` are rewritten to drive the unavailable outcome from a `confirmationUnavailable` decision returned by `promptForApproval`; the "does not call promptForApproval when canConfirm is false" assertion is deleted (the `ask` branch now always prompts).
- `test/handlers/gates/helpers.test.ts` — the two `deriveResolution` ask+block cases swap the 4th arg semantics (`canConfirm``confirmationUnavailable`) and update the test names.
Tests that must stay (genuinely exercise the preserved layer):
- `test/authority/permission-prompter.test.ts` waiting-before-outcome ordering — the bracketing is **not** reordered; this invariant must stay green.
- `test/handlers/gates/runner.test.ts` and `test/handlers/external-directory-integration.test.ts` `confirmation_unavailable` decision-event assertions — still valid, now marker-driven.
- The yolo `auto_approved` single-entry path ([#526]) — untouched; must stay green.
## Invariants at Risk
This step touches the ask-path bracketing and the `DenyingAuthorizer`, both refactored in [#555].
[#555]'s documented invariants and their pinning tests:
- **Review-log bracketing order** (`waiting` before the authorizer is consulted) — pinned by `test/authority/permission-prompter.test.ts` ("logs permission_request.waiting before the outcome").
Preserved: `PermissionPrompter.prompt` is not reordered.
- **`DenyingAuthorizer`/`ParentAuthorizer` do not emit the UI-prompt event** — pinned by the Step 1 "does-not-emit" assertions.
Preserved: the marker addition adds no UI event; `DenyingAuthorizer.authorize` still only denies.
- **yolo emits a single `auto_approved` entry** ([#526]) — pinned in the runner yolo fast-path tests.
Preserved: the yolo fast-path is upstream of the gate and untouched.
- **`confirmation_unavailable` decision-event resolution still emitted** — pinned by `test/handlers/gates/runner.test.ts` and the external-directory integration suite; re-pointed at the marker in Step 2.
The one **deliberate** change to a prior outcome: [#555]/the roadmap described the unavailable path as a single `blocked` / `confirmation_unavailable` review entry.
Uniform escalation changes it to `waiting` + `denied` (`resolution: confirmation_unavailable`).
This is an intentional design decision (recorded in the Decide gate), and the architecture roadmap's Step 2 **Outcome** line is corrected to match in Step 3 — so a later reader does not treat the old "byte-identical" wording as a regressed invariant.
## TDD Order
1. **Add the `confirmationUnavailable` marker (preparatory, additive).**
Red→green across three unit surfaces: `PermissionPromptDecision` gains the field; `DenyingAuthorizer.authorize` returns it; `PermissionPrompter`'s denied entry surfaces `resolution: confirmation_unavailable` when the marker is set (and `denied` otherwise).
Nothing else breaks — the field is new and the `DenyingAuthorizer` path is still unreachable in production (this is the tidy-first "make the change that makes the change easy").
Tests: `test/authority/denying-authorizer.test.ts`, `test/authority/permission-prompter.test.ts`.
Commit: `refactor(pi-permission-system): mark DenyingAuthorizer decisions confirmation-unavailable`.
2. **Dissolve `canConfirm()` — atomic (interface removal + all consumers + all tests).**
Deleting `GatePrompter`, removing `canConfirm` from `PermissionGateParams`, and changing `deriveResolution`'s signature each break `runner.ts` and its tests at the type level simultaneously, so they land together (per the testing-skill export-removal rule).
- `src/`: delete `gate-prompter.ts`; add `AskEscalator` + rework `authorizer-selection.ts`; rewire `runner.ts`, `permission-gate.ts`, `helpers.ts` as in Module-Level Changes.
- Migrate every test/fixture off the two-method `{ canConfirm, prompt }` mock: `test/helpers/gate-fixtures.ts` (drop the `canConfirm` option; single-method `{ escalate }` role; retype `GatePrompter["prompt"]``AskEscalator["escalate"]`), `test/helpers/handler-fixtures.ts` (`prompter: AskEscalator`; default stub is `{ escalate }`), `test/helpers/external-directory-fixtures.ts` (the "unavailable" prompter returns a `confirmationUnavailable` decision from `escalate` rather than `canConfirm: false`), and the per-file `vi.fn<GatePrompter["prompt"]>()` sites in `test/handlers/{input,input-events,tool-call,tool-call-events}.test.ts``AskEscalator["escalate"]`.
- Update `test/permission-gate.test.ts`, `test/handlers/gates/helpers.test.ts`, `test/authority/authorizer-selection.test.ts` per Test Impact Analysis.
- The `GatePrompter["prompt"]``AskEscalator["escalate"]` retype and the `prompt:``escalate:` mock-key rename are single-line per-symbol edits (safe to script per AGENTS.md; no multi-line regex).
Run `pnpm run check` immediately after this commit (shared-interface + export removal).
Commit: `refactor(pi-permission-system): dissolve canConfirm; the ask path always escalates`.
3. **Docs + roadmap completion.**
Update `architecture.md` (tree, narrative, Step 2 `✅` heading + Mermaid node, corrected Outcome line), `permission-prompter.md`, and the package SKILL fixture note per Module-Level Changes.
Commit: `docs(pi-permission-system): mark Phase 9 Step 2 complete; dissolve canConfirm`.
## Risks and Mitigations
- **Review-log output changes for the unavailable path.**
Accepted at the Decide gate (operator prioritized a coherent codebase over log-shape stability).
Mitigation: the `confirmation_unavailable` resolution is preserved in both the decision event and the review-log `denied` entry, so the diagnostic signal survives; the Step 3 doc correction records the change so it is not later mistaken for a regression.
- **Large atomic Step 2 (interface deletion fans out to ~10 test files).**
Mitigation: Step 1 shrinks it by pre-landing the marker; the fan-out edits are mechanical single-token retypes; `pnpm run check` runs immediately after the commit; the full suite runs before the docs commit.
- **A hidden third consumer of `GatePrompter`/`canConfirm`.**
Mitigation: the grep in Module-Level Changes enumerates every `src/` importer and every `canConfirm` `src/` site; `tsc` will reject any missed consumer after `gate-prompter.ts` is deleted.
## Open Questions
None — the two design forks (uniform escalation vs. bracketing-bypass; preserve vs. collapse the review-log signal) were resolved at the Decide gate: **uniform escalation + preserve the signal in the review entry.**
No follow-up issues are filed; the deferred work ([#557] Step 3, [#472] `ModelTriageAuthorizer`) already has tracking issues.
[#472]: https://github.com/gotgenes/pi-packages/issues/472
[#526]: https://github.com/gotgenes/pi-packages/issues/526
[#527]: https://github.com/gotgenes/pi-packages/issues/527
[#555]: https://github.com/gotgenes/pi-packages/issues/555
[#557]: https://github.com/gotgenes/pi-packages/issues/557