Skip to content

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:

json
{
  "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:

patchmitigateacceptno action needed
new5824196
duplicate of an open issue31942
out of scope00041
insufficient information00016

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:

json
{
  "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:

json
{
  "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:

json
{
  "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 designWhat the model does with itWhat ops can seeWhat you can measure
No escape memberassigns the tail to the nearest legal membernothing; counts look completenothing, without a labelled sample
other aloneselects it on a low single-digit percentagea count that trendsa lower bound on the uncovered rate
other plus a free-text companionmust produce a phrase, which discourages lazy usethe phrases, readable weeklya lower bound, plus the next members to add
other plus companion, and per-member definitionsfewer near-miss cases reaching otherphrases that are mostly genuinely novelthe 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

Back to top