Skip to content
Janeiro.ai
Pattern

Structured Output That Fails Closed

Fail closed on model JSON: versioned schemas, constrained decoding, one repair loop, and no side effects until validation passes—treat output like an API.

6 min read

Downstream code cannot parse a vibe. If the model’s next token becomes a tool argument, a UI block, or a workflow transition, the response is an API response. Structured output means a versioned schema, a validator, a single repair attempt, and a deny path. It does not mean “we asked for JSON in the prompt.”

This is the sister pattern to tool use with least privilege. Tools validate arguments. Structured output validates everything else the model is allowed to say to a machine—including the plan object in plan-and-execute.

Context

Prompting “return JSON” was a 2023 habit. Models still wrap objects in fences, invent keys, and drop required fields under Portuguese diacritics or long ticket text. Constrained decoding raised the floor. It did not remove schema drift, nor the need to fail closed when the vendor’s constraint is off.

OpenAI structured outputs and Gemini JSON schemas moved the constraint into decoding. That raised the floor. It did not remove schema drift, nor the need to fail closed when the vendor’s constraint is off or you are on a self-hosted model without it.

Brazilian enterprise UIs and ops tools make this concrete. A support agent that emits intent, locale, and citations[] can be tested in CI. A support agent that emits a paragraph cannot. Dual-locale Portuguese without dual hallucinations depends on fields you can lint: formality, treatment, and whether the reply assumed você or o senhor. Eval culture in when buyers ask for eval packs is much easier when the unit under test is an object, not a story.

The OWASP LLM Top 10 treats insecure output handling as a primary failure. Executing a string you did not validate is the same class of bug as executing SQL from a form. JSON Schema and Pydantic (or Zod on the TypeScript side) are the boring fix. The production rule: no side effects before model_validate.

The pattern

Declare a schema with a version. Ask the model for that schema—constrained decoding when the vendor supports it, schema-in-prompt when it does not. Validate. On failure, repair once with the validator errors. On a second failure, stop. Never call a tool or render a customer-facing action from a raw string.

LLM → raw payload → schema validate
  ├ ok → downstream
  ├ fail → one repair → validate again
  └ fail again → stop + reason (no side effects)

Version the schema like an API

AnswerV1 and AnswerV2 are cheaper than a silent field rename that breaks every client. Put the version in the object or in the model route. Migrate readers before writers. Treat a required field addition as a breaking change. This is ordinary API work; agents make it urgent because the “client” is a prompt you will forget to update.

Constrained decoding is not validation

Vendor JSON mode can still emit values that pass the grammar and fail the business rule (a negative refund, a locale you do not serve). Keep application invariants in Pydantic or Zod. Use enums for locales (pt-BR, pt-PT, en) instead of free strings.

When to use

Use structured output whenever a machine will consume the tokens: tool arguments, planner steps, UI cards, eval labels, router decisions. Skip it for a human-only chat with no automation. Do not structure the customer-visible paragraph if that paragraph is the product—structure the metadata beside it (tone, citations, needs_hitl).

  • Ship now if any parser uses regex on model text before a write.
  • Ship now if you cannot name the schema version in production.
  • Skip for one-off analyst chats with no downstream.

Implementation notes

One validate function on the hot path. The model adapter may use function calling for tools and structured outputs for final objects. Both end in the same validator. Pair with eval gates for shipping that include malformed and almost-valid payloads.

Fail closed

class AnswerV1(BaseModel):
    model_config = ConfigDict(extra="forbid")
    intent: Literal["billing", "incident", "other"]
    locale: Literal["pt-BR", "pt-PT", "en"]
    citations: list[str]
    body: str

def complete(prompt, llm) -> AnswerV1:
    raw = llm.generate(prompt, schema=AnswerV1.model_json_schema(), strict=True)
    try:
        return AnswerV1.model_validate_json(raw)
    except ValidationError as err:
        raw2 = llm.repair(raw, errors=err.errors(), schema=AnswerV1)
        return AnswerV1.model_validate_json(raw2)  # raise to caller; do not execute

What belongs in the object

Put machine decisions in fields: intent, locale, citation ids, needs_hitl, abstain. Put prose in one string the UI can show. Do not nest a second unstructured “notes” blob that your code will later parse. If a ReAct step needs a tool call, that is a tool protocol, not a JSON essay.

Failure modes

  • Prompt-only JSON. Works in the demo. Breaks on the first long Portuguese ticket.
  • Repair loops. The model oscillates. Cap at one. Surface the error.
  • Schema drift. Prompt says V2, validator still V1. Version both.
  • Extra fields allowed. A future model adds execute: true. Forbid extras.
  • Valid JSON, invalid action. Amount constraints live in the application schema, not in hope.
  • Structured prose only. You validated the wrapper and still executed the body as a command.

Golden sets in the languages you ship should include malformed Portuguese payloads, not only English happy paths.

Trade-offs

Schemas reduce flexibility and increase testability. Support agents will want a free-form “other” intent; give them one enum value, not an escape hatch that disables the validator. Constrained decoding can cost latency on some vendors; measure it. It is still cheaper than a bad write.

Self-hosted open weights often lack strict JSON mode. Then you pay for a grammar-constrained decoder or you accept higher repair rates. Do not pretend a system prompt is the same control. See cost-aware model routing under FX pressure.

  • Constrained decoding + validate — Fewer parse failures. Vendor lock-in on schema features.
  • One repair then stop — Bounded cost and honest errors. More visible failures.
  • Versioned schemas — Safe migrations. Discipline on prompts and clients.
  • Prompt-only JSON — Fast demo. Silent production breaks.

Free-form text is for people. Downstream code needs a versioned schema, a validator, and a deny path—not a regex and a hope.

What to do next

  • Name the production schema version and forbid extra fields.
  • Put model_validate (or Zod parse) on the hot path before any tool or UI write.
  • Add two eval fixtures: missing required field, and valid JSON that violates a business rule. Wire them into eval gates for shipping.
agentsevalstools

Published by . Original editorial for operators. How this was made