TLDR: Two apps sharing one database table. One app's Zod validation silently rejected values the other app wrote. 55 of 75 contacts became un-editable overnight. The fix: validate only the fields you own, and treat everything else as opaque data.
The Setup
I'm building out the operational stack for an ecommerce business — two separate Next.js apps, a supply-chain operations app and a KOL CRM app (KOL = key opinion leader, our affiliate/influencer layer), both talking to the same Supabase project.
One shared contacts table. One roles text[] column. No database-level constraint on what values are allowed — just a plain array of strings.
The apps divided ownership informally: the KOL CRM writes kol, the supply-chain app writes supplier, co-packer (contract manufacturer), and customer. A contact can carry both. One contact, for example, is both a KOL and a supplier.
Made total sense… until it didn't.
The Wall
I went to add the supplier role to that contact in the supply-chain app.
Error. A useless digest error — because Next.js production masks thrown server-action messages to protect against leaking internals. So I'm staring at a hash, not an explanation.
I started digging. Checked the database. Nothing wrong there. Checked the network. Fine. Started wondering if it was a permissions issue…
It wasn't. It was me.
The Wrong Instinct
The supply-chain app's Zod schema defined roles as an enum: z.enum(['supplier', 'co-packer', 'customer']). When I submitted ["kol", "supplier"] for that contact — because they already had kol from the CRM app — Zod rejected it on the server before a single byte hit the database.
My first instinct? Just add 'kol' to the enum.
That would've "worked." For about a week.
Here's the problem: I don't own the kol value. The KOL CRM app does. And roles text[] has no DB constraint — that means the CRM can write any string it wants, any time, without telling the supply-chain app. The moment it adds a new role type — ambassador, podcast-guest, whatever — my enum breaks again.
Enumerating a value space you don't own is a ticking clock, not a fix.
What Actually Fixed It
The real fix was a change in mental model. The supply-chain app should validate only the values it owns, and pass everything else through as opaque data it doesn't touch.
Three changes:
Contact.roles→string[]— no enum. Any non-empty string is valid.- Foreign roles render as read-only chips in the edit dialog. You can see
kolis there. You can't toggle it. The supply-chain app has no business writing it. - Always preserved on write — when submitting an update, the foreign roles get merged back in before the
UPDATEhits Supabase.
I also fixed the error-handling while I was in there — converted throw to a typed ActionResult<T> = {data} | {error} return so production actually shows real toast messages instead of digest hashes. That masked error was a symptom of the same underlying issue: the system was hiding information I needed to debug it.
Why This Matters
Any time two services share a database table, you have a coordination problem that your validation layer doesn't automatically know about.
The database doesn't care. It'll hold whatever values either app writes. But if app A validates app B's values at write time — even accidentally, even via a "complete" enum — you've created a hidden coupling that breaks silently and only in production.
The fix isn't coordination overhead or a shared schema contract meeting. It's a simple rule: validate only what you own. Preserve the rest untouched.
The read-only chip in the UI is the embodiment of that rule. It says: we see this, we respect it, we're not touching it.