Treat model output like form input from the internet. A guardrail chain has three stages: schema validation, content policy, then business rules.
ts
const Answer = z.object({
reply: z.string().max(1200),
citations: z.array(z.string().url()).min(1),
refund_amount: z.number().min(0).max(500),
});
async function guarded(input: string, attempt = 0): Promise<z.infer<typeof Answer>> {
const raw = await model(input);
const parsed = Answer.safeParse(raw);
if (!parsed.success) {
if (attempt >= 2) throw new GuardrailError("schema", parsed.error);
return guarded(input + "\nPrevious output was invalid: " + parsed.error.message, attempt + 1);
}
if (!parsed.data.citations.every(inCorpus)) throw new GuardrailError("citation_not_in_corpus");
return parsed.data;
}Note the business rule doing real work: a citation that is not in your corpus is a hallucination, and a `refund_amount` cap turns a prompt-level wish into an enforced invariant. Emit a metric per guardrail name so you can see which one fires most.
Exercise: add a guardrail counter to your dashboard and find the single most-triggered rule; it usually points at a prompt or schema defect rather than a model defect.