Skip to content
Chapter 6 of 7 · ElicitationValidating the elicited value

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 arrivedA benign explanationWhat actually prevents it
-1 for a bounded numberclient ignored minimum; user brought a convention from elsewhereserver-side bounds check plus a description that names the convention
4e9 for a bounded numbernon-interactive client answering from a policy fileserver-side bounds check
"25000" as a stringclient that reads text and does not coercestrict parsing with an explicit coercion policy
a field you did not requestclient merged in stored defaultsignore unknown keys, or reject on them
a required field absentaction: "accept" with partial contentrequired-field check independent of action
an id outside the enum you sentstale cached form, or content assembled programmaticallyre-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.

python
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

Back to top