InfraHouse

The database is the adult in the room when you build with an AI agent

The database is the adult in the room when you build with an AI agent

An AI coding agent gives you velocity: ship the feature, reshape the schema, keep moving. A relational database gives you consistency: ACID guarantees — every row valid, every constraint held, no matter how the writes arrive. They are good at different things by nature, and that difference is something a working engineer can use on purpose: move fast with the agent, then lean on the database to keep the result correct.

We learned this on our own product — twice, in two different ways. This is what happened, and what a CTO should take from it.

The shortcut that bought us speed

We build LoopProof, a service that turns AWS Security Hub, Amazon GuardDuty, and Amazon Inspector findings into tracked Linear issues with closed-loop remediation evidence. Every security finding becomes a Linear ticket, LoopProof syncs the resolution in Linear back to Security Hub, and it keeps a per-finding audit trail. The Security Hub / GuardDuty / Inspector to Linear bridge is the product — you triage cloud security findings in Linear, where your engineers already work, and the evidence flows back to AWS on its own. It runs as a set of AWS Lambda functions over a PostgreSQL operational store.

Inspector fans a single vulnerability out across every resource it touches, so LoopProof collapses those into one “group” and files one Linear ticket per group. Early on, we stored the group’s membership — which findings belong to it — as a JSON array inside a single column on the group row. That shape is fast to build and easy to change. There is no migration to run and no second table to maintain, exactly the ergonomics NoSQL advocates hold up as a virtue. That is why an agent optimizing for velocity reaches for it: you append to an array and move on.

That shortcut has a formal name: it is a first normal form violation. A repeating group stuffed into one cell. And the property that makes it convenient — the whole membership lives in one row — is the property that makes it dangerous. Every membership change rewrites the entire row.

The bill came due as a race condition

The symptom was one finding that Inspector had closed but LoopProof still showed as open, forever. Not a cosmetic lag — a finding that was structurally unreachable by the code that closes findings.

The cause was two Linear tickets, byte-for-byte identical, same group, created 0.93 seconds apart. Two Lambda invocations processed two findings of the same group at almost the same instant. Both read the store, both saw “no group exists yet,” both created a Linear ticket, and both wrote the group row. The write was an unconditional upsert — last write wins — so the second write overwrote the first, including the JSON membership array. The first finding was orphaned: it still pointed at the first ticket, but the group row, the only thing the close path ever reads, now knew only the second. When the second ticket resolved, it dispositioned its own members. The orphan was never in that list, so it stuck open.

Two Lambda invocations read the group row at the same time, each files its own Linear ticket, and\nthe second write overwrites the first, dropping a member from the shared JSON\narray.

Both invocations see “no group yet,” so both create a Linear ticket. The second write is an unconditional upsert on the same row, and it overwrites everything the first wrote — including the membership array.

This was not a rare edge. When we backfilled a proper membership column and checked, 361 of 368 live groups — 98% — had at least one member the JSON array had silently dropped. The race had been eating data for as long as the shape existed. Nothing had ever cross-checked the array against reality, so nothing had noticed.

The intuitive fix does not work, and it is worth knowing why

The obvious patch is to check before you write: begin a transaction, count the groups, and only insert if the count is zero. Under PostgreSQL’s default isolation level, read committed, that does not save you. Two transactions both run the count, neither sees the other’s uncommitted insert, both read zero, and both insert. You have reproduced the same race with more ceremony.

Making check-then-act correct in application code means reaching for serializable isolation with retry logic, or explicit row locks. That is real machinery to build and maintain. The database already ships a simpler answer: a unique constraint on the group key, and one atomic guarded upsert against it. The upsert is a single insert that the constraint arbitrates on conflict, so exactly one concurrent caller creates the group and files the ticket while the rest step aside — no retry loop, no locks for you to manage. That guarded upsert is the concurrency control. The application does not have to be clever, because the schema refuses to be wrong.

Here is the whole thing — a single statement:

INSERT INTO vuln_groups (group_key, state, issue)
VALUES (:key, 'open', :ticket)
ON CONFLICT (group_key) DO UPDATE
    SET state = 'open', issue = EXCLUDED.issue
    WHERE vuln_groups.state IN ('resolved', 'suppressed')  -- only reopen a closed group
RETURNING group_key;

A row comes back only when this caller inserted a brand-new group or reopened a closed one. If the group already exists and is live, the guarded DO UPDATE … WHERE matches nothing, the statement returns no row, and that caller knows it lost — so it attaches to the existing group instead of filing a second Linear ticket. Exactly one concurrent caller gets the row and creates the ticket; the rest attach.

Two invocations run the same guarded upsert. The unique key lets exactly one win: it gets a row back\nand files the Linear ticket, while the other gets no row and attaches to the existing\ngroup.

One statement, arbitrated by the unique key. The winner gets a row from RETURNING and files the ticket; the loser gets no row and attaches — no duplicate group, no duplicate ticket.

That closes one half of the incident. A unique key on the group means one group is one row and one Linear ticket, so the duplicate ticket cannot happen again. It does nothing for what lives inside that row, though. Two writers adding members to the same legitimate group still read the array, append, and write the whole thing back, and one update still clobbers the other. That is the second half of the race, and it needs a different fix.

The other half: let the relational model do its job

The deeper correction was to stop storing membership as an array at all. A finding belongs to exactly one group, because its group key is a pure function of the finding. So the relationship is one-to-many, not many-to-many, and the normalized form for one-to-many puts a foreign key on the child row: no junction table in between. We added the group key as a column on the findings table and made each finding responsible for its own membership. The group row keeps only its identity, state, and ticket.

A one-to-many foreign key: the findings table carries a group_key column pointing at the vuln_groups\nprimary key. The group row no longer stores a membership\narray.

Membership stops being a stored array and becomes a query — SELECT uid FROM findings WHERE group_key = :key, with open members filtered by status. The vuln_groups row sheds its member arrays entirely.

The payoff is in the write path. With membership expressed as rows keyed by each finding’s own id, two concurrent findings are two independent inserts. There is no shared cell to overwrite. The clobber that stranded our finding can no longer be represented at all. Normalization here did real work: it removed the race by construction, rather than papering over it in application code.

The same race replayed. On the left, two writers rewrite one shared row and the second clobbers the\nfirst. On the right, two writers insert two independent rows and both\nsurvive.

The same two concurrent writers, run against each shape. A shared cell forces a whole-row rewrite and loses data; independent rows have nothing to overwrite.

The two fixes are complementary. The unique key guarantees one group is one row and one Linear ticket; the foreign key guarantees membership that cannot be clobbered. We needed both — one for each half of the incident: the unique key to stop the duplicate group and its duplicate ticket, the foreign key to stop the dropped member. Neither one closes the other’s gap.

The same lesson again: a nullable column is a rule the code will break

The race was one instance. Here is another, from the same codebase, that arrived at the same answer by a different road.

I imposed one hard rule in LoopProof: every finding must cite a Linear ticket. Every one. A resolution cites the Linear ticket that fixed it. A risk acceptance cites the Linear ticket that signed off on it. A grace period cites the Linear ticket that granted it. Even a finding closed before LoopProof ever tracked it gets a fallback Linear ticket. No disposition exists without a Linear ticket behind it, and that ticket has to appear in the audit comment we write back to Security Hub. That is the entire point of a remediation-evidence product: a disposition with no cited authority is not evidence.

The first version kept the Linear ticket in the Security Hub comment. Comments are snapshots written at push time, so the value drifted — a finding would resolve, the comment would be rewritten, and the ticket would quietly fall out of it. We ended up with the governing ticket sitting in the database but missing from the Security Hub comment a customer would actually read.

The second version promoted the Linear ticket to its own column on the findings table. Better, except the column allowed nulls, and a nullable column is a rule the code is free to skip. Writes kept landing without one. The invariant lived in the application, and the application broke it on a regular basis.

The difference between a rule the code can skip and one it cannot is a single keyword:

governing_ticket text            -- version 2: nullable, so a write can omit it
governing_ticket text NOT NULL   -- version 3: the database rejects a finding without one

The third version held. We made the column NOT NULL, made the governing Linear ticket a required property of the finding entity, and routed every writer — the sync loop, the human-close webhook, the admin tools — through that one entity. A disposition without a Linear ticket became impossible to express in code and impossible to store in the database. The constraint earned its keep the moment we shipped it: the migration exposed a whole class of writes the code had been getting away with — terminal closes that carried no Linear ticket — by rejecting them at the door. The database did more than document the rule. It caught the code, ours and the agent’s, in the act of breaking it, and refused.

What this means if you build with AI agents

None of this is an argument against coding agents. We shipped the fix — a normalized foreign key, an atomic guarded upsert, and the cleanup of every stranded record — fast, with the agent doing most of the typing. The point is about the division of labor.

An agent optimizes for velocity: the feature exists, the schema bends, a JSON blob is one keystroke away. A relational database optimizes for consistency: constraints, isolation levels, normalization, atomic writes. Those pressures pull in opposite directions, and the failure mode is letting velocity win a decision that belongs to consistency. Denormalize for speed and you have quietly made the database’s guarantees your application’s problem — and application code loses that fight under concurrency.

The practical guidance is short. Let the schema encode the invariants you actually depend on: a finding has exactly one group, a group’s key is unique, a governing record is never null. Reach for a constraint before you reach for application logic, because a constraint is enforced on every write including the ones your agent did not anticipate. The agent makes you fast. The database keeps you correct. You want both, and you get both by not asking either one to do the other’s job.


InfraHouse builds LoopProof, which turns AWS Security Hub, Amazon GuardDuty, and Amazon Inspector findings into tracked Linear issues with closed-loop remediation evidence synced back to AWS. If you are drowning in cloud security findings and want them triaged in Linear where your engineers already work, start with a 20-minute call.