EAGLE_OPERATIONS
← Writing

Policy gates: turning review habits into checks that block the merge

Code review catches most things, but not on a busy day. Here is how I turned the rules my team kept repeating into CI gates that fail the build instead.

Isabel OliveiraAugust 21, 20268 min read

Every team has a list of rules that live in people's heads. Do not ship a table without row level security. Do not expose a function without revoking the default grant. Do not write a migration that drops a column in one step. The rules are correct, everyone agrees with them, and they still get broken, because review depends on a human being alert at the exact moment it matters.

The fix that worked for me was not more discipline. It was moving each rule out of people's heads and into a check that fails the build.

Why review is the wrong place for these rules

Review is good at judgement: is this the right abstraction, does this name make sense, is this the simplest way to solve the problem. It is bad at mechanical verification, because mechanical verification is boring and humans skip boring things under pressure.

A rule belongs in CI when three things are true: it is objective, it is expensive to get wrong, and it is easy to miss when you are tired. Row level security on a multi tenant table is all three. Whether a variable should be called count or total is none of them.

What a gate looks like

A gate is a small script that inspects the diff, decides, and exits with a status code. Exit zero means pass. Anything else fails the job, and the pull request cannot merge. That is the whole contract, and it is why shell is usually enough.

#!/usr/bin/env bash
# Fails when a migration creates a table without enabling RLS.
set -euo pipefail

added_migrations=$(git diff --name-only "origin/${BASE_BRANCH}...HEAD" \
  | grep -E '^migrations/.*\.sql$' || true)

[ -z "$added_migrations" ] && exit 0

failed=0
for file in $added_migrations; do
  # Every table created in this migration
  tables=$(grep -ioP 'create\s+table\s+(if\s+not\s+exists\s+)?\K[\w."]+' "$file" || true)

  for table in $tables; do
    if ! grep -iq "alter table $table enable row level security" "$file"; then
      echo "::error file=$file::table $table is created without RLS"
      failed=1
    fi
  done
done

exit $failed

That is deliberately unglamorous. It reads the diff, it looks for a pattern, it prints an error the author can act on, and it exits non zero. No framework, no service, no dashboard.

The gates that earned their place

The set below is what survived on a platform with several client accounts sharing one database. Each one exists because the failure it prevents is expensive and silent.

  • A new table must enable row level security, or one account can read another account's rows.
  • A function created in a public schema must revoke the default execute grant, because the default is more permissive than people expect.
  • A destructive migration, meaning drop or truncate or a column type change that loses data, is blocked and requires an explicit override with a reason.
  • Migration files must carry a timestamp, so two branches never produce the same ordering.
  • A new remote procedure needs its migration in the same pull request, so the deployed schema and the deployed code never disagree.
  • A code change must come with a test, checked by comparing changed source files against changed test files.
  • Anything that writes to a queue must have monitoring, checked by looking for the alert registration in the same change.

Make the failure message do the teaching

A gate that says failed is a gate people learn to resent. A gate that says exactly which table is missing which statement is a gate that teaches the rule while it enforces it. After a few months the gates fired less, not because they got weaker, but because everyone had learned the rules by reading the error messages.

The override you have to design on purpose

Sooner or later a destructive migration is genuinely correct. If there is no way through, people work around the gate, and a gate people route around is worse than no gate, because it produces false confidence.

So build the exit and make it loud. A marker in the pull request body that the gate reads, a required second reviewer, and a line in the log saying who overrode what. The cost of going around should be visibility, not friction.

One caveat learned the hard way: a gate that reads the pull request body reads the payload from the event that triggered it. Re running the job after you edit the description does not help, because the payload is from the original event. Close and reopen the pull request, or push a commit, so a fresh event fires.

What this actually bought

Two incidents that never happened. One was a change that would have exposed personal data across account boundaries. The other was a delete against production data with a filter that did not do what the author expected. Both were caught before merge, by a script that runs in under a second.

The value of a gate is measured in the incident that never gets a postmortem.

Where to start

Do not try to write all of them at once. Take the rule your team repeats most often in review, the one someone types out again every couple of weeks, and turn that single rule into a script that exits non zero. Ship it as a warning first, watch it for a week to see how often it is wrong, then promote it to blocking.

The second one is easier, because by then the shape is obvious.

About the author

Isabel Oliveira is a Senior Data Engineer who owns a revenue data platform end to end and leads a small data team.

isaolivcld@gmail.com