Skip to content

15 min read

Sentinel values in descriptions

On this page

A sentinel is a value that sits inside a field's normal range and has been given a second meaning on top of its own, almost always "there was nothing here". Sentinels are old and they are usually harmless, because in ordinary code the convention is written next to the reader — -1 for not found, an empty string for unset. In a schema handed to a model the convention is written in a description, which travels to the model and to nobody else, and the type the consumer compiles against does not record it.

That produces three failures that look unrelated until they are lined up: a string field that sometimes holds a word no parser can type, a number field whose zero means two different things, and an enum whose description quietly reduces a three-way distinction to one test. All three come from prose expressing something the schema was capable of expressing structurally, and all three produce responses that validate.

The tell is a literal in the prose

The first one is a date. An accounts-receivable service declared due_date as a string with the description ISO 8601 date, or the word unknown if the document does not state one, and about one response in twenty came back as unknown. Strict mode had held, the field was a string, and the string was in the response. The schema had said string and the description had quietly said string | unknown, and the difference between those two types was recorded nowhere that any consumer could read.

The second is the discount_fraction field from chapter 1, on naming, which carried the description Use 0 if none. Zero is inside the domain of that field — a supplier can and does grant a nought percent discount, and some contracts say so explicitly — so absence and a stated zero become the same value, and a consumer counting "documents with no discount terms" is counting both. Worse, the clause hands the model a legal, low-risk answer for every case where it is not sure, which is exactly what produced the 431 zeroes that chapter opened on.

The third is a support-triage schema with severity as an enum of low | medium | high, described as how urgent this is; use high for anything the customer is upset about. In production, 60 percent of tickets came back high, against an expected distribution that the support lead put at roughly 45 / 40 / 15. Nothing is wrong with the enum. The description is a classification rule with a single trigger, and the trigger is satisfied by very nearly every ticket that reaches a support queue, because customers who are not upset generally do not open tickets. The three-way distinction collapsed into one test, and the test passes almost always.

The severity case is worth sitting with, because the diagnostic move generalises. When one member of an enum dominates, read its description as a rule and estimate what fraction of your actual inputs satisfy it. Support tickets are a selected population: someone was annoyed enough to write in. If the trigger for high is customer displeasure, the trigger's base rate on that population is somewhere north of 80 percent, so a well-behaved model following the instruction faithfully should produce a large majority of high — which is roughly what it produced. The 60 percent figure is not evidence that the model is failing to follow the description. It is evidence that the model is following it, and that the description encodes a rule nobody would have endorsed if it had been written out as "mark most tickets high".

The size of the gap is what makes that diagnosis stick rather than being one story among several. The support lead's expected distribution put high at 15 percent of tickets and the field returned it on 60 — four times as many. That is far too large to be drift at a boundary and far too one-directional to be noise. A boundary that is merely fuzzy moves cases between adjacent members in both directions and leaves the totals roughly where they were; this pulled cases out of both other members, in one direction, across most of the traffic. A single member taking a multiple of its expected share is the signature of a trigger satisfied by a population it was not written against, and the way to confirm it is to read the trigger and estimate what fraction of the inputs meet it, not to read more responses.

The rate in the first of the three is worth pausing on as a rate rather than as a count, because one response in twenty sits in the worst possible band for being noticed. A defect appearing on every twentieth record will not show up in a smoke test of ten. It will not show up when an engineer clicks through a handful of invoices in staging. It will not push a failure counter anywhere, because nothing failed. It shows up on the first day somebody aggregates the column, or on the first day a value reaches a consumer written by a team that has never read this schema — which is to say, weeks later and somewhere else. All three of these failures share that property, and it is why they are found by distribution checks rather than by tests.

The shared shape is that the description is expressing something the schema is capable of expressing structurally, and is expressing it worse. Optionality is a type. A unit is part of a name. A classification boundary is a set of definitions attached to members. When any of those ends up in prose, the model applies it with a failure rate and the consumer cannot see it at all.

There is a mechanical way to find these in an inherited schema: search the descriptions for quoted literals and sentinel words. unknown, N/A, none, TBD, null written as text, -1, 0, empty string, the word.

bash
rg -n --json '"description":' schemas/ \
  | rg -i '\b(unknown|n/?a|tbd|none|-1|empty string|the word|use 0|use zero)\b'

Fixing each one in the structure

For due_date, the fix is to make absence a value the type already has:

json
{
  "due_date": {
    "type": ["string", "null"],
    "description": "The payment due date as YYYY-MM-DD. null if the document does not state one."
  }
}

null is not a sentinel in the sense that unknown was, because it is a distinct JSON type rather than a string that happens to look unlike a date. Every downstream language already has a representation for it, a Postgres loader maps it to NULL deliberately rather than by accident, and a query for "invoices with no stated due date" is now IS NULL and correct.

Some strict-mode implementations require every declared property to be present in the output, which is precisely why nullability rather than optionality is the right tool: the key is always there, and the absence is carried in the value. Where you need to distinguish "the document has no due date" from "the document has one but it is unreadable", that is a second dimension and it wants a second field:

json
{
  "due_date": { "type": ["string", "null"], "description": "..." },
  "due_date_status": {
    "enum": ["stated", "not_stated", "stated_but_unparseable"],
    "description": "Whether the document states a due date. Use stated_but_unparseable only when a date is clearly present but its format is ambiguous, e.g. 03/04/25."
  }
}

For discount_fraction, the same move applies with an extra consequence. Making the field ["number", "null"] and writing null if the document states no discount; 0 only if it explicitly states a zero or nil discount separates the two cases that Use 0 if none had merged, and it removes the free escape hatch. The model can still abstain, but abstaining is now visible as a null rather than hiding inside a plausible number.

For severity, the enum stays and the description stops classifying. What replaces it is a definition per member with a trigger that is observable in the ticket and that no other member's trigger also matches. JSON Schema has no native per-member description, so the usual encodings are a rubric in the field description or a oneOf of consts — oneOf being the keyword for "exactly one of these alternatives holds", and const a branch that admits a single literal value, which together give each member somewhere of its own to carry prose:

json
{
  "severity": {
    "oneOf": [
      { "const": "low",    "description": "The customer can still complete their task; this is a question, a cosmetic issue, or a feature request." },
      { "const": "medium", "description": "The customer is blocked on one task but a documented workaround exists, or the issue affects a single non-billing feature." },
      { "const": "high",   "description": "The customer cannot log in, cannot complete a payment, has lost data, or reports the issue affecting more than one of their users." }
    ],
    "description": "Choose the highest severity whose definition the ticket satisfies."
  }
}

The triggers are things a reader can check against the ticket text, they partition the space rather than overlapping, and the tie-break sentence says what to do when two match. Customer emotion has been removed entirely, because it correlated with nothing the support team actually schedules on. After the change, the distribution over 2,000 tickets came out 41 / 44 / 15, close enough to the support lead's estimate that the remaining gap was worth investigating rather than being the whole story.

SymptomWhat the description was doingStructural fixHow you detect it
~5% of due_date values are unknownadding an inhabitant to string["string", "null"], plus a status enum if you need to distinguish causesgrep descriptions for sentinel literals
discount_fraction is 0 far more often than documents lack discountsmaking absence collide with a real value, and offering a safe answer["number", "null"]; 0 means a stated 0%compare the zero rate against a hand-labelled sample
60% of tickets are highclassifying, with one trigger nearly every input satisfiesper-member definitions with disjoint observable triggerscompare the member distribution against an expected one

The detection column is the part worth institutionalising, because all three of these failures produce responses that validate. Nothing in the request path raises anything. The only signals available are a distribution that does not match what a human would produce, and a sample of a few dozen inputs checked by hand.

Four ways to encode "not stated"

There is more than one defensible answer, and which one you want depends on what the consumer has to do with the distinction.

EncodingDownstream costModel-side costWhen it is right
In-domain sentinel string ("unknown")every consumer must know the magic word; the type lieslow — one legal stringnever for new work; it is what you are migrating off
In-domain sentinel value (0, -1)collides with real values; silently changes aggregateslow, and dangerously attractiveonly where the value is provably outside the real domain
Nullable type (["string","null"])consumers must handle null, which they should anywaygives the model a free abstention on every documentthe default
Nullable value plus a status enumtwo fields to keep consistent; a validation rule to writeslightly higher; the model must justify the nullwhen the reason for absence drives different behaviour

The row that surprises people is the second one, because -1 for "no value" feels safe in a field like days_until_expiry where negatives are impossible. It is safe until the schema is reused on a document type where the contract has already expired, at which point -1 is both a sentinel and a legitimate answer, and the aggregate that counted expired contracts is now off by however many nulls there were. In-domain sentinels are a bet that the domain will not grow, and schemas outlive that bet.

The chain there is short enough to run against any sentinel you are considering. The value is chosen because it is currently impossible. Impossibility is a property of the documents currently being fed in rather than of the type, since nothing in integer forbids a negative. The documents then change — a new source, a new customer, a backfill of historical contracts — and the value becomes possible. From that moment the field holds two meanings with no marker separating them, and every aggregate computed over it is wrong by however many of each it contains. Nothing failed, nothing was deployed, and the schema was not edited. The type simply stopped being true, and the only thing that moved was the input.

Where prose is still the right place

Not every rule living in a description is a type in disguise, and stripping descriptions back to nothing is a worse schema than the one you started with. Some instructions have no structural equivalent at all: quote the clause verbatim, do not paraphrase, if the header total and the sum of line items disagree, use the header total, the party named first in the preamble is the buyer even if the signature block is ordered differently. Those are procedures for reading the source, they resolve ambiguity that lives in the document rather than in the type, and no combination of enum, pattern and nullability expresses them.

The test that separates the two cases is whether the description names a value or names a procedure. Use 0 if none names a value, so the type should carry it. Use the header total names a procedure for choosing between two values that are both legal instances of the type, so prose is correct. A description that consists entirely of value-naming clauses is a type definition written in English, and it will be applied with a failure rate somewhere between one and ten percent depending on how tempting the sentinel is.

Making a field nullable is also not free, and the honest version of this chapter says what it costs. A nullable field gives the model a legal way to decline on every document, and some of those declines are the model being lazy rather than the document being silent. On the accounts-receivable schema, the unknown rate had been 5.1 percent; after the change the null rate settled at 6.4 percent, and hand-checking 200 nulls found 188 documents that genuinely stated no due date and 12 where a date was present and had been missed. That is a real regression of about 0.6 percentage points in recall — the share of documents that do state a due date on which the model returns it — taken deliberately in exchange for a field whose values a consumer can trust. If your application cannot afford abstention, the answer is not to remove the null; it is to keep the null and measure it, so the 0.6 points shows up as a number on a dashboard rather than as 340 wrong emails.

The shape of that measurement is the transferable part of it. A null rate on its own says nothing, because it mixes two populations that look identical in the response: documents genuinely stating no due date, and documents stating one the model failed to find. Only the second is a regression. Nothing in the output distinguishes them, so splitting them takes a person reading a sample of the nulls against the source documents — which is why the 200 were hand-checked rather than queried. Twelve misses in a sample of 200 is roughly one null in seventeen being a failure rather than an abstention, and that ratio is what turns a null rate into a statement about recall. Adopting nullability without running that sample is taking the trade this chapter describes while measuring only the half of it you are being paid.

A description that names a literal is a type declaration in the wrong file, applied by a sampler rather than a compiler and invisible to every consumer that reads the schema. Moving it into the structure costs a migration and a small, measurable amount of recall, and it buys a field whose values mean what the type says they mean.

The severity fix in this chapter leaned on something the other two did not: the enum was already there, doing work that the description could not undo, and only the prose around it needed repair. That raises the question of what the enum was actually buying, and whether a description that simply lists the six allowed values would have bought the same thing. Those two look equivalent from the outside, and at decode time they are not the same mechanism at all.

Schema design for LLMs — read the current version at https://daviddominguez.dev/learn/en/schema-design-for-llms/sentinel-values-in-descriptions

Back to top