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.rolesstring[] — no enum. Any non-empty string is valid.
  • Foreign roles render as read-only chips in the edit dialog. You can see kol is 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 UPDATE hits 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.