One of the 21 third-party apps I audited this summer had a leftover maintenance endpoint that would drop every production table on a single GET request. The only thing between the open internet and an empty database was a secret in the query string. That was one app out of 21, a story rather than a trend, but it stayed with me because nothing about the app looked reckless. The demo ran clean. The destructive path just sat there, one leaked URL away from firing.

Leaks own the headlines. When people picture data going wrong in an AI-built app, they picture the read half: a stranger seeing rows they shouldn’t, a public bucket serving private files. That half is real, and a vibe coding security audit is where I’ve covered it. This post is about the other half, the one that shows up in incident writeups instead of vulnerability reports: whether your rows survive.

The audits put a number on that half. Across the AxonBuild audit record, the Data Integrity & Safety pillar averaged 51.6 out of 100, scored on 20 of the 21 third-party apps. Four patterns account for most of what I found: foreign keys that cascade into tables nobody meant to empty, hard deletes with no way back, money in types that corrupt it, and backups that have never been restored. AI tools generate all four by default, for a mundane reason: the version you can recover from and the version that merely works are indistinguishable on the day they’re generated.

The cascade that takes your orders with it

The most expensive delete in an AI-built app is usually one nobody asked for. ON DELETE CASCADE on a foreign key tells the database: when this row goes, delete everything pointing at it. Generators reach for it because it makes the demo clean: the delete button works, no orphaned rows, no foreign-key errors.

Then someone removes a user: a test account, a duplicate, a GDPR request. The cascade walks the foreign keys from user to orders to payments and removes all of it in one transaction, exactly as instructed. Postgres documents each referential action precisely: CASCADE deletes, RESTRICT refuses, SET NULL detaches. The database did what the schema said; a default nobody read did the choosing.

The one I keep coming back to was a multi-tenant B2B SaaS whose token ledger was the proof of who paid for what. Whoever built it understood that table mattered: its row-level security was so strict even the service role couldn’t delete from it. I checked. Then I read the foreign keys. The ledger was chained ON DELETE CASCADE up to the organizations table, and cascades run as the table owner and ignore RLS entirely, so one hard delete of an organization would have taken every wallet, ledger row, and billing record under it. The same repo held the fix: safer RESTRICT and SET NULL keys, written inside a CREATE TABLE IF NOT EXISTS aimed at tables that already existed, which Postgres skips without an error. A committed schema diff confirmed the live database had drifted from the code. The fix was written, committed, and never applied, and nothing would ever have said so.

The rule that falls out of it: financial records don’t cascade. RESTRICT on anything you’re required to keep turns the disaster into an error message, and it costs a few characters of schema.

Delete you can’t undo

Most AI-built apps ship one kind of delete: the permanent kind. DELETE FROM runs, the row is gone, and nothing short of a backup restore brings it back. Soft delete vs hard delete sounds like an implementation detail until the first wrong click, because the soft version, a deleted_at timestamp the app filters on, turns “gone forever” into “hidden until you flip a column back.”

-- Hard delete: the row is gone, and every ON DELETE CASCADE
-- pointing at it fires on the way out.
delete from invoices where id = $1;

-- Soft delete: an update, so no cascade fires and the row
-- is still there when someone asks for it back.
update invoices set deleted_at = now() where id = $1;

The app behaves identically either way; your reads just add where deleted_at is null. The difference only shows up on the day something deletes the wrong thing, and that day is too late to add the column.

Looks fine
Actually fine
The delete button works and the record disappears from the app
The row still exists with a deleted_at timestamp, and you can bring it back
Deleting a test user cleans up all their data
A delete can’t reach a table you’re required to keep, like payments or invoices
Automatic daily backups are turned on
You’ve restored one into a scratch database and watched the rows come back
Looks fine
The delete button works and the record disappears from the app
Deleting a test user cleans up all their data
Automatic daily backups are turned on
Actually fine
The delete button works and the record disappears from the app
The row still exists with a deleted_at timestamp, and you can bring it back
Deleting a test user cleans up all their data
A delete can’t reach a table you’re required to keep, like payments or invoices
Automatic daily backups are turned on
You’ve restored one into a scratch database and watched the rows come back

Data that’s quietly wrong

Not every data-loss bug removes a row. Two patterns corrupt data in place instead, and they take longer to surface because nothing is visibly missing.

The first is money in a floating-point column. A float can’t represent 0.10 exactly, so totals drift by fractions of a cent, and across enough transactions the drift becomes real money. Postgres ships an exact numeric type recommended specifically for monetary amounts, and most payment systems avoid the question by storing integer cents. Generators pick float because it’s the obvious type for a number, and the drift stays invisible until someone reconciles the books.

The second is the read-modify-write race on any running total: a credits balance, an inventory count, a usage meter.

// Read-modify-write: two requests both read 10, both write 9.
// One decrement vanishes, and nothing records that it did.
const { credits } = await getUser(id)
await setCredits(id, credits - 1)

// Atomic: the database does the arithmetic, so concurrent
// requests can't overwrite each other.
await db`update users set credits = credits - 1 where id = ${id}`

With one user, the first version is correct every time you test it. Under two simultaneous requests, both read the same starting value and one write overwrites the other. Whether the customer ends up with free credits or a double charge depends on which write loses; either way the ledger is wrong and nothing errors.

The backup you’ve never restored

Every failure above has the same last line of defense, which is why the most dangerous object in your infrastructure is a backup nobody has restored. Turning backups on is a setting. Restoring one is a rehearsal, and only the rehearsal produces knowledge: whether the nightly job has been running at all, whether the restore takes ten minutes or six hours, and where the backup physically lives.

The first restore you ever run should not be the one your business depends on.

That last item sounds paranoid until you look at how the known incidents went. In July 2025, an AI coding agent deleted a production database despite explicit instructions to change nothing, then reported that rollback was impossible. The rollback worked; the founder learned that mid-incident instead of during a rehearsal. In April 2026, a Cursor agent deleted the production volume of a SaaS company called PocketOS in nine seconds, and the platform’s own backups went with it, because volume-level backups lived on the very volume being deleted. The data came back only because Railway’s CEO stepped in two days later and restored it from a copy outside that path. Nine seconds is not a window anyone reacts in. The facts that decided how both stories ended were learnable in advance, on a calm afternoon, by restoring one backup and reading where it came from.

The incidents share a second lesson too: in each one, an agent doing development work held a path to production data. That half of the story is its own set of bugs, with its own fixes. On the recovery side, managed platforms have made the mechanics easy (Supabase sells point-in-time recovery as an add-on); easy mechanics still tell you nothing until you’ve run them once.

The one afternoon that proves it

None of the fixes in this post needs a rewrite or a second engineer. Together they fit in an afternoon, and each one converts a hope into a fact.

  • Audit every ON DELETE CASCADE in your schema, and move any key that can reach payments, invoices, or audit records to RESTRICT or SET NULL.
  • Add a deleted_at column to anything a user or a bug could remove by mistake, and filter reads on it instead of running DELETE.
  • Store money as integer cents or numeric, and rewrite every running-total update as one atomic SQL statement.
  • Restore your latest backup into a throwaway database, time it, compare row counts, and note where the backup physically lives.

If you’d rather have this verified against evidence than against your own answers, a Beyond the Demo Audit runs the cascade and restore drills against your real schema and reports what it found, with the file and line attached. Either way, the recoverable delete and the rehearsed restore are two of the checks that separate an app that’s ready to launch from one that only demos like it.

The delete that works and the delete you can recover from look identical in a demo. You find out which one you shipped the first time you need the second one, and by then the answer arrives with a customer attached.

Common questions about data loss

Does turning on backups mean my data is protected?

No. It means a job runs on a schedule. Whether the job’s output can become a working database again is a separate claim, and the only one that matters mid-incident. Restore a backup into a scratch database once, compare row counts, and time it; after that you have a recovery plan instead of a setting.

Is a hard delete ever the right choice?

Sometimes, and the law is the usual reason. A GDPR erasure request means the data must actually be gone; a soft-deleted row doesn’t satisfy it. Keep hard deletes as a narrow exception for rows you’re required to remove, and deleted_at as the default for everything else.

Why does AI generate these bugs so consistently?

Because every one of them is invisible under demo conditions. ON DELETE CASCADE, a bare DELETE, float money, and read-modify-write updates all behave perfectly with one user, a small dataset, and no mistakes, and a demo contains nothing else. The generator gets its feedback from the demo, so the demo’s blind spots become your schema’s defaults.