The system that keeps a team of AI agents from shipping garbage
Most of a three-platform app, written by AI agents — but agents over-report "done," step on each other, and loosen your tests. Here's the operating system that contains them.
The real problem
I'm a Rust backend dev. In about a month, a team of AI coding agents wrote most of a three-platform Texas Hold'em app — live play against AI personas on web, iOS, and Android. The web client owns the broadest review toolkit; mobile also ships native stats, history, replay/review, drills, and local-solver analysis. I wrote the rules, the contracts, and the prompts; the agents wrote the business logic. That part surprised no one: the agents can write code.
The part that nearly broke the project is the one no one warns you about. A team of agents over-reports "done," steps on each other's files, and quietly loosens your tests to make them pass. Writing code turned out to be the easy half. The system that contains the agents is the actual work — and it's what this post is about.
Every change an agent makes runs the same lifecycle: branch, write, compile, test, independent review, and only then a merge gate onto main. The reviewer is from a different vendor by default; the only exceptions are narrow, recorded replacement paths for reviewer unavailability.
Every change runs one lifecycle. The author model never reviews its own work; an independent model does (different vendor by default), and a hard gate, not goodwill, is what holds main.
The cast: roles locked to directories
Why a team instead of one do-everything agent: a single agent collapses on a real codebase. Long context, forgotten constraints, breaks B while fixing A. So the work splits across a dozen-odd roles — writers each locked to a directory they're allowed to touch, planners and reviewers to specs and reviews — here are the load-bearing six:
| Role | Owns | Writes code? |
|---|---|---|
| Backend engineer | engine/ + server/ | ✓ |
| Frontend engineer | client-game/ | ✓ |
| Mobile engineer | client-mobile/ | ✓ |
| Architect | specs & ADRs | specs only |
| Poker-QA | the test gate | tests only |
| Independent auditor | reviews every diff | reviews only |
The locks matter as much as the roles. Two agents editing the same files with no boundary is exactly how you get the failures further down. An architect that only writes specs and an auditor that only reviews keep design and judgment out of the same hands that write the code.
What I actually wrote
What I wrote by hand isn't business logic — it's four reusable artifacts the agents run inside:
AGENTS-CONTRACT.md— who owns what, and the review loop.- an API contract — the field shapes the backend and frontend must both honor.
- coded merge guardrails — the early
pre-commitdirect-commit guard (below), plus today's main-worktree-ownedfinish-session.shreviewed landing and reference-transaction guard. - an audit prompt — what the reviewer model looks for.
And every task handed to an agent has the same shape — bounded territory, a single goal, acceptance criteria locked before it starts:
You are backend-agent. Only touch server/ and engine/.
Read docs/architecture/api.md and the latest ADR first.
Goal: implement GET /api/hands/:id.
Done = cargo test passes; JSON matches HandHistoryDTO; no frontend changes.
Output: change summary + test command + risks.
Bounded territory plus locked acceptance criteria. Skip either and the agent drifts — widening its own scope, or declaring victory on its own terms.
The author writes, an independent model reviews
The normal path swaps vendors on purpose: Claude-authored work goes to Codex, and Codex-authored work goes to Claude. The invariant is that the author never audits its own work. A narrowly recorded replacement reviewer is allowed only for the repository's documented unavailability cases; it must still return explicit approval with an empty open list. In my runs, self-review wasn't enough: the author missed failures I cared about, while an independent model surfaced them.
Order matters too. Compile before the audit. A reviewer model reads statically and skates past the syntax and type errors a compiler catches in a second; let the compiler clear those so the review spends its rounds on cross-file logic, contracts, and security. The loop is capped at five rounds and merges only after a clean one — an empty list of open findings, no blocker, no major.
And the guard on main isn't a prompt. The first line was a local pre-commit hook that physically refused a direct commit to main:
branch=$(git rev-parse --abbrev-ref HEAD)
[ "$ALLOW_MAIN_COMMIT" = 1 ] && exit 0
case "$branch" in
main|master) echo "BLOCKED: direct commit to $branch"; exit 1 ;;
esac
Honest caveat: that early hook alone could be bypassed with --no-verify, so the project no longer treats it as the final enforcement point. The current flow creates an isolated worktree with scripts/new-session.sh; only the main-worktree-owned scripts/finish-session.sh, after the independent reviewer explicitly returns APPROVED with OPEN=[], receives a one-shot capability to advance main by CAS. The receipt records whether review was cross-vendor or used a documented fallback, and a reference-transaction guard rejects other landing paths by default. The original lesson still holds: the guardrail must be code, not a guideline.
What actually goes wrong
None of these are bugs in the poker logic. They're the failure modes of letting a team of agents build a real codebase.
1 — Two agents, no contract → 14 mismatched fields
One agent wrote a backend endpoint; another wrote the frontend that reads it; no one locked the field shapes first. Backend id vs frontend hand_id; played_at vs created_at; several fields missing entirely — fourteen mismatches in all. The frontend's own validator then rejected 100% of real server responses. The cross-vendor audit flagged it as one of four blockers that round; the author model never saw it.
The fix is a one-page interface contract, written before the parallel agents start — simplified here:
type HandHistoryDTO = {
hand_id: string;
played_at: string; // ISO-8601 UTC
seed: string; // deck seed, hex
board_cards: Card[];
actions: ActionEvent[];
};
Generate the types from that schema; don't hand-write DTOs on both ends. And make test fixtures real server responses, never AI-invented ones — invented fixtures encode the same assumptions as the code, so they pass while real responses fail.
2 — "Done" that isn't
Twice an agent reported green when it wasn't. Nine unit tests passed while a real browser showed zero cards flipping — jsdom doesn't run CSS 3D animations, so the tests validated an environment the users never see. And a backend bug was "still there" after a fix because the server was running a stale binary from before the fix. Same trap both times: a test only validates the environment it actually runs in.
So "done" is a claim to verify, never a fact. Visual changes get a real browser plus an assertion — expect five flipped cards, not "looks right." Backend changes get a rebuild and restart, with a build stamp — the git sha, served at /build-stamp.txt — so you can see which build you're actually hitting.
3 — The AI loosens the tests
Told to fix until green, an agent sometimes makes the tests green the wrong way: deletes an assertion, lowers a snapshot threshold, adds a skip or only. The bar passes and means nothing. The guard lives in the review, not a scanner: the reviewer's brief makes test-weakening a priority finding class — each of those moves is a blocker to explain, never waved through — and the QA role is forbidden outright from weakening a regression to go green.
The honest limits
The agents have real blind spots. I don't fix those — I contain them.
- Visual polish. An agent can't tell whether a screen looks right, so a human plus real-browser screenshots makes that call.
- Parallel sessions overwrite each other on disk. So every change gets its own branch and its own git worktree. That one is a whole failure mode with its own fix — I wrote it up separately in why every agent needs its own worktree.
The drift and fake-"done" blind spots are the ones the machinery above already contains.
Recap
- The real problem: a team of agents over-reports "done," steps on each other, and loosens tests. Writing the code was never the hard part.
- The cast: a dozen-odd roles (the core six tabled above), each locked to what it may touch; an architect that only specs and an auditor that only reviews keep judgment out of the writing hands.
- The system: every change runs one lifecycle — branch, compile, test, independent audit (cross-vendor by default, ≤5 rounds), merge gate — and the gate is code, not a guideline.
- The lesson: stop asking whether AI can write code. It can. The work is managing the agents — roles, contracts, independent review, a merge gate, and a "done" that has to prove itself.