03 · 6/6

Module 3 · Lesson 6 of 6

Model Output Is Untrusted Input

9 min read · July 30, 2026

Count what this module has handed you: a system prompt that reads like an interface definition, fixtures curated like a test suite, the whole tuple versioned with a deploy pipeline, mechanisms that make output schema-shaped by construction, and schemas designed so the model can actually fill them. One promise from module 2 is still outstanding. When the failure-modes lesson met well-formed garbage, it said: treat model output as untrusted input, and module 3 will make that mechanical.

The sentence sounds like a metaphor. Read it literally instead, because it's an architecture statement: the model sits outside your trust boundary. The closest thing you already operate is a third-party webhook: you published the payload contract, the sender agreed to it, and you still validate every delivery, because the sender is not yours to trust. The model is that counterparty, with one upgrade: it's a webhook that hallucinates, fluently, in exactly the shape you asked for.

The trust boundary you already enforce everywhere else

Every boundary in your system already runs validation: user forms, webhook payloads, third-party API responses, messages consumed off a queue. Nobody debates this; OWASP's input validation guidance is decades of the same lesson: validate at the boundary, allowlist over denylist, never trust the sender's self-description. The only question is whether "the provider guarantees the schema" exempts the LLM boundary. It doesn't, four ways:

  • Not every response is the schema. Refusals arrive outside the mechanism (a dedicated field, a refusal stop reason), and truncation can still cut a response short. Lesson 4 left both doors explicitly open.
  • Not every call uses the mechanism. Fallback chains cross providers with different structured-output support, and a provider swap is a model migration: the seam that saves an outage shouldn't silently drop your shape guarantee. Validation at your boundary holds whichever driver served.
  • Providers ship bugs and betas. Constrained decoding is their infrastructure, still evolving. Defense in depth exists because upstream guarantees fail; you don't skip webhook signature checks because the sender "seems reliable."
  • Schema-valid garbage parses clean. The strongest reason. {"order_id": "ORD-000000"} is a perfect instance of your schema describing an order that has never existed. Shape guarantees end where reality begins.

The validation itself costs microseconds against a call you're already billing in seconds. There is no performance argument against it; there is only forgetting.

One schema object, three jobs

Here's where "mechanical" gets cashed out. The naive setup maintains three artifacts by hand: a TypeScript type for the compiler, a JSON Schema for the request, a validator for the response. Three artifacts that must agree is a drift bug with a countdown timer; you've debugged this as stale API clients and mismatched DTOs. The fix is the same one you used there: a single source of truth that generates the other two. In TypeScript, Zod is the idiomatic choice:

typescript
import * as z from "zod";
 
// THE definition. Field rules from lesson 5, expressed once.
const Triage = z.object({
  category: z.enum(["billing", "shipping", "product_defect", "account", "unknown"])
    .describe("Primary issue. Use 'unknown' when none clearly applies; never guess."),
  sentiment: z.enum(["negative", "neutral", "positive"]),
  order_id: z.string().regex(/^ORD-\d{6}$/).nullable()
    .describe("Order ID exactly as written in the ticket. Null when not stated."),
  refund_amount_usd: z.number().nonnegative().nullable()
    .describe("Refund amount the customer requests, in USD. Null unless they name a figure."),
});
 
type Triage = z.infer<typeof Triage>;          // job 1: the compile-time type
const wire = z.toJSONSchema(Triage);           // job 2: the request-side schema (lesson 4)
const parsed = Triage.safeParse(candidate);    // job 3: the response-side guard

One declaration, three jobs: the type your code compiles against, the schema that constrains decoding, and the validator at the boundary cannot disagree, because they're the same object. The .describe() calls flow into the generated JSON Schema, so lesson 5's descriptions-as-docs ride along. And notice safeParse checks things the provider never promised: the regex on order_id, non-negativity on the refund. Your validator can be stricter than the wire schema, and usually should be.

Repair, once, with the error in hand

When validation fails, you hold something valuable: a machine-generated description of exactly what's wrong. Zod's error says refund_amount_usd: expected number, received string. The move is the repair re-ask: send the failed output and the validator error back, ask for a corrected version.

Validation failed. Now what?

  • 1st failureRepair once, error in handre-ask with the invalid output and the validator message; high hit rate, one extra full-priced call
  • 2nd failureClassify as invalid_outputinto the failure enum from module 2; the degradation ladder decides what your caller sees
  • Semantic failure on a write pathFail closed, no repaira hallucinated order ID isn't a formatting slip; re-asking is asking the model to guess more convincingly
The failure branch. One repair attempt with the error in hand, then the module 2 ladder. Never an unbounded loop.

Bound it at one attempt. The distinction that keeps this from becoming an infinite money spigot: transport retries (the 429s and timeouts in the retries playbook) re-send the same request because the failure was environmental. A repair is a semantic retry: a new, modified request, at full price, fixing a failure the model itself produced. If the corrected attempt fails validation too, the output isn't nearly-right, and the right classification is invalid_output in module 2's failure enum, where the degradation ladder already knows what your caller gets instead.

Beyond shape: semantic checks

The third lying 200 from module 2 was well-formed garbage, and no schema catches it, because schemas define possible values, not true ones. The boundary needs a second stage: checks against reality.

  • Referential integrity: the extracted order_id exists, and belongs to this customer. A hallucinated-but-well-formatted ID is this bug class's signature move.
  • Range and policy: the refund is positive, under the auto-approval cap, in a currency you support.
  • Cross-field invariants: category: "billing" with a null order ID and a five-figure refund is legal per schema and absurd per business; absurdity checks are yours to write.

This is the same foreign-key-and-constraints discipline your database enforces, applied one boundary earlier, and it's where validation ends rather than expands indefinitely: whether the drafted reply is polite, whether the category is the one a human would pick, whether quality drifted this quarter, none of that is checkable in code at request time. Checkable facts get validated here; distributional quality belongs to evals, module 6's subject. One rule governs the gap: on a write path, fail closed. A schema-perfect tool call asking to mutate state that flunks a semantic check gets refused, logged, and if it recurs, alarmed on: the same posture your API takes toward any suspicious authenticated request.

The module, assembled: a typed LLM function

Every lesson in this module built a part; the capstone is noticing they compose into one export. Module 2 assembled the client seam so transport concerns exist once. Stack this module's artifacts on top and the natural unit appears: the typed LLM function, a factory that takes a versioned prompt and a Zod schema and returns an ordinary async function.

  1. Complete via the seamllm clientmodule 2 owns transport, stop reasons, cost logging
  2. Branch on refusal / truncationstop reasonsthe 200s that lie, handled before parsing
  3. safeParse against the schemazodshape + strictness the wire never promised
  4. Repair once on failureboundederror message in hand; then invalid_output
  5. Semantic checksyour rulesreality: IDs exist, policy holds; fail closed on writes
  6. Return typed valuez.infercallers import a function, never a prompt
The output pipeline inside every typed LLM function. Each stage has a named failure branch; nothing falls through.
typescript
export function llmFunction<TVars, TOut>(
  prompt: VersionedPrompt<TVars>,          // lesson 3's registry entry
  schema: z.ZodType<TOut>,                 // one schema, three jobs
  check?: (out: TOut) => Promise<string | null>,  // semantic stage, null = ok
) {
  return async (vars: TVars): Promise<TOut> => {
    const rendered = prompt.render(vars);            // validates vars, owns fencing
    let res = await llm.complete({ ...rendered, schema: z.toJSONSchema(schema) });
    if (res.stopReason !== "complete") throw classify(res);   // refusal / truncation
 
    let parsed = schema.safeParse(JSON.parse(res.text));
    let repaired = false;
    if (!parsed.success) {                           // repair: once, error in hand
      repaired = true;
      res = await llm.complete(withRepair(rendered, res.text, parsed.error));
      parsed = schema.safeParse(JSON.parse(res.text));
      if (!parsed.success) throw invalidOutput(parsed.error);
    }
    const reason = check && (await check(parsed.data));
    if (reason) throw semanticFailure(reason);       // fail closed on write paths
 
    log.info("llm.fn", { prompt: prompt.tag, model: res.model,
                         usage: res.usage, repaired });
    return parsed.data;                              // TOut, honestly earned
  };
}
 
// The rest of the codebase sees none of this module:
export const triageTicket = llmFunction(ticketTriage, Triage, checkOrderExists);
// await triageTicket({ ticketBody }) -> Triage. A function. That's the point.

Callers import triageTicket and get a typed promise; prompts, fixtures, schemas, repair loops, and log lines are implementation details behind a signature, which is what "production-grade" has meant at every layer of this track: the mess handled once, in one reviewed place.

One honest limit remains. These functions know only what you put in the prompt. Ask triageTicket about a customer's actual subscription tier and it can't know; models don't have your database. Module 4 is about fixing that: retrieval, embeddings, and feeding your own data into the context, where it will meet this same trust boundary from the other direction.

Key takeaways

  • The model sits outside your trust boundary: a webhook counterparty that hallucinates. Validate its output at the boundary like any untrusted input, per the same OWASP logic you apply everywhere else.
  • Schema guarantees don't exempt you: refusals and truncation arrive outside the schema, fallbacks cross providers with different support, providers ship bugs, and schema-valid garbage parses clean.
  • One Zod schema does three jobs (compile-time type, wire schema via z.toJSONSchema, response validator via safeParse), so the three artifacts can never drift, and your validator can be stricter than the wire.
  • Repair once, with the validator's error in hand, then classify as invalid_output into module 2's ladder. Repairs are semantic retries at full price: bounded, dashboarded, never looped.
  • Schemas define possible values; semantic checks (IDs exist, policy holds, invariants hold) define plausible ones. Checkable facts validate in code; quality belongs to evals (module 6). Write paths fail closed.
  • The module composes into the typed LLM function: versioned prompt in, validated z.infer type out, everything between handled once. The rest of your codebase imports functions, not prompts.