DEV Community

HaoPeng Zhang
HaoPeng Zhang

Posted on

The Tag Linter: How I Catch Untagged n8n Nodes Before They Reach Production

Yesterday I posted the pure/effectful tag system: every node declares whether it can leave something behind you can't take back. The policy only works if every effectful node is tagged. Two weeks in, I found the failure mode — and it wasn't the policy. It was the tags rotting.

The rename that broke the policy

I renamed a node. That is the whole incident.

The node was an HTTP POST to a Discord webhook, added during a debugging session and left in place because the alerts turned out to be useful. It had a policy entry: effectful, idempotent: false — zero retries, straight to review. Then I renamed it from "POST Discord Alert" to "Alert: Discord" for readability, and the policy table — keyed by node name, exactly as I published yesterday — silently stopped covering it. The node was unprotected again. Nothing warned me. The weekly tally kept counting, the sweeper kept sweeping, and one node had quietly opted out of all of it.

That is the shape of the problem: tags are declared once, workflows are edited forever. Every edit is a chance for the declaration and the reality to diverge. A policy that depends on humans remembering to update a config table after every rename is a policy with a known hole. I needed the hole closed mechanically.

The linter

The fix is a small Node script, lint-node-tags.js, that runs inside the pre-publish gate from the earlier post. It parses the workflow JSON and checks the policy against reality. Three checks:

1. Heuristic classification. The script classifies every node by what it does, not by what the policy says:

const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function heuristicKind(node) {
  const t = node.type || '';
  if (t.includes('httpRequest')) {
    const m = (node.parameters?.method || 'GET').toUpperCase();
    return MUTATING.has(m) ? 'effectful' : 'pure';
  }
  if (/emailSend|slack$|telegram|discord/i.test(t)) return 'effectful';
  if (/_(insert|update|upsert|append)$|spreadsheetFile/i.test(t)
      && /write|append|insert/i.test(JSON.stringify(node.parameters?.operation || '')))
    return 'effectful';
  return 'pure'; // code, set, if, merge, llm completions that only feed the next node
}
Enter fullscreen mode Exit fullscreen mode

A GET that only reads is pure for retry purposes — it leaves nothing behind. A POST is effectful. The heuristic is deliberately conservative: anything it can't classify confidently is flagged for a human, not defaulted.

2. Coverage both ways. Every heuristically-effectful node must have a policy entry declaring kind and, for effectful nodes, the idempotent field. And every policy entry must match a live node by id — an entry pointing at a node id that no longer exists is a stale entry, which is what a rename looks like from the script's side. The fix for the rename trap: key the policy on the node's stable UUID, keep the human-readable name as a label. When the id matches but the label differs, the linter warns "renamed — label updated" and moves on.

3. No weaker declarations. If the policy says pure but the heuristic says effectful, the linter fails. The policy may be stricter than the heuristic (tagging a GET as effectful because the endpoint has side effects the method doesn't reveal), never weaker. Disagreements resolve in favor of the stricter tag, and the human records why.

One extra rule I added after the first run: any effectful node with n8n's built-in "Retry On Fail" enabled and no idempotency key is a hard fail. That combination is the exact duplicate-message shape from the idempotency post, and it should never reach production.

What it caught

I ran it over 21 workflow files — the free templates in the repo plus my production set. Seven violations:

  • 3 untagged effectful nodes. The Discord webhook POST (the rename incident). An Email node in a flow that started as a test and got promoted to production without anyone re-tagging it. A spreadsheet append in a weekly reporting flow that everyone thought of as "just logging" — until you consider the duplicate row a re-run would create.
  • 2 stale entries. Renamed nodes, policy still pointing at the old names. Both had been unprotected for weeks.
  • 2 missing idempotent declarations. Effectful nodes with entries but no idempotent field — the policy defaulted them to review-only, which was safe but hid the fact that one of them did carry an idempotency key and could have retried safely.

The scary one was the Discord webhook: Retry On Fail was on, no idempotency key, untagged. That is a duplicate alert on every transient failure — the precise failure the whole tag system exists to prevent, sitting inside the system, invisible. The linter paid for itself on the first run.

Where it sits

The linter is the mechanical enforcement of the tag page, and the tag page was the mechanical enforcement of the retry budget. The stack keeps the same shape: each layer answers one question, and the linter's question is "does the declaration match reality?" Tags are policy; the linter is the audit. It runs in seconds, in the pre-publish gate, before any workflow reaches production. A failed lint is a failed publish — same as a failed contract test.

The deeper lesson is the one the rename taught me: any safety property that lives in a separate file from the thing it protects will drift. The linter doesn't eliminate the drift. It just makes the drift loud, early, and cheap — which is all any of these mechanisms have ever done.

What is your version of the rename trap — the mechanical failure mode your policy quietly assumes away?


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.

Top comments (0)