--- issue: 726 issue_title: "pi-permission-system: permission decisions record no responder provenance — a human approval is indistinguishable from an auto-approval" --- # Decision provenance — `decidedBy` on every permission decision ## Release Recommendation **Release:** ship independently Phase 13 Step 6 carries `Release: independent` in the roadmap, and the phase's two release batches ("presentation-payload", "presentation-contract") are both closed — Steps 1–4 have landed. This change is additive to the review log and version-skew tolerant on the forwarding wire, so it ships as a `feat:` on its own. ## Problem Statement The review log records that a permission request was resolved, but not **what resolved it**. Every terminal event states an outcome (`approved`, `blocked`, `session_approved`) and a `resolution`, and the decider is left to be inferred from the event name — which works for the fast paths and fails completely on the ask path, where a human at a TUI dialog, a registered `Authorizer` link, an absent-authority denial, and a relayed answer from another session all collapse into the same `{approved, state, denialReason}`. The reported case is exactly that failure. A `pre-completion-reviewer` subagent ran `find /`, the `external_directory` gate fired correctly, the ask was forwarded, `forwarded_permission.prompted` was logged, and 21.5 s later `forwarded_permission.approved` appeared. Three explanations — the operator clicked approve, the prompt rendered somewhere unseen and something else resolved it, an auto-approve path resolved it after the prompt opened — leave byte-identical traces. For a gate whose entire purpose is the distinction between *the user approved a filesystem-wide read* and *the system approved it on the user's behalf*, "a decision was made" without "by whom" is not an audit record. Measured on the operator's live 7.44 MB review log (9522 lines): **1432** terminal prompted decisions (1160 `permission_request.approved` + 272 `.denied`) carry no decider, against 8 `authorizer_chain_resolved` and 15 `model_judge.decision` entries — so a chain link *could* have decided some of them, and nothing in the log says whether it did. The information is not missing; it is discarded. Each resolution site knows what decided at the moment it decides, and the fact dies before the log write. ## Goals - Add a `DecisionSource` discriminated union (`decidedBy`) threaded **from each decision site**, never inferred from an event name or a `resolution` value. - Record it on every terminal review-log entry: the ask path, the session/yolo/infrastructure fast paths, the policy-deny path, the gate-error boundary, and both sides of the forwarding exchange. - Carry it on `ForwardedPermissionResponse` so a subagent's decision is traceable end to end: the child's own terminal entry names the session that answered **and** what within that session decided. - Make the field structurally unforgettable — required on `PermissionPromptDecision` and `GateBypass` once the threading is complete, so `tsc` proves every decision names its decider. - Non-breaking: additive to the review log; the wire field is optional and tolerantly read, so a version-skewed child or parent degrades rather than failing. ## Non-Goals - **`permissions:decision` bus event.** Operator decision at planning time: hold off until the channel's consumers are known. `PermissionDecisionEvent` is untouched by this plan, and the roadmap's Step 6 `Outcome:` is corrected to match the narrowed scope. - **`/permissions` history view** (issue's third suggestion). No such view exists — `/permission-system` (`src/config-modal.ts`) is a config modal. Nothing to surface into. - **The cross-ID-space join** (the issue's secondary observation). Resolved by [#752]: `ParentAuthorizer` now adopts the requester's `requestId` as the forwarded request's `id`, so `permission_request.*` and `forwarded_permission.*` share one id. - **Responder agent name on `forwarded_permission.*`** (the rest of the secondary observation). The serving node's *human* is the decider on the prompted path; an agent name there names the wrong actor. Not planned. - Collapsing the flat review-log columns (`surface`, `matchedPattern`, `sessionApprovalPattern`) into `decidedBy` — see Open Questions. - [#753] (the gate-error boundary emits no `permissions:decision`) — filed separately during Step 9, still open, and touching the same boundary. This plan writes `decidedBy` onto that boundary's review entry but does not add the missing emit. - [#610] Step 10 (cross-session prompt/decision correlation), which also enriches the review-log write path. The roadmap says land Steps 6 and 10 in sequence, not concurrently. ## Background ### Where decisions are made | Site | Terminal review event | What decides | Recorded today | | ----------------------------------------- | ----------------------------------------------------- | ------------------------------ | ----------------------------- | | `gates/bash-external-directory.ts` bypass | `permission_request.session_approved` | session rules | no | | `gates/bash-path.ts` bypass | `permission_request.session_approved` | session rules | no | | `gates/external-directory.ts` bypass | `permission_request.infrastructure_auto_allowed` | infra-read containment | by event name | | `gates/runner.ts` session fast path | `permission_request.session_approved` | session rule + pattern | `sessionApprovalPattern` only | | `gates/runner.ts` yolo fast path | `permission_request.auto_approved` | `yoloMode` / `origin: "yolo"` | by event name | | `permission-gate.ts` deny arm | `permission_request.blocked` (`policy_denied`) | config rule | `matchedPattern` only | | `permission-gate.ts` allow arm | *(no entry)* | config rule | n/a | | `authority/permission-prompter.ts` | `permission_request.approved` / `.denied` | **whoever the chain returned** | **no** | | `handlers/tool-call-boundary.ts` | `permission_request.blocked` (`gate_error`) | a thrown gate | by `resolution` | | `authority/forwarded-request-server.ts` | `forwarded_permission.auto_approved` / `.auto_denied` | serving node's rules | by event name | | `authority/forwarded-request-server.ts` | `forwarded_permission.approved` / `.denied` | serving node's chain | **no** | | `authority/approval-escalator.ts` | `forwarded_permission.response_received` | the parent — opaque | `responderSessionId` only | A plain policy `allow` writes no review entry at all, so it is out of the log's scope by construction; its provenance would only ever have surfaced on the bus, which this plan excludes. ### Why the ask path loses it `composeAuthorizerChain` (`src/authority/authorizer-chain.ts`) maps an `AuthorizerVerdict` to a `PermissionPromptDecision` through `decideFromVerdict`, which produces a bare `{approved, state}`. The link's **name** is available one layer up — `AuthorizerSelection.resolveConfiguredLinks` logs it as `authorizer_chain_resolved` — and is then dropped when each link is wrapped as an anonymous `{ authorize }` object. So the chain structurally cannot say which link decided, only which links were consulted. Downstream, `LocalUserAuthorizer` calls `requestPermissionDecision` (`src/authority/permission-prompt-component.ts`), the single place the `mode === "tui"` inline-dialog vs `select`/`input`-fallback dispatch is made. That dispatcher is where the human's *surface* is known — the pure reducer below it (`permission-prompt-decision.ts`) must not learn about provenance. ### Constraints that already hold - **`writeLine` bounds and redacts nested values.** `capLogFieldWidths` (`src/log-field-cap.ts`) recurses through plain objects and arrays, and `redactedJsonStringify` (`src/log-redaction.ts`) masks by key name through a JSON replacer. A nested `decidedBy` is therefore width-capped and key-name redacted with no new work — and no new write path may bypass `writeLine`. - **Fail-closed at the wire boundary.** `decidedBy` arrives off disk in a response file, so its reader is a tolerant `asX`-style guard beside its type (the `asPromptPayload` precedent), and it must be depth-bounded: a recursive guard over untrusted JSON is a stack-overflow surface. #752 set this precedent when it validated an adopted request id before letting it name a file. - **Additive wire fields are version-skew tolerant** (ADR 0011 §9): an older parent sends no `decidedBy`, and the child records `decision: null` inside the forwarded variant rather than rejecting the response. - **Required beats conventional.** Step 1's landed note records the lesson: `PromptPermissionDetails.payload` is required, "making 'every ask carries a complete payload' a compile-time guarantee rather than a convention." The same applies here, reached by lift-and-shift. ## Design Overview ### The type New module `src/authority/decision-source.ts`, beside the decision types it annotates: ```typescript /** * What decided a permission request, recorded at the decision site. * * Never inferred from an event name or a `resolution` value: each site * constructs its own variant, so a new resolution path cannot silently * inherit another's provenance. */ export type DecisionSource = | { kind: "user"; via: "dialog" | "select" } | { kind: "authorizer"; name: string; verdict: "allow" | "deny"; reason: string | null } | { kind: "rule"; surface: string; pattern: string | null; origin: string | null } | { kind: "session_approval"; surface: string; pattern: string | null } | { kind: "yolo"; pattern: string | null } | { kind: "infrastructure_read" } | { kind: "unavailable"; reason: string } | { kind: "gate_error"; reason: string } | { kind: "forwarded"; responderSessionId: string | null; /** The serving node's own source; `null` when it sent none (version skew). */ decision: DecisionSource | null; }; ``` The union is **self-contained**: each variant repeats the detail that made it decisive. On a local review line that duplicates `surface` and the pattern column; across the forwarding boundary it is the only shape that survives, because `ForwardedPermissionResponse` carries no surface, pattern, or origin column to lean on. `origin` and the link `name` are new facts appearing on a review line for the first time. The `forwarded` variant is recursive through an object property, which TypeScript resolves lazily; an exhaustive `switch (source.kind)` still type-checks as total. Measured growth on the operator's 7.44 MB log: 5777 decision-bearing lines at 765 bytes average, `+95` bytes for a `rule` variant and `+134` for a nested forwarded one — a **7.4% worst case**, against the 28.7% #746 removed. ### The seam `decidedBy` rides the object each site already produces, so no site gains a second thing to remember: - `PermissionPromptDecision` (`src/authority/permission-dialog.ts`) gains `decidedBy` — every producer of a decision states who produced it. - `GateBypass` (`src/handlers/gates/descriptor.ts`) gains `decidedBy` — the gate that short-circuits is the decider, and the runner stamps it onto the bypass's log entry (and its decision facts, for the one bypass that carries them). - `ForwardedPermissionResponse` (`src/authority/permission-forwarding.ts`) gains an optional `decidedBy`. It is **not** merged into `GateRunner`'s shared `logContext`. That context holds the facts every resolution of a gate shares; the decider is by definition not shared, so each write stamps its own. ### Call-site sketches The chain, once links are named: ```typescript // authorizer-chain.ts — links become { name, authorize } for (const link of links) { const verdict = await link.authorize(details, query, log); if (verdict.kind === "defer") continue; return decisionFromVerdict(link.name, verdict); // stamps { kind: "authorizer", name, … } } return terminal.authorize(details); ``` The relay, on the child: ```typescript // approval-escalator.ts — the parent's answer, nested under this session's view of it const response = readForwardedPermissionResponse(this.logger, responsePath); return response ? { ...response, decidedBy: { kind: "forwarded", responderSessionId: response.responderSessionId, decision: asDecisionSource(response.decidedBy) ?? null } } : abandon("The parent session's permission response could not be read"); ``` `abandon` already names which path gave up; it becomes `{ kind: "unavailable", reason }` over the same string, so the denial reason and the provenance cannot drift. The dispatcher, where the human's surface is decided: ```typescript // permission-prompt-component.ts — requestPermissionDecision const via = view.mode === "tui" ? "dialog" : "select"; const decision = await (view.mode === "tui" ? presentInline(…) : presentFallback(…)); return { ...decision, decidedBy: { kind: "user", via } }; ``` Stamping here rather than inside `reducePrompt` keeps the pure decision model free of provenance and puts `via` exactly where the surface choice is made — the decide-once rule. ### Edge cases - **Yolo.** Two arms reach the yolo fast path: a composition-stage rewrite (`origin: "yolo"` on the matched rule, #526) and an `ask` synthesized after resolution (#712). Both are `{ kind: "yolo", pattern }`, where `pattern` is the preserved `matchedPattern` — including a sentinel like ``, which is what makes a yolo grant over a synthetic ask legible. - **Grant-scope translation.** `ForwardedRequestServer.applyGrantScope` rewrites `approved_for_serving_session` to a plain `approved`; it must carry `decidedBy` through unchanged, or a whole-session grant loses its decider. - **Serving-node policy arms.** `resolveDecision` currently reads only `.state` off `policy.resolve(...)`; it takes the whole `PermissionCheckResult` so the `rule` variant can carry `matchedPattern` and `origin`. - **Escalation failure.** The `catch` arm in `resolveDecision` becomes `{ kind: "gate_error", reason }` rather than an unattributed deny. - **Version skew, both directions.** A newer child reading an older parent's response records `decision: null`; an older child reading a newer parent's response ignores the unknown field (its reader is already an allowlist). ## Module-Level Changes ### New - `src/authority/decision-source.ts` — the `DecisionSource` union, the depth-bounded tolerant guard `asDecisionSource`, and small constructors where a site would otherwise repeat a literal. - `test/authority/decision-source.test.ts` — guard behavior: each variant round-trips, a malformed variant yields `undefined`, nesting beyond the bound yields `undefined`, an unknown `kind` yields `undefined`. ### Changed | File | Change | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `src/authority/permission-dialog.ts` | `PermissionPromptDecision.decidedBy`; `createDeniedPermissionDecision` takes the source; `requestPermissionDecisionFromUi` stamps `{kind:"user",via:"select"}` | | `src/authority/permission-prompt-component.ts` | `requestPermissionDecision` stamps `via` at the mode dispatch | | `src/authority/permission-prompt-decision.ts` | decision literals in `reducePrompt` — stamped by the dispatcher above, so this file changes only if the type requires a placeholder during the transition | | `src/authority/authorizer.ts` | `Authorizer` links carry a `name` where the chain consumes them (a `NamedAuthorizer` wrapper; the public `registerAuthorizer` signature is unchanged) | | `src/authority/authorizer-chain.ts` | `composeAuthorizerChain` takes named links; `decideFromVerdict` stamps `{kind:"authorizer",name,verdict,reason}` | | `src/authority/authorizer-selection.ts` | `resolveConfiguredLinks` returns named links instead of anonymous ones | | `src/authority/denying-authorizer.ts` | `{kind:"unavailable",reason}` | | `src/authority/approval-escalator.ts` | `abandon` stamps `unavailable`; the response path nests the parent's source; `forwarded_permission.response_received` logs `decidedBy` | | `src/authority/forwarded-request-server.ts` | policy arms stamp `rule`; `catch` stamps `gate_error`; `applyGrantScope` preserves it; `recordForwardedDecision` writes it to the log **and** the response file | | `src/authority/permission-forwarding.ts` | `ForwardedPermissionResponse.decidedBy?: DecisionSource` | | `src/authority/forwarding-io.ts` | `readForwardedPermissionResponse` admits the field through its tolerant read (an allowlist reader silently drops an unlisted field) | | `src/authority/permission-prompter.ts` | `writeReviewEntry` writes `decidedBy` on the approved/denied entries | | `src/permission-gate.ts` | the `deny` arm's `permission_request.blocked` write stamps `rule` — the source is passed in beside `messages`, derived from the resolved check | | `src/handlers/gates/descriptor.ts` | `GateBypass.decidedBy` | | `src/handlers/gates/runner.ts` | bypass branch merges `gate.decidedBy` into the log entry; session and yolo fast paths stamp their own; the `rule` source is built once from `check` and handed to `applyPermissionGate` | | `src/handlers/gates/external-directory.ts` | bypass carries `{kind:"infrastructure_read"}` | | `src/handlers/gates/bash-external-directory.ts` | bypass carries `{kind:"session_approval",…}` | | `src/handlers/gates/bash-path.ts` | bypass carries `{kind:"session_approval",…}` | | `src/handlers/tool-call-boundary.ts` | `recordGateError` stamps `{kind:"gate_error",reason}` | ### Test touch points `decidedBy` becomes a **required** field on `PermissionPromptDecision`, so per the AGENTS.md rule for a new required field on a shared interface, the grep is for *constructors*, not use sites. Measured: ~150 decision object literals across 19 test files plus 5 helpers — `test/permission-gate.test.ts`, `test/composition-root.test.ts`, `test/authority/{local-user-authorizer,permission-prompt-component,permission-dialog,authorizer-selection,permission-prompter,denying-authorizer,forwarded-request-server,approval-escalator,authorizer-chain,permission-prompt-decision}.test.ts`, `test/handlers/{shell-tool-alias,input,input-events,tool-call,tool-call-events}.test.ts`, `test/handlers/gates/runner.test.ts`, and `test/helpers/{handler,authorizer,external-directory,forwarding,gate}-fixtures.ts`. Many are `toEqual` **assertions**, which break as soon as production starts setting the field regardless of optionality — so the migration is per-producer, not deferrable. The TDD order below is decomposed accordingly, and the required-ness flip is isolated to a final step (lift-and-shift; never a single step rewriting a large test file wholesale). ### Doc updates | File | Change | | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `docs/architecture/architecture.md` | mark Step 6 `✅` on the heading and the Mermaid node `S6`; add a `Landed:` note; correct the Step 6 `Outcome:` to drop "and decision event" (bus excluded by operator decision); flip the `decidedBy` health-metric row | | `docs/architecture/architecture.md` module tree | new `authority/decision-source.ts` entry; amend the `permission-prompter.ts`, `approval-escalator.ts`, `forwarded-request-server.ts`, and `permission-forwarding.ts` entries to state current behavior (no issue-number provenance trail — only an active constraint earns a ref) | | `docs/architecture/permission-prompter.md` | step 3 of the bracket names `decidedBy` alongside the state and denial reason | | `docs/subagent-integration.md` | the correlatability paragraph gains the provenance half: the child's terminal entry now names the responder **and** what within it decided | | `.pi/skills/package-pi-permission-system/SKILL.md` | Log-writes section: `decidedBy` is stamped at each decision site, nested and therefore already covered by the recursive width cap and key-name redaction; the wire field is tolerantly read and depth-bounded | The health-metric recompute command is `grep -rn "decidedBy" packages/pi-permission-system/src | wc -l` (baseline **0**, measured at planning time), and the roadmap's own note requires the creating step to use the roadmap's name — which this plan does. Predicted post-change value: ~20–30 sites (one per decision site plus the type module and the threading points); the row's target is `≥ 1`, so the prediction is not load-bearing, but the row must move off `0`. ## Test Impact Analysis 1. **New unit tests the change enables.** `asDecisionSource` is a pure guard with a depth bound — directly unit-testable, as `asPromptPayload` is. Each decision site becomes assertable in isolation: `denying-authorizer.test.ts` can pin `{kind:"unavailable"}` without a log fixture, and `authorizer-chain.test.ts` can pin *which* link decided, which no test can express today. 2. **Tests that become redundant.** None are removed. The existing suites assert outcomes, and provenance is a new orthogonal fact — the assertions are extended, not replaced. The one candidate for simplification is `forwarded-request-server.test.ts`'s indirect "which arm ran" checks (currently inferred from the logged event name), which can assert `decidedBy` directly. 3. **Tests that must stay as-is.** `permission-gate.test.ts`'s deny/ask/allow branching, `runner.test.ts`'s fast-path ordering, and `approval-escalator.test.ts`'s abandonment paths all exercise control flow this change threads a field through without altering. Their existing assertions are the regression guard that threading changed nothing else. ## Invariants at risk | Invariant | Origin | Pinned by | | ------------------------------------------------------------------------------------------ | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Every review-log line is width-capped and key-name redacted, with no bypass of `writeLine` | Step 4 (#746), ADR 0010 | `test/log-field-cap.test.ts` + `test/logging.test.ts` — **add** a nested-`decidedBy` case: the cap and the mask must reach into the nested object | | One request id runs from the child's gate to the serving node's decision | Step 9 (#752) | `test/authority/approval-escalator.test.ts` id-adoption tests — unchanged; this plan adds a field beside the id, never a second id | | A forwarded request/response missing a newer field degrades rather than failing | Step 3 (#745), ADR 0011 §9 | `test/authority/forwarded-request-server.test.ts` version-skew tests — **add** a response with no `decidedBy` yielding `decision: null` | | A relaying node runs no chain links (one chain per node) | ADR 0007 §7 (#727) | `test/authority/authorizer-selection.test.ts` — naming the links must not change `linksFor`'s relaying arm, which still returns `[]` | | The bounded-delegation envelope caps an `allow` on an excluded surface to `defer` | #635 | `test/authority/delegation-envelope.test.ts` — the envelope wraps `authorize`; naming links must wrap the **named** value so the cap still applies before the name is read | | An adopted inbound value cannot steer this process | #752 | new `decision-source.test.ts` depth-bound case | The quantitative invariant here is log size, measured above: `+7.4%` worst case on a 7.44 MB corpus. That is a prediction from a real line census, not a prose argument, and the `/tdd-plan` session should re-measure against the finished shape before closing. ## TDD Order Each cycle is red → green → commit. `decidedBy` is introduced **optional** and tightened to required in the final code cycle, so no single cycle has to rewrite a large test file wholesale. 1. **The type and its guard.** `test/authority/decision-source.test.ts` covers `asDecisionSource` for each variant, malformed input, unknown `kind`, and nesting past the depth bound. No production consumer yet. `refactor(pi-permission-system): add the decision-source union and its tolerant guard (#726)` 2. **The human decider.** `requestPermissionDecision` stamps `{kind:"user",via}` at the mode dispatch; `requestPermissionDecisionFromUi` and `createDeniedPermissionDecision` carry the source; `PermissionPrompter.writeReviewEntry` writes `decidedBy`. Tests: `permission-prompt-component.test.ts` (both modes), `permission-dialog.test.ts`, `permission-prompter.test.ts`. `feat(pi-permission-system): record the human decider on prompted decisions (#726)` 3. **Named chain links.** `composeAuthorizerChain` takes `{name, authorize}` links; `decideFromVerdict` stamps `{kind:"authorizer",name,verdict,reason}`; `AuthorizerSelection.resolveConfiguredLinks` supplies the names, still wrapping each in the delegation envelope. The export shape of `composeAuthorizerChain` changes, so its callers and their tests move in this same commit. Tests: `authorizer-chain.test.ts`, `authorizer-selection.test.ts`, `delegation-envelope.test.ts`. `feat(pi-permission-system): name the authorizer link that decided an ask (#726)` 4. **The unavailable paths.** `DenyingAuthorizer` and `ParentAuthorizer.abandon` stamp `{kind:"unavailable",reason}` over the string each already produces. Tests: `denying-authorizer.test.ts`, `approval-escalator.test.ts`. `feat(pi-permission-system): attribute absent-authority denials (#726)` 5. **The non-prompting local paths.** `GateBypass.decidedBy` on the three bypass sites; the runner's session and yolo fast paths; `applyPermissionGate`'s deny arm; `recordGateError`. Tests: `runner.test.ts`, `permission-gate.test.ts`, `tool-call-events.test.ts`, `external-directory-fixtures.ts` consumers. `feat(pi-permission-system): record the decider on non-prompting resolutions (#726)` 6. **The forwarding wire.** `ForwardedPermissionResponse.decidedBy?`; `forwarded-io` admits it; the serving node stamps `rule` / `gate_error` and preserves it through `applyGrantScope`; the child nests it as `{kind:"forwarded",…}` and logs it on `response_received`. Includes the version-skew case (`decision: null`). Tests: `forwarded-request-server.test.ts`, `approval-escalator.test.ts`, `forwarding-io.test.ts`, and the round-trip in `composition-root.test.ts`. `feat(pi-permission-system): carry decision provenance across the forwarding boundary (#726)` 7. **Tighten to required.** `decidedBy` becomes required on `PermissionPromptDecision` and `GateBypass`; residual fixtures and mock returns are updated. Pure type flip — the behavior is already in place, so this is the compile-time guarantee, not new function. `refactor(pi-permission-system): require a decider on every permission decision (#726)` 8. **Nested-value bound regression.** Add the nested-`decidedBy` cases to the width-cap and redaction suites, asserting a long nested `reason` is capped and a sensitive-keyed nested value is masked. `test(pi-permission-system): pin the width cap and redaction over nested provenance (#726)` 9. **Docs.** Architecture module tree, Step 6 `✅` + Mermaid node + `Landed:` note + corrected `Outcome:` + health-metric row; `permission-prompter.md`; `subagent-integration.md`; the package skill. `docs(pi-permission-system): record decision provenance and mark Phase 13 Step 6 complete (#726)` ## Risks and Mitigations | Risk | Mitigation | | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | A recursive tolerant guard over on-disk JSON is a stack-overflow surface | Depth bound in `asDecisionSource`, returning `undefined` past it; covered by a cycle-1 test. Forwarding is depth-1 by invariant, so the bound costs nothing real | | ~150 decision literals across 24 test files make a one-shot migration a large-blast-radius commit | Per-producer decomposition (cycles 2–6), each touching the files for one decision site; the required-ness flip is isolated to cycle 7 | | Naming chain links could disturb the delegation envelope or the relaying-node "no links" rule | Envelope wraps the named link's `authorize`, so the cap still runs before the name is read; `linksFor`'s relaying arm still returns `[]`. Both pinned by existing tests listed under Invariants | | Self-contained variants grow the review log | Measured: +7.4% worst case on a real 7.44 MB corpus, against −28.7% just removed by #746. Every nested string is already width-capped by `capLogFieldWidths` | | A nested object could escape the width cap or the key-name mask | It does not — both recurse — but cycle 8 pins it rather than trusting the reading | | An authorizer link's `reason` is model-generated text now persisted to the log | It already is: `permission_request.denied` writes `denialReason` today. The bus, which ADR 0011 §6 calls the narrowest renderer, is excluded from this change entirely | | Steps 6 and 10 both enrich the review-log write path | The roadmap says land them in sequence; this plan lands first and #610 rebases onto it | ## Open Questions - **Should `permissions:decision` eventually carry `decidedBy`?** Deferred by operator decision at planning time: the channel's consumers are not yet known, and the bus is the narrowest renderer under ADR 0011 §6. Revisit when a consumer is identified; the shape would likely narrow to kind + decider identity, dropping free-text `reason`. Not filed as an issue — it is a watch item, not scheduled work. - **Should the flat review-log columns collapse into `decidedBy` later?** The duplication is `surface`, the pattern column (`matchedPattern` / `sessionApprovalPattern`), `reason`/`denialReason`, and `responderSessionId`/`targetSessionId`. If it becomes annoying, the removal is of the **flat columns**, not `decidedBy` — the newer field is the one with no gaps. That is mechanical: `renderReviewLogFacts` (`src/presentation/review-log-renderer.ts`) is the single producer of `surface`/`matchedPattern` for all review lines, and `sessionApprovalPattern` has one write site in `runner.ts`. It would ship as a `feat!:` with a migration note, the same class as #746 removing `message`. Not filed — speculative until the duplication proves costly. - **Does a `{kind:"authorizer"}` decision want the link's own `model_judge.decision`-style detail inline?** No: a link already records its own trail through the injected `AuthorizerLog`, keyed by `requestId`. `decidedBy` names *which* link decided; the link's record says *why*. Revisit only if a link's trail proves hard to join. [#610]: https://github.com/gotgenes/pi-packages/issues/610 [#752]: https://github.com/gotgenes/pi-packages/issues/752 [#753]: https://github.com/gotgenes/pi-packages/issues/753