January 13, 2026
Enforcing compliance in CI
Why a build should fail when a money-movement command skips its identity gate, and how to write that check
The rule that can’t depend on memory
A regulated wallet we built moves money through 18 distinct commands: send, schedule, request payment, cash out, swap, and more. Every one of them has to be gated behind identity verification before it can execute — that’s not a design preference, it’s a regulatory requirement. The problem with a rule like that is that it’s trivial to satisfy on day one and trivial to forget on day two hundred, when a new engineer adds command number nineteen and doesn’t know the gate exists, let alone that it’s mandatory.
Code review catches this sometimes. It shouldn’t be the only thing catching it. A rule that matters this much needs to be enforced somewhere that can’t get tired, distracted, or talked out of an exception — which means it needs to run in CI, on every pull request, before the command can merge.
What the gate actually looks like
In this system, every money-movement command is a class that implements a
Command interface, and the identity gate is a decorator or middleware that
wraps command handlers:
@RequiresVerification(VerificationLevel.Full)
export class CashOutCommand implements Command {
async handle(ctx: CommandContext, input: CashOutInput) {
// ...
}
}
The convention is simple: any command in the commands/money-movement/
directory must carry a @RequiresVerification decorator, or must appear on
an explicit, reviewed allowlist of commands that are exempt for a documented
reason (a read-only balance check registered in the same directory by
mistake, for instance). The rule isn’t “every command needs a decorator” —
it’s “every command in this directory needs a decorator, or a reviewed
reason why not.”
Writing the check
The check itself doesn’t need a compliance engine or a policy DSL. It needs a script that walks the command directory, parses each file’s decorators (or inspects the built module at runtime), and fails loudly if it finds a gap:
import { readdirSync } from "node:fs";
import { Project } from "ts-morph";
const project = new Project();
const commandFiles = readdirSync("src/commands/money-movement")
.filter((f) => f.endsWith(".ts"));
const allowlist = new Set(loadAllowlist());
const offenders: string[] = [];
for (const file of commandFiles) {
const source = project.addSourceFileAtPath(`src/commands/money-movement/${file}`);
const classes = source.getClasses();
for (const cls of classes) {
const hasGate = cls.getDecorator("RequiresVerification") !== undefined;
if (!hasGate && !allowlist.has(cls.getName() ?? "")) {
offenders.push(`${file}: ${cls.getName()}`);
}
}
}
if (offenders.length > 0) {
console.error("Commands missing an identity verification gate:");
offenders.forEach((o) => console.error(` ${o}`));
process.exit(1);
}
This runs in the same CI job as the unit test suite, before anything else gets a chance to merge. It’s a static check, not a runtime test, which matters: it catches the gap even if nobody wrote a test that would have exercised the missing gate, and it catches it on the commit that introduces the command, not on the first real transaction that slips through.
Why static analysis beats a runtime test here
A runtime integration test that asserts “calling CashOutCommand without
verification throws” is still worth having, and this system has one. But it
only protects the command someone thought to write that test for. The CI
static check protects the command nobody thought about, which is the one
that actually gets missed — new commands added under deadline pressure,
commands renamed or split during a refactor, a command moved out of the
allowlist without anyone noticing the gate went with it.
The two checks are complementary, not redundant: the static check enforces that the annotation exists at all; the runtime test proves the annotation actually does something when a request without valid identity verification reaches the handler.
Keeping the allowlist honest
An allowlist is the natural escape hatch for a rule like this, and escape hatches decay unless they’re reviewed. Every entry in ours carries a reason and an owner in a comment next to it, and the CI check fails if an entry references a command that no longer exists — dead allowlist entries are a sign the rule stopped being maintained, and a clean failure here is cheaper than a stale allowlist quietly protecting nothing.
What this means for you
If a regulatory requirement can be violated by a single line of code an engineer forgets to write, the requirement needs an automated check, not a checklist item in a PR template. The check doesn’t need to be sophisticated — ours is a few dozen lines that walk a directory and inspect decorators — but it needs to run on every change, fail the build on any gap, and have no quiet way around it beyond a reviewed, visible allowlist. That’s the difference between “we require identity verification before money moves” being a policy and it being a fact about the codebase.
30 minutes with a senior engineer.
Tell us what you're building. You'll leave with an honest opinion, even if it's "you don't need us."
Reference calls with past clients are available under NDA during evaluation.