Dev.to AI 🤖 Ai 👁 0 📖 4 min read

The Spread Collision: When LLM Output Silently Overwrites Your Workflow State

My repo got its first external pull request this week — a cross-border SEO workflow from a contributor. Reviewing it took fifteen minutes. One line in it will now become a permanent lint rule. Here is the line: resu

My repo got its first external pull request this week — a cross-border SEO workflow from a contributor. Reviewing it took fifteen minutes. One line in it will now become a permanent lint rule. Here is the line:

results.push({ json: { ...item.json, ...parsed, rawContent: content } });

parsed is JSON.parse() of the model's raw reply. This one spread operator can corrupt your data without ever throwing an error. I call it a spread collision, and I have now seen it in the wild.

What the line does

The workflow is a six-node pipeline: read a product spreadsheet, build a multilingual listing prompt per row, call a chat-completions endpoint, parse the model's JSON reply, write the results back to a spreadsheet. The parse node above takes the model's structured output — listing title, bullet points, description, keywords, SEO blog copy — and merges it into the item at the top level.

At that point in the pipeline, item.json is the HTTP response body: id, choices, created, model, object, usage — the full API envelope. The spread puts the model's keys on top of that envelope, at the same level.

Three ways it goes wrong

1. The model overwrites API metadata. If the generated JSON contains a model key — and SEO workflows routinely ask the model to fill a model field for product model numbers — the response's real model value is silently replaced. Same for id, created, object. You now have items whose provenance metadata is model-generated fiction, and nothing downstream can tell.

2. The envelope pollutes the output. The full choices array rides into the results spreadsheet. Every row carries the entire raw API response — the same text you already stored in rawContent — serialized as nested objects into cells. Column bloat at best; at worst, the spreadsheet writer maps the wrong nested field to a visible column and your listing sheet shows API metadata instead of listing copy.

3. The worse variant: it eats the real data. This exact pattern one node earlier — { ...productRow, ...parsed } — is the version that deletes real data. The product row has name, desc, sku. The model was asked to generate a better title and description. If its output uses those same keys, the merge overwrites the actual product data with generated text. No error. No failed execution. A green run and a spreadsheet full of rows where you can no longer tell what the product actually was.

The signature of a spread collision is that everything looks fine. JSON.parse succeeded. The workflow ran green. The spreadsheet filled up. The corruption is in the values, not the shape, so no type check, no schema validation, and no retry policy will catch it. The retry budget post, the tag linter — none of my earlier machinery covers this, because this failure never presents as a failure.

The fix

Three options, in order of preference:

Namespace it. Give the model's output exactly one key and let it fight with nothing:

results.push({ json: { source: item.json, generated: parsed, rawContent: content } });

Collision becomes structurally impossible. Downstream nodes read generated.title instead of title. If that feels verbose, that verbosity is the point — it is the audit trail.

Pick explicitly. If you want flat fields for the spreadsheet writer, name them:

const g = parsed || {};
results.push({ json: {
  sku: item.json.sku, name: item.json.name,   // input: untouched, never overwritten
  listingTitle: g.title || '', bullets: g.bullets || [],
  seoBlog: g.blog || '', rawContent: content
}});

The model's keys never touch the input's keys because they live in separate variable scopes. An explicit pick is also documentation: it tells the next reader exactly which generated fields this workflow depends on.

Lint it. I added a fourth check to the tag linter from the last post: any Code node whose source matches /\.\.\.(item\.json|\$json).*\.\.\.(parsed|output|result)/ fails the pre-publish gate with "top-level spread of unvalidated LLM output". A top-level spread of anything the model produced is now treated like an untagged effectful node — it does not reach production.

The review

For the record, the PR review left four notes: the hardcoded relay endpoint against a README that says "any OpenAI-compatible key works", the hardcoded model id against the same sentence, this spread collision, and two hardcoded /data paths of which the README mentions one. The contributor gets a better workflow; the repo gets a new lint rule. That is the trade I want every first PR to produce — one real finding that becomes permanent machinery.

The deeper pattern is one this whole series keeps circling: the dangerous failures in LLM workflows are never the ones that throw. The model change protocol exists because drift is silent. The tag linter exists because renames are silent. The spread collision is the third member of the family — a merge that overwrites real data with generated text, in a green run, on purpose-shaped syntax that looks completely correct.

What is your version — the line that looks right, runs green, and quietly replaces your data?

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 AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.