DEV Community

Taylor Zhu
Taylor Zhu

Posted on

Every Gate Leaves a Receipt: A Ship-Readiness Matrix for AI-Assisted PRs

A checklist that produces no evidence is a wish with bullet points. When a model writes most of a diff, the review question stops being "does this code look right?" and becomes "did the checks that would catch this class of bug actually run, on this commit, recently?" Those are different questions, and only the second one is answerable from files on disk.

This post gives you a copyable gate matrix, a ~60-line checker that fails closed, and a negative test plan that proves the checker actually blocks. Every part of it is useful with zero AI in the loop.

The failure shape

The scenario below is illustrative, not a specific incident. The shape, though, is common.

An agent opens a pull request. CI is green. The green run reused a cached dependency directory, skipped the integration suite because a path filter stopped matching months ago, and never ran the migration dry-run at all. Nothing in the PR says which checks were skipped. Green is green, so you merge. The first real signal arrives three days later in production.

The fix is not a better prompt. It is making every gate emit a receipt: a small JSON file bound to a commit SHA, containing the command that produced it, the result, and a timestamp. A checker then fails closed when a receipt is missing, stale, tampered with, or belongs to a different commit.

Gate classes: hard, soft, advisory

Not every check deserves the same blocking power. Split them:

  • Hard gates block merge. Missing receipt means no merge — the override, if you allow one, is a separate logged commit, not a checkbox.
  • Soft gates require a named human acknowledgement attached to the PR. They still emit a receipt, but status: "ack" is acceptable.
  • Advisory gates report only. Their purpose is to generate data until you trust them enough to promote them.

The matrix

Gate Question it answers Receipt must contain Fail-closed rule
tests.unit Does the changed function still do what its name says? commit, cmd, status, finished_at missing, or older than 2h → block
tests.integration Do the service boundaries still hold? commit, cmd, status, run_id skipped by path filter → block
deps.lockfile Did the resolved dependency graph change in this diff? before/after lockfile hash hash changed with no note → block
migration.dryrun Did the schema change run against prod-shaped data? cmd, row counts before/after, status dry-run absent → block
secrets.scan Did the diff introduce a credential? scanner name + version, commit scanner older than policy → block
rollback.proof Can you undo this inside the agreed window? revert commit, measured time no revert commit → block
owner.ack Did whoever owns this path read the diff? name, commit, timestamp ack predates last push → block

Two rules carry the whole design. The receipt is bound to the SHA, and the checker reads receipts rather than the CI badge.

The artifact: a receipt checker

Node 18+, no dependencies. Save as shipgate.mjs.

#!/usr/bin/env node
// shipgate.mjs — fail closed unless every required gate left a fresh receipt.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";

const DIR = process.env.RECEIPT_DIR ?? "artifacts/receipts";
const HEAD = process.env.GIT_SHA ?? "";

const REQUIRED = [
  { id: "tests.unit",        maxAgeH: 2  },
  { id: "tests.integration", maxAgeH: 6  },
  { id: "secrets.scan",      maxAgeH: 24 },
  { id: "deps.lockfile",     maxAgeH: 24 },
  { id: "migration.dryrun",  maxAgeH: 24 },
  { id: "rollback.proof",    maxAgeH: 24 },
];

function load(dir) {
  const byGate = new Map();
  for (const file of readdirSync(dir)) {
    if (!file.endsWith(".json")) continue;
    try {
      const r = JSON.parse(readFileSync(join(dir, file), "utf8"));
      if (r && typeof r.gate === "string") byGate.set(r.gate, r);
    } catch {
      // An unparseable receipt is a missing receipt.
    }
  }
  return byGate;
}

const receipts = load(DIR);
const blockers = [];
const now = Date.now();

for (const { id, maxAgeH } of REQUIRED) {
  const r = receipts.get(id);
  if (!r) { blockers.push(`${id}: no receipt`); continue; }
  if (!r.commit || r.commit !== HEAD) {
    blockers.push(`${id}: receipt is for ${String(r.commit).slice(0, 8)}, HEAD is ${HEAD.slice(0, 8)}`);
  }
  if (r.status !== "pass") blockers.push(`${id}: status=${r.status}`);
  if (!r.cmd) blockers.push(`${id}: no command recorded, so it is not reproducible`);
  const ageH = (now - Date.parse(r.finished_at)) / 36e5;
  if (!Number.isFinite(ageH) || ageH > maxAgeH) {
    blockers.push(`${id}: receipt is ${Math.round(ageH)}h old (max ${maxAgeH}h)`);
  }
}

if (blockers.length) {
  console.error("SHIP GATE: BLOCKED");
  for (const b of blockers) console.error("  - " + b);
  process.exit(1);
}
console.log(`SHIP GATE: PASS — ${REQUIRED.length} receipts verified against ${HEAD.slice(0, 8)}`);
Enter fullscreen mode Exit fullscreen mode

Emitting a receipt from CI

Wrap the command; never let a failing step write pass.

emit_receipt() {
  local gate="$1" status="$2" cmd="$3"
  mkdir -p artifacts/receipts
  jq -n --arg gate "$gate" --arg commit "${GIT_SHA:?}" \
        --arg status "$status" --arg cmd "$cmd" \
        --arg finished_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
        '{gate:$gate, commit:$commit, status:$status, cmd:$cmd, finished_at:$finished_at}' \
    > "artifacts/receipts/${gate}.json"
}

set -euo pipefail
npm ci --ignore-scripts

if npm run test:unit; then
  emit_receipt tests.unit pass "npm run test:unit"
else
  emit_receipt tests.unit fail "npm run test:unit"
  exit 1
fi
Enter fullscreen mode Exit fullscreen mode

Prove the checker blocks

An untested gate is a gate you do not have. Four commands, run locally:

node shipgate.mjs; echo "baseline, expect 0, got $?"

cp artifacts/receipts/tests.unit.json /tmp/r.json
rm artifacts/receipts/tests.unit.json
node shipgate.mjs; echo "missing receipt, expect 1, got $?"

jq '.commit="deadbeef"' /tmp/r.json > artifacts/receipts/tests.unit.json
node shipgate.mjs; echo "wrong SHA, expect 1, got $?"

jq '.finished_at="2020-01-01T00:00:00Z"' /tmp/r.json > artifacts/receipts/tests.unit.json
node shipgate.mjs; echo "stale receipt, expect 1, got $?"

mv /tmp/r.json artifacts/receipts/tests.unit.json
Enter fullscreen mode Exit fullscreen mode

The baseline case matters as much as the failures. If the checker never exits 0, your team will route around it within a week.

Debugging a blocked gate in fifteen minutes

  1. Read the blocker line, not the log. Each line names a gate and a reason: missing, stale, wrong SHA, or non-pass.
  2. Wrong SHA? The gate ran, but on an older commit. Re-run it on HEAD instead of re-emitting a receipt with a patched SHA — the second option turns your gate into decoration.
  3. Stale? Look at the age limit for that gate. Unit tests get 2 hours because they are cheap. Integration tests get 6. Raise the limit only with a reason you would write in the PR.
  4. Missing? Check the CI path filter first. Filters outlive the file layout they were written for.
  5. Everything passes locally, nothing passes in CI? Reproduce in a clean container before touching the checker.

Where a hosted free tier fits

Step 5 is where a scratch environment earns its place. MonkeyCode is an open-source project that, per its own outreach material, provides free model access and a free server option, with an advertised allowance of 10 million tokens. Treat those figures as vendor-stated and verify the current terms and quotas yourself before you build anything around them.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Two places it genuinely helps this workflow, and one place it does not:

  • Scaffolding the boring part. Hand a model your repo layout and the matrix above, and ask it to generate the REQUIRED list plus the per-gate age limits. You still review every line; the value is skipping the blank-page step.
  • A clean box for step 5. When a gate blocks and your laptop cannot reproduce the failure, run the checker and the failing command in a fresh hosted environment instead of debugging a polluted local cache.
  • Not a replacement for CI. A hosted free tier has no required-status-check integration, no build provenance, and no promise that it will be there next quarter. Keep the authoritative run inside your own pipeline.

Do not paste production data, credentials, or customer records into shared infrastructure. If a gate needs prod-shaped data, generate a synthetic fixture and record the fixture version in the receipt.

Who should not use this

  • Solo prototypes. Seven gates for a weekend project is ceremony. Start with tests.unit and rollback.proof.
  • Repos without merge protection. A checker that nothing is required to run is documentation theatre. Wire it in as a required status check or skip the whole idea.
  • Teams with no prod-shaped test data. A migration.dryrun receipt against an empty database is worse than no receipt, because it looks like evidence.
  • Anyone who needs the free tier to be permanent. Plan for it to change; the matrix does not depend on it.

The one thing to take away

If a gate cannot produce a file that names the commit it ran against, you are not enforcing a gate — you are trusting a colour. Start with two gates, make the checker exit non-zero, and test that it blocks before you trust a single green run.

If you want to try the scaffolding and the clean-box steps end to end, the project's repository and its current free-tier documentation are the place to start — and measure the checker's real cost on your own pipeline before you add gate number three.

Top comments (0)