{
  "name": "why? \u2014 watch this n8n",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      },
      "id": "w1000000-0000-4000-8000-000000000001",
      "name": "Every 15 minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "/* ------------------------------------------------------------------ *\n *  THE ONLY TWO THINGS YOU NEED TO EDIT\n * ------------------------------------------------------------------ */\n\n// Your n8n's address, no trailing slash.\nconst n8nBaseUrl = 'https://CHANGE-ME.app.n8n.cloud';\n\n// Where alerts go. A Slack incoming webhook or a Discord webhook - both work,\n// the message is sent in a shape either will accept.\nconst alertWebhookUrl = 'https://CHANGE-ME';\n\n/* ------------------------------------------------------------------ */\n\nreturn [{ json: { n8nBaseUrl, alertWebhookUrl } }];"
      },
      "id": "w1000000-0000-4000-8000-000000000002",
      "name": "Settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        200,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.n8nBaseUrl }}/api/v1/executions?limit=20",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "n8nApi",
        "options": {
          "timeout": 15000
        }
      },
      "id": "w1000000-0000-4000-8000-000000000003",
      "name": "List recent runs",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        400,
        0
      ]
    },
    {
      "parameters": {
        "jsCode": "/* Only fetch detail for executions this workflow has not already judged.\n * A quiet instance therefore costs exactly one API call per poll. */\nconst memory = $getWorkflowStaticData('global');\nconst cfg = $('Settings').first().json;\n\nconst rows = ($input.first().json.data || []).filter(\n  r => r.status === 'success' || r.status === 'error' || r.status === 'crashed'\n);\nif (!rows.length) return [];\n\nconst ids = rows.map(r => Number(r.id) || 0);\nconst highest = Math.max(...ids, Number(memory.watermark || 0));\n\n// First ever run: take the mark and stay quiet. Announcing a week-old backlog\n// as though it just happened is how an alert channel gets muted on day one.\nif (!memory.watermark) {\n  memory.watermark = highest;\n  return [];\n}\n\nconst fresh = rows\n  .filter(r => (Number(r.id) || 0) > Number(memory.watermark))\n  .slice(0, 15);\n\nmemory.watermark = highest;\nreturn fresh.map(r => ({ json: { id: r.id, baseUrl: cfg.n8nBaseUrl } }));"
      },
      "id": "w1000000-0000-4000-8000-000000000004",
      "name": "Only what is new",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        600,
        0
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $json.baseUrl }}/api/v1/executions/{{ $json.id }}?includeData=true",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "n8nApi",
        "options": {
          "timeout": 20000
        }
      },
      "id": "w1000000-0000-4000-8000-000000000005",
      "name": "Fetch each run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        800,
        0
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/* why? for n8n - analysis engine (no DOM, no network).\n * Shared by the content script and the offline CLI. */\n(function (root) {\n  'use strict';\n\n  function isBlank(v) {\n    if (v === null || v === undefined) return true;\n    if (typeof v === 'string') return v.trim() === '';\n    if (Array.isArray(v)) return v.length === 0;\n    if (typeof v === 'object') return Object.keys(v).length === 0;\n    return false;\n  }\n\n  // Every map below is keyed by something an attacker controls: payload field\n  // names, payload values, node types. A plain {} inherits from\n  // Object.prototype, so a field called \"__proto__\" reads back the prototype\n  // itself - truthy, so the \"not seen yet\" branch is skipped - and the next\n  // write lands on Object.prototype, corrupting every object on the n8n page.\n  // Null-prototype maps have no such key to hit.\n  function dict(src) {\n    var d = Object.create(null);\n    if (src) Object.keys(src).forEach(function (k) { d[k] = src[k]; });\n    return d;\n  }\n\n  function show(v, max) {\n    max = max || 60;\n    var s;\n    try { s = JSON.stringify(v); } catch (e) { s = String(v); }\n    if (s === undefined) s = 'undefined';\n    return s.length > max ? s.slice(0, max) + '...' : s;\n  }\n\n  function asEmbeddedJson(s) {\n    if (typeof s !== 'string' || s.length > 20000) return null;\n    var t = s.trim();\n    if (t.charAt(0) !== '{' && t.charAt(0) !== '[') return null;\n    try { var p = JSON.parse(t); return p && typeof p === 'object' ? p : null; } catch (e) { return null; }\n  }\n\n  function join(prefix, k) {\n    if (!prefix) return k;\n    return prefix.slice(-2) === '::' ? prefix + k : prefix + '.' + k;\n  }\n\n  var MAX_DEPTH = 24;\n\n  function flatten(value, prefix, out, limit, depth) {\n    if (limit === undefined) limit = 8;\n    depth = depth || 0;\n    // An API can hand back arbitrarily nested JSON. Without this, a deep\n    // payload overflows the stack and takes the whole scan down with it -\n    // which reads to the user as \"nothing wrong here\".\n    if (depth > MAX_DEPTH) { out.push([prefix, '{...}', true]); return out; }\n    if (typeof value === 'string') {\n      var inner = asEmbeddedJson(value);\n      if (inner) return flatten(inner, prefix + '::', out, limit, depth + 1);\n      out.push([prefix, value]);\n      return out;\n    }\n    if (value === null || typeof value !== 'object') { out.push([prefix, value]); return out; }\n    if (Array.isArray(value)) {\n      if (!value.length) { out.push([prefix, []]); return out; }\n      if (value.length > limit) { out.push([prefix, '[' + value.length + ' items]', true]); return out; }\n      for (var i = 0; i < value.length; i++) flatten(value[i], prefix + '[' + i + ']', out, limit, depth + 1);\n      return out;\n    }\n    var keys = Object.keys(value);\n    if (!keys.length) { out.push([prefix, {}]); return out; }\n    if (keys.length > limit) { out.push([prefix, '{' + keys.length + ' fields}', true]); return out; }\n    for (var j = 0; j < keys.length; j++) flatten(value[keys[j]], join(prefix, keys[j]), out, limit, depth + 1);\n    return out;\n  }\n\n  function itemsOf(runData, name) {\n    var runs = runData[name];\n    if (!runs || !runs.length) return null;\n    var items = [], stored = false;\n    for (var i = 0; i < runs.length; i++) {\n      var r = runs[i];\n      if (!r || !r.data || !r.data.main) continue;\n      stored = true;\n      for (var b = 0; b < r.data.main.length; b++) {\n        if (r.data.main[b]) items = items.concat(r.data.main[b]);\n      }\n    }\n    return stored ? items : null;\n  }\n\n  // Nodes whose whole job is to remove things. Emitting nothing is them\n  // working, not them failing - flagging these is how a tool gets uninstalled.\n  var REDUCERS = dict({\n    'n8n-nodes-base.filter': 'filtered everything out',\n    'n8n-nodes-base.removeDuplicates': 'found nothing new',\n    'n8n-nodes-base.limit': 'kept nothing',\n    'n8n-nodes-base.splitInBatches': 'finished looping',\n    'n8n-nodes-base.compareDatasets': 'found no differences',\n    'n8n-nodes-base.merge': 'merged to nothing',\n    'n8n-nodes-base.if': 'sent nothing down either branch',\n    'n8n-nodes-base.switch': 'matched no branch'\n  });\n\n  function nodeType(exec, name) {\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].name === name) return nodes[i].type || '';\n    return '';\n  }\n\n  function hasUpstream(exec, name) {\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    for (var from in conns) {\n      var mains = (conns[from] && conns[from].main) || [];\n      for (var m = 0; m < mains.length; m++) {\n        var b = mains[m] || [];\n        for (var c = 0; c < b.length; c++) if (b[c] && b[c].node === name) return true;\n      }\n    }\n    return false;\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Two silent failures that have nothing to do with AI.\n   *\n   * Most of what this tool learned to spot needs a model in the loop, which\n   * is no help at all to the majority of n8n workflows - Code, HTTP, Sheets,\n   * Slack. These two are the ones that bite those workflows, and neither\n   * shows up anywhere in n8n's UI.\n   * ------------------------------------------------------------------ */\n\n  // 1. A 200 response carrying an error. The HTTP node is perfectly happy -\n  //    it got a response - and the API is telling you it refused. Rate limits,\n  //    expired tokens and validation failures all arrive this way.\n  var ERROR_KEYS = dict({\n    error: 1, errors: 1, error_message: 1, errormessage: 1, error_description: 1,\n    exception: 1, fault: 1, failure: 1\n  });\n  var NEGATIVE = dict({ error: 1, failed: 1, failure: 1, denied: 1, rejected: 1, unauthorized: 1 });\n\n  function errorShaped(json) {\n    if (!json || typeof json !== 'object') return null;\n    var hit = null;\n    Object.keys(json).slice(0, 60).forEach(function (k) {\n      if (hit) return;\n      var key = k.toLowerCase().replace(/[^a-z_]/g, '');\n      var v = json[k];\n\n      // { error: \"rate limit exceeded\" } - but not { error: null } or { errors: [] }\n      if (ERROR_KEYS[key] && !isBlank(v)) {\n        hit = { key: k, why: 'carries an error', value: show(v, 80) };\n        return;\n      }\n      // { success: false } / { ok: false }\n      if ((key === 'success' || key === 'ok' || key === 'succeeded') && v === false) {\n        hit = { key: k, why: 'says it did not succeed', value: 'false' };\n        return;\n      }\n      // { status: \"failed\" }\n      if ((key === 'status' || key === 'state' || key === 'result') && typeof v === 'string'\n          && NEGATIVE[v.toLowerCase().replace(/[^a-z]/g, '')]) {\n        hit = { key: k, why: 'reports a failed state', value: show(v, 40) };\n        return;\n      }\n      // { statusCode: 429 } inside a body the HTTP node treated as fine\n      if ((key === 'statuscode' || key === 'status_code' || key === 'code')\n          && typeof v === 'number' && v >= 400 && v <= 599) {\n        hit = { key: k, why: 'carries an HTTP error code', value: String(v) };\n      }\n    });\n    return hit;\n  }\n\n  function errorPayloads(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var out = [];\n    Object.keys(rd).forEach(function (name) {\n      if (out.length >= 3) return;\n      var items = itemsOf(rd, name);\n      if (!items || !items.length) return;\n      for (var i = 0; i < Math.min(items.length, 50); i++) {\n        var hit = errorShaped(items[i] && items[i].json);\n        if (hit) {\n          out.push({ node: name, key: hit.key, why: hit.why, value: hit.value,\n                     item: items.length > 1 ? i : null, total: items.length });\n          return;\n        }\n      }\n    });\n    return out;\n  }\n\n  // 2. Items going missing partway through. Fifty rows in, forty-seven out -\n  //    nobody notices three customers were dropped.\n  //\n  //    Only judged where the count MUST be preserved: an HTTP node runs once\n  //    per item, and a Code node set to run once for each item does too. A\n  //    Code node in its default mode legitimately turns fifty items into one,\n  //    which is why this cannot simply compare every node.\n  function itemLoss(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var byName = dict();\n    nodes.forEach(function (n) { if (n && n.name) byName[n.name] = n; });\n\n    var out = [];\n    Object.keys(rd).forEach(function (name) {\n      var def = byName[name];\n      if (!def) return;\n      var oneToOne = def.type === 'n8n-nodes-base.httpRequest'\n        || (def.type === 'n8n-nodes-base.code'\n            && def.parameters && def.parameters.mode === 'runOnceForEachItem');\n      if (!oneToOne) return;\n\n      var got = itemsOf(rd, name);\n      if (!got) return;\n      var up = upstreamOf(exec, name);\n      var had = up ? itemsOf(rd, up) : null;\n      if (!had || !had.length) return;\n      if (got.length >= had.length) return;\n\n      out.push({ node: name, from: up, had: had.length, got: got.length,\n                 lost: had.length - got.length });\n    });\n    return out;\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Expressions pointing at fields that are not there.\n   *\n   * {{ $json.customer.email }} where the input has no `customer` does not\n   * error. It resolves to nothing, the node runs, the field goes out blank,\n   * and n8n marks the run successful. Rename a field upstream, or hit a\n   * record shaped slightly differently, and a workflow starts writing empty\n   * values into a CRM until somebody downstream complains.\n   *\n   * This is probably the most common silent failure in n8n, it has nothing to\n   * do with AI, and nothing surfaces it. The workflow stores the expressions\n   * and the run stores what actually arrived, so it can simply be checked.\n   * ------------------------------------------------------------------ */\n\n  // An expression that handles absence on purpose is not a bug. ||, ??, ?.,\n  // a ternary or an explicit if() all mean the author already thought about\n  // the field being missing.\n  var HAS_FALLBACK = /\\|\\||\\?\\?|\\?\\.|\\bif\\s*\\(|\\?[^:]*:/;\n  var MAX_REFS = 40;\n\n  function normPath(s) {\n    return String(s)\n      .replace(/\\[\\s*['\"]([^'\"]+)['\"]\\s*\\]/g, '.$1')\n      .replace(/^\\./, '')\n      .split('.')\n      .filter(Boolean);\n  }\n\n  function refsIn(src) {\n    var out = [], m;\n    // {{ $json.a.b }} and {{ $json[\"a\"][\"b\"] }}\n    var re = /\\$json((?:\\.[A-Za-z_$][\\w$]*|\\[\\s*['\"][^'\"\\]]+['\"]\\s*\\])+)/g;\n    while ((m = re.exec(src)) !== null && out.length < MAX_REFS) {\n      out.push({ from: null, segs: normPath(m[1]), text: '$json' + m[1] });\n    }\n    // {{ $('Some Node').item.json.a.b }} - equally silent when wrong\n    var re2 = /\\$\\(\\s*['\"]([^'\"]+)['\"]\\s*\\)\\s*\\.\\s*(?:item|first\\(\\)|last\\(\\))\\s*\\.\\s*json((?:\\.[A-Za-z_$][\\w$]*)+)/g;\n    while ((m = re2.exec(src)) !== null && out.length < MAX_REFS) {\n      out.push({ from: m[1], segs: normPath(m[2]), text: \"$('\" + m[1] + \"')\u2026json\" + m[2] });\n    }\n    return out;\n  }\n\n  // How far down the path does this item get? Returns the number of segments\n  // that resolved, so the report can say which one actually broke.\n  function depthOn(json, segs) {\n    var v = json, i = 0;\n    for (; i < segs.length; i++) {\n      if (v === null || v === undefined || typeof v !== 'object') break;\n      if (!Object.prototype.hasOwnProperty.call(v, segs[i])) break;\n      v = v[segs[i]];\n    }\n    return i;\n  }\n\n  function brokenRefs(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var def = null;\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].name === nodeName) def = nodes[i];\n    if (!def || !def.parameters) return [];\n\n    var up = upstreamOf(exec, nodeName);\n    var ownInput = up ? itemsOf(rd, up) : null;\n\n    var out = [], seen = dict();\n\n    flatten(def.parameters, '', [], 64).forEach(function (leaf) {\n      if (out.length >= 4) return;\n      var raw = typeof leaf[1] === 'string' ? leaf[1] : '';\n      if (raw.indexOf('{{') === -1 && raw.charAt(0) !== '=') return;\n      if (HAS_FALLBACK.test(raw)) return;              // absence already handled\n\n      refsIn(raw).forEach(function (ref) {\n        if (out.length >= 4) return;\n\n        // Which items should this have resolved against?\n        var against = ref.from ? itemsOf(rd, ref.from) : ownInput;\n        // Nothing to check against is not evidence of anything.\n        if (!against || !against.length) return;\n\n        var best = 0;\n        for (var k = 0; k < Math.min(against.length, 50); k++) {\n          var d = depthOn(against[k] && against[k].json, ref.segs);\n          if (d > best) best = d;\n          if (best === ref.segs.length) return;         // resolves on some item\n        }\n\n        var key = String(leaf[0]) + '|' + ref.text;\n        if (seen[key]) return;\n        seen[key] = true;\n\n        var missing = ref.segs[best];\n        var parent = best === 0\n          ? (ref.from ? '\"' + ref.from + '\"' : 'its input')\n          : ref.segs.slice(0, best).join('.');\n\n        out.push({\n          param: String(leaf[0]) || '(parameter)',\n          expr: ref.text,\n          missing: missing,\n          where: parent,\n          resolvedDepth: best,\n          items: against.length\n        });\n      });\n    });\n\n    return out;\n  }\n\n  // Every node in the run, not just the blamed one - a broken reference three\n  // steps upstream is what produced the empty field you are staring at.\n  function allBrokenRefs(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var out = [];\n    Object.keys(rd).forEach(function (name) {\n      if (out.length >= 4) return;\n      brokenRefs(exec, name).forEach(function (b) {\n        if (out.length >= 4) return;\n        b.node = name;\n        out.push(b);\n      });\n    });\n    return out;\n  }\n\n  // Which node do we blame? The one n8n flagged, or the first that quietly\n  // emitted nothing.\n  function blame(exec) {\n    var res = (exec.data && exec.data.resultData) || {};\n    var rd = res.runData || {};\n    if (exec.status === 'error') {\n      var err = res.error || {};\n      return {\n        name: (err.node && err.node.name) || res.lastNodeExecuted,\n        why: 'failed', kind: 'failed', error: err\n      };\n    }\n    var ordered = Object.keys(rd).map(function (n) {\n      var runs = rd[n];\n      return { name: n, idx: (runs && runs[0] && runs[0].executionIndex) || 0, items: itemsOf(rd, n) };\n    }).sort(function (a, b) { return a.idx - b.idx; });\n\n    for (var i = 0; i < ordered.length; i++) {\n      var n = ordered[i];\n      if (n.items === null) continue;\n\n      if (!n.items.length) {\n        var type = nodeType(exec, n.name);\n        // A reducer emptying, or a trigger with nothing to hand on, is the\n        // workflow behaving - report it as such, not as a fault.\n        if (REDUCERS[type]) {\n          return { name: n.name, why: REDUCERS[type], kind: 'filtered', error: null };\n        }\n        if (!hasUpstream(exec, n.name)) {\n          return { name: n.name, why: 'had nothing to start from', kind: 'filtered', error: null };\n        }\n        return { name: n.name, why: 'produced 0 items', kind: 'broke', error: null };\n      }\n\n      var allBlank = true;\n      for (var k = 0; k < n.items.length; k++) if (!isBlank(n.items[k].json)) { allBlank = false; break; }\n      if (allBlank) return { name: n.name, why: 'produced empty output', kind: 'broke', error: null };\n    }\n    return null;\n  }\n\n  // Which node never ran as a result? Names the downstream victim for the list view.\n  function skipped(exec, blamedName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    var outs = (conns[blamedName] && conns[blamedName].main) || [];\n    for (var m = 0; m < outs.length; m++) {\n      var branch = outs[m] || [];\n      for (var c = 0; c < branch.length; c++) {\n        if (branch[c] && branch[c].node && !rd[branch[c].node]) return branch[c].node;\n      }\n    }\n    return null;\n  }\n\n  function upstreamOf(exec, name) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var runs = rd[name];\n    if (runs && runs[0] && runs[0].source && runs[0].source[0] && runs[0].source[0].previousNode) {\n      return runs[0].source[0].previousNode;\n    }\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    for (var from in conns) {\n      var mains = (conns[from] && conns[from].main) || [];\n      for (var m = 0; m < mains.length; m++) {\n        var branch = mains[m] || [];\n        for (var c = 0; c < branch.length; c++) if (branch[c] && branch[c].node === name) return from;\n      }\n    }\n    return null;\n  }\n\n  function expressions(exec, nodeName) {\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var def = null;\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].name === nodeName) def = nodes[i];\n    if (!def || !def.parameters) return [];\n    var hits = [];\n    // Bounded, not Infinity: node parameters are workflow-supplied and a\n    // pathological node would otherwise flatten into millions of leaves.\n    flatten(def.parameters, '', [], 64).forEach(function (leaf) {\n      var raw = typeof leaf[1] === 'string' ? leaf[1] : '';\n      if (raw.indexOf('{{') !== -1 || raw.charAt(0) === '=') hits.push(leaf);\n    });\n    return hits.slice(0, 2);\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Provenance - claims the node could not have got from its input.\n   *\n   * Shape profiling catches output that looks wrong. It cannot catch output\n   * where every field is present, correctly typed, plausibly formatted, and\n   * simply invented. That is the expensive failure with a model in the loop:\n   * \"everything looks correct individually but it makes shit up\".\n   *\n   * No judge model and no ground truth are needed for the damaging half of\n   * it. n8n stores both sides of every node. If the output carries a phone\n   * number, an email domain, an id or a link that appears nowhere in what\n   * the node was handed, the model did not read that - it produced it.\n   *\n   * Deliberately restricted to tokens that should be COPIED rather than\n   * composed. A summary is free to paraphrase; an account number is not.\n   * ------------------------------------------------------------------ */\n\n  var CLAIM_MIN_DIGITS = 6;   // below this it is a quantity, a price, a year\n  var MAX_CLAIMS = 6;\n\n  function haystack(items) {\n    var parts = [];\n    (items || []).slice(0, 200).forEach(function (i) {\n      try { parts.push(JSON.stringify(i && i.json)); } catch (e) { /* skip */ }\n    });\n    return parts.join(' ').toLowerCase();\n  }\n\n  function claimsIn(json) {\n    var out = [];\n    flatten(json, '', [], 12).forEach(function (leaf) {\n      if (typeof leaf[1] !== 'string') return;\n      var s = leaf[1], m;\n\n      // Email: compare the DOMAIN, never the whole address. Enrichment\n      // legitimately composes first.last@domain from parts it was given, and\n      // flagging that would be exactly the false positive that gets a tool\n      // uninstalled. An unknown domain, though, was invented outright.\n      var re = /[^\\s@\"']+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})/g;\n      while ((m = re.exec(s)) !== null) {\n        out.push({ kind: 'email domain', value: m[1], field: leaf[0], full: m[0] });\n      }\n\n      var reU = /https?:\\/\\/([A-Za-z0-9.-]+)/gi;\n      while ((m = reU.exec(s)) !== null) {\n        out.push({ kind: 'link', value: m[1], field: leaf[0], full: m[0] });\n      }\n\n      var reN = /\\d[\\d\\s().-]{4,}\\d/g;\n      while ((m = reN.exec(s)) !== null) {\n        var digits = m[0].replace(/\\D/g, '');\n        if (digits.length < CLAIM_MIN_DIGITS) continue;\n        out.push({ kind: 'number', value: digits, field: leaf[0], full: m[0].trim() });\n      }\n    });\n    return out;\n  }\n\n  /* ---- AI root nodes ----------------------------------------------------\n   *\n   * An Agent's real context does not arrive down its main input. The model\n   * sees whatever its tools returned, whatever memory held, and whatever a\n   * vector store retrieved - all of which n8n stores on the SUB-nodes, under\n   * channels like ai_tool and ai_memory rather than main.\n   *\n   * Checking an agent's output against its main input alone would therefore\n   * call every correctly-retrieved fact an invention. Any RAG or tool-using\n   * agent would light up red on its first run.\n   *\n   * The channel names are read from the data rather than hardcoded: n8n keeps\n   * adding connection types, and a list baked in here would silently go stale\n   * and start producing exactly the false positives it was meant to prevent.\n   */\n  function itemsAnyChannel(runData, name) {\n    var runs = runData[name];\n    if (!runs || !runs.length) return null;\n    var items = [], stored = false;\n    for (var i = 0; i < runs.length; i++) {\n      var r = runs[i];\n      if (!r || !r.data) continue;\n      var channels = Object.keys(r.data);\n      for (var c = 0; c < channels.length; c++) {\n        var branches = r.data[channels[c]];\n        if (!Array.isArray(branches)) continue;\n        stored = true;\n        for (var b = 0; b < branches.length; b++) {\n          if (Array.isArray(branches[b])) items = items.concat(branches[b]);\n        }\n      }\n    }\n    return stored ? items : null;\n  }\n\n  // Sub-nodes wired into this node by anything other than a main connection.\n  function helpersOf(exec, nodeName) {\n    var conns = (exec.workflowData && exec.workflowData.connections) || {};\n    var out = [];\n    Object.keys(conns).forEach(function (from) {\n      var byType = conns[from] || {};\n      Object.keys(byType).forEach(function (type) {\n        if (type === 'main') return;\n        (byType[type] || []).forEach(function (branch) {\n          (branch || []).forEach(function (link) {\n            if (link && link.node === nodeName) out.push({ name: from, type: type });\n          });\n        });\n      });\n    });\n    return out;\n  }\n\n  // Everything the node could legitimately have drawn on.\n  function contextFor(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var ctx = [];\n\n    var up = upstreamOf(exec, nodeName);\n    var main = up ? itemsOf(rd, up) : null;\n    if (main) ctx = ctx.concat(main);\n\n    helpersOf(exec, nodeName).forEach(function (h) {\n      var got = itemsAnyChannel(rd, h.name);\n      if (got) ctx = ctx.concat(got);\n    });\n\n    // The agent's own intermediate steps hold the tool observations it acted\n    // on. Those are retrieved context, not invention.\n    var own = itemsOf(rd, nodeName) || [];\n    own.forEach(function (it) {\n      var steps = it && it.json && it.json.intermediateSteps;\n      if (Array.isArray(steps)) ctx.push({ json: steps });\n    });\n\n    return ctx;\n  }\n\n  // Only a model can \"make something up\". A Code node that mints an order id,\n  // or an HTTP node returning a reference number, is behaving normally - and\n  // asking where those digits came from produces an alert on nearly every\n  // healthy run. So provenance applies to nodes whose text a model wrote, and\n  // to nothing else.\n  var AI_HINT = /langchain|openai|anthropic|cohere|mistral|ollama|llm|agent|chatmodel/i;\n\n  function isModelNode(exec, nodeName) {\n    if (AI_HINT.test(nodeType(exec, nodeName))) return true;\n    // Whatever it is called, a node with a language model wired into it is\n    // producing model output.\n    var helpers = helpersOf(exec, nodeName);\n    for (var i = 0; i < helpers.length; i++) {\n      if (/languageModel|Model$/i.test(helpers[i].type)) return true;\n    }\n    return false;\n  }\n\n  function provenance(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var items = itemsOf(rd, nodeName);\n    if (!items || !items.length) return [];\n    if (!isModelNode(exec, nodeName)) return [];\n\n    var input = contextFor(exec, nodeName);\n    // With nothing to compare against, say nothing. A check that did not run\n    // must never read as a clean bill of health.\n    if (!input || !input.length) return [];\n\n    var hay = haystack(input);\n    if (!hay) return [];\n    var hayDigits = hay.replace(/\\D/g, '');\n\n    var miss = [], seen = dict();\n    items.slice(0, 50).forEach(function (it) {\n      claimsIn(it && it.json).forEach(function (c) {\n        var v = String(c.value).toLowerCase();\n        if (!v || seen[v]) return;\n        var found;\n        if (c.kind === 'number') {\n          // Digits are compared stripped, so \"+44 161 555 0123\" in the input\n          // covers \"441615550123\" in the output.\n          found = hayDigits.indexOf(v) !== -1;\n          // A phone number moving between international and national form -\n          // +44 161 555 0123 becoming (0161) 555-0123 - shares only its tail,\n          // because the trunk zero replaces the country code. Matching the\n          // last seven digits keeps that legitimate rewrite from reading as an\n          // invention. The cost is the occasional missed short id, which is\n          // the right way round: a false alarm here is what gets this muted.\n          if (!found && v.length >= 7) found = hayDigits.indexOf(v.slice(-7)) !== -1;\n        } else {\n          found = hay.indexOf(v) !== -1;\n        }\n        if (found) return;\n        seen[v] = true;\n        miss.push(c);\n      });\n    });\n    return miss.slice(0, MAX_CLAIMS);\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Run against previous run.\n   *\n   * Everything else here answers \"is something broken\", which is a question\n   * people ask rarely. While actually building a workflow they hit Execute\n   * dozens of times a day and ask a different one: did that change help?\n   *\n   * n8n answers it with a JSON tree and no memory of the last run, so the\n   * comparison happens in the builder's head. This does it properly: two\n   * executions of the same workflow, field by field.\n   *\n   * Deliberately NOT the drift profile. That learns \"normal\" over many runs\n   * and is the right tool for a silent regression in production. This is the\n   * opposite - the immediately previous run, no history needed, useful on the\n   * second execution of a workflow that is ten minutes old.\n   * ------------------------------------------------------------------ */\n\n  var TEXT_SHIFT = 0.25;    // report a text length move beyond this fraction\n\n  function leafMap(items) {\n    var map = dict();\n    if (!items || !items.length) return map;\n    flatten(items[0] && items[0].json, '', [], 12).forEach(function (leaf) {\n      map[String(leaf[0])] = leaf[1];\n    });\n    return map;\n  }\n\n  function compareRuns(prev, curr) {\n    var pNode = resultNode(prev), cNode = resultNode(curr);\n    var pItems = pNode ? resultItems(prev, pNode) : null;\n    var cItems = cNode ? resultItems(curr, cNode) : null;\n\n    var changes = [];\n\n    // Item count first: 50 rows becoming 3 matters more than any field.\n    var pn = pItems ? pItems.length : 0;\n    var cn = cItems ? cItems.length : 0;\n    if (pn !== cn) {\n      changes.push({ field: '(item count)', kind: 'count', was: String(pn), now: String(cn) });\n    }\n    // When the workflow stops producing where it used to, the result node\n    // falls back to an earlier one - and diffing that node's fields against\n    // the old one's compares two unrelated shapes. Every field reads as\n    // \"gone\", burying the single fact that matters: it stopped at a different\n    // place. Report the move and say nothing else.\n    if (pNode !== cNode) {\n      changes.unshift({ field: '(last node with output)', kind: 'moved',\n                        was: String(pNode || 'nothing'), now: String(cNode || 'nothing') });\n      return { node: cNode, changes: changes, identical: false, nodeMoved: true };\n    }\n\n    var a = leafMap(pItems), b = leafMap(cItems);\n    var keys = [], seenKey = dict();\n    Object.keys(a).concat(Object.keys(b)).forEach(function (k) {\n      if (!seenKey[k]) { seenKey[k] = true; keys.push(k); }\n    });\n\n    keys.forEach(function (k) {\n      var was = a[k], now = b[k];\n      var hadIt = k in a, hasIt = k in b;\n\n      if (hadIt && !hasIt) {\n        changes.push({ field: k, kind: 'gone', was: show(was, 40), now: '\u2014' });\n        return;\n      }\n      if (!hadIt && hasIt) {\n        changes.push({ field: k, kind: 'new', was: '\u2014', now: show(now, 40) });\n        return;\n      }\n      if (isBlank(was) && !isBlank(now)) {\n        changes.push({ field: k, kind: 'filled', was: 'empty', now: show(now, 40) });\n        return;\n      }\n      if (!isBlank(was) && isBlank(now)) {\n        changes.push({ field: k, kind: 'emptied', was: show(was, 40), now: 'empty' });\n        return;\n      }\n      if (typeOf(was) !== typeOf(now)) {\n        changes.push({ field: k, kind: 'type', was: typeOf(was), now: typeOf(now) + ' ' + show(now, 30) });\n        return;\n      }\n      if (typeof was === 'string' && typeof now === 'string') {\n        if (was === now) return;\n        // Long text rewords on every run of anything with a model in it.\n        // Reporting the whole string as \"changed\" would drown the real\n        // signal, so past a certain length only a size move is worth saying.\n        if (was.length > 80 || now.length > 80) {\n          var shift = Math.abs(now.length - was.length) / Math.max(1, was.length);\n          if (shift >= TEXT_SHIFT) {\n            changes.push({ field: k, kind: now.length < was.length ? 'shorter' : 'longer',\n                           was: was.length + ' chars', now: now.length + ' chars: ' + show(now, 50) });\n          }\n          return;\n        }\n        changes.push({ field: k, kind: 'changed', was: show(was, 40), now: show(now, 40) });\n        return;\n      }\n      if (was !== now) {\n        changes.push({ field: k, kind: 'changed', was: show(was, 40), now: show(now, 40) });\n      }\n    });\n\n    return { node: cNode, changes: changes, identical: changes.length === 0 };\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Diagnosis - which KIND of wrong.\n   *\n   * From a user describing how he debugs an agent by hand: \"that usually\n   * helps pinpoint whether it's hallucinating, using bad context, or just\n   * making the wrong decision from correct data.\"\n   *\n   * Those three branches need different fixes - a retrieval bug, a prompt\n   * bug, and a model bug are not the same afternoon of work - and knowing\n   * which one you are in is most of the job. n8n stores everything needed to\n   * separate them: the ask, what came back from the tools, and the answer.\n   *\n   * The third branch is the honest limit. \"Correct data, wrong conclusion\"\n   * needs to know the right answer. What IS detectable is the subset where\n   * the answer contradicts its own source - every field plausible, one of\n   * them disagreeing with the document it came from.\n   * ------------------------------------------------------------------ */\n\n  var ID_RE = /\\b[A-Z][A-Z0-9]{1,}-\\d{2,}\\b/g;      // INV-8842, ACC-88421905\n  var NUM_RE = /\\b\\d{4,}\\b/g;                        // order numbers, ids\n  var MAIL_RE = /[^\\s@\"']+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g;\n  var URL_RE = /https?:\\/\\/[^\\s\"',]+/g;\n\n  function isYear(s) {\n    var n = Number(s);\n    return s.length === 4 && n >= 1900 && n <= 2100;\n  }\n\n  // The specifics a request is *about*. Common words are useless here - only\n  // things that must appear verbatim if the right record was found.\n  function distinctiveTokens(items) {\n    var text = haystack(items);\n    var raw = [];\n    [ID_RE, NUM_RE, MAIL_RE, URL_RE].forEach(function (re) {\n      re.lastIndex = 0;\n      var m;\n      while ((m = re.exec(text)) !== null) raw.push(m[0]);\n    });\n    var out = dict(), list = [];\n    raw.forEach(function (t) {\n      var k = String(t).toLowerCase();\n      if (isYear(k)) return;\n      if (out[k]) return;\n      out[k] = true;\n      list.push(k);\n    });\n    return list;\n  }\n\n  // Main input versus what the sub-nodes actually returned. contextFor() folds\n  // these together, which is right for grounding and wrong here: the whole\n  // question is whether the retrieval covered the ask.\n  function splitContext(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var up = upstreamOf(exec, nodeName);\n    var ask = (up ? itemsOf(rd, up) : null) || [];\n\n    var retrieved = [], failedTools = [], toolNodes = 0;\n    helpersOf(exec, nodeName).forEach(function (h) {\n      // A language model is not a source of facts - it is the thing being\n      // checked. Only tools, retrievers, vector stores and memory count.\n      if (/languageModel|outputParser/i.test(h.type)) return;\n      toolNodes++;\n      var runs = rd[h.name] || [];\n      var errored = runs.some(function (r) { return r && r.error; });\n      if (errored) failedTools.push(h.name);\n      var got = itemsAnyChannel(rd, h.name);\n      if (got) retrieved = retrieved.concat(got);\n    });\n\n    // Observations inside intermediateSteps are retrieved context too, and on\n    // a default-configured agent they are the only record of a tool result.\n    (itemsOf(rd, nodeName) || []).forEach(function (it) {\n      var steps = it && it.json && it.json.intermediateSteps;\n      if (!Array.isArray(steps)) return;\n      if (!toolNodes) toolNodes = steps.length ? 1 : 0;\n      steps.forEach(function (s) {\n        if (s && s.observation !== undefined) retrieved.push({ json: s.observation });\n      });\n    });\n\n    return { ask: ask, retrieved: retrieved, toolNodes: toolNodes, failedTools: failedTools };\n  }\n\n  function norm(v) {\n    return String(v).toLowerCase().replace(/[^a-z0-9]+/g, '');\n  }\n\n  // Leaf field name -> every value seen under it. Keyed on the last path\n  // segment so item[3].tier and tier are the same field.\n  function fieldValues(items) {\n    var map = dict();\n    (items || []).slice(0, 100).forEach(function (it) {\n      flatten(it && it.json, '', [], 12).forEach(function (leaf) {\n        var v = leaf[1];\n        if (v === null || v === undefined || typeof v === 'object') return;\n        var s = String(v);\n        // Free text cannot contradict anything usefully, and one-character\n        // values collide with everything.\n        if (s.length < 2 || s.length > 60) return;\n        var path = String(leaf[0]);\n        var key = path.split(/[.:]/).pop().replace(/\\[\\d*\\]/g, '');\n        if (!key) return;\n        (map[key] = map[key] || []).push(norm(s));\n      });\n    });\n    return map;\n  }\n\n  function diagnose(exec, nodeName) {\n    if (!isModelNode(exec, nodeName)) return null;\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var answer = itemsOf(rd, nodeName);\n    if (!answer || !answer.length) return null;\n\n    var split = splitContext(exec, nodeName);\n\n    // 1. A tool blew up and the agent answered anyway. Root cause, not a\n    //    symptom - everything downstream of this is explained by it.\n    if (split.failedTools.length) {\n      return {\n        branch: 'bad context',\n        why: 'a tool it depends on failed, and it answered anyway',\n        evidence: split.failedTools.map(function (n) { return n + ' errored'; })\n      };\n    }\n\n    // 2. Tools were wired in and returned nothing at all.\n    if (split.toolNodes && !split.retrieved.length) {\n      return {\n        branch: 'bad context',\n        why: 'nothing came back from its tools, and it answered anyway',\n        evidence: ['no retrieved context in this run']\n      };\n    }\n\n    // 3. The ask names specifics the retrieval never found. Only judged when\n    //    there was retrieval to judge and the ask HAS specifics - otherwise\n    //    the check has not run and must say nothing.\n    if (split.toolNodes && split.retrieved.length) {\n      var wanted = distinctiveTokens(split.ask);\n      if (wanted.length) {\n        var have = haystack(split.retrieved);\n        var haveDigits = have.replace(/\\D/g, '');\n        var missed = wanted.filter(function (t) {\n          if (/^\\d+$/.test(t)) return haveDigits.indexOf(t) === -1;\n          return have.indexOf(t) === -1;\n        });\n        if (missed.length === wanted.length) {\n          return {\n            branch: 'bad context',\n            why: 'what it was asked about never appears in what it retrieved',\n            evidence: missed.slice(0, 4).map(function (t) { return '\"' + t + '\" not in any retrieved document'; })\n          };\n        }\n      }\n    }\n\n    // 4. The answer disagrees with its own source. This is the detectable\n    //    slice of \"everything looks correct individually\": each field is\n    //    plausible, one of them contradicts the record it came from.\n    var ctxAll = split.ask.concat(split.retrieved);\n    if (ctxAll.length) {\n      var ctxVals = fieldValues(ctxAll);\n      var ansVals = fieldValues(answer);\n      var clashes = [];\n      Object.keys(ansVals).forEach(function (k) {\n        var known = ctxVals[k];\n        if (!known || !known.length) return;          // field not in the source\n        // Many records may share a field; matching ANY of them is fine.\n        ansVals[k].forEach(function (v) {\n          if (!v || known.indexOf(v) !== -1) return;\n          if (known.length > 12) return;              // too varied to judge\n          clashes.push(k + ' = ' + v + ', but its source says '\n            + known.slice(0, 3).join(' / '));\n        });\n      });\n      if (clashes.length) {\n        return {\n          branch: 'wrong decision',\n          why: 'the answer contradicts the data it was given',\n          evidence: clashes.slice(0, 3)\n        };\n      }\n    }\n\n    // 5. Facts in the answer that came from nowhere.\n    var invented = provenance(exec, nodeName);\n    if (invented.length) {\n      return {\n        branch: 'hallucinated',\n        why: 'the answer contains details that appear in nothing it was given',\n        evidence: invented.slice(0, 3).map(function (c) {\n          return c.field + ': ' + c.full + ' (' + c.kind + ')';\n        })\n      };\n    }\n\n    return null;\n  }\n\n  /* ---- the backwards trace ----------------------------------------------\n   *\n   * \"I trace it backwards: what context it received, what it retrieved, which\n   * tools it called, and what instructions it followed.\" That is the slow\n   * step, done by hand, through nested JSON. n8n has all of it already.\n   *\n   * Two sources, because either can be absent: the agent's own\n   * intermediateSteps (only present when Return Intermediate Steps is on),\n   * and the sub-nodes' own stored runs (always there if the node ran).\n   */\n  function summarise(v, max) {\n    if (v === null || v === undefined) return '';\n    if (typeof v === 'string') return v.length > max ? v.slice(0, max) + '\u2026' : v;\n    return show(v, max);\n  }\n\n  function agentTrace(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var steps = [];\n\n    (itemsOf(rd, nodeName) || []).forEach(function (it) {\n      var list = it && it.json && it.json.intermediateSteps;\n      if (!Array.isArray(list)) return;\n      list.slice(0, 20).forEach(function (s) {\n        var a = (s && s.action) || {};\n        steps.push({\n          from: 'step',\n          tool: a.tool || a.toolName || 'unknown tool',\n          input: summarise(a.toolInput !== undefined ? a.toolInput : a.input, 120),\n          output: summarise(s && s.observation, 200)\n        });\n      });\n    });\n\n    // Sub-nodes that actually ran. Reported even when intermediateSteps is\n    // missing, which is the common case - the option is off by default, and\n    // it is also dropped entirely when streaming is enabled.\n    helpersOf(exec, nodeName).forEach(function (h) {\n      var runs = rd[h.name];\n      if (!runs || !runs.length) return;\n      var got = itemsAnyChannel(rd, h.name) || [];\n      steps.push({\n        from: 'node',\n        tool: h.name,\n        kind: String(h.type).replace(/^ai_/, ''),\n        calls: runs.length,\n        output: got.length ? summarise(got[0] && got[0].json, 200) : '(nothing stored)'\n      });\n    });\n\n    return steps;\n  }\n\n  // An Execute Workflow node that returns nothing is NOT a payload problem -\n  // the cause is inside the child workflow. Pointing at the parent's webhook\n  // fields here would be actively misleading.\n  function subWorkflow(exec, nodeName) {\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    for (var i = 0; i < nodes.length; i++) {\n      var n = nodes[i];\n      if (n.name !== nodeName) continue;\n      if (n.type !== 'n8n-nodes-base.executeWorkflow') return null;\n      var w = n.parameters && n.parameters.workflowId;\n      if (!w) return { id: null, name: null };\n      if (typeof w === 'string') return { id: w, name: null };\n      return { id: w.value || null, name: w.cachedResultName || null };\n    }\n    return null;\n  }\n\n  function analyze(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var target = blame(exec);\n    if (!target || !target.name) return { clean: true };\n\n    var sub = subWorkflow(exec, target.name);\n    if (sub && target.why !== 'failed') {\n      return {\n        clean: false, target: target, err: {}, exprs: [], keys: [], context: [],\n        from: null, items: null, total: 0,\n        skipped: skipped(exec, target.name), sub: sub\n      };\n    }\n\n    var err = target.error || {};\n    var errText = [err.message, err.description].filter(Boolean).join(' ');\n    var exprs = expressions(exec, target.name);\n    var up = upstreamOf(exec, target.name);\n    var items = up ? itemsOf(rd, up) : null;\n    var keys = [], context = [];\n    var total = items ? items.length : 0;\n\n    if (items && items.length) {\n      var scan = Math.min(total, 200);\n      var leaves = [];\n      for (var i = 0; i < scan; i++) flatten(items[i].json, total > 1 ? 'item[' + i + ']' : '', leaves);\n\n      var hits = [], blanks = [];\n      leaves.forEach(function (leaf) {\n        var raw = typeof leaf[1] === 'string' ? leaf[1] : '';\n        if (raw.length > 3 && errText && errText.indexOf(raw) !== -1) hits.push(leaf);\n        else if (!leaf[2] && isBlank(leaf[1])) blanks.push(leaf);\n        else context.push(leaf);\n      });\n\n      function group(list, label) {\n        var by = dict(), order = [];\n        list.forEach(function (l) {\n          var k = String(l[0]).replace(/\\[\\d+\\]/g, '[]');\n          if (!by[k]) { by[k] = { path: k, first: l, n: 0 }; order.push(k); }\n          by[k].n++;\n        });\n        return order.map(function (k) {\n          var g = by[k];\n          if (total <= 1) return [[g.path, g.first[1], g.first[2]], label];\n          if (g.n === 1) return [[g.first[0], g.first[1], g.first[2]], label + ' (1 of ' + total + ' items)'];\n          return [[g.path, g.first[1], g.first[2]], label + ' in ' + g.n + ' of ' + total + ' items'];\n        });\n      }\n\n      keys = group(hits, 'appears in the error');\n      if (target.why !== 'failed' || (!keys.length && !exprs.length)) {\n        keys = keys.concat(group(blanks, 'empty'));\n      } else {\n        context = context.concat(blanks);\n      }\n\n      var ctxBy = dict(), ctxOrder = [];\n      context.forEach(function (c) {\n        var k = String(c[0]).replace(/\\[\\d+\\]/g, '[]');\n        if (!ctxBy[k]) { ctxBy[k] = [k, c[1], c[2]]; ctxOrder.push(k); }\n      });\n      var seen = dict();\n      context = ctxOrder.map(function (k) { return ctxBy[k]; }).filter(function (c) {\n        var top = String(c[0]).split(/[.[:]/)[0];\n        seen[top] = (seen[top] || 0) + 1;\n        return seen[top] <= 2;\n      }).slice(0, 4);\n    }\n\n    return {\n      clean: false, target: target, err: err, exprs: exprs, keys: keys,\n      context: context, from: up, items: items, total: total,\n      skipped: target.why !== 'failed' ? skipped(exec, target.name) : null\n    };\n  }\n\n  // One-line verdict for the executions list.\n  //   kind: 'error'    - n8n already told you\n  //         'silent'   - reports success, produced nothing, should not have\n  //         'filtered' - produced nothing on purpose (Filter, dedupe, no input)\n  function verdict(exec) {\n    var r = analyze(exec);\n    if (r.clean) return null;\n    if (r.target.why === 'failed') {\n      return {\n        kind: 'error', node: r.target.name,\n        text: r.target.name + ' failed' + (r.err.httpCode ? ' (' + r.err.httpCode + ')' : '')\n      };\n    }\n    if (r.sub) {\n      return {\n        kind: 'silent', node: r.target.name, key: null, sub: r.sub,\n        text: r.target.name + ' returned nothing from sub-workflow'\n      };\n    }\n    if (r.target.kind === 'filtered') {\n      return {\n        kind: 'filtered', node: r.target.name,\n        text: r.target.name + ' ' + r.target.why + (r.skipped ? ' \u2192 ' + r.skipped + ' never ran' : '')\n      };\n    }\n    // Naming the empty key is the whole point of the tool, so it belongs in\n    // the one-line verdict. \"Push to CRM never ran\" is the symptom three nodes\n    // downstream; \"extraction was empty\" is the thing you go and fix.\n    var key = null, klabel = '';\n    if (r.keys.length) { key = r.keys[0][0][0]; klabel = String(r.keys[0][1]); }\n\n    var text;\n    if (key && klabel.indexOf('empty') === 0) {\n      text = key + ' ' + klabel.replace(/^empty/, 'was empty')\n           + ' \u2192 ' + r.target.name + ' produced nothing';\n    } else if (r.skipped) {\n      text = r.target.name + ' ' + r.target.why + ' \u2192 ' + r.skipped + ' never ran';\n    } else {\n      text = r.target.name + ' ' + r.target.why;\n    }\n    return { kind: 'silent', text: text, node: r.target.name, key: key };\n  }\n\n  var REPLAY_METHODS = dict({ GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1, HEAD: 1 });\n\n  // The webhook path comes out of workflowData, which is not trusted: n8n\n  // templates are shared and imported freely, and in a shared workspace someone\n  // else wrote the node you are debugging. A path of \"../rest/workflows\" would\n  // turn the Replay button into a one-click CSRF against the user's own n8n,\n  // authenticated with their session cookie, on a same-origin fetch. So the\n  // final URL is resolved and then checked to still be under /webhook/.\n  function webhookUrl(origin, rawPath) {\n    var clean = String(rawPath === undefined || rawPath === null ? '' : rawPath).trim();\n    if (!clean || clean.length > 512) return null;\n    if (/[?#\\\\]/.test(clean)) return null;\n\n    var decoded = clean;\n    try { decoded = decodeURIComponent(clean); } catch (e) { return null; }\n    if (decoded.indexOf('..') !== -1) return null;\n\n    var base, url;\n    try {\n      base = new URL(origin);\n      url = new URL('/webhook/' + clean.replace(/^\\/+/, ''), base);\n    } catch (e) { return null; }\n\n    if (url.origin !== base.origin) return null;\n    if (url.pathname.indexOf('/webhook/') !== 0) return null;\n    return url.href;\n  }\n\n  function replayTarget(exec, origin) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var nodes = (exec.workflowData && exec.workflowData.nodes) || [];\n    var hook = null;\n    for (var i = 0; i < nodes.length; i++) if (nodes[i].type === 'n8n-nodes-base.webhook') hook = nodes[i];\n    if (!hook) return null;\n    var items = itemsOf(rd, hook.name);\n    if (!items || !items.length) return null;\n    var p = hook.parameters || {};\n\n    var url = webhookUrl(origin, p.path);\n    if (!url) return null;\n\n    var method = String(p.httpMethod || 'POST').toUpperCase();\n    if (!REPLAY_METHODS[method]) method = 'POST';\n\n    var payload = items[0].json || {};\n    return { url: url, method: method, body: payload.body === undefined ? {} : payload.body };\n  }\n\n  // n8n may hand back \"flatted\" (index-referenced) execution data.\n  function unflatten(arr) {\n    if (!Array.isArray(arr)) return arr;\n    var seen = {};\n    function ref(v) {\n      if (typeof v !== 'string') return v;\n      var i = Number(v);\n      return (String(i) === v && i >= 0 && i < arr.length) ? node(i) : v;\n    }\n    function node(i) {\n      if (seen[i] !== undefined) return seen[i];\n      var raw = arr[i];\n      if (raw === null || typeof raw !== 'object') { seen[i] = raw; return raw; }\n      var out = Array.isArray(raw) ? [] : {};\n      seen[i] = out;\n      if (Array.isArray(raw)) raw.forEach(function (v) { out.push(ref(v)); });\n      else Object.keys(raw).forEach(function (k) { out[k] = ref(raw[k]); });\n      return out;\n    }\n    return node(0);\n  }\n\n  function normalise(raw) {\n    var exec = (raw && raw.data && raw.data.resultData) ? raw : (raw && raw.data) ? raw.data : raw;\n    if (exec && typeof exec.data === 'string') {\n      try {\n        var parsed = JSON.parse(exec.data);\n        exec.data = Array.isArray(parsed) ? unflatten(parsed) : parsed;\n      } catch (e) { /* leave it; caller reports */ }\n    }\n    return exec;\n  }\n\n  /* ------------------------------------------------------------------ *\n   * Output shape profiling - \"confidently wrong\" detection.\n   *\n   * An empty output is easy. The expensive failures are the ones that come\n   * back populated and plausible: an LLM answering \"I'm sorry, I can't help\n   * with that\" where JSON should be, a field that silently became null, an\n   * extraction that collapsed from 200 characters to 12.\n   *\n   * No judge model is needed for that, and no SDK wrapping either. n8n has\n   * already stored what this node's output normally looks like. Learn the\n   * shape from the runs that were fine, then flag the run that breaks it.\n   * ------------------------------------------------------------------ */\n\n  var MIN_PROFILE_RUNS = 8;     // below this, \"normal\" is not established\n  var MAX_ENUM = 6;             // a field with few distinct values is a set\n  var LEN_COLLAPSE = 0.4;       // 40% of the shortest ever seen\n  var MAX_FIELDS = 64;          // payload width is attacker-controlled\n\n  var FORMATS = dict({\n    email: /^[^@\\s]+@[^@\\s.]+\\.[^@\\s]+$/,\n    url: /^https?:\\/\\/[^\\s]+$/i,\n    isoDate: /^\\d{4}-\\d{2}-\\d{2}([T ]|$)/\n  });\n  var FORMAT_NAMES = Object.keys(FORMATS);\n\n  // Ranked by how sharply the finding points at a cause. A type flip or a\n  // field that was never empty going empty is unambiguous; a number drifting\n  // out of its usual band is the softest signal here.\n  var DRIFT_RANK = { type: 0, empty: 1, format: 2, unexpected: 3, shrank: 4, range: 5 };\n  var BIG_DROP = 50;\n\n  function rankOf(d) {\n    var r = DRIFT_RANK[d.kind];\n    if (r === undefined) r = 9;\n    // A text field that lost most of its body outranks everything but an\n    // outright type flip: it carries the refusal or the truncation verbatim,\n    // which is the line that makes the cause obvious to a human. Ranking it\n    // by kind alone buries it under the consequences it caused.\n    if (d.kind === 'shrank' && (d.drop || 0) >= BIG_DROP) r = 0.5;\n    return r;\n  }\n\n  function typeOf(v) {\n    if (v === null || v === undefined) return 'null';\n    if (Array.isArray(v)) return 'array';\n    return typeof v;\n  }\n\n  function formatsOf(v) {\n    var out = [];\n    if (typeof v === 'string') {\n      FORMAT_NAMES.forEach(function (f) { if (FORMATS[f].test(v)) out.push(f); });\n    }\n    return out;\n  }\n\n  // Fold one item's json into a profile. Call once per clean run.\n  function addToProfile(profile, json) {\n    profile = profile || { runs: 0, fields: dict() };\n    profile.runs++;\n    if (!json || typeof json !== 'object') return profile;\n\n    Object.keys(json).slice(0, MAX_FIELDS).forEach(function (k) {\n      var v = json[k];\n      var f = profile.fields[k];\n      if (!f) {\n        // Runs need not share a schema, so cap the total too - otherwise a\n        // node emitting dynamic keys grows this map on every single run.\n        if (Object.keys(profile.fields).length >= MAX_FIELDS) return;\n        f = profile.fields[k] = {\n          seen: 0, filled: 0, types: dict(), values: dict(), distinct: 0,\n          lenMin: null, lenMax: null, numMin: null, numMax: null, formats: dict()\n        };\n      }\n      f.seen++;\n      if (!isBlank(v)) f.filled++;\n\n      var t = typeOf(v);\n      f.types[t] = (f.types[t] || 0) + 1;\n\n      if (typeof v === 'string') {\n        f.lenMin = f.lenMin === null ? v.length : Math.min(f.lenMin, v.length);\n        f.lenMax = f.lenMax === null ? v.length : Math.max(f.lenMax, v.length);\n        if (f.distinct <= MAX_ENUM) {\n          if (f.values[v] === undefined) { f.values[v] = 0; f.distinct = Object.keys(f.values).length; }\n          f.values[v]++;\n        }\n        formatsOf(v).forEach(function (fmt) { f.formats[fmt] = (f.formats[fmt] || 0) + 1; });\n      } else if (typeof v === 'number' && isFinite(v)) {\n        f.numMin = f.numMin === null ? v : Math.min(f.numMin, v);\n        f.numMax = f.numMax === null ? v : Math.max(f.numMax, v);\n      }\n    });\n    return profile;\n  }\n\n  function dominantType(f) {\n    var best = null, n = 0, total = 0;\n    for (var t in f.types) { total += f.types[t]; if (f.types[t] > n) { n = f.types[t]; best = t; } }\n    return (total && n / total >= 0.9) ? best : null;\n  }\n\n  // What does this item break, compared with what the node normally emits?\n  // Every rule needs the field to have been consistent BEFORE, so a field that\n  // was always varied never trips anything.\n  // opts.pooled - the profile was built from SEVERAL workflows rather than\n  // this node's own past. Different workflows legitimately emit different\n  // field sets, so an absent field means \"this one does not use it\", not\n  // \"it went empty\". Judging absence against a pooled profile flags healthy\n  // workflows for the crime of being different, which is worse than useless.\n  function driftAgainst(profile, json, opts) {\n    var out = [];\n    if (!profile || profile.runs < MIN_PROFILE_RUNS || !json || typeof json !== 'object') return out;\n    var pooled = !!(opts && opts.pooled);\n\n    Object.keys(profile.fields).forEach(function (k) {\n      var f = profile.fields[k];\n      if (f.seen < profile.runs) return;             // not always present: skip entirely\n      if (pooled && !(k in json)) return;            // different schema, not a fault\n      var v = json[k];\n\n      // 1. always filled, now empty\n      if (f.filled === f.seen && isBlank(v)) {\n        out.push({ field: k, kind: 'empty', was: 'always filled', now: show(v, 30) });\n        return;\n      }\n      if (isBlank(v)) return;\n\n      // 2. type changed\n      var dom = dominantType(f);\n      var t = typeOf(v);\n      if (dom && t !== dom) {\n        out.push({ field: k, kind: 'type', was: dom, now: t + ' ' + show(v, 30) });\n        return;\n      }\n\n      if (typeof v === 'string') {\n        // 3. a value outside a small, stable set\n        if (f.distinct > 0 && f.distinct <= MAX_ENUM && f.values[v] === undefined) {\n          out.push({\n            field: k, kind: 'unexpected',\n            was: 'only ever ' + Object.keys(f.values).map(function (s) { return JSON.stringify(s); }).join(', '),\n            now: show(v, 40)\n          });\n          return;\n        }\n        // 4. format broke. Checked BEFORE length: \"was always a valid email,\n        // now \\\"unknown\\\"\" says more than \"shrank from 33 chars to 7\".\n        var broke = null;\n        FORMAT_NAMES.forEach(function (fmt) {\n          if (broke) return;\n          if (f.formats[fmt] === f.seen && !FORMATS[fmt].test(v)) broke = fmt;\n        });\n        if (broke) {\n          out.push({ field: k, kind: 'format', was: 'always a valid ' + broke, now: show(v, 40) });\n          return;\n        }\n        // 5. text collapsed - the classic \"I'm sorry, I cannot...\" reply\n        if (f.lenMin !== null && f.lenMin >= 25 && v.length < Math.floor(f.lenMin * LEN_COLLAPSE)) {\n          out.push({\n            field: k, kind: 'shrank', drop: f.lenMin - v.length,\n            was: 'normally ' + f.lenMin + '-' + f.lenMax + ' chars',\n            now: v.length + ' chars: ' + show(v, 60)\n          });\n          return;\n        }\n      } else if (typeof v === 'number' && f.numMin !== null) {\n        // 6. numeric well outside the observed band\n        var span = f.numMax - f.numMin;\n        var pad = span > 0 ? span : Math.max(1, Math.abs(f.numMax) * 0.5);\n        if (v < f.numMin - pad || v > f.numMax + pad) {\n          out.push({\n            field: k, kind: 'range',\n            was: 'normally ' + f.numMin + '-' + f.numMax, now: String(v)\n          });\n          return;\n        }\n      }\n    });\n\n    // Field order in the payload is arbitrary, so the first drift found is not\n    // the most useful one to show. A contact_email collapsing to \"unknown\" and\n    // a summary collapsing to \"I'm sorry, I can't help with that\" are both\n    // 'shrank' - the second is the one that explains the run. Rank by how much\n    // the finding narrows down the cause, then by how much text was lost.\n    out.sort(function (a, b) {\n      var d = rankOf(a) - rankOf(b);\n      if (d) return d;\n      return (b.drop || 0) - (a.drop || 0);\n    });\n    return out;\n  }\n\n  // Which node's output should we profile? The last one that produced items -\n  // that is the workflow's actual result.\n  function resultNode(exec) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    var best = null, bestIdx = -1;\n    Object.keys(rd).forEach(function (n) {\n      var items = itemsOf(rd, n);\n      if (!items || !items.length) return;\n      var idx = (rd[n][0] && rd[n][0].executionIndex) || 0;\n      if (idx >= bestIdx) { bestIdx = idx; best = n; }\n    });\n    return best;\n  }\n\n  function resultItems(exec, nodeName) {\n    var rd = (exec.data && exec.data.resultData && exec.data.resultData.runData) || {};\n    return itemsOf(rd, nodeName);\n  }\n\n  root.WHY_ENGINE = {\n    analyze: analyze, verdict: verdict, replayTarget: replayTarget,\n    normalise: normalise, show: show, isBlank: isBlank,\n    addToProfile: addToProfile, driftAgainst: driftAgainst,\n    resultNode: resultNode, resultItems: resultItems,\n    provenance: provenance, agentTrace: agentTrace, helpersOf: helpersOf,\n    contextFor: contextFor, isModelNode: isModelNode,\n    diagnose: diagnose, splitContext: splitContext, nodeType: nodeType,\n    compareRuns: compareRuns, errorPayloads: errorPayloads, itemLoss: itemLoss,\n    brokenRefs: brokenRefs, allBrokenRefs: allBrokenRefs,\n    MIN_PROFILE_RUNS: MIN_PROFILE_RUNS\n  };\n})(typeof window !== 'undefined' ? window : globalThis);\n\n\n/* ---- what to look at, and where to shout ---------------------------- */\n\nconst E = globalThis.WHY_ENGINE;\nconst cfg = $('Settings').first().json;\n\n// Survives between runs. This is what lets shape drift work at all: the\n// profile of \"normal\" is built over days, not over one poll.\nconst memory = $getWorkflowStaticData('global');\nmemory.profiles = memory.profiles || {};\nmemory.counts = memory.counts || {};\n\nconst MAX_PROFILE_KEYS = 200;\nconst findings = [];\n\nfor (const item of $input.all()) {\n  const raw = item.json;\n  const exec = E.normalise(raw && raw.data !== undefined ? raw : raw);\n  if (!exec || !exec.data || !exec.data.resultData) continue;\n\n  const wf = (exec.workflowData && exec.workflowData.name) || exec.workflowId || 'workflow';\n  const id = exec.id;\n\n  // 1. Reported success, produced nothing useful.\n  const v = E.verdict(exec);\n  if (v && v.kind !== 'filtered') {\n    const key = String(exec.workflowId) + '|' + String(v.node);\n    memory.counts[key] = (memory.counts[key] || 0) + 1;\n    findings.push({\n      kind: v.kind === 'error' ? 'failed' : 'produced nothing',\n      workflow: wf, execution: id, detail: v.text, seenBefore: memory.counts[key] - 1\n    });\n    continue;\n  }\n  if (v) continue;                         // filtered - the workflow working\n\n  const node = E.resultNode(exec);\n  if (!node) continue;\n  const items = E.resultItems(exec, node);\n  const sample = (items && items.length) ? items[0].json : null;\n\n  // 2. Details that appear nowhere in what the node was given.\n  const invented = E.provenance(exec, node);\n  if (invented.length) {\n    findings.push({\n      kind: 'invented details', workflow: wf, execution: id,\n      detail: invented.map(c => c.field + ': ' + c.full + ' (' + c.kind + ')').join(', ')\n    });\n  }\n\n  // 3. Output that no longer matches what this node normally produces.\n  if (sample) {\n    const pkey = String(exec.workflowId) + '|' + node;\n    const prof = memory.profiles[pkey];\n    if (prof) {\n      const drift = E.driftAgainst(prof, sample);\n      if (drift.length) {\n        findings.push({\n          kind: 'output changed shape', workflow: wf, execution: id,\n          detail: drift.slice(0, 3)\n            .map(d => d.field + ' ' + d.kind + ' - was ' + d.was + ', now ' + d.now).join(' | '),\n          baseline: prof.runs\n        });\n      }\n    }\n    // Learn from it either way. A profile that only ever absorbs perfect runs\n    // freezes, and then flags every legitimate change to the workflow.\n    if (prof || Object.keys(memory.profiles).length < MAX_PROFILE_KEYS) {\n      memory.profiles[pkey] = E.addToProfile(prof || null, sample);\n    }\n  }\n}\n\nif (!findings.length) return [];\n\n/* ---- one message, not one per finding ------------------------------- */\n\nconst lines = findings.slice(0, 12).map(f => {\n  const age = f.seenBefore ? '  (seen ' + f.seenBefore + 'x before)' : '';\n  const base = f.baseline ? '  (vs ' + f.baseline + ' previous runs)' : '';\n  return '\u2022 [' + f.kind + '] ' + f.workflow + ' #' + f.execution + age + base + '\\n    ' + f.detail;\n});\nif (findings.length > 12) lines.push('\u2022 \u2026and ' + (findings.length - 12) + ' more');\n\nconst text = 'why? found ' + findings.length + ' run'\n  + (findings.length === 1 ? '' : 's') + ' worth looking at\\n\\n' + lines.join('\\n')\n  + '\\n\\n' + cfg.n8nBaseUrl + '/home/executions';\n\n// content = Discord, text = Slack. Sending both means one webhook field works\n// for either without the user having to know which.\nreturn [{ json: { text: text, content: text, findingCount: findings.length } }];"
      },
      "id": "w1000000-0000-4000-8000-000000000006",
      "name": "why? engine",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1000,
        0
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Settings').first().json.alertWebhookUrl }}",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ text: $json.text, content: $json.content }) }}",
        "options": {
          "timeout": 15000
        }
      },
      "id": "w1000000-0000-4000-8000-000000000007",
      "name": "Tell me",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1200,
        0
      ]
    }
  ],
  "connections": {
    "Every 15 minutes": {
      "main": [
        [
          {
            "node": "Settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings": {
      "main": [
        [
          {
            "node": "List recent runs",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "List recent runs": {
      "main": [
        [
          {
            "node": "Only what is new",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Only what is new": {
      "main": [
        [
          {
            "node": "Fetch each run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch each run": {
      "main": [
        [
          {
            "node": "why? engine",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "why? engine": {
      "main": [
        [
          {
            "node": "Tell me",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "none",
    "saveManualExecutions": true,
    "executionTimeout": 120
  }
}