Chapter 5 of 7 · 13 min read
Folded enums and escape members
On this page
A dimension here is one question a field answers: what should we do about this, what kind of thing is this, how bad is it. An enum is a good container for exactly one of those. A folded enum is one whose member list has quietly picked up members belonging to a second question, which happens by accretion rather than by design — somebody needed a value for a case the list did not cover, the nearest available slot was the enum in front of them, and the new member was defensible on its own.
The failure is invisible in a way that other schema failures are not. Every value emitted is legal, every value is spelled correctly, and the decoder is working exactly as the previous chapter described. What is missing is an axis, and it is missing from every single response, so no aggregate over the field can recover it. This chapter is about noticing the fold from the member list alone, splitting it, and then the related problem of a vocabulary that is complete enough to look complete.
Here is a security-triage schema with one folded field in it:
{
"severity": {
"enum": ["low", "medium", "high", "critical"],
"description": "impact if exploited"
},
"is_exploitable": { "type": "boolean" },
"affected_component": { "type": "string" },
"recommended_action": {
"enum": ["patch", "mitigate", "accept", "duplicate"]
}
}Read the four members of recommended_action on their own and each is
defensible. Patching, mitigating and accepting are the three things a security
team can do about a vulnerability, and they are genuinely exclusive in the sense
that you pick one as the primary response. duplicate is not one of those
things. It is a statement about the report's relationship to other reports, and
it says nothing about what happens to the underlying vulnerability, which in the
common case is still unfixed and still needs a patch. The field is asking "what
should we do about this vulnerability?" and "what kind of report is this?" and
only has room for one answer.
Two questions folded into one field
Every engineer has met this shape in a database before, usually as an
orders.status column that started as pending | paid and grew to
pending | paid | shipped | refunded | disputed | fraud_hold. Payment state,
fulfilment state and dispute state are three lifecycles, and a shipped order that
later gets disputed has one column and two truths. The team writes disputed,
the shipping state is gone, and six months later someone writes a query that
counts shipped orders and gets a number that is quietly too low. The database
version at least fails in a way you can reconstruct from the audit log. The
extraction version has no audit log, and the model resolves the collision fresh
on every document according to which question the report's wording emphasises.
There is a mechanical test for whether a field has been folded. Write the
question the field answers as a single sentence, and see whether you need an
"and". "What is the recommended action, and is this report new?" is two
questions, so it is two fields. A softer version of the same test is to check
whether any pair of members could both be true of the same input without
contradiction: patch and duplicate can, patch and accept cannot. Members
that can co-occur belong to different dimensions, or to a multi-label field — one
that carries a set of values at once rather than a single choice — and both of
those are structurally different from an enum.
The cost of the fold shows up as a distribution nobody can interpret. Here is one quarter of security reports laid out on both axes, the grid the single field had been projecting onto one of them:
| patch | mitigate | accept | no action needed | |
|---|---|---|---|---|
| new | 58 | 24 | 19 | 6 |
| duplicate of an open issue | 31 | 9 | 4 | 2 |
| out of scope | 0 | 0 | 0 | 41 |
| insufficient information | 0 | 0 | 0 | 16 |
The grid is worth adding up before anything is read off it. Its sixteen cells total 210, which is the number of valid inbound reports for the quarter, so every report appears in exactly one cell and none appears in two — which is the check that the two axes really are independent questions rather than overlapping ones. Reading across the rows, a hundred and seven reports were new, 46 were duplicates of something already open, 41 were out of scope and 16 carried too little information to act on. Reading down the columns instead gives the actions, and the columns are what the single field was supposed to have been reporting all along.
The 62 patches the quarterly report claimed are the top-left cell plus four
stragglers. The 31 in the second row were recorded as duplicate, and their
action was destroyed on the way into the field. The 41 out-of-scope reports had
been distributed across accept and duplicate roughly by whichever the wording
suggested, which is why accept looked like a real category with 60-odd members
when the team's actual policy of accepting a known risk had been exercised 19
times.
Going row by row shows that the field was not losing one axis uniformly, which is worse than it sounds. In the top row it recorded the action and the status was the common case, so nothing looked missing. In the second row it recorded the status and the action was destroyed. In the bottom two rows it recorded neither cleanly, spreading 41 out-of-scope reports and 16 unusable ones across whichever action word the report's language happened to suggest. So which axis survives depends on the row, and a projection that varies by row cannot be corrected for afterwards, because the rule deciding what gets dropped is the wording of the report rather than anything recorded in the data.
Splitting it, and the rule that comes out
The repair is to give each question its own field:
{
"triage_disposition": {
"enum": ["new", "duplicate", "out_of_scope", "insufficient_information"],
"description": "The report's status relative to work already tracked. Use duplicate only when the report describes an issue already filed. Use insufficient_information when the report does not describe a concrete issue."
},
"recommended_action": {
"enum": ["patch", "mitigate", "accept", "no_action_needed"],
"description": "The primary response to the underlying vulnerability, independent of whether this report is a duplicate. Use no_action_needed only when there is no vulnerability to act on."
}
}The general rule is one dimension per enum, and it is worth stating what it is
not. It is not an argument for replacing enums with booleans. A set of genuinely
exclusive options is one field, and turning severity into four booleans would
let the model emit low: true, critical: true and force you to write
reconciliation code for a contradiction the enum made impossible. It is also not
an argument against enums with many members; a fifteen-member enum answering one
question is fine, and a four-member enum answering two is not.
Splitting a field does add work, because two fields can now be jointly
nonsensical in ways one field could not.
triage_disposition: insufficient_information with recommended_action: patch
is a contradiction, and no per-field constraint prevents it, since each value is
legal on its own. That is a cross-field rule, and chapter 2, on where a rule
belongs, put those in the system prompt and in the validator rather than in a
description, because no single field owns them.
Where the two things really are simultaneous rather than orthogonal, an enum is the wrong container in a third way. A report that needs both a patch and an interim mitigation is not two dimensions and not one choice; it is a set:
{
"recommended_actions": {
"type": "array",
"items": { "enum": ["patch", "mitigate", "accept", "no_action_needed"] },
"uniqueItems": true,
"minItems": 1
}
}That trades a decisive single value for an array whose length the model now also decides, and the failure mode changes from "the wrong member" to "too many members", because a model asked for a set will tend to include anything plausible. Reach for it when the multiplicity is real and common, not to avoid making the vocabulary decide.
The reason it tends that way is structural rather than a habit of models. An enum
forces one choice, so the members compete with each other: picking patch costs you
mitigate. An array with minItems: 1 and no upper bound removes the competition,
because adding a member costs nothing the grammar is able to charge for. At each
element the question in front of the model is whether this action is plausible for
this report, not whether the set as a whole is now complete — and "plausible" is
satisfied by more members than "necessary" is. Nothing in the schema pushes back, so
the length of the array ends up being a property of the model's caution rather than a
property of the report.
An escape member makes the tail countable
The previous chapter left a subscriptions team with a measured 9.2 percent of
cancellations that fitted none of the eight reason codes, being absorbed into
whichever code read closest and inflating too_expensive by seven points.
Splitting dimensions does not touch that, because the problem is not that
reason was answering two questions; it is that the vocabulary was incomplete
and the schema had no way to say so.
The fix is a member whose meaning is "none of the above", paired with a field that says what the above should have been:
{
"cancellation_reason": {
"enum": ["too_expensive", "missing_feature", "switching_to_competitor",
"no_longer_needed", "poor_support", "technical_problems",
"billing_error", "involuntary_payment_failure", "other"],
"description": "Use other only when the cancellation text satisfies none of the other definitions. Do not use other because you are undecided between two members."
},
"other_reason": {
"type": ["string", "null"],
"description": "When cancellation_reason is other, a short phrase in the customer's own words describing why they cancelled. null otherwise."
}
}The second sentence of the other description is doing specific work. An escape
member is exactly the kind of legal, safe answer that chapter 3 showed a model
will reach for when it is unsure, and without that sentence other collects two
populations — cases outside the vocabulary, which is what you want, and cases
inside it where two members were close, which is what you do not.
The companion field is doing the same disciplinary work by a different route, and the
route is what matters. A grammar treats every member of an enum alike. There is no way
to declare one of them a last resort, and nothing in the mask makes other costlier
to reach than billing_error. What a schema can do instead is attach a consequence to
the choice. Selecting other obliges the model to write a phrase into other_reason,
which means committing in the customer's own words to a cancellation that supposedly
matches none of the eight definitions — a harder sentence to produce for a case that
does match one. The prose and the paired field push in the same direction, and only
the second of them changes what the model has to do rather than what it has been
asked to do.
On 12,000 cancellations a month, other came back on 3.1 percent, about 370
rows. Re-labelling a fresh sample of 200 by hand put the genuinely-uncovered rate
at 8.7 percent, essentially unchanged from before. The escape member had surfaced
roughly a third of the tail rather than all of it, which is the honest result and
still worth having: reading the 370 free-text strings for one month produced two
obvious new codes, switching_to_annual_plan and employer_stopped_reimbursing,
together accounting for 4.4 points of what had been landing in too_expensive.
After adding them, other settled at 2.6 percent and the hand-labelled uncovered
rate fell to 5.1 percent.
The "roughly a third" is the one comparison in that paragraph doing real work, and it
is a single division. other was selected on 3.1 percent of cancellations; hand
labelling put the genuinely uncovered share at 8.7 percent. The first over the second
is a bit more than a third, and that ratio is the escape member's actual yield —
not the 3.1, which looks like a reassuringly small tail, and not the 8.7, which is
unobservable without paying for the labels. The ratio also fixes the direction of the
error. other can only ever be a lower bound on the uncovered rate, because a case
the vocabulary does not cover may still land in a member that reads close to it, while
a case the vocabulary does cover has no reason to end up in other. The gap between
the two figures is the part of the tail the members are still quietly absorbing, and
it stays measurable only the expensive way.
| Escape design | What the model does with it | What ops can see | What you can measure |
|---|---|---|---|
| No escape member | assigns the tail to the nearest legal member | nothing; counts look complete | nothing, without a labelled sample |
other alone | selects it on a low single-digit percentage | a count that trends | a lower bound on the uncovered rate |
other plus a free-text companion | must produce a phrase, which discourages lazy use | the phrases, readable weekly | a lower bound, plus the next members to add |
other plus companion, and per-member definitions | fewer near-miss cases reaching other | phrases that are mostly genuinely novel | the closest estimate available short of labelling |
Two things the escape member does not settle
The number that matters — how much of your input the vocabulary fails to cover —
is still not computable from the model's outputs. The other rate is a lower
bound on it, and the gap between 3.1 and 8.7 percent shows how loose a bound it
can be. A team that watches only the other rate will conclude their vocabulary
is 97 percent complete when it is 91 percent complete, and the six-point
difference is sitting inside the members that read closest to whatever is
missing. The escape member converts part of a silent failure into a visible one,
which is a real improvement over the previous chapter's position and is not the
same as solving it.
The companion field also has to be null when it does not apply, or you get a
free-text field on every row that the model fills in with a restatement of the
enum member. Enforcing that relationship in the schema rather than in the
validator means a discriminated union, which is a set of alternative object
shapes told apart by one field holding a fixed value: one variant where code is
the const other and other_reason is required, another where code is one of
the remaining members and other_reason is absent. Schema Design • Prabal
works through the Pydantic form of this. It is the strictest encoding available
and it comes with two caveats: some providers' strict modes restrict oneOf in
ways that make it awkward at the top level, and the variant objects introduce a
level of nesting that the schema did not have before.
One dimension per enum, and a member for everything else, are the two halves of the same discipline: a field should be able to represent what is true of the input, and where it cannot, it should say so in a value somebody can count. Both repairs make the schema more honest and neither makes the model better, which is the correct division of labour — the model's job is to answer the question, and the schema's job is to ask one it can answer.
Nor does one-dimension-per-enum reduce the number of decisions the model makes.
Splitting recommended_action produced two fields where there was one, and
adding other_reason produced a third. That direction continues: a schema that
has been carefully decomposed into single-purpose fields is a schema with more
fields than the one you started with, and the natural instinct at that point is
to group the related ones back together under a triage object, and the ones
under that into an assessment object, so the shape reads well to the humans
maintaining it. Grouping is not free, and its cost is paid in exactly the place
this topic has not looked at yet.
Schema design for LLMs — read the current version at https://daviddominguez.dev/learn/en/schema-design-for-llms/folded-enums-and-escape-members