The AI Boundary
A customs checkpoint between the app and any language model: validated, budgeted, logged β or it doesn't cross.
Wera never trusts a language model. Every AI call goes through one checkpoint that checks the monthly budget before spending, validates the response against a schema, retries exactly once with the validation errors attached, and writes a ledger entry either way. The app receives a typed success or one of five typed failures β never raw model output.
Language models are brilliant, occasionally wrong, sometimes slow, and always billed by the token. Treating one like a regular function β call it, use the answer β imports all four properties straight into your product. Wera's answer is a customs checkpoint: goods (model responses) enter the country only after inspection, nothing crosses without a declaration form (the schema), there's a hard import quota (the budget), and every crossing is stamped into a ledger (ai_runs) whether it was admitted or turned away.
Watch one call cross the border
The build journey
Define failure before defining success
The first artifact isn't a prompt β it's a type listing the exact five ways an AI call can fail.
Here is the entire failure vocabulary of AI in this product β six lines from packages/ai/src/index.ts:
export type AiError =
| { kind: "validation"; issues: string[] }
| { kind: "timeout" }
| { kind: "rate_limited"; retryAfterMs?: number }
| { kind: "budget_exceeded" }
| { kind: "provider"; message: string };
An AI failure is exactly one of five things:
The model answered, but its answer failed the schema check twice β and here are the precise complaints.
The model took too long.
The provider said "slow down" β and possibly told us when to try again.
We've spent this month's AI allowance; nobody even called the model.
Something else went wrong at the provider, with the message attached.
Wrap any model in the checkpoint
createAiProvider takes a raw client, a logger, and a budget store, and returns the only AI interface the rest of the app ever sees.
A factory producing generateObject<T> / generateText, both returning AiResult<T> β a discriminated union of success or AiError.
Because a return type can't be ignored the way an exception can. Every caller is forced, at compile time, to say what the UI does on budget_exceeded. That's how "AI paused: budget" became a designed screen state instead of a stack trace.
Budget pre-check β attempt 1 β schema.safeParse β on failure, attempt 2 with the issues appended to the prompt ("Return corrected JSON only. Issues: β¦") β typed error. Thrown network errors map to timeout/rate_limited/provider. One ai_runs row per call, always.
Give every use case a schema and a deterministic key
Job analysis, resume tailoring, communication drafts, caption extraction β each is a function bundling a prompt with the zod schema its answer must satisfy.
analyzeJob, tailorResume, draftCommunication, extractPostingsFromCaption β plus schemas like jobAnalysisSchema (fitScore: z.number().int().min(0).max(100)).
The schema is the contract the retry negotiates against. "fitScore must be an integer 0β100" isn't a hope in the prompt β it's a gate the response physically passes or doesn't.
Each call carries an idempotency key like resume-tailoring:{userId}:{applicationId} β sent to the provider so a network retry can't double-bill, and stamped into the run's metadata for tracing.
Keep two engines behind one socket
Production runs OpenAI; dev and tests run a deterministic heuristic client. Callers can't tell β that's the point.
Two RawAiClient implementations in apps/web/src/lib/ai.ts: OpenAiResponsesClient and HeuristicJobAnalysisClient (keyword matching + templates, plus magic prompt markers like SIMULATE_TIMEOUT to force error paths in tests).
Deterministic tests: the E2E suite exercises every typed error state without a network, a bill, or flakiness. The heuristic client is a fake, not a mock β it really analyzes, just cheaply.
resolveAiClientFromEnv: explicit AI_PROVIDER wins; otherwise production defaults to OpenAI, everything else to heuristic. The checkpoint wraps whichever engine appears.
The central idea: errors as values
There are two ways a pharmacy can handle a bad prescription. It can set off a fire alarm β everyone evacuates, and somewhere three floors up a manager learns "something happened." Or the pharmacist can hand back a slip that says exactly what's wrong: "insurance limit reached" or "dosage illegible, resubmit." Wera's AI layer is the second pharmacy. Nothing explodes; every failure comes back as a labeled slip the screen knows how to display β a retry button, an "AI paused: budget" notice, a "try editing the posting" hint.
AiResult<T> = {ok:true; value:T; runId} | {ok:false; error:AiError; runId}. The compiler forces exhaustive handling: you cannot touch value without narrowing on ok, and aiErrorMessage(kind) in actions.ts maps each error kind to designed UI copy. Exceptions still exist internally β AiProviderFailure is thrown by raw clients β but they're caught at the boundary and converted to data. The rule: exceptions for programmer errors, values for expected failures.
Tradeoff: result types are viral (every caller grows an if (!result.ok) branch) and can be silently discarded in a way unchecked exceptions can't. The codebase accepts the verbosity because AI failure isn't exceptional β it's Tuesday. A subtler win: the runId rides on both arms, so any UI complaint ("my tailoring failed at 3pm") joins to an exact ai_runs ledger row with tokens, latency, and error kind.
On validation failure, attempt 2 isn't a blind re-roll β the prompt gains "Your previous response failed validation. Return corrected JSON only. Issues: fitScore: Expected integerβ¦". Feeding a machine its own error report is the cheapest self-correction loop in AI engineering: one retry catches most schema slips; more retries mostly burn budget on a confused model.
Apply it
It's the 28th of the month and the AI budget is spent. A user clicks "Generate tailored resume." What happens?
The model returns perfectly valid JSON⦠with an empty proposedBullets array. What does the user experience?
Wera wants to try Anthropic models next month. How many call sites change?
Boundary design for unreliable dependencies
What this module teaches generalizes far beyond AI: any dependency that is slow, costly, or untrustworthy (payment processors, third-party APIs, scrapers, models) deserves the same four-part treatment β validate at the boundary, budget the spend, type the failures, ledger every call. The industry names in play: anti-corruption layer, result types, and observability-by-construction.
Best practices
- Validate external responses with a runtime schema β compile-time types don't survive a network hop.
- Return failures as typed values so the UI is forced to design every error state.
- Meter cost/quota before the call; log usage after, success or not.
- Keep a deterministic fake implementation for tests β with switches to force each error path.
Common pitfalls
- Parsing model JSON with a bare
JSON.parseand trusting the shape β the bug arrives on day 30, not day 1. - Unbounded retry loops against a model that keeps failing the same way β burning budget on confusion.
- Logging prompts and responses by default β a privacy liability; Wera logs metadata only (model, tokens, cents, latency).
- Letting the env decide which engine runs without a loud indicator β the silent-swap bug from Module 1.
The runtime-validation library doing gate duty at every boundary in this codebase.
β Article Designing robust and predictable APIs with idempotencystripe.comThe canonical essay on idempotency keys β the same mechanism Wera sends with every AI call.
β Docs OpenAI Responses API referenceplatform.openai.comThe raw API OpenAiResponsesClient wraps β compare it to the checkpoint to see exactly what the boundary adds.
β