Are We Drifting? — Part 13: AI Output Is Untrusted Input
Are We Drifting? — Part 13: AI Output Is Untrusted Input
Section titled “Are We Drifting? — Part 13: AI Output Is Untrusted Input”This series has been about keeping representations of a concept from drifting apart. A language model is the most enthusiastic drift generator you can plug in: ask it for a status and it may invent one; ask it for a number and it may send a string; ask it for JSON and it may return almost JSON. This part is about putting it behind the same kind of gate as everything else.
Are we drifting here?
Section titled “Are we drifting here?”The instinct is to treat a model’s output as data, because it looks like data. It is not. It is a suggestion shaped like data, produced by a process with no obligation to your types.
So it drifts in every direction at once. It returns "Done" when your enum is ready. It collapses a nested object into a string. It hallucinates a field. It emits a difficulty of "very hard" when the allowed set is beginner | intermediate | advanced. None of this is a bug in the model — it is the model doing exactly what it does. The bug is letting that output into your system unchecked, where it becomes a row, an event, a decision — a piece of drift with a long half-life.
The question is the one you would ask of any external system: is this untrusted shape forced to conform to a contract before it crosses the boundary, or is it trusted on sight?
The principle: a model is a boundary, like any other
Section titled “The principle: a model is a boundary, like any other”You already have a discipline for untrusted input. A web form is validated before it becomes a command. A third-party API’s response is mapped to your own model at the adapter and never leaked past it. A model is the same kind of boundary, and gets the same treatment:
- Declare the expected output shape — as a schema, not a hope.
- Communicate the shape to the model — via structured-output / JSON-Schema constraints.
- Validate the response against the shape.
- On failure, retry with the validation error as feedback — bounded to a couple of attempts.
- Surface a clean typed value, or a normal application error — never a raw, half-valid blob.
The frontend has done this for years: a Zod schema validates an untrusted shape at the edge before it is allowed in. The same idea in Go is a schema-tagged struct plus JSON-Schema generation plus validation plus a retry loop. Different language, identical rule:
Untrusted shapes must conform to a declared contract before they cross the boundary. A model is untrusted. Validate it like a form, isolate it like a vendor.
Here is how the boundary works. The raw provider types — the OpenAI or Anthropic response objects — stop at the adapter. The application talks to a typed interface (Summarize(ctx, input) (domain.WorkoutSummary, error)) and receives a validated domain value or a clean error. A schema mismatch is a breaking change, not a runtime surprise.
The vocabularies become the model’s contract
Section titled “The vocabularies become the model’s contract”Here is where the series closes a loop.
The single most effective way to stop a model from inventing a value is to give it the closed set to choose from. And the closed set already exists — it is the enum vocabulary from Part 4: Enums as Shared Vocabulary.
The same manifest that generated the Go constants, the TypeScript union, and the SQL CHECK constraint can generate the enum constraint in the JSON Schema sent to the model. So the model is handed exactly the set of wire values the rest of the system recognizes, and structured-output enforcement makes a value outside that set impossible to return. Then validation on the way back checks membership against the same generated IsValid() — and because the model was constrained by the manifest and validated against the manifest, a value it returns is, by construction, a value every other layer already agrees about.
The vocabulary that prevented drift between your database and your frontend now prevents drift between your model and your database. One source; one more projection; the boundary gated the same way.
The concrete version: VideoStatus is declared once in the manifest with five wire values — uploading, processing, ready, failed, archived — and every layer gets that same closed set derived from it.
The JSON Schema fragment that goes to the model is generated from the same manifest:
{ "VideoStatus": { "type": "string", "enum": ["uploading", "processing", "ready", "failed", "archived"] }}The model cannot return "done" or "in_progress" or "complete" — the structured-output constraint refuses it before the response even arrives. Then IsValid() on the way back in checks membership against the same five values, so what reaches the domain layer is a VideoStatus that every other layer — Go, TypeScript, SQL — was generated to agree with.
And when the model does fail validation, that failure is itself a declared thing — an entry in the error vocabulary from Part 5: The Error Manifest (AI_STRUCTURED_OUTPUT_INVALID, retryable), normalized at the adapter exactly like a billing provider’s failure. The unhappy path of the AI boundary is governed by the same manifest as every other failure.
The velocity payoff
Section titled “The velocity payoff”The check this removes is the one teams skip until it burns them: did the model actually return the shape I assumed, with values my system recognizes?
When the model is constrained by a generated schema and validated against the same vocabulary, you can let it produce structured data and trust the result — not because the model is reliable, but because the boundary is. Invalid output is rejected and retried; what reaches your domain is a typed value drawn from sets the rest of the stack already shares. The model becomes a normal, gated input source, which means you can build on it at the speed you build on a form — instead of treating every AI feature as a fragile special case to be hand-audited.
What’s next
Section titled “What’s next”Part 14: The Wall of Gates and What Comes Next collects the full wall of drift gates the series has walked past, asks the title question one last time, and is specific about where the work goes next.