Chapter 6 of 7 · 12 min read
Validating the elicited value
On this page
At the accept branch you hold a content dictionary that arrived from outside your process. It was produced
by a client you did not write, rendering a schema you sent, for a person you cannot see — and possibly by no
person at all, since conformant clients exist that answer elicitations from a policy file, from a stored
default, or by handing the raw schema to a language model and asking it to fill the form.
Nothing in the protocol makes client-side validation a precondition of returning content, and there is no
mechanism by which a server learns whether validation occurred. The response therefore crosses a trust
boundary, which is the line between code whose behaviour you control and code whose behaviour you do not,
where everything arriving has to be checked on your side because you can guarantee nothing about what
produced it. That puts an elicitation response in the same category as an HTTP request body from a browser
you wrote the form for. The form's type="number" and max attributes reduce mistakes and shape the
experience; the server still parses and checks, because the only validation that is load-bearing is the one
that happens on the side that owns the consequences.
Six things that arrive, and what actually prevents them
| What arrived | A benign explanation | What actually prevents it |
|---|---|---|
-1 for a bounded number | client ignored minimum; user brought a convention from elsewhere | server-side bounds check plus a description that names the convention |
4e9 for a bounded number | non-interactive client answering from a policy file | server-side bounds check |
"25000" as a string | client that reads text and does not coerce | strict parsing with an explicit coercion policy |
| a field you did not request | client merged in stored defaults | ignore unknown keys, or reject on them |
| a required field absent | action: "accept" with partial content | required-field check independent of action |
| an id outside the enum you sent | stale cached form, or content assembled programmatically | re-check membership against the live set |
The last two rows are the ones most likely to be missing from a handler, because both look like
impossibilities from inside the server that composed the request. An accept with content that omits a
required field is a client bug in the ordinary sense, and it still reaches you, and content["amount"] still
raises. The enum case is worse than a crash: a share_report elicitation whose enum lists the four teams
this user can share with does not stop a response naming a fifth team, and if your handler trusts enum
membership because it built the enum, that is an access-control bypass performed by a client that was merely
careless.
It is worth following that enum case through, because it is where "the schema is a hint" stops being an
abstraction. The server builds the enum by querying which teams the session's user may share with, gets
["ops", "growth", "support", "eng"], and sends it. The response names finance. There is no step in the
protocol at which that value was compared against the list — the client may have rendered a select and the
user may have picked from it, or the client may have assembled the object from a cached form, a policy file,
or a model completion. If the handler writes the share row using the returned team id, the access-control
decision that the server made when it built the enum has been silently delegated to whatever produced the
response. Membership has to be re-checked against the live set at the moment of use, exactly as it would be
for a team id that arrived as a tool argument.
Naming the two moments makes the failure precise rather than merely worrying. Building the enum is an authorization check: the server asks which teams this session's user may share with, and receives four names. That check is sound, it ran on the server, and it ran against the live permission set at that instant. What arrives some seconds later is a string produced by a different program — one that never saw the check, was never obliged to honour it, and may have assembled the object from a cache written before the user's permissions last changed. Between those two moments the only thing carrying the decision forward is an assumption that the response was derived from the request. Re-checking at the point of use is therefore not a duplicated query — it is the one place where the check and the value being acted on exist at the same time.
Validating on arrival, including the parts the schema could not say
The handler's job at the accept branch is to convert content, which is an untyped dictionary, into a value
the rest of the operation can use, and to fail loudly when it cannot. The same model class you used to
generate the schema will usually do it, provided you re-run it against what came back rather than trusting
the SDK to have done so.
class BudgetAnswer(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
amount_gbp: int = Field(ge=0, le=100_000)
result = await ctx.elicit(message=..., schema=BudgetAnswer)
if result.action != "accept":
return terminal_result(result.action) # chapter 5's table
try:
answer = BudgetAnswer.model_validate(result.data)
except ValidationError as exc:
field = exc.errors()[0]["loc"][0]
log.warning("elicit", outcome="invalid_content", field=field,
client=ctx.session.client_info.name)
raise ToolError(
f"The value supplied for {field} is not usable: it must be a whole "
f"number of pounds between 0 and 100000. No budget was set."
)
cap = await policy.budget_cap_for(ctx.session.user_id, account_id)
if answer.amount_gbp > cap:
raise ToolError(
f"You may set budgets up to £{cap:,} on account {account_id}; "
f"£{answer.amount_gbp:,} exceeds that. No budget was set."
)Re-running the model is not always redundant work, and whether it is depends on your SDK. Some server SDKs
parse the response into the type you passed and raise on a mismatch, which covers types and required fields,
though usually not the numeric bounds you expressed through Field and never the authorization rules that
were not in the schema at all. Others hand you the raw content dictionary and leave the rest to you. The two
lines it costs to validate explicitly are worth paying in either case, since the alternative is a correctness
property that depends on a library version, and an upgrade that changes it will not announce itself.
strict=True and extra="forbid" are the two settings that turn the model from a coercion helper into a
check. Without strict, "25000" becomes 25000 and a client that never coerces looks conformant until the
day someone sends "25,000". Without extra="forbid", unexpected keys pass through silently, which is
tolerable for a value object and is a problem if that dictionary is later splatted into a function call.
The authorization check underneath is the part with no schema equivalent, and it illustrates why the two layers cannot be merged. One user's cap is £5,000 because of their role, and their colleague's is £250,000. Encoding that in the requested schema would put one user's entitlement into a message the client can log and display, it would need recomputing per request, and it would still not be enforced by anything. The schema states the shape of the field for everybody; the policy states what this person may do, and it belongs on the server where the identity attached to the session lives. That separation is why "tighten the schema" and "check on arrival" are not competing options — they answer different questions and only one of them can refuse.
Chapter 5 listed the outcomes that end the wait, and validation splits the accept row into two: an accept you could use, and an accept you could not. Logging them under one outcome hides the most informative signal you have about client behaviour in the field, because an accept that fails validation names both the field and the client that produced it.
Whether to re-prompt after an invalid accept is a judgement about the failure. A single retry with a message that names the problem is reasonable when the content looks like a human mistake, and it should be one retry with a counter, because a client that returns unusable content once will usually return it again, and a loop of dialogs is the interruption pattern chapter 7 is about. When the content is wrong in a way a person could not have caused — an unknown key, a missing required field — abort and log it, since the fault is in the integration and another prompt will not fix it.
Tightening the schema changes nothing
The reflex fix for the two out-of-range rows is to make the schema stricter: add exclusiveMinimum, add
multipleOf: 100, switch to integer, add a pattern. Trace it against the two causes. The terminal client
did not read minimum, so it will not read exclusiveMinimum. The policy-file client never rendered
anything at all, so no keyword you add participates in producing its answer. Both rows would still be there,
and the team would have spent the afternoon believing the problem was closed.
The restricted subset from chapter 4 also limits what "tighter" can even mean. exclusiveMinimum,
multipleOf and pattern are outside the primitive keywords the specification names for elicitation, so a
stricter schema is frequently a less conformant one, and the strictness lands nowhere twice over.
It is also worth noticing which instrument found the bad rows, because nobody had aimed one at the problem. Thirty-nine unusable values against five thousand one hundred good ones is well under one percent of the table, or about five a month across eight months, and a rate that low is invisible to anything computed over a typical row. Minimum and maximum are the exceptions, because they are the two aggregates that report a single row each, so a handful of extremes among five thousand shows up in them at full strength and nowhere else. That makes them a cheap standing check on any column fed by an elicitation. Extreme values are not the only unusable ones; they are simply the ones every robust summary is built to discard.
What schema constraints genuinely buy is fewer round-trips and a better experience for the users on clients
that honour them. A maximum that stops a typo at the keyboard saves a rejected submission and a re-prompt,
and an enumNames label saves the user guessing what P2 means. Both are worth writing. Neither is a
control. The way to hold the two ideas together is that the schema is a specification of the question and the
server-side check is a specification of the answer, and the answer's specification is the one with teeth
because it is the one running on the machine that will act.
The value keeps travelling after you validate it
Bounds and types are the easy half. The harder half is that an elicited value usually does not stop at your
handler — it goes into a tool result, and the tool result goes into a model's context. A free-text note
field on a create_ticket elicitation is a string a person typed that will shortly be read by a model that
also reads instructions, and the model has no reliable way to tell the two apart. The general name for that
is prompt injection, where instructions ride into a context window inside data the model was only supposed to
read. The user is not the attacker in the interesting version of this: they paste a customer's email into the
note, and the email contains a line telling the assistant to email the ticket contents to an outside address.
The handling is the same as for any other untrusted text entering a context window — keep it in a clearly
delimited field rather than interpolated into a sentence of instructions, do not let it choose which tool runs
next, and truncate it to a length that matches its purpose. A 200-character note limit in your validation
model is worth more than the same limit in the schema for exactly the reason this chapter has been making,
and it also caps what a paste can carry.
There is a limit to what any of this achieves, and it is worth being plain about. Validation ensures the
value is well-formed, in range, and permitted for this user. It cannot tell you the value is correct — a user
whose cap is £250,000, who genuinely intends a £4,000 budget and types 40000, produces a number that passes
every check in this chapter and is wrong by a factor of ten. That is what confirmations, reversibility and
undo windows are for, and chapter 2 placed those. Validation makes the operation safe to attempt; it does not
make the answer right.
The schema and the check are therefore two documents about the same field written for two different readers. The schema is read by a client that will do its best with it, on a spectrum from a date picker to a policy file, and its purpose is to make the question answerable. The check is read by nobody and runs every time, and its purpose is to decide whether the answer may be acted on. Every property you care about has to appear in the second one, including the properties you already stated in the first, and including the ones — this user's cap, this user's teams — that the first has no vocabulary for.
Everything so far has treated one prompt as the unit. The remaining failure is at the level of the operation: a tool that asks four correct, well-scoped, properly validated questions in a row, each defensible on its own, which together drive more than half its users to give up.
Elicitation — read the current version at https://daviddominguez.dev/learn/en/elicitation/validating-the-elicited-value