Blog

Zero of twenty-five conversations

2026-08-13

An isometric illustration of a round sieve on three short legs with one large stone resting on its mesh, too big to pass through

You open your support console and it shows nothing. Not an error — an empty list, where twenty-five conversations should have been.

The API had returned all twenty-five. One of them carried a value in an enum-shaped field that the client’s copy of the schema had never heard of. Validation refused the response, and refusing a response means refusing all of it.

One unfamiliar string cost twenty-five rows.

One schema, two consumers

A capability states each response shape once, and two very different things validate against it — including yours, if you write a client.

The Worker validating its own response is checking itself. It has to be strict. That enum is what the ingest path branches on and what the database column holds — so tolerating a member it does not recognize would license the capability to store a value it cannot handle. Strictness there is not pedantry; it is the thing keeping bad data out.

A management client reading that response is crossing a trust boundary. Your client did not write the response and does not control the Worker that did. The system on the other end might be a fork, a half-finished deploy, a version ahead of it, a bug, or hostile — none of which is downstream of how anybody cuts their releases.

Same schema. Opposite correct behavior.

The fix that is not a loosening

The obvious repair is to make the enum permissive. That is wrong, because it degrades the producer’s own check — you would be relaxing the constraint that protects your database to help a reader.

So the tolerance is published beside the producer’s shape rather than inside it. asRead(X) takes the same Zod object and returns it with every enum reachable through it read as a string.

The producer’s enums are untouched. The Worker still validates itself exactly as strictly as before, and this module never sees those enums at parse time.

That distinction is the whole design. asRead is not “validate less”. It is “validate the same, except for the one axis where the reader legitimately knows less than the writer.”

Why the client could not just fix it locally

When the console hit this, the obvious workaround was to widen the shape on your side — copy the kit’s schema, relax the enum, move on.

That produces a mirror of a projection: a second definition of the same response, maintained by hand, in a different repository, drifting from the original from the moment it is written. Every field added upstream is now a field somebody has to remember to add downstream, and the mirror’s whole reason for existing is that nobody noticed a change in time.

Publishing asRead beside the real schema means the client gets its tolerance from the same source as its shape. There is one definition, and one derived reading of it.

When you need this

Any time your schema crosses a version boundary you do not control, which is more often than it sounds:

  • A dashboard reading an API deployed on its own schedule.
  • A mobile app that cannot be force-upgraded, reading a backend that ships weekly.
  • Any client of a system that might legitimately be newer than the client is.

The failure is characteristic and worth recognizing. Everything works in development, where both sides are the same commit. It works in staging, where you deploy them together. It breaks in production the first time a new enum member appears in real data — and it breaks by showing nothing, which reads as an empty state rather than as an error.

The general rule

A schema shared by a producer and a consumer is doing two jobs, and the jobs have different tolerances.

Write for the strict one. Publish the tolerant reading beside it, derived rather than duplicated. Then a client that has fallen behind renders the twenty-four rows it understands, instead of none of the twenty-five it was sent.