17 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 635 | Forwarded accessIntent is dropped before Authorizer Chain escalation |
Retro: #635 — Forwarded accessIntent is dropped before Authorizer Chain escalation
Stage: Planning (2026-07-26T17:26:32Z)
Session summary
Planned the fix for a third-party bug report (LukeWang-Plus): buildForwardedAskDetails() in src/authority/forwarded-request-server.ts drops request.accessIntent when reconstructing PromptPermissionDetails for a forwarded ask, so an Authorizer Chain link receives display strings instead of the child-fixed access facts.
Tracing the consumer surfaced a second, unreported defect from the same missing field: delegation-envelope.ts decides exclusion with details.accessIntent?.surface ?? details.surface, so a forwarded ask falls back to the display surface (the tool name, e.g. write) and a chain link's allow on a forwarded path-gate ask escapes the bounded-delegation checkpoint that ADR 0007 §5 applies to the identical local ask.
Plan committed at packages/pi-permission-system/docs/plans/0635-forwarded-access-intent-to-authorizer.md as two steps: one red→green cycle (projection + envelope pin + user-facing breaking-change docs) and one docs commit.
Observations
- Third-party issue, so the
ask_userdirection gate was mandatory. It took three rounds: the operator first asked how #635 relates to #610 (which also has a provenance/serving-node flavor), then asked for concrete examples of the envelope side effect before deciding. Both follow-ups were legitimate — the first ask presented the envelope consequence abstractly, and an abstract security-boundary tradeoff is not decidable. Lesson for future planning asks: when a question is about a behavior change, lead with the concrete before/after scenarios, not the option labels. - Operator decisions: (a) plan #635 alone but record the shared cross-issue principle so #610's planning session inherits a decided frame; (b) accept the delegation tightening and ship it as a breaking change (
fix(pi-permission-system)!:,23.0.3→24.0.0). - The #610 relationship resolved into a coherent principle rather than a merge: both issues stem from the serving node reconstructing a degraded projection of the forwarded request, but they pull in opposite disclosure directions — #635 wants richer evidence on a trusted in-process seam, #610 explicitly wants less text on the any-extension
pi.eventsbroadcast. The principle recorded as "Reconstruction fidelity at the serving node" (fidelity up for in-process seams, disclosure down for broadcasts) covers both halves. It goes indocs/architecture/architecture.mdunder## The authority model, not a new ADR — it is descriptive of already-decided architecture (ADR 0007 §5, ADR 0008 §2). - The fix realizes an assertion ADR 0008 already makes: "a serving node's chain links … review forwarded asks against the child-fixed fact set." So the ADR needs no edit; the code was simply not yet true to it.
- Disclosure boundary is the sharp design point.
ForwardedAccessIntentis structurally assignable toForwardedAccessFacts, soaccessIntent: request.accessIntenttype-checks while carryingrequesterCwdandprincipalat runtime — a silent wideningtsccannot catch. The plan uses a field-by-fieldtoAccessFactshelper with an explicitForwardedAccessFactsreturn type (compile-checked against future field drift) plus an exact-keys test assertion. - Verified rather than argued that the
permissions:ui_promptpayload is unaffected:buildUiPrompt(src/permission-ui-prompt.ts) reads onlyrequestId/source/surface/value/agentName/message/forwardingand neveraccessIntent, so the #292 non-degraded-broadcast contract is byte-identical after the change. - Scoping check that narrowed the blast radius: the tightening bites only when the cross-cutting
path/external_directorygate raised the forwarded ask. Abashask and a per-tool ask (wheretool.tssetsgateSurface = tcc.toolName) already agree with the display surface, so they are unchanged — which also means #620's allow-capable opaque-bash adjudicator is untouched by this fix. - Test-gap note worth carrying into implementation:
delegation-envelope.test.tstests the envelope over synthetic details andforwarded-request-server.test.tstests the server over a fake escalator, but nothing composed the two. That gap is exactly why the forwarded escape went unnoticed, so the plan adds a composition test rather than two more unit assertions. - Only one exact-object assertion on
escalateexists in the suite (grep -rn "escalate).toHaveBeenCalledWith({" test/→ 1 hit), so the red step is well bounded. - No follow-up issues filed — #610 and #620 already exist and cover the deferred work.
Stage: Implementation — TDD (2026-07-26T17:41:26Z)
Session summary
Implemented both planned steps in two commits: c0790ad6 (fix!) landed the toAccessFacts projection in buildForwardedAskDetails, the corrected PromptPermissionDetails.accessIntent doc comment, four tests, the migration note, and the docs/configuration.md sentence; 08164e85 (docs) landed the architecture doc's new "Reconstruction fidelity at the serving node" subsection, the forwarded-request-server.ts module-tree clause, and the package skill update.
Production change was ~20 lines (one new module-private function plus a conditional spread).
Test count 2668 → 2672 (+4); check, root lint, full test, and fallow dead-code all green.
Observations
- The
tidy-first-assessorreturned "no preparatory tidying warranted" and explicitly declined to extract the file-localmakeQuery/makeLink/makeDetailshelpers fromdelegation-envelope.test.tsandauthorizer-selection.test.ts, reasoning that the change touches neither file and that the new composition test deliberately uses the real captured details rather than a syntheticmakeDetails. That is the correct scope boundary, and a useful data point for the assessor's first-live-use checkpoint. - The red step was strong evidence rather than ceremony: the envelope-composition test failed with
expected { kind: 'allow' } to deeply equal { kind: 'defer' }, which demonstrated the checkpoint escape rather than merely asserting the plan's claim about it. The plan predicted three of the five new/edited assertions would fail; exactly those three did, and the two written as guards (forwardedbashpasses through; version-skew absence) passed from the start, confirming they guard rather than drive. - Deviation from the plan, deliberate: the plan's invariants table proposed pinning the disclosure boundary with
Object.keys(details.accessIntent).sort(). A whole-objecttoEqualondetails.accessIntentis strictly stronger —toEqualrequires matching key sets for non-undefinedvalues, so it rejects a leakedrequesterCwd/principaland pins the values — and matches the testing skill's "prefer strong assertions that match the entire expected value". The pre-completion reviewer independently endorsed this. rumdl'sMD057false-positived on the newdocs/configuration.mdmigration link when it sat inside a parenthetical at the end of a very long line (column 321), reporting the target "does not exist" although it did. Splitting the line into three sentences (the project's one-sentence-per-line convention anyway) cleared it. Worth remembering: anMD057"does not exist" on a link whose target demonstrably exists is a line-length/parse artifact, not a broken path — fix the line, do not go hunting the path.- No production change was needed in
delegation-envelope.ts. Its existingdetails.accessIntent?.surface ?? details.surfacealready preferred the gate-authoritative surface; it simply never had one for a forwarded ask. The fix is entirely upstream of the checkpoint, which is why the blast radius stayed as narrow as the plan predicted. - Pre-completion reviewer: PASS — ready for
/ship-issue. No warnings. It independently verified all five invariants (including greppingbuildUiPromptto confirm it never readsaccessIntent, rather than accepting the plan's claim) and confirmed zero diff ondelegation-envelope.ts,permission-ui-prompt.ts, andforwarding-io.ts.
Stage: Final Retrospective (2026-07-26T18:02:31Z)
Session summary
One continuous session carried #635 from a third-party bug report through planning, TDD, and ship: pi-permission-system@24.0.0 released with a ~20-line production change (toAccessFacts projection in buildForwardedAskDetails), four new tests, a migration note, and a new architecture-doc principle.
The reported defect (an Authorizer Chain link receiving no structured access facts for a forwarded ask) came with a second, unreported one attached: the bounded-delegation checkpoint was reading the display surface for forwarded asks, so a forwarded path ask escaped an exclusion the identical local ask was subject to.
The dominant friction was a three-round ask_user gate that only became decidable once concrete before/after scenarios replaced abstract option labels.
Observations
What went well
- The
/plan-issuethird-party gate created the space that found the second bug. Because the prompt forbids treating a third-party issue as a spec, the session traced the consumer of the dropped field (delegation-envelope.ts) instead of implementing the reported three-line fix directly. That trace is what surfaced the checkpoint escape — a higher-severity defect than the one filed. Had the issue been the operator's own, the "proposed change is the working hypothesis" path would plausibly have shipped the reported fix and the escape silently along with it. - The red step produced evidence, not ceremony.
The new composition test failed with
expected { kind: 'allow' } to deeply equal { kind: 'defer' }— a literal demonstration of the security escape, not an assertion of the plan's claim about it. The plan had predicted the exact test gap that hid the bug (delegation-envelope.test.tstests the envelope over synthetic details;forwarded-request-server.test.tstests the server over a fake escalator; nothing composed the two), and predicted which three of five assertions would fail. All three predictions held. - A disclosure boundary was made compile-checked rather than commented.
ForwardedAccessIntent extends ForwardedAccessFacts, so the obviousaccessIntent: request.accessIntenttype-checks while leakingrequesterCwd/principalat runtime. The field-by-fieldtoAccessFactshelper with an explicitForwardedAccessFactsreturn type turns any future field on that interface into a compile error at the projection site. tidy-first-assessorscope boundary held again, declining to extract shared helpers fromdelegation-envelope.test.ts/authorizer-selection.test.tsbecause the change touches neither file.
What caused friction (agent side)
missing-context— the firstask_userpresented the bounded-delegation side effect as three abstract option labels ("accept the tightening / accept as breaking / preserve") with no concrete scenario. The operator bounced it twice: first asking how #635 related to #610, then "Give me some concrete examples to help me understand." Every fact needed for the A/B/C scenario table (forwardedbashunchanged, forwardedpath-gate changed, forwarded per-tool-gate unchanged) was already loaded in context at the time of the first ask —tool.ts,path.ts,delegation-envelope.ts, andpermission-ui-prompt.tshad all been read. Impact: two extra operator round-trips and 2 extragh issue viewcalls (#610, #620); no rework of any artifact, since nothing had been written yet.missing-context— related open issues were never searched for. Step 4 of/plan-issuesays to read every issue the body references; #635's body references none, so the sweep returned nothing and the session asked its first question without knowing #610 existed. The operator supplied it. Impact: folded into the round-trip cost above; also meant the cross-issue principle (the eventual "Reconstruction fidelity at the serving node" subsection) was operator-prompted rather than agent-proposed.instruction-violation(self-identified) — the newdocs/configuration.mdsentence was written as a single 321-column line with three sentences in it, violating themarkdown-conventionsone-sentence-per-line rule.rumdlreported it asMD057"Relative link ... does not exist" for a file that demonstrably existed, sending the session to verify the path (ls -la docs/migration/) before recognizing the real cause. Impact: 4 tool calls; fixed by splitting the sentence, which was the correct formatting anyway.other(path slip) — areadused/Users/chris/development/pi/pi-permission-system/..., dropping thepi-packages/packages/segment. Impact: 1 wasted call. Worth recording as dogfooding: this package's ownexternal_directorygate denied it and the denial message named the corrected absolute path, so recovery was immediate.other(typo) —git log --oneline 3instead of-3. Impact: 1 wasted call.instruction-violation(self-identified) — the retro session loaded thegithub-voiceskill, which the/retroprompt does not list. Impact: wasted context, no rework.
What caused friction (user side)
- Nothing to correct — both operator bounces were the right intervention and materially improved the outcome. The second ("give me concrete examples") converted an undecidable abstract security tradeoff into a decision the operator made confidently in one round. The one forward-looking opportunity: the operator was holding #610 as relevant context before the first ask. Surfacing a known-related issue at issue-triage time would have shortcut a round — though the more robust fix is on the agent side, since the agent should search for siblings rather than depend on the operator remembering.
Diagnostic details
- Model-performance correlation — well matched, no mismatch found.
Planning, TDD, and this retrospective ran on
anthropic/claude-opus-5(judgment-heavy: a security-boundary tradeoff, a breaking-change call, and a cross-issue design principle). Ship ran onanthropic/claude-sonnet-5(mechanical: push, CI polling, release-PR merge) — appropriate cost placement. Both subagents (tidy-first-assessor,pre-completion-reviewer) ran onanthropic/claude-sonnet-5, correct for read-only judgment work. - Escalation-delay tracking — no
rabbit-holefriction points. The longest same-error sequence was theMD057false positive at 3 consecutive calls, well under the 5-call escalation threshold. - Unused-tool detection — for the related-issue
missing-context:gh issue list --state open --searchwas available and never run, andcolgrepwas never used to find other open work touchingforwarded-request-server.ts. Either would have surfaced #610 without the operator supplying it. - Feedback-loop gap analysis — no gap.
Verification ran incrementally throughout: full
check/lint/testbaseline before any edit; file-scopedviteston red and again on green; package-scopedcheckplus full package suite immediately after the green edit; rootlintafter each documentation edit (which is what caught theMD057artifact before commit); fulltest/check/rootlint/fallow dead-codeafter the last step;lintandfallowagain as pre-push gates. - Assessor checkpoint status — the
tidy-firstskill carries a "first-live-use checkpoint (added 2026-07-13)" instructing removal once the scope boundary has held across a handful of issues. 42 retro files now reference the assessor, with the boundary explicitly recorded as holding in0579,0594,0611, and this one, and its recommendations taken as-is in0538and0611. The validation window is well past "a handful." Operator decision at retro: do not retire it yet — the assessor's frequent "no preparatory tidying warranted" verdict is exactly what a weaker model would also produce, so the checkpoint is narrowed to one more validation run onanthropic/claude-opus-5before deletion.
Changes made
.pi/prompts/plan-issue.md(Decide section) — added the rule that anask_useroption set whose differentiator is a behavior change must carry the concrete before/after in the pre-ask message, alongside the existing #533 unfamiliar-domain rule..pi/prompts/plan-issue.md(Gather context step 4) — extended related-issue gathering past body-referenced issues to an open-issue search on the same module or symbol..pi/skills/markdown-conventions/SKILL.md(Lines and sentences) — added theMD057-false-positive mapping: an existing relative link reported missing is a long-sentence artifact, not a broken path..pi/skills/tidy-first/SKILL.md— rewrote the first-live-use checkpoint as a model checkpoint rather than deleting it: the scope boundary has held, but the next dispatch over a structurally substantial change must run onanthropic/claude-opus-5to confirm the recurring "nothing warranted" verdict is genuine and not a model-strength artifact.