Name the Mutation or Don't Merge: A Fail-Closed Side-Effect Checklist
If a pull request can mutate anything outside the process, you do not merge it until every write has an owner, a dry-run, an idempotency key, and a fail-closed abort. Green tests on fixtures are not that evidence. AI-ass
If a pull request can mutate anything outside the process, you do not merge it until every write has an owner, a dry-run, an idempotency key, and a fail-closed abort. Green tests on fixtures are not that evidence. AI-assisted diffs hide mutations in helpers, generated clients, and "temporary" scripts that survive review.
This is a copy-paste gate, not a vibe. You inventory writes, attach an evidence pack, and block merge when the pack is incomplete. The checklist below is for services that can touch databases, queues, files, webhooks, mail, or money.
Why tests miss the write
Unit tests mock the client. Integration tests hit a disposable schema. Neither proves what happens when the same code runs against a shared queue or a live billing endpoint. AI tools are especially good at making the happy path compile. They are not responsible for the side effect.
You need a different question than "does it pass?" Ask: what can this diff mutate, and what happens if that mutation runs twice?
What counts as a mutation
Treat any of the following as a write surface. If you cannot name it, you cannot merge it.
- SQL or ORM
INSERT/UPDATE/DELETE, including migrations that backfill rows - Queue publish, stream append, scheduled job enqueue
- HTTP
POST/PUT/PATCH/DELETEto a system you do not own - File, object-store, or cache writes that other services read
- Mail, SMS, webhook, or notification dispatch
- Billing, quota, or feature-flag mutations
- Secret or config writes (vault, SSM,
.envgeneration)
Reads that can trigger lazy writes (cache fill, analytics fire-and-forget, "log this to the warehouse") count too. If the process can change the world, it is a mutation.
Gate 1: Inventory before review
Do not start the human review until the PR names every write surface. Put the inventory in the description, not in a chat thread.
## Side-effect inventory
| Surface | Direction | Owner | Dry-run | Idempotency | Abort |
|---|---|---|---|---|---|
| `billing.capture` | POST /v1/charges | payments | `--dry-run` flag | `Idempotency-Key` | fail closed if key missing |
| `jobs.enqueue(invoice)` | queue | billing-worker | local redis only | job id = invoice_id | no enqueue if dry-run |
Fail closed if the table is empty and the diff still contains write verbs. Empty because "nothing writes" is allowed only after a scanner says the same thing.
A scanner you can run locally
Label this as a starting template, not a complete static analyzer. Run it on the changed files. Extend the patterns for your language. Exit non-zero when a write verb has no matching inventory row.
#!/usr/bin/env bash
# sidefx-scan.sh — fail closed if write-like tokens appear without inventory
set -euo pipefail
ROOT="${1:-.}"
INVENTORY="${2:-SIDEFX.md}"
PATTERNS='INSERT |UPDATE |DELETE |\.create\(|\.save\(|\.destroy\(|enqueue\(|publish\(|put_object|POST |PATCH |send_email|capture\('
if [[ ! -f "$INVENTORY" ]]; then
echo "FAIL: $INVENTORY missing" >&2
exit 2
fi
hits=$(git diff --unified=0 origin/main...HEAD -- "$ROOT" \
| grep -E '^\+' | grep -Ev '^\+\+\+' | grep -E "$PATTERNS" || true)
if [[ -z "$hits" ]]; then
echo "OK: no write-like tokens in diff"
exit 0
fi
missing=0
while IFS= read -r line; do
token=$(echo "$line" | grep -Eo 'enqueue\(|publish\(|capture\(|put_object|send_email|INSERT |UPDATE |DELETE ' | head -n1 || true)
if [[ -n "$token" ]] && ! grep -Fqi "$token" "$INVENTORY"; then
echo "UNOWNED: $line" >&2
missing=1
fi
done <<< "$hits"
if [[ "$missing" -ne 0 ]]; then
echo "FAIL: write token not named in $INVENTORY" >&2
exit 1
fi
echo "OK: every write-like token is named"
Wire it as a required check. Do not let the model "fix" the inventory by deleting the table. The inventory is the contract.
# .github/workflows/sidefx.yml
name: side-effect-gate
on: pull_request
jobs:
inventory:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: bash scripts/sidefx-scan.sh . SIDEFX.md
Gate 2: Evidence pack per surface
For each row, attach four artifacts. Missing any one is a no-merge.
- Dry-run path. A flag, header, or environment that executes the code without committing the write. Print what would happen. If the library cannot dry-run, wrap it and fail closed when the wrapper is absent in staging.
-
Idempotency key. A stable key derived from the business id, not from
uuid4()at call time. Replay must not double-charge, double-mail, or double-enqueue. - Audit line. One structured log or table row that records actor, surface, key, and outcome. Logs that dump payloads with secrets fail this gate.
- Abort. If the key, owner, or dry-run flag is missing, the process stops. Retries do not invent a key. Fallbacks do not switch to a live endpoint.
Proposed helper (unexecuted example — adapt to your stack):
# proposed: fail-closed write wrapper
from dataclasses import dataclass
@dataclass(frozen=True)
class WriteGate:
surface: str
owner: str
dry_run: bool
idempotency_key: str
def allow(self) -> None:
if not self.owner:
raise RuntimeError(f"{self.surface}: owner required")
if not self.idempotency_key:
raise RuntimeError(f"{self.surface}: idempotency key required")
if self.dry_run:
return
def mutate(gate: WriteGate, fn):
gate.allow()
if gate.dry_run:
print({"would_call": gate.surface, "key": gate.idempotency_key})
return {"status": "dry-run"}
return fn(gate.idempotency_key)
You review the wrapper, not the model's confidence. If the PR calls the raw client beside the wrapper, that is an unowned mutation.
Gate 3: Staging proof, not fixture proof
Fixtures lie about fan-out. Before merge, run the write path once in a staging environment that cannot reach production credentials.
Minimum proof:
- Dry-run output captured in the PR (command + snippet, not a screenshot of a chat)
- One replay with the same idempotency key showing no second mutation
- Abort test: unset the key or the owner and show the process exits non-zero
- Credential check: production hostnames and live keys are absent from staging config
# proposed staging probe — replace endpoints with yours
export DRY_RUN=1
export IDEMPOTENCY_KEY="invoice:1842"
./bin/app enqueue-invoice --id 1842
# expect: would_call=jobs.enqueue key=invoice:1842
unset IDEMPOTENCY_KEY
if ./bin/app enqueue-invoice --id 1842; then
echo "FAIL: abort did not fire" >&2
exit 1
fi
If you cannot run staging, you do not merge a new write surface. Local Docker is acceptable only when it uses the same wrapper and cannot resolve production DNS.
Decision table you can paste into the PR template
| Question | Pass evidence | Fail closed when |
|---|---|---|
| What mutates? | Inventory table matches scanner hits | Diff has write tokens, table omitted |
| Who owns it? | Named team or on-call in the row | "TBD" or model-generated owner |
| Can it dry-run? | Command + output in the PR | Flag ignored in non-dev env |
| Is replay safe? | Same key, second call is a no-op | New UUID per attempt |
| Does abort work? | Unset key → non-zero exit | Catch-and-continue |
| Can it reach prod? | Staging deny-list / pinned roots | Prod URL in default config |
| Are secrets quiet? | Redacted audit line | Token or PAN in logs |
Keep the table short. If a row cannot be filled, the gate is closed. Do not negotiate "just this once" for generated code. Generated code is why the gate exists.
Using an assistant without handing it the merge button
A coding assistant is useful for enumeration, not for approval. You can point it at a diff and ask it to list write surfaces you missed. You still require the scanner, the inventory, and the staging abort.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
If you already run reviews on a laptop that should not keep paid inference on all day, MonkeyCode's free model access and free server option are enough to host that enumeration step. Feed it the diff and the inventory template. Reject any suggestion that adds a write client without a dry-run path. The server does not replace CI. It is a place to draft the table before the required check runs.
Do not paste production secrets into that session. Do not let the assistant "complete" the abort test by deleting it.
Limitations
This checklist does not prove correctness of business logic. It does not replace load tests, schema review, or legal hold. The scanner is pattern-based; a renamed helper will dodge it until you update the patterns. Idempotency keys do not help if two surfaces share no common id.
Who should not use this as written:
- Docs-only or comment-only PRs (skip the gate; do not fake an inventory)
- Throwaway prototypes with no shared data and no network writes
- Teams without a staging environment that is actually isolated — fix that first
- Pipelines that cannot fail closed (warnings-only checks train people to ignore them)
If your agent can call tools, pin those contracts separately. This article is only about mutations that escape the process after merge.
What you do on Monday
Add SIDEFX.md to the repo. Add the scanner as a required status. Paste the decision table into the PR template. For the next AI-assisted change that touches a queue or an HTTP client, refuse merge until the four artifacts exist.
You are not slowing the model down. You are refusing to ship an unowned write. That is the whole job.
If you want a second pass over a noisy diff, run the inventory draft on MonkeyCode's free server with free model access, then throw away anything that cannot survive the scanner.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.