13 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 596 | pi-permission-system: carry the structured access intent onto the forwarded-permission wire |
Retro: #596 — carry the structured access intent onto the forwarded-permission wire
Stage: Planning (2026-07-18T15:25:00Z)
Session summary
Planned Phase 12 Track A Step 2 — thread the child-fixed access facts from the raising gate through the escalation edge and onto the forwarded wire as the ForwardedAccessIntent field ADR 0008 (Step 1) specified.
The plan is a feat: sequence of six small cycles (wire type + tolerant read; edge serialization; four gate-emission steps) plus a docs: completion step, filed at packages/pi-permission-system/docs/plans/0596-structured-intent-forwarded-wire.md.
Skipped the ask_user gate: the issue is the operator's own and its proposed change is fully constrained by the already-accepted ADR 0008.
Observations
- Fact / identity split drove the design.
ADR 0008 groups a forwarded ask into what is accessed (fixed at the child gate) and who/where requests (a requester-session property).
The plan mirrors that: the gate emits
{ surface, matchValues, boundaryValue }(only it can produce the match set off theAccessPath), and the escalation edge (ParentAuthorizer) stampsrequesterCwd+principal. This avoids threading cwd into every gate and localizes principal-stamping to the one layer that owns session identity. ForwarderContextgainscwd(fromExtensionContext.cwd, already present) soParentAuthorizersourcesrequesterCwdat the edge — the one shared-interface tightening. Its fixture blast radius is contained by the centralmakeForwarderContextfactory (test/helpers/forwarding-fixtures.ts); inlineForwarderContextfakes must addcwdin the same commit (the AGENTS.md tightened-shared-type fixture-grep rule).- No
GateDescriptorchange needed.GateDescriptor.promptDetailsisOmit<PromptPermissionDetails, "requestId">, and the runner spreadspromptDetailsintoescalate(...), so addingaccessIntent?toPromptPermissionDetailsmakes it ride through every descriptor automatically — the facts land onpromptDetails, satisfying the issue's "onto the descriptor/details" target without a structural edit todescriptor.ts. - ADR-0002 string boundary held explicitly.
Each gate converts its
AccessPathto strings (matchValues()/boundaryValue()) at emit; the wire carriesstring[], never anAccessPath. A Step-1 test asserts the serialized shape is strings only; the existingpermission-manager.tsimport lint is untouched. - Tolerant-read touch point ([#558]) —
readForwardedPermissionRequestreconstructs an allowlist, so the new field is silently dropped unlessasForwardedAccessIntentis wired in; that extension is the first cycle and is round-trip tested (well-formed / malformed / absent). - Scope fence against Step 3 ([#597]) — serving still re-derives from display strings;
forwarded-request-server.ts,index.ts'sservingPolicy, thehasDisplayFieldsfloor, and agent-scoped resolution are all Non-Goals. The serving-read metric stays 0; only the forwarded-wire metric moves to ≥ 1. - Non-breaking — an additive optional field with a tolerant read; no config/schema/default/observable-decision change.
Commits are
feat:/test:/docs:, none breaking. - Release: mid-batch — defer (batch "cross-session-intent", tail = Step 3 / #597). Step 2 is not the batch tail, so it cuts no release on its own.
- No follow-up issues filed — Step 3 (#597) already exists; ADR 0008 records the two deferred edges (single-surface fact set, multi-hop principal identity).
One resolved-in-plan design choice:
principalnests a self-contained copy rather than reusing the top-levelrequesterSessionId/requesterAgentName, because Step 3 readsintent.principal.agentNameand a self-contained fact object is cleaner. - Next stage is
/tdd-plan(the plan has red→green→commit cycles).
Diagnostic details
- Feedback-loop gap analysis — grounded every design claim in source before writing: read all target files (
permission-forwarding.ts,forwarding-io.ts,approval-escalator.ts,permission-prompter.ts, the six gate factories,forwarder-context.ts,access-path.ts) and confirmedForwarderContextlackscwdwhileExtensionContext.cwdexists (permission-gate-handler.ts:73), which is what made the edge-sourcedrequesterCwdviable.
Stage: Implementation — TDD (2026-07-18T16:12:28Z)
Session summary
Executed all seven plan steps plus two Tidy-First preparatory refactors: threaded a structured ForwardedAccessIntent from each permission gate, through the escalation edge, onto the forwarded wire.
Nine plan-execution commits (1 wire type + tolerant read, 1 edge serialization, 4 gate-emission steps, 1 docs) landed green; two extra commits resolved the pre-completion reviewer's WARN.
Test count 2472 → 2491 (+19); pre-completion reviewer returned WARN, both findings addressed.
Observations
- Tidy-First paid off exactly as scoped.
The
tidy-first-assessorrecommended two dependency-free prep refactors — bundlingapproval-escalator.ts's three relayed optionals (message/display/sessionApproval) into oneForwardedRequestFactsobject (the parameter-relay smell), and hoistingdescribeToolGate'sdecisionValueinto a local. Both landed first, so Step 2 added one field to an existing bundle instead of extending two method signatures, and Step 5 reused the local for the single-value fact form. The assessor also correctly declined theForwarderContext/cwdfixture audit as near-zero blast radius — confirmed whenpnpm run checkpassed after thecwdwidening with onlymakeForwarderContexttouched (all inline fakes already setcwdor use the factory). - Fact/identity split held.
The gate emits
{ surface, matchValues, boundaryValue }(what's accessed, the only facts unreconstructable downstream);ParentAuthorizerstampsrequesterCwd(via the newForwarderContext.cwd/getCwd) andprincipal.requesterCwdsourced at the edge fromctx.cwd— the assessor's audit made this cleaner than per-gate threading (skill-input has notcc). descriptor.tsneeded no change (a plan-predicted simplification): facts ride onpromptDetailsvia theOmit<PromptPermissionDetails, "requestId">, and the runner already spreadspromptDetailsintoescalate(...).- Fact-construction helpers folded into Step 1 per the assessor (they return the Step-1 wire type):
accessFactsFromPath/accessFactsFromValueinhandlers/gates/helpers.ts, so Steps 3–6 are one-line calls. ADR-0002 honored — gates convertAccessPath→ strings at emit. - Two eslint frictions, both self-caught. (1)
Partial<ForwardedAccessIntent>types nested fields as non-null, so the tolerant reader's runtime=== nullchecks trippedno-unnecessary-condition; fixed by typing the candidate fields asunknown(the correct tolerant-read shape). (2)boundaryValue()returnsstring, so|| nullis not flagged byprefer-nullish-coalescing— the pre-commit auto-fix silently stripped my speculativeeslint-disabledirective (leaving a blank line), which the reviewer flagged; removing the directive entirely was correct. - Pre-completion reviewer: WARN — two non-blocking findings, both resolved before finishing: (1) stray blank line in
helpers.ts(removed); (2) the plan's own "Invariants at risk" section asked for a test co-asserting display fields +accessIntenton one request, which was missing — strengthened theapproval-escalatorstamp test to assertsource/surface/valuealongsideaccessIntent, and addedhelpers.test.tsunit tests for both fact helpers including the empty-boundary→nulledge case. - Reviewer note (non-issue): a full-monorepo
pnpm run testshowed 2 pre-existing pi-autoformat acceptance flakes (real-pi-CLI RPC timeouts under concurrent load); zero pi-autoformat files touched, standalone re-run green. - Release: mid-batch — defer (batch "cross-session-intent", tail = Step 3 / #597).
Next step is
/ship-issue.
Stage: Final Retrospective (2026-07-18T17:20:47Z)
Session summary
Shipped issue #596 end to end in one continuous session: planned Phase 12 Track A Step 2, executed six TDD cycles bracketed by two Tidy-First prep refactors, handled the pre-completion reviewer's WARN, then pushed, verified CI green, closed the issue, and deferred the release per the operator's ship-time confirmation.
The change threads a structured ForwardedAccessIntent from every permission gate through the escalation edge onto the forwarded wire (ADR 0008), non-breaking, test count 2472 → 2491.
An exceptionally low-friction session — the only user input across all four stages was the one release-defer decision at ship time.
Observations
What went well
- The
tidy-first-assessor's rejection list was as valuable as its recommendations (novel win). Beyond the two prep refactors it recommended, it pre-verified theForwarderContext/cwdwidening's fixture blast radius as near-zero and told me not to hunt inline fakes — confirmed empirically whenpnpm run checkpassed after the widening with onlymakeForwarderContexttouched. The "Rejected as scope creep" analysis saved a speculative grep-and-edit pass across five test files the plan had flagged as candidates. - The pre-completion reviewer caught a plan-named invariant gap.
The plan's own "Invariants at risk" section asked for a test co-asserting display fields +
accessIntenton one request; I built all the gates but never wrote that combined assertion. The reviewer flagged exactly that gap (WARN), and closing it strengthened theapproval-escalatorstamp test plus addedhelpers.test.tsedge-case coverage — the reviewer doing precisely its job on a self-inflicted omission. - Incremental verification throughout.
Green baseline (
check+ rootlint+test) before any edit;pnpm run checkafter every shared-type change; the target test file after each red→green; full suite + root lint +fallow dead-codeafter the last step and again pre-push. No end-of-session verification pile-up. - Deferred-release path exercised cleanly.
/ship-issueread the plan's**Release:** mid-batch — defermarker up front, asked once, closed the issue, and skipped the release-please merge — decoupling "work is onmain" (close) from "cut a version" (batch tail) exactly as designed.
What caused friction (agent side)
other(speculative lint suppression) — in Step 1 I wroteboundaryValue: path.boundaryValue() || nullwith a preemptive// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing.boundaryValue()returns a non-nullablestring, so the rule never fired; the pre-commit auto-fix stripped the unused directive and left a stray blank line inside the object literal, which the reviewer flagged (WARN). Impact: one cosmetic reviewer WARN + a small remediation cycle (re-added the disable → eslint unused-directive error → removed it entirely). Self-caught by the auto-fix and reviewer; no behavior rework.other(staging/autoformat interaction) — the first Step-1 commit attempt did not finalize: pi-autoformat reflowedhelpers.tsafter it was staged (anMMstate), and the commit ended without a log line. Impact: one extragit add+ re-commit; no rework. Already covered by the AGENTS.md note that autoformat reflows afterEdit/Write.
What caused friction (user side)
- None — the session was a model of minimal, well-placed oversight.
The operator's single intervention (the release-defer
ask_userat ship time) was exactly the strategic-judgment call the workflow reserves for a human; everything else ran unattended from a plan the operator had already reviewed.
Diagnostic details
- Model-performance correlation — the parent session alternated
anthropic/claude-opus-4-8andanthropic/claude-sonnet-5(operator-driven) across the heavy-reasoning stretches. The two subagent dispatches (tidy-first-assessor,pre-completion-reviewer) ran on their configured models for judgment-heavy tasks (preparatory-refactor assessment; quality review) — appropriate, no mismatch. - Escalation-delay tracking — no
rabbit-holefriction; both eslint frictions resolved within 1–2 tool calls, no sequence approached the 5-call threshold. - Unused-tool detection — no
missing-contextgaps; the planning stage had already grounded every design claim in source (theForwarderContextcwd finding), so implementation needed no exploratory search. - Feedback-loop gap analysis — verification was incremental at every stage (see "What went well"); no lens found a deferred-verification gap.
Changes made
.pi/skills/code-design/SKILL.md— added a### Speculative eslint-disable directivessubsection under "Biome / ESLint linter conflicts": add a disable only after the linter reports the rule (the pre-commit auto-fix strips an unused directive and leaves a stray blank line), and a||-default on a non-nullable primitive does not tripprefer-nullish-coalescing.packages/pi-permission-system/docs/retro/0596-structured-intent-forwarded-wire.md— appended this Final Retrospective stage entry.