
The trap: “delete the parent row” quietly misses whatever the foreign keys don’t cover
Here’s the scenario that should worry any founder running a multi-tenant product. A customer asks you to delete their account. Your system runs its hard delete — a permanent erase, not a soft flag — the operation returns success, the org disappears from your dashboards, and everyone moves on. Except the customer’s chat history and uploaded knowledge base are still sitting in your database, fully readable, indefinitely.
The operation didn’t fail. It did exactly what the code told it to. The problem is that “delete this organization and let the database cascade the rest” is a much smaller instruction than it looks, and the gap between what it sounds like and what it does is invisible until someone goes looking.
This one caught us in Zybo AI, and it’s worth walking through because the cause is a data-model pattern that’s extremely common — not an exotic bug.
Why the cascade wasn’t complete
A foreign-key cascade (an FK is a database rule saying “this row belongs to that parent row”) only deletes children that are actually linked to the parent by that rule. Delete the parent, and everything the database knows is attached to it goes too. That’s the whole appeal — it’s automatic.
But it’s only as complete as your foreign keys are. In our schema, most rows were scoped by organization_id, enforced by a real FK to the organizations table. Delete the org, those rows cascade, done.
The catch: several of the biggest tables — conversations, messages, documents, chunks, prompt templates, and attachments — were scoped by a separate tenant_id column. And tenant_id had no foreign key back to organizations at all. As far as the database’s cascade logic was concerned, those rows weren’t children of the org. They weren’t children of anything.
So both hard-delete paths — the admin-triggered one and the customer-initiated 30-day account purge — called orgRepo.delete({ id }), cascaded everything keyed on organization_id, and silently left everything keyed only on tenant_id untouched. Two scoping identifiers, one enforced and one not, and the deletion path only knew about the enforced one.
That’s the trap in one sentence: an FK cascade tells you what the schema’s constraints happen to reach, not what a data-erasure request legally requires you to remove. Those are two different questions, and nothing in the API surface makes the difference obvious.
The fix: walk the tables explicitly, in one transaction
We added purgeOrganization(), which stops relying on the cascade to be complete. It:
- Explicitly walks and deletes the tenant-scoped tables in FK-safe order (children before parents, so nothing is orphaned mid-delete);
- Deletes the organization, letting the FK cascade handle everything that is correctly linked to
organization_id; - Deletes the now-orphaned tenant rows themselves.
All of it runs inside a single transaction, so the erase is all-or-nothing — no half-deleted state where the org is gone but the messages remain.
What to check in your own system
If you take one thing from this, make it the audit, not the specific tables:
- Don’t equate FK cascade with complete erasure. They only match if every table holding user data is reachable through the foreign keys you’re cascading from.
- Look for parallel scoping identifiers. If your data model has more than one way to say “this belongs to that customer” — an
organization_idand atenant_id, or an account ID plus a workspace ID — check whether both are FK-enforced. The unconstrained one is exactly where a delete leaks. - Audit deletion paths against the data model, not the schema. List every table that stores user data and confirm your hard delete reaches each one. “What does the parent row cascade?” is the wrong question. “What tables still hold this customer’s data after the delete?” is the right one.
For GDPR and any right-to-erasure obligation, a delete that returns success while data survives is worse than an error — an error you’d notice. This one you wouldn’t, until an auditor or a customer asked you to prove the data was gone.








