The structural invariant is that every installation write routes through makeInstallationOctokit (src/github/client.ts ~707-730), whose request hook suppresses all mutations when mode ≠ live. Two raw timeoutFetch writers escape it:
1. src/review/visual/actions-fallback.ts ~103 — POST /repos/{o}/{r}/actions/workflows/visual-capture-fallback.yml/dispatches. Nothing in src/review/visual/** or visual-wire.ts consults resolveRepoActionMode/agentPaused. runVisualCapture short-circuits only on args.mode === "paused" (src/queue/processors.ts ~8088) — so under dry_run it proceeds and fires a real workflow_dispatch, starting a genuine Actions run and burning CI minutes in the target repo. That directly contradicts the documented dry-run contract ("suppresses the terminal GitHub-side write only").
2. src/github/app.ts ~612 — POST /actions/runs/{id}/cancel. Both current call sites happen to gate correctly (agent-action-executor.ts ~603-609 is inside the live branch; processors.ts ~6166-6177 checks mode !== "live"), so this is safe by caller discipline, not by construction — a third caller would silently escape.
Note this compounds with the kill-switch issue (#9049): dry-run is currently defeated fleet-wide by a manifest agentDryRun: false, so this path's misbehavior is presently masked rather than absent.
Fix
Thread AgentActionMode into dispatchVisualCaptureFallback and skip on non-live. Better: route both raw writers through a shared installationWrite() helper applying the same suppression check as the Octokit hook, so the invariant is structural again rather than conventional.
The structural invariant is that every installation write routes through
makeInstallationOctokit(src/github/client.ts~707-730), whose request hook suppresses all mutations when mode ≠live. Two rawtimeoutFetchwriters escape it:1.
src/review/visual/actions-fallback.ts~103 —POST /repos/{o}/{r}/actions/workflows/visual-capture-fallback.yml/dispatches. Nothing insrc/review/visual/**orvisual-wire.tsconsultsresolveRepoActionMode/agentPaused.runVisualCaptureshort-circuits only onargs.mode === "paused"(src/queue/processors.ts~8088) — so underdry_runit proceeds and fires a realworkflow_dispatch, starting a genuine Actions run and burning CI minutes in the target repo. That directly contradicts the documented dry-run contract ("suppresses the terminal GitHub-side write only").2.
src/github/app.ts~612 —POST /actions/runs/{id}/cancel. Both current call sites happen to gate correctly (agent-action-executor.ts~603-609 is inside thelivebranch;processors.ts~6166-6177 checksmode !== "live"), so this is safe by caller discipline, not by construction — a third caller would silently escape.Note this compounds with the kill-switch issue (#9049): dry-run is currently defeated fleet-wide by a manifest
agentDryRun: false, so this path's misbehavior is presently masked rather than absent.Fix
Thread
AgentActionModeintodispatchVisualCaptureFallbackand skip on non-live. Better: route both raw writers through a sharedinstallationWrite()helper applying the same suppression check as the Octokit hook, so the invariant is structural again rather than conventional.