Your Agent Won't Ask What a Field Means — It Just Guesses (And Ships It)
Day 7: Schemas for a Reader That Can't Ask Questions
Your agent will never ping you on Slack to ask what `status: string` actually means. It reads the field once, takes its best guess, and writes code on top of that guess — confidently, silently, and sometimes wrong. That one fact is enough to rewrite what 'good schema design' means in 2026.
The incident
We asked an agent to add partial-refund support to an order service. The `Order` type had a `status: string` field, documented nowhere except a wiki page two years stale, plus a handful of unrelated booleans — `isPaid`, `isRefunded`, `isCancelled`. The agent never found the wiki page. It found the status strings already in the codebase — "pending", "shipped", "delivered", "cancelled" — pattern-matched the naming convention, and invented "partially_refunded". A reasonable guess. The wrong value. It compiled. It passed unit tests, because those tests mocked the DB layer and never once enumerated the valid statuses. And it looked exactly like the other four strings in the diff. The reviewer approved it in ninety seconds — it read as clean, idiomatic code, because it was. It shipped. Three days later, the finance dashboard's status filter — a hardcoded `IN ('pending','shipped','delivered','cancelled','refunded')` written long before this feature existed — quietly dropped every partially-refunded order from revenue reconciliation. No crash, no error. Just money missing from a report, for a status value nobody had ever approved.
Why this is new
A human hitting an ambiguous `status: string` with no visible enum does one of two things: greps for existing usages and guesses conservatively, or — more likely — pings the schema owner. Either way, ambiguity gets resolved by a question. An agent has no equivalent move. It has a prompt, a schema, and a codebase to pattern-match against, and it commits to the first internally-consistent answer it produces. It doesn't feel unsure, and it definitely doesn't tell you it's unsure — the code just comes out looking confident. That's the whole shift Day 7 is about. Schema ambiguity used to be a minor tax on human readers: a Slack message, a five-minute detour. Now it's a silent failure mode, because the reader who used to catch it by asking a question isn't in the loop anymore.
The diagnosis: two failure shapes
- ▹Enums with no exhaustiveness contract — the valid set of values lives in a wiki, a comment, or 'whatever's currently in the DB,' never enforced by the type itself. Any string technically type-checks, so instead of getting rejected, an agent just invents a new plausible-looking one.
- ▹Optional-flag soup — a pile of independent booleans and nullable fields meant to represent a handful of real states, but the type permits all 2^n combinations of them. `isPaid=false, isRefunded=true, shippedAt=<date>` compiles fine, even though it means refunding something that was never paid, after you shipped it. Nothing in the schema says that combination is nonsense.
Both shapes trace back to the same root cause: the schema describes a wider universe of states than you actually intend to allow, and the gap between 'representable' and 'intended' gets filled in by convention, docs, or tribal memory — exactly the stuff an agent can't see.
The fix we shipped: discriminated unions
We collapsed the status string and the flag soup into one discriminated union, where each valid state carries only the fields that make sense for it. Invalid combinations aren't discouraged by a comment somewhere — they're absent from the type. Neither a human nor an agent can construct them, because there's nothing to construct them with.
// Before: status + independent flags — 4 fields, ~2^4 combinations,
// most of them nonsense (e.g. refunded but never paid).
type OrderBefore = {
status: string; // "pending" | "shipped" | "delivered" | ... (nowhere enforced)
isPaid?: boolean;
isRefunded?: boolean;
isCancelled?: boolean;
shippedAt?: string;
};
// After: a discriminated union — one member per state you actually intend.
type Order =
| { status: "pending" }
| { status: "paid"; paidAt: string }
| { status: "shipped"; paidAt: string; shippedAt: string }
| { status: "delivered"; paidAt: string; shippedAt: string; deliveredAt: string }
| { status: "cancelled"; cancelledAt: string }
| { status: "refunded"; paidAt: string; refundedAt: string }
| { status: "partially_refunded"; paidAt: string; refundedAt: string; refundAmount: number };
// Exhaustiveness is enforced by the compiler, not a wiki page:
function describe(o: Order): string {
switch (o.status) {
case "pending": return "awaiting payment";
case "paid": return "paid, not yet shipped";
case "shipped": return "in transit";
case "delivered": return "delivered";
case "cancelled": return "cancelled";
case "refunded": return "fully refunded";
case "partially_refunded": return `refunded $${o.refundAmount}`;
default: {
const _exhaustive: never = o; // compile error if a status is added and not handled
return _exhaustive;
}
}
}Two things changed here, not one. The union makes 'refunded but unpaid' unrepresentable, full stop. And the `never`-based exhaustiveness check means that the moment an agent adds a new status variant, every switch over `Order` in the codebase stops compiling until it's handled explicitly. That's the compiler asking the question a human reviewer used to ask, except it can't be skipped in ninety seconds.
Before/after: same agent, same prompt
- ▹Old schema, prompt "add partial refund support": the agent adds `status: "partially_refunded"` as a bare string plus a new optional flag. Compiles clean. No error anywhere. Ships a value the finance query never knew existed.
- ▹New schema, same prompt: the agent tries to slot that same string into a switch over the union, and TypeScript rejects it outright — `partially_refunded` isn't a member. In every run we tried, its next move was to add the variant to the union type itself, right next to the others, which puts the new state exactly where every consumer's exhaustiveness check will now catch it.
- ▹The second agent isn't smarter. The second schema just has no room left for a plausible-looking wrong answer to survive in.
The rule for Day 7
Design schemas as if the reader can't ask a clarifying question — because for a growing share of your code's readers, it can't. In practice: every enum needs one source of truth the type system enforces — a union type, a `const` array with a derived type, a Zod or io-ts schema, not a comment. Every set of fields that only makes sense in certain combinations belongs in a discriminated union, not a pile of independent optionals. And if you can't say, in one sentence, what an invalid combination of your optional fields would even mean, that's the seam where an agent will eventually hallucinate a value that fits the shape and breaks the world.
Where this connects forward
All of this holds while the schema is standing still. Tomorrow's lesson is what happens when the schema itself is moving under a live system — an agent running a migration, adding a union variant, splitting a table, while other agents and humans are still reading and writing the old shape. 'Invalid states unrepresentable' has to survive that transition, not just the steady state you designed for — otherwise you've just traded one seam for another.
Extend your knowledge
- ▹Read Alexis King's "Parse, Don't Validate" — the canonical argument for encoding invariants in types instead of runtime checks; it's the theoretical backbone of this lesson.
- ▹If you're on TypeScript, practice writing a `never`-based exhaustiveness check on a union you already own — add a new variant and see how many call sites the compiler forces you to touch.
- ▹If your schema lives at a runtime boundary (API request bodies, DB rows from an ORM), look at Zod's or io-ts's discriminated union support — it gives you the same unrepresentable-invalid-state guarantee at parse time, not just compile time.
- ▹Audit one schema an agent regularly writes against this week using the two failure shapes above — you're looking for a `string` enum with no enforced source of truth, or three-plus optional booleans on the same type.
Discussion
Chat with Chi Cong (AI) about this article. Your conversation is private to you — you can publish a summary for others when you're done.