12 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 558 | pi-permission-system: grant-scope selection on forwarded approvals |
Retro: #558 — grant-scope selection on forwarded approvals
Stage: Planning (2026-07-09T00:00:00Z)
Session summary
Planned Phase 9 Step 4: offering the human at the serving node a scope ("this subagent only" vs "the whole session") when approving a forwarded "for this session" request.
The design rides the child's already-computed SessionApproval suggestion into the ForwardedPermissionRequest, adds a serving-node-internal approved_for_serving_session decision state, and records a whole-session grant into the serving node's shared SessionRules so #557's serve-time evaluation suppresses future prompts for the parent and its children.
Committed docs/plans/0558-forwarded-grant-scope-selection.md (ad5403ab) as three implementation cycles (feat/feat/test) plus a docs cycle.
Observations
- Two
ask_userdecisions drove the design (operator is the issue author,gotgenes): (1) a whole-session grant records only on the serving node — the child gets a plainapproved, does not record, and re-forwards its next identical action (single source of truth over child-avoids-re-forward); (2) a two-step dialog — the base 4-option prompt is unchanged, and picking "for this session" opens a second scopeselectonly for a forwarded ask that carries a suggestion. The two-step choice keptrequestPermissionDecisionFromUi's local-ask path byte-identical. approved_for_serving_sessionis serving-node-internal: the dialog produces it,ForwardedRequestServer.applyGrantScoperecords + translates it toapprovedbefore the response is written, so it never reaches disk or the child. Added to the union andisPermissionDecisionState(guard completeness) but the on-diskForwardedPermissionResponse.statestays within the four legacy values. Grep-verified the only state branch sites are the dialog andpermission-gate.ts(checksapproved_for_sessiononly, unaffected) — nonever-exhaustive switch, so widening the union is low-ripple.- Marker vs new-state: considered a
grantScopemarker (likeautoApproved/confirmationUnavailable) but chose an honest new state; the server's translation toapprovedis the load-bearing part either way, and a marker leaves the state saying "subagent" while meaning "serving." - Invariants at risk carried forward from #557: the #292 non-degraded broadcast (emit fires once before the scope select), the one-emit-site rule, and the
processSingleForwardedRequest < 60 lineshealth target (hold by keepingapplyGrantScopea separate method; the #557 retro's explicit lesson was to runpnpm fallow health, not justfallow dead-code). - Doc surfaces beyond
src/:docs/subagent-integration.md(README-linked) describes the forwarding approve/deny flow and needs the scope choice; the roadmap step is marked complete in the implementation docs commit (package-skill rule), and a new ADR-0006 records the two decisions. - Design-review pass: the widened interfaces (
PromptPermissionDetails+1,ForwardedPermissionRequest+1,ForwardedRequestServerDeps+1 narrowSessionApprovalRecorder,RequestPermissionOptions+1) each follow an established precedent (Step 3'sforwardingfield, the existing optional display fields) and introduce no LoD/output-argument/scattered-decision smell;SessionApproval.toForwardedData()avoids field reach-through inGateRunner. - No follow-ups filed: the three-way scope and cross-cwd/cross-surface portability are admitted-not-shipped, already tracked by the roadmap's resolved-direction 4 and #565 — nothing speculative to file.
Stage: Implementation — TDD (2026-07-09T14:00:00Z)
Session summary
Implemented all four planned cycles from a green 2290-test baseline, landing Phase 9 Step 4.
Cycle 1 (feat) rides the child's SessionApproval into the forwarded request (toForwardedData, PromptPermissionDetails.sessionApproval, ForwardedPermissionRequest.sessionApproval); cycle 2 (feat) wires the serving-node scope selection end-to-end (new approved_for_serving_session state, two-step dialog, buildForwardedScopeLabels, ForwardedRequestServer.applyGrantScope + recorder dep, index.ts); cycle 3 (test) adds two composition-root round-trip tests; cycle 4 (docs) adds ADR-0006, marks the roadmap step complete, and updates subagent-integration.md.
Final suite 2310 tests (+20); pre-completion reviewer returned PASS.
Observations
- Pre-completion reviewer: PASS — all deterministic gates green (
check, rootlint,test2310,fallow dead-code); every named cross-step invariant held. No warnings. - Plan deviation (one file):
src/authority/forwarding-io.tswas not in the plan's Module-Level Changes but had to change. The tolerant request read (readForwardedPermissionRequest) reconstructs only known fields, so the newsessionApprovalwas silently stripped on read — the cycle-2 server red test (records a whole-session grant) surfaced it (recorder never called). Added anasForwardedSessionApprovalnarrowing helper mirroring the file's existingasNullableDisplayString/asUiPromptSourcetolerant parsers. Lesson for future plans: when a plan adds an optional field to a serialized contract with a tolerant (field-allowlist) reader, list the reader as a touch point — an on-disk round-trip is not free. - Step 3 invariant held:
processSingleForwardedRequeststayed at ~43 lines (< 60) becauseapplyGrantScopewas factored as a separate method and the existingrecordForwardedDecision(...)call site absorbed the one added call — verified with a raw line count, per the #557 retro'sfallow healthlesson. - #292 non-degraded broadcast held: the single
permissions:ui_promptemit still fires once inLocalUserAuthorizer.authorizebefore the firstselect; the new second (scope)selectlives downstream inrequestPermissionDecisionFromUiand does not perturb it. - Two self-corrected lint slips (both
@typescript-eslint/no-unnecessary-condition): a test useddetails && "sessionApproval" in details(rewrote toexpect.not.objectContaining), andbuildRequestOptionsusedpattern ?? "*"on astring-typed element (rewrote to guardpatterns[0]truthiness — a suggestion with no usable pattern simply offers no scope). Both caught by the pre-commit eslint hook, fixed before the commit landed. - Round-trip tests are real, not hollow: they drive two real factory instances on separate buses with real 250ms polling — the parent's serving poll runs the actual two-step dialog via a scope-aware
ui.select, records into the sharedSessionRules, and the child re-forwards. Whole-session proves no second prompt + parent's own action session-approved; subagent-only proves scope containment (parent still prompts). - Serving-node-only recording confirmed end-to-end: the whole-session choice returns
approved_for_serving_session,applyGrantScoperecords + translates to plainapproved, and the round-trip test confirms the child records nothing and re-forwards.
Stage: Final Retrospective (2026-07-09T18:15:00Z)
Session summary
Shipped Phase 9 Step 4 end-to-end in one continuous context: planned it (two ask_user design decisions), executed four TDD cycles from a green 2290-test baseline to 2310, passed the pre-completion reviewer on the first dispatch, then pushed, closed #558, and merged release-please PR #567 (pi-permission-system v20.3.0).
The run was clean — no rework commits and no user-caught errors; the only friction was one under-listed plan touch point (surfaced and fixed in-cycle by a red test) and two self-corrected lint slips the pre-commit hook caught before they landed.
Observations
What went well
- First-dispatch pre-completion PASS.
Unlike #557 (which needed a FAIL→fix→re-dispatch on an unmet LOC target), the reviewer returned PASS on the first run — the plan carried the Step 3
< 60 linesinvariant forward explicitly and the implementation held it (applyGrantScopefactored as a separate method,processSingleForwardedRequestat 43 lines), so the reviewer verified rather than caught it. - Real, non-hollow round-trip tests.
The composition-root tests drive two real factory instances on separate event buses with real 250ms polling; the parent's serving poll runs the actual two-step
ui.selectdialog, records into the sharedSessionRules, and the child re-forwards — proving the whole-session grant suppresses a second prompt and the subagent-only grant stays contained, not just that the units wire up. - Clean release handling on an
UNSTABLEPR.release_pr_mergerefused withUNSTABLE;statusCheckRollupshowed a genuineIN_PROGRESSCI check (not the empty-rollupGITHUB_TOKENcase), so the flow waited on the PR's own run viaci_watch, then retried and merged by rebase — the ship protocol's distinction held without a force-merge.
What caused friction (agent side)
missing-context(plan stage) — the plan's Module-Level Changes omittedsrc/authority/forwarding-io.ts. Its tolerant reader (readForwardedPermissionRequest) reconstructs only an allowlist of known fields, so the newsessionApprovalwas silently stripped on read. Impact: added friction, no rework — the cycle-2 server red test (records a whole-session grant, recorder never called) surfaced it immediately, and anasForwardedSessionApprovalhelper (mirroring the file's existingasXparsers) fixed it in the same cycle/commit.tsccould not have caught it (the field is optional), only the cross-consumer round-trip.other(TDD stage) — two self-corrected lint slips, both@typescript-eslint/no-unnecessary-condition, caught by the pre-commit eslint hook before landing. The first (deps.escalate.mock.calls[0]?.[0]then a truthiness guard) is the exactfn.mock.calls[0]anti-pattern thetestingskill already names — a salience miss, not a missing rule; rewrote toexpect.not.objectContaining. The second (pattern ?? "*"on astring-typed element) rewrote to guardpatterns[0]truthiness. Impact: two blocked commit attempts, each fixed in one edit; no re-planning.
What caused friction (user side)
- None material.
The two planning
ask_userdecisions (serving-node-only recording; two-step dialog) were answered promptly and decisively, and both shaped the design cleanly — the two-step choice in particular kept the local-ask dialog byte-identical.
Diagnostic details
- Model-performance correlation — the only subagent dispatch was the
pre-completion-revieweronanthropic/claude-sonnet-5(48 tool uses, ~206s): judgment-heavy verification (invariant cross-checks, LOC measurement, acceptance-criteria tracing) well-matched to the model. No mismatch. - Escalation-delay tracking — no
rabbit-holepoints; both lint slips and the plan-touch-point miss resolved in a single corrective edit each, none exceeding one tool call. - Unused-tool detection — nothing missed; the work ran in one context with full prior knowledge, so
colgrep/Explorewere unnecessary, and the pre-commit hook + red tests caught the two slips that a pre-emptive tool could have. - Feedback-loop gap analysis — incremental verification was strong (
pnpm run checkafter each shared-type change, per-file red/green vitest, full suite + root lint before commits). The one small gap: cycles 1 and 2 attemptedgit commitbefore runningpnpm run lint, relying on the pre-commit eslint hook to catch the slips — low impact (the hook is exactly that net), but running lint before the commit attempt would avoid the blocked-commit round-trip.
Changes made
- Appended this Final Retrospective stage entry to
packages/pi-permission-system/docs/retro/0558-forwarded-grant-scope-selection.md. - Added a tolerant-reader touch-point heuristic to
.pi/prompts/plan-issue.md(Module-Level Changes section): a plan step that adds a field to a serialized contract whose reader reconstructs only an allowlist of known fields must list that reader, since the field is silently dropped on read and the gap surfaces only in a cross-consumer round-trip test, nottsc(Refs #558).