Chapter 3 of 7 · 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.
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:
{
"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:
{
"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:
{
"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.
| Symptom | What the description was doing | Structural fix | How you detect it |
|---|---|---|---|
~5% of due_date values are unknown | adding an inhabitant to string | ["string", "null"], plus a status enum if you need to distinguish causes | grep descriptions for sentinel literals |
discount_fraction is 0 far more often than documents lack discounts | making 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 high | classifying, with one trigger nearly every input satisfies | per-member definitions with disjoint observable triggers | compare 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.
| Encoding | Downstream cost | Model-side cost | When it is right |
|---|---|---|---|
In-domain sentinel string ("unknown") | every consumer must know the magic word; the type lies | low — one legal string | never for new work; it is what you are migrating off |
In-domain sentinel value (0, -1) | collides with real values; silently changes aggregates | low, and dangerously attractive | only where the value is provably outside the real domain |
Nullable type (["string","null"]) | consumers must handle null, which they should anyway | gives the model a free abstention on every document | the default |
| Nullable value plus a status enum | two fields to keep consistent; a validation rule to write | slightly higher; the model must justify the null | when 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