Dev.to WebDev πŸ›  Dev πŸ‘ 1 πŸ“– 6 min read

The Schema Guard: How I Catch LLM Output Changes Before They Break My n8n Pipelines

My inbox triage workflow ran fine for six weeks. Then, on a Tuesday, it quietly started dumping every single email into the "low confidence" bucket -- three hundred of them, all waiting for human review. The workflow had

My inbox triage workflow ran fine for six weeks. Then, on a Tuesday, it quietly started dumping every single email into the "low confidence" bucket -- three hundred of them, all waiting for human review. The workflow hadn't changed. The model hadn't been swapped. The LLM had simply decided that morning to return confidence on a 0-100 scale instead of 0-1. My IF confidence > 0.8 node kept working. It just kept being wrong. So I built a schema guard: a contract check between every LLM output and the nodes that consume it.

The cost ledger post covered per-run spend tracking. The retry budget post covered how many times an LLM call may fail. This one covers the failure mode neither of them sees: the call succeeds, the JSON parses, and the data is wrong-shaped.

The missing layer

n8n gives you execution logs, error branches, and retries. What it doesn't give you is a type contract between "the model returned something" and "the next node uses it." A Code node that reads $json.items will happily run when items is undefined -- JavaScript just gives you undefined, the IF node treats it as falsy, and the workflow routes everything down the wrong branch. No error. No alert. The run is green.

LLM outputs drift in shape all the time, and none of these are "errors" in the model's eyes:

  • a key gets renamed (confidence becomes confidence_score)
  • a type changes (0-1 float becomes 0-100 integer)
  • an enum becomes free text ("billing" becomes "this looks like a billing question")
  • a new key appears and an old one quietly stops appearing

My prompt contract process (covered earlier) versions the input side. The schema guard versions the output side: a declared shape, checked on every run, with mismatches quarantined instead of passed downstream.

The guard: one Code node after every LLM parse

Right after the node that parses the model's JSON -- and before any node that reads a field from it -- sits a Guard node. Same shape everywhere, contract declared per workflow:

// Guard node: validate LLM output shape against the contract
const SCHEMAS = {
  'inbox-triage': {
    version: 3,
    required: { subject: 'string', category: 'string', confidence: 'number' },
    enums:    { category: ['billing', 'support', 'sales', 'spam'] },
    ranges:   { confidence: [0, 1] },
  },
};

const data = $input.first().json;
const schema = SCHEMAS[$workflow.name] || SCHEMAS['inbox-triage'];
const problems = [];

// required keys + types
for (const [key, type] of Object.entries(schema.required)) {
  if (!(key in data)) problems.push(`missing:${key}`);
  else if (typeof data[key] !== type) problems.push(`type:${key}=${typeof data[key]}`);
}
// enums
for (const [key, allowed] of Object.entries(schema.enums || {})) {
  if (key in data && !allowed.includes(data[key]))
    problems.push(`enum:${key}="${String(data[key]).slice(0, 40)}"`);
}
// ranges
for (const [key, [lo, hi]] of Object.entries(schema.ranges || {})) {
  if (key in data && (data[key] < lo || data[key] > hi))
    problems.push(`range:${key}=${data[key]}`);
}
// new keys are warnings, not failures -- worth knowing, not worth blocking
const known = new Set(Object.keys(schema.required));
const newKeys = Object.keys(data).filter(k => !known.has(k));

const out = { ...data, _schema_version: schema.version, _guard_ok: problems.length === 0 };
if (problems.length) {
  out._guard_problems = problems;
  // log to drift_log (workflow, schema_version, problems, raw keys, ts)
  // -> route to output 1: quarantine (Slack alert + manual review queue)
  return [{ json: out }, { json: null }];
}
if (newKeys.length) {
  // log newKeys to drift_log as warnings only
}
return [{ json: out }, { json: null }];

Three details that matter:

  1. The contract is versioned, and the version travels with the data. _schema_version: 3 is stamped on every passing item. When the drift alert fires, the first thing I check is whether the version in the data matches the version in the node -- a version skew means I deployed a prompt change without updating the contract, which is my bug, not the model's.
  2. New keys are warnings, never failures. Models add keys constantly ("helpful" elaborations). Blocking on that would mean alert fatigue within a week. New keys get logged and reviewed in the weekly tally; required-key problems get an immediate alert.
  3. Quarantine, don't retry. A schema mismatch is almost never transient -- retrying the same prompt against the same model usually returns the same wrong shape (this is why the retry budget post excludes schema failures from the retry budget entirely). Quarantined items go to a human review queue with the raw output attached, so I can see what changed.

The morning query

The guard writes every problem to drift_log. A scheduled workflow runs this at 07:00 alongside the cost ledger digest:

-- schema drift per workflow, last 24h
SELECT workflow, schema_version,
       COUNT(*) FILTER (WHERE problems LIKE '%missing%') AS missing_key,
       COUNT(*) FILTER (WHERE problems LIKE '%type%')    AS type_change,
       COUNT(*) FILTER (WHERE problems LIKE '%enum%')    AS enum_drift,
       COUNT(*) FILTER (WHERE problems LIKE '%range%')   AS range_drift,
       COUNT(*) AS total_mismatches
FROM drift_log
WHERE ts >= NOW() - INTERVAL 24 HOUR
GROUP BY workflow, schema_version
HAVING COUNT(*) > 0;

The alert rule is simple: any row in this query fires a Slack alert. Zero is the only acceptable number of schema mismatches per day. Unlike the cost ledger's ratio-based alert, schema drift has no "normal background level" -- a contract either holds or it doesn't. One mismatch is one investigation.

The weekly digest separately lists new-key warnings, which is how I learned that one model had started adding a reasoning field to every response -- harmless, but a 15% token increase I would otherwise never have noticed (and yes, that fed back into the cost ledger).

The incident that paid for it

Three weeks after wiring the guard, the alert fired on inbox triage: 41 mismatches in 24 hours, all enum:category="this looks like a billing question". The model had stopped returning my four allowed categories and started returning free-text descriptions.

The cause was a prompt edit I'd made the day before -- I'd asked the model to "explain its reasoning briefly" in the same response, and the explanation bled into the category field. The guard caught it within a day; the fix was one prompt line (reasoning goes in a separate reasoning key, category stays strict enum) and a schema version bump to 4.

Without the guard, the failure mode was silent misrouting: the IF node's strict match on "billing" failed, everything fell through to the default branch, and the human review queue filled up over a weekend. The workflow would have looked perfectly healthy in n8n's execution list -- all green, all wrong.

What the guard is not

It's not a fixer. It doesn't repair bad output, doesn't fall back to a second model, doesn't guess what the model meant. Quarantine is a deliberate dead end -- I'd rather look at 41 raw outputs myself than build an auto-repair layer that silently "fixes" drift and hides the signal.

It's not for free text. Summaries, drafts, generated content -- anything a human reads -- doesn't get a schema. The guard is only for structured outputs that machines consume downstream. Roughly a third of my LLM calls have guards; the rest don't need them.

And it's one more node in every pipeline. The validation itself is microseconds, but it is a place where a bug in my contract can block good data -- which is exactly why the version stamp travels with the data, and why contract changes go through the same shadow-run testing as prompt changes.

I'm building AI Automation Lab -- n8n workflow templates for AI automation -- and keeping a free lab on GitHub. I also send occasional notes on n8n reliability patterns: get them here.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.