15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 726 | pi-permission-system: permission decisions record no responder provenance — a human approval is indistinguishable from an auto-approval |
Retro: #726 — permission decisions record no responder provenance
Stage: Planning (2026-08-16T22:25:05Z)
Session summary
Planned Phase 13 Step 6: a DecisionSource discriminated union (decidedBy) threaded from each decision site into the review log and across the forwarding wire.
Inventoried all twelve terminal decision sites and confirmed the issue's diagnosis — the ask path is where provenance is genuinely lost, because composeAuthorizerChain collapses a link decision, a human dialog decision, an absent-authority denial, and a relayed parent answer into the same {approved, state, denialReason}.
Plan committed at packages/pi-permission-system/docs/plans/0726-decision-provenance.md with nine red→green→commit cycles.
Observations
- Two of the issue's three asks were already resolved or moot.
The cross-ID-space join complaint was fixed by #752 (the forwarding edge adopts the requester's
requestId), and the/permissionshistory view it asks about does not exist —/permission-systemis a config modal. Only the provenance half is real work. - Operator decided at the clarification gate to exclude the
permissions:decisionbus event: consumers of the channel are not yet known, so widening it is premature. This narrows the roadmap's own Step 6Outcome:line, which claims "everypermission_request.*and decision event names its decider" — the plan lists correcting that line as a doc update. - Operator chose nested forwarded provenance (
{kind:"forwarded", responderSessionId, decision}) over a flat relay, and self-contained variants over lean ones. Self-contained is load-bearing rather than stylistic:ForwardedPermissionResponsehas nosurface/pattern/origincolumn, so a lean variant would lose which parent rule fired the moment it crossed the boundary. - Measured rather than estimated, from the operator's live 7.44 MB review log: 9522 lines, 1432 terminal prompted decisions with no decider recorded, 5777 decision-bearing lines averaging 765 bytes.
Predicted log growth is +7.4% worst case (a
rulevariant adds 95 bytes, a nested forwarded one 134) — set against the 28.7% #746 removed. - Confirmed by reading
log-field-cap.tsandlog-redaction.tsthat both the width cap and the key-name mask recurse into nested objects, so a nesteddecidedByneeds no new bounding work. The plan pins this with a regression test rather than trusting the reading. - The recursive tolerant guard is a fail-closed surface:
decidedByarrives off disk, soasDecisionSourceis depth-bounded. Same class as #752's filename-safety guard on an adopted request id — adoption is where an inbound value first gets to steer this process. - Migration risk is concentrated in tests, not production: ~150 decision object literals across 19 test files plus 5 helpers.
Many are
toEqualassertions, which break as soon as production sets the field regardless of optionality — so the decomposition is per-producer (cycles 2–6) with the required-ness flip isolated to cycle 7, rather than optional-then-required as a blanket shield. - Sequencing note for whoever picks this up: #610 (Step 10) also enriches the review-log write path, and the roadmap says land Steps 6 and 10 in sequence. This lands first.
Stage: Implementation — TDD (2026-08-17T01:47:50Z)
Session summary
Landed all nine planned TDD cycles plus two Tidy-First preparatory commits (13 commits total).
decidedBy is now stamped at all twelve terminal decision sites and carried across the forwarding wire, required on PermissionPromptDecision and GateBypass.
Test count went 3010 → 3065 (+55) with pnpm run check, root pnpm run lint, and pnpm fallow dead-code all clean.
Observations
- The
tidy-first-assessorearned its keep by rejecting more than it recommended. It declined a blankettest/helpers/decision factory over the ~150 literals — correctly, on the grounds that most aretoEqualassertions pinning the value under test, which no factory can supply, and that pre-collapsing them would be the large-blast-radius commit the plan's own Risks table mitigates via per-producer decomposition. It also declined aGateBypassbuilder (three sites sharing onlyaction: "allow") and aPermissionGateParamsnarrowing (already role-scoped). Its two Recommended commits both paid off: naming the chain links first made cycle 3 a two-line change, and defaulting the filler decisions in two helpers absorbed edits cycles 2 and 3 would otherwise have made by hand. - One assessor claim needed checking rather than trusting: it described 12 call sites as "unexercised filler".
Reading them showed a mix — in
permission-prompter.test.tsline 83's test subject is that an approval logspermission_request.approved, so hiding the decision in a default would have harmed it. Adding the default and dropping the literal only at the genuinely-filler sites was the right resolution; a default parameter forces nothing. - Design decision not in the plan:
UnattributedDecision(Omit<PermissionPromptDecision, "decidedBy">). The plan sketched the dispatcher stamping{kind:"user", via}but did not name the type that makes it work under required-ness. This is the same shapeGateBypass.decisionuses for the request id (#752's "a gate keeps emitting only what it knows"), which is why it felt idiomatic rather than invented. It settles a real connascence question: havingreducePromptandrequestPermissionDecisionFromUieach name their own surface would be two sites that must agree with the dispatcher'smode === "tui"branch. - Deviation from the plan (minor): the plan's cycle-5 sketch had the bash bypasses carrying a session pattern.
They cannot — a whole-command bypass covers many tokens at once, each possibly matched by a different session grant, so one pattern would be a guess.
They record the surface with
pattern: null, and the entry's existingtokens/externalPathslists what was covered. - Cycle 8 was a characterization cycle, not a feature one: two of its three tests passed on first run, because
capLogFieldWidthsalready recursed and the redaction replacer descends by nature. The plan predicted this correctly ("pins it rather than trusting the reading"), andtest:was the right commit type. The one failure was my own expectation being wrong — at width 10 the cap also shortenedname: "model-judge", which is correct behavior. - The scripted test migration in the required-ness flip is the risk the AGENTS.md scripted-substitution warning describes, and it did misfire twice: it added
decidedByto an assertion overpresentInlinePermissionPrompt(deliberately unattributed) and missed a bypass log assertion. Both were caught bytoEqual's exactness within one run — the exact-assertion convention is what made a scripted edit safe to attempt at all. The reviewer re-read everytest/hunk and found no further slips. - Two
Editcalls failed on a wrong absolute path (pi/pi-permission-system/...instead ofpi/pi-packages/packages/pi-permission-system/...) and were correctly blocked by theexternal_directorygate — the package's own gate catching a path mistake in a change to that package. - Anchoring an
Editon a decorative─rule line failed as AGENTS.md warns; re-anchoring on the adjacent uniquedescribe(...)line worked first time.
Stage: Final Retrospective (2026-08-17T02:04:27Z)
Session summary
Planning, TDD implementation, and ship all ran in a single session rather than the documented one-stage-per-session flow.
Shipped pi-permission-system v26.1.0 (Phase 13 Step 6): a DecisionSource union stamped at all twelve terminal decision sites and carried across the forwarding wire, in 13 commits with test count 3010 → 3065.
The pre-completion reviewer returned PASS with no fix-required findings.
Observations
What went well
- The
tidy-first-assessor's rejections were its most valuable output, which is novel — prior retros have credited what it recommends. It declined a blanket decision-factory over the ~150 test literals on the grounds that most aretoEqualassertions pinning the value under test (which no factory can supply), and that pre-collapsing them would be exactly the large-blast-radius commit the plan's own Risks table mitigates. A recommendation-only reading of that report would have produced a worse change. - A design element absent from the plan emerged cleanly during implementation:
UnattributedDecision(Omit<PermissionPromptDecision, "decidedBy">). It felt idiomatic rather than invented becauseGateBypass.decisionalready used the identical shape for the request id, recorded in the #752Landed:note. The architecture doc's landed-notes discipline paid off as a source of reusable patterns, not just history. - Convergent iteration on the required-ness flip was not a rabbit hole: the
tscerror count fell monotonically 52 → 16 → 11 → 8 → 1 → 0 across six tool calls, each targeting a strictly smaller residue. Worth distinguishing from the >5-call escalation signal, which is about repeated failure on the same error. - The package's own
external_directorygate blocked two malformedEditpaths in a change to that same package — the system under test catching a real mistake in its own development.
What caused friction (agent side)
other— twoEditcalls were issued with a hand-built absolute path missing thepi-packages/packages/segment (/Users/chris/development/pi/pi-permission-system/test/authority/permission-prompter.test.ts). Both were denied by theexternal_directorygate. Impact: two wasted tool calls, no rework. Every other file tool call in the session used a repo-relative path and none failed.wrong-abstraction— the bulk test migration in9f39b1a9used a Python script to insertdecidedBy: DECIDED_BY_HUMANacross ~16 files. The script was line-oriented and safe from the regex boundary-spanning corruptionAGENTS.mdalready warns about, but it could not tell a mock producer from an assertion, and misfired twice: it attributed apresentInlinePermissionPromptassertion that must stay unattributed, and left a bypass log assertion unextended. Impact: two extra fix cycles inside one TDD step; no commit rework, becausetoEqual's exactness turned both into immediate red. The safety property was the project's exact-assertion convention, not anything about the script.missing-context— red expectations were written before checking the fixture defaults they depend on, twice producing a red that failed for the wrong reason:makeCheckResult({state: "allow"})defaults toorigin: "builtin"with nomatchedPattern(expected"global"/"*"), and areviewLogFieldMaxWidthof 10 also truncatesname: "model-judge"(11 chars), not only the intended long field. Impact: ~4 extra tool calls across cycles 5, 6, and 8. Both fixes improved the tests — the forwarded-server case now sets explicit pattern/origin values rather than relying on defaults.instruction-violation(self-identified) — the firstread_sessioncall in this stage usedtypes: ["model_change"], which the/retroprompt explicitly forbids for model attribution (#737); it returned three phantom switches all naming the same model. Impact: one wasted tool call; the unfiltered read followed immediately. Notable only because the rule exists because of a prior retro and still did not fire preemptively.instruction-violation(self-identified) — anEditanchored on a decorative─rule line failed, asAGENTS.mdwarns. Impact: one wasted tool call; re-anchoring on the adjacent uniquedescribe(...)line worked first time.
What caused friction (user side)
- Nothing to flag.
The one clarification gate carried three genuinely open decisions and all three answers changed the design: excluding the bus event narrowed the scope (and required correcting the roadmap's own
Outcome:line, which overclaimed), nesting shaped the recursive type, and self-contained variants are what let the record survive the forwarding hop. The two follow-up questions attached to those answers ("how does this work in TypeScript?", "explain the duplication") were the highest-leverage intervention in the session — answering them surfaced the concrete duplication table and the named deprecation path, both of which went into the plan.
Diagnostic details
- Model-performance correlation — the whole session ran on
anthropic/claude-opus-5(verified from inline[provider/model]labels in an unfilteredread_session). Both subagents (tidy-first-assessor,pre-completion-reviewer) ran on their frontmatter defaultanthropic/claude-sonnet-5. No mismatch: both did judgment-heavy read-only work well within sonnet's range, and the reviewer independently traced all twelve decision sites, the depth bound, and everytest/hunk of the scripted migration. - Escalation-delay tracking — no
rabbit-holefriction points. The longest single-error sequence (six calls on the cycle-7tscresidue) was monotonically convergent and is not the pattern the >5-call signal targets. - Unused-tool detection —
colgrepwas never used; all exploration wasgrepplus targetedread. Defensible here because the decision sites are enumerable by review-log event name (permission_request./forwarded_permission.), which is an exact-match problem. No dispatch was warranted for either friction point above. - Feedback-loop gap analysis — no gap.
pnpm run checkplus the package suite ran after every one of the nine cycles; rootpnpm run lintran at cycles 1, 5, 7, and completion;pnpm fallow dead-codeat baseline and completion. The 18 Biome formatting findings the Python script introduced surfaced at cycle 7's own gate, not at the end.
Changes made
AGENTS.md§ Edit tool batches — added the scripted-bulk-test-edit rule beside the existingperl -0777regex-corruption rule: a script cannot tell a mock producer from an assertion, so correctness rests on the suite, and only exact assertions (toEqual/toHaveBeenCalledWith) provide that; a touchedtoMatchObject/objectContainingsite must be re-read by hand.AGENTS.md§ Shell and search — added the repo-relative file-tool-path rule..pi/skills/tidy-first/SKILL.mdStep 3 — added that a Rejected item's reasoning should be read, since one contradicting the plan is a signal to re-examine the plan.
Considered and not landed: a rule about checking fixture defaults before writing red expectations (self-correcting, ~4 tool calls), louder emphasis on the unfiltered read_session rule (#737 — self-corrected within one call), a colgrep nudge (grep was correct for an exact-match enumeration), and anything about running all stages in one session (operator's choice, and it worked).