AI agents

A Validator Wrapper That Names the First Failing Field

Antonio Evans10 min read

A workbench scene: a JSON document being passed through a narrow funnel labeled 'validator'; where it exits, a single index card drops out showing a red path like 'user.address.zip', the received value 'null'.
Key points

Key points

  1. 1A validator wrapper catches the schema error, extracts the first failing path, expected type and received value, and returns them as a structured object instead of a message string.
  2. 2Tag each failure with a stop reason and the prompt or schema version so counts can be grouped and compared over time.
  3. 3Log each miss exactly once at the wrapper boundary, so retries and nested callers do not multiply the same row.
  4. 4Group the logged rows by path to rank the fields that break most often, then fix prompts or schemas in that order.

Why a validation message is the wrong unit of work

When a schema check fails, most stacks hand you a string like "must have required property 'currency'" or "expected integer, got string". That string is fine for a human reading one error. It is useless for the work that actually follows: deciding which prompt to change, which field to loosen, and whether a failure is permanent or worth a retry.

The first problem is that messages are not stable identifiers. Validators reword them across releases, and the same underlying miss produces different text depending on which keyword fired first. If you group logs by message text you get a long tail of near-duplicates. A structured path, by contrast, is a key you can index: /items/0/unitPrice is always the same string for the same kind of miss.

The second problem is that a message hides whether retrying is worth anything. Some validation failures are transient (the model returned prose wrapped around JSON, or a field arrived as a stringified number) and a second attempt with a stricter instruction fixes them. Others are structural: the model was never told the field exists, or the schema demands a format the model keeps ignoring. LLM tool calling guides make this split explicit, separating transport and transient failures that the orchestration layer should retry silently from input validation failures where the payload itself is structurally wrong and only a corrected request can help. A wrapper that records a stop reason lets you tell those two cases apart without guessing.

The third problem is versioning. Prompts and schemas change, and a failure rate is meaningless unless you know which prompt produced it. Recording a prompt version alongside each failure is what lets you answer "did the last change help?" rather than "are things worse than I remember?"

The error object: five fields and a decision rule

Design the object before you write the wrapper. Five fields carry almost all the triage value, plus a couple of context fields you will want later.

FieldTypeWhat it answersExample
pathstringWhere did it break/items/1/quantity
expectedstringWhat the schema wantedinteger
receivedstringWhat actually arrived (type plus a short rendering)string: "3"
reasonenumShould the caller retry, repair or abandonschema_type_mismatch
promptVersionstringWhich prompt or schema produced thisorder-extract-v7

Beyond those five, add schemaVersion, a request or run id, and a timestamp. The run id is what lets you join a validation row back to the raw model output when you need to look.

The reason field is the decision rule made concrete. Pick a small closed set and stick to it:

  • schema_missing_required: a required key was absent. Retrying the identical prompt rarely helps; fix the prompt or make the field optional.
  • schema_type_mismatch: present but wrong type, for example a number sent as a string. Often repairable by coercion or a targeted retry.
  • schema_format_violation: right type, wrong shape (a date in three different formats, a currency code that is not three capital letters). Structured output guides note that format and pattern constraints like an email or an ISO currency code are exactly where models drift.
  • parse_error: the body was not parseable as the target format at all. Treat as transient more often than the others.
  • schema_rejected_value: an enum or range check failed. Usually a prompt problem.

That mapping from reason to action is the insight worth keeping: the reason code, not the message, tells the caller what to do next.

Writing the wrapper

The job of the wrapper is small on purpose: run the validator, catch its failure, read the first failing path and the expected type from the validator's own error structure, build the object, log it once, and return it. It never throws on a validation miss, so callers can branch on a returned value.

Validators already carry most of what you need. The Python jsonschema library's ValidationError exposes path, validator (the name of the failed keyword), validator_value (what the schema asked for), instance (the value being validated) and json_path, plus a context for nested errors (jsonschema error handling docs). JavaScript validators such as Ajv return an array of { instancePath, keyword, message, params } entries, and express-validator's result object exposes path and a formatter so you can reshape errors into your own type (express-validator validation results). The wrapper's job is to translate whichever shape you use into one canonical object.

The decision rule for "first failure" matters. Some validators return every error; some return only the first. If you have a list, prefer the one with the shortest path, or the first in document order depending on which you find more actionable, and record the choice so it does not drift. Do not loop over all errors to build a list: a list of failures per call multiplies your log volume and makes counting by path ambiguous (one request contributes many rows). One row per failed validation keeps the math clean.

Here is a compact Python wrapper. It assumes a jsonschema validator and a JSON-schema-with-$defs design similar to the order schema shown in the API testing JSON schema guide, but the pattern transfers to any validator that exposes a path.

from dataclasses import dataclass, asdict
import json, logging
from jsonschema import Draft202012Validator
from jsonschema.exceptions import ValidationError

log = logging.getLogger("validation")

@dataclass
class ValidationMiss:
    path: str
    expected: str
    received: str
    reason: str
    prompt_version: str
    schema_version: str

def _reason(err: ValidationError) -> str:
    if err.validator == "required":
        return "schema_missing_required"
    if err.validator == "type":
        return "schema_type_mismatch"
    if err.validator in ("format", "pattern", "minLength"):
        return "schema_format_violation"
    if err.validator in ("enum", "minimum", "maximum", "minItems"):
        return "schema_rejected_value"
    return "schema_other"

def _short(value) -> str:
    text = json.dumps(value, ensure_ascii=False, default=str)
    return text if len(text) <= 120 else text[:117] + "..."

def validate_or_miss(payload, schema, *, prompt_version, schema_version):
    validator = Draft202012Validator(schema)
    err = next(validator.iter_errors(payload), None)
    if err is None:
        return None
    miss = ValidationMiss(
        path=err.json_path,                       # e.g. "$.items[1].quantity"
        expected=str(err.validator_value),
        received=_short(err.instance),
        reason=_reason(err),
        prompt_version=prompt_version,
        schema_version=schema_version,
    )
    log.warning("validation_miss %s", json.dumps(asdict(miss)))
    return miss

Two details are load-bearing. First, iter_errors gives you the first error without building the whole list, which matches the "first failure" rule and avoids the cost of validating everything when you are going to stop at one. Second, the log line is the only place the miss is written. Every caller gets the object back but does not log again. That is what keeps each failure to exactly one row.

If your runtime is JavaScript, the same shape works with Ajv:

const miss = (err, promptVersion, schemaVersion) => ({
  path: err.instancePath || "$",
  expected: err.keyword,
  received: short(err.data),
  reason: reasonFor(err.keyword),
  promptVersion,
  schemaVersion,
});

The choice of expected deserves a note. Some teams want the JSON type ("integer"), others want the failed keyword ("type"). They answer different questions. The type tells you what to coerce or what to tell the model; the keyword tells you how the schema is failing overall. For aggregation, the keyword is usually the better grouping key; for repair, the type is. I would record both: expected as the type, and fold the keyword into reason.

Logging each miss exactly once

Log volume is where structured validation quietly goes wrong. If the wrapper logs, and then the caller also logs a friendly message, and then a retry wrapper logs the outer failure, one miss becomes three rows and every count you compute is inflated in ways that depend on control flow. Fix this with a simple rule: only the wrapper writes the structured row; everything else either consumes the returned object or logs free text that is explicitly not counted.

This is the same discipline that Temporal's non-retryable error pattern enforces from the other direction. Temporal retries all activity failures by default, so a permanent failure (invalid input, a missing record) is retried with backoff for minutes before the error reaches the workflow, when it could have failed in milliseconds. Marking an error type non-retryable delivers it immediately. Your reason field is the validation equivalent: schema_missing_required is permanent for a given prompt version and should be counted and escalated, not retried. parse_error may be transient and worth one more attempt. The stop reason makes the retry policy data-driven instead of a blanket setting.

Structuring the log as a single JSON object on one line matters for the aggregation step. A line-oriented format with the five fields at the top level means your log store can treat them as columns. Structuring validation errors in REST APIs makes the same argument for API responses: expose a machine-readable error name and a path so the client can aggregate and present, rather than a pre-rendered message. Machine-readable first, human message second.

The other logging trap is retries in the wrapper. If you give the wrapper an internal retry loop ("try validation, maybe repair the payload, validate again"), log the final outcome once per attempt or once per call, but choose and document it. Counting attempts is the more useful default, because it tells you how often a repair worked. Prefix attempt rows with an attempt field so you can filter to attempt = 1 for pure failure rates.

Turning rows into a plan: a worked example

Suppose you have 4,000 extraction calls in a day and 600 of them fail validation on the first attempt. That is 15 percent. Rows grouped by path and reason might look like this (illustrative numbers for the method, not a benchmark):

pathreasoncountdistinction
/currencyschema_format_violation210model returns "usd", "US dollars", "$"
/items/0/quantityschema_type_mismatch150quantity sent as "3"
/dueDateschema_format_violation120dates in three formats
/customer/idschema_missing_required80id nested differently, key absent
/items/0/unitPriceschema_type_mismatch40stringified price

Rank by count, then fix by cost. The currency miss is 210 rows for one enum or pattern constraint: add an explicit "ISO 4217, three capital letters" instruction to the prompt and a coercion step that uppercases and maps obvious cases, or make the schema accept a currency object. The quantity type mismatch is 150 rows fixable by a single integer coercion, since the model gives you a numeric string. The date format is a prompt-level fix if you show one example; a repair step if you must accept several formats. The missing customer id is 80 rows that no retry will fix, because the model is putting the id somewhere else entirely: change the prompt to show the nesting, or loosen the schema and normalize after. The remaining 40 unitPrice rows might be covered by the same integer coercion as quantity.

Notice what happened: 15 percent failure became 4 actionable tickets, each with a row count that justifies the work. Without the structured object, you would have 600 lines of near-identical messages and no way to order them. That ordering is the whole return on the wrapper.

The worked numbers also make a budget decision legible. If 560 of the 600 misses are fixable by prompt and coercion changes and 80 are structural, then the marginal value of a repair-and-retry layer is roughly the size of the fixable set, and the value of a prompt change is independent of it. You would not build a repair loop to chase 40 rows and you would not rewrite a prompt for a one-off.

Aggregating and keeping it honest

Once rows are single-line JSON with the fields at top level, grouping is a one-liner in most log stores: group by path and reason, count, and divide by total calls for a rate. Two habits keep the numbers trustworthy.

First, always compute failure rate against calls that were actually attempted, not against all requests. If a request failed at the network layer, it never reached validation and should not inflate the denominator. Keeping a separate parse_error reason for bodies that never parsed keeps it out of schema-failure rates, which you want to track separately because they have different owners.

Second, track the rate per prompt_version and per schema_version, not just overall. When you change a prompt, the first thing you want is a before-and-after of schema_missing_required for the field you touched. If the schema changed, a spike in schema_type_mismatch on a field that used to be a string may be a schema bug, not a model bug. The MLflow structured outputs guide recommends logging raw and parsed outputs, setting validation failure metrics, and treating schema violations as something to catch early rather than discover in production; the wrapper is the cheapest place to emit that metric.

A third habit: keep a small golden set of known-bad payloads with their expected path and reason, and assert that the wrapper returns them. This is the same idea as testing a schema with known-good and known-bad fixtures. If a validator upgrade changes which error is "first", the golden set catches it before your dashboards show a phantom spike.

There is also a boundary decision to record: what counts as a validation failure versus a semantic failure. A response can pass schema and still be wrong, for example a total that does not equal the sum of line items. Those are separate test layers, and the schema wrapper should not be asked to catch them; it answers "can consumers safely parse the body," not "are the values correct." Keeping the wrapper narrow is what keeps its rows meaningful.

If you are running the extraction against a mix of providers and want to compare failure rates per model without wiring a separate client for each, that is the kind of routing Quanta Inference is built for. You can point one OpenAI-compatible endpoint at different model families, keep your wrapper unchanged, and let the prompt_version and provider fields in your rows do the comparison.

Rolling it out without a big rewrite

Adopt this in three small steps.

  1. Add the wrapper around existing validation calls, returning the object but not yet changing any caller. Log it, and count. You now know the failure rate and the top paths in production.
  2. Change one or two high-volume callers to branch on reason instead of a message. For example, coerce numeric strings when the reason is schema_type_mismatch and the path is on your coercion allow-list, and skip retrying when the reason is schema_missing_required.
  3. Attach prompt and schema versions to the call sites and start a before-and-after view on the top three paths. Fix prompts in ranked order.

Keep the wrapper's public shape stable from day one: callers depend on path and reason, not on the message text. Treat the reason enum as an API and add values deliberately. If you later want the wrapper to also fail fast inside a workflow engine, the non-retryable mechanism in workflow systems maps cleanly onto the same decision you already encoded in reason. And if some part of the triage involves decisions better made by a person looking at patterns across runs, an AI expert who reads your context and returns finished analyses (https://quantaintelligence.ai/agents) can work from the same log rows, but the rows have to exist first.

None of this requires a new service. It is one function, one log line, and a five-field object.

FAQ

FAQ

What exactly counts as "the first failing field"?

It is the first error the validator reports, which for jsonschema with iter_errors is the first in its evaluation order, and you read its json_path for the pointer (jsonschema docs). If your validator returns all errors, define and document a tie-break (shortest path, or first in document order) so the choice stays consistent across releases.

Why log in the wrapper instead of at the call site?

Because logging at the call site multiplies rows by call depth and retries, which makes counts unreliable. Logging once at the wrapper boundary gives one row per failed validation, and callers that need more context can add fields without changing the count of failures.

Should the wrapper throw on a miss?

No, if your callers need to branch on the result. Returning a structured object lets a caller decide to coerce, retry, escalate or abandon based on the reason field, which is exactly the split that treats input validation failures differently from transient transport errors. Throwing forces every caller to catch and re-derive the same information.

How do I decide which reasons are worth retrying?

Treat reasons that indicate a wrong type or unparseable body as candidates for one repair attempt, and treat missing-required-field and rejected-value reasons as permanent for that prompt version. This is the same reasoning behind marking structural failures as non-retryable so they fail fast instead of burning a backoff schedule. Validate the choice against your own before-and-after rates.

Does recording the received value risk logging sensitive data?

It can if the field holds personal or secret data. Store a type name and a short rendering rather than the raw value for sensitive paths, and apply your existing redaction rules before the wrapper logs. The pointer and the reason carry most of the triage value even when the value is withheld.

Sources

Sources

  1. LLM Tool Calling Error Handling: Retries and Fallbacks
  2. The guide to structured outputs and function calling with ...
  3. Handling Validation Errors - jsonschema 4.26.0 documentation
  4. Errors and Validation Results
  5. API Testing JSON Schema Validation Guide
  6. Non-Retryable Errors
  7. Structuring validation errors in REST APIs | by Łukasz Lalik
  8. Enforce, Validate, Observe: 3 LLM Structured Output ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles