03

The AI Boundary

A customs checkpoint between the app and any language model: validated, budgeted, logged β€” or it doesn't cross.

TL;DR

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

✍️
Caller
πŸ’°
Budget guard
✨
Model
πŸ›ƒ
Zod check
πŸ“’
ai_runs ledger
Click "Next Step" to begin

The build journey

1
Stage 4 Β· The loop

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:

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 };
PLAIN ENGLISH

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.

2
Stage 4 Β· The loop

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.

What

A factory producing generateObject<T> / generateText, both returning AiResult<T> β€” a discriminated union of success or AiError.

Why

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.

How

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.

3
Stage 4 Β· The loop

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.

What

analyzeJob, tailorResume, draftCommunication, extractPostingsFromCaption β€” plus schemas like jobAnalysisSchema (fitScore: z.number().int().min(0).max(100)).

Why

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.

How

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.

4
Stage 4 Β· The loop

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.

What

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).

Why

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.

How

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.

πŸ’‘
The retry that teaches

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?

πŸŽ“ Level Up

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.parse and 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.