{"name":"Ad budget guardian workflow","nodes":[{"id":"3f1a0c10-0001-4a01-8b01-000000000001","name":"Every 3 Hours","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.2,"position":[-260,300],"parameters":{"rule":{"interval":[{"field":"hours","hoursInterval":3}]}}},{"id":"3f1a0c10-0002-4a01-8b01-000000000002","name":"Load Guarded Accounts","type":"n8n-nodes-base.code","typeVersion":2,"position":[0,300],"parameters":{"jsCode":"// Accounts under budget guard. Budgets in account currency, per calendar month.\nconst ACCOUNTS = [\n  { account_id: 'act_1029384756', platform: 'meta',   label: 'Brand • Meta Prospecting', monthly_budget: 45000, daily_cap: 2200, min_daily: 400 },\n  { account_id: 'act_5647382910', platform: 'meta',   label: 'Brand • Meta Retargeting', monthly_budget: 18000, daily_cap: 900,  min_daily: 150 },\n  { account_id: 'act_4827719930', platform: 'meta',   label: 'Brand • Meta Advantage+',  monthly_budget: 30000, daily_cap: 1500, min_daily: 300 }\n];\n\n// Month-to-date window + how far through the month we are (fractional day precision).\nconst now = new Date();\nconst y = now.getUTCFullYear();\nconst m = now.getUTCMonth();\nconst daysInMonth = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();\nconst pad = (n) => String(n).padStart(2, '0');\nconst monthStart = `${y}-${pad(m + 1)}-01`;\nconst today = `${y}-${pad(m + 1)}-${pad(now.getUTCDate())}`;\n// elapsed = 0.0 at midnight on the 1st, 1.0 at the end of the month\nconst elapsed_fraction = ((now.getUTCDate() - 1) + now.getUTCHours() / 24) / daysInMonth;\n\nreturn ACCOUNTS.map((a) => ({\n  json: {\n    ...a,\n    since: monthStart,\n    until: today,\n    days_in_month: daysInMonth,\n    days_elapsed: (now.getUTCDate() - 1) + now.getUTCHours() / 24,\n    elapsed_fraction: Number(elapsed_fraction.toFixed(4)),\n    checked_at: now.toISOString()\n  }\n}));"}},{"id":"3f1a0c10-0003-4a01-8b01-000000000003","name":"Fetch Month-To-Date Spend","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[260,200],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $json.account_id }}/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"account_id,spend,impressions,frequency,purchase_roas,actions"},{"name":"time_range","value":"={{ JSON.stringify({ since: $json.since, until: $json.until }) }}"},{"name":"level","value":"account"}]},"options":{}}},{"id":"3f1a0c10-0004-4a01-8b01-000000000004","name":"Fetch Yesterday Delivery","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[260,420],"parameters":{"url":"=https://graph.facebook.com/v21.0/{{ $json.account_id }}/insights","sendQuery":true,"queryParameters":{"parameters":[{"name":"fields","value":"spend,impressions,clicks,cpm"},{"name":"date_preset","value":"yesterday"},{"name":"level","value":"account"}]},"options":{}}},{"id":"3f1a0c10-0012-4a01-8b01-000000000012","name":"Normalize Month-To-Date Row","type":"n8n-nodes-base.code","typeVersion":2,"position":[400,160],"parameters":{"jsCode":"// Insights answers with { data: [ { ... } ] }. Flatten to one row per account\n// so downstream nodes can read spend/impressions directly.\nreturn $input.all().map((item) => {\n  const row = (Array.isArray(item.json.data) ? item.json.data[0] : null) || item.json;\n  return {\n    json: {\n      spend: Number(row.spend || 0),\n      impressions: Number(row.impressions || 0),\n      frequency: Number(row.frequency || 0),\n      purchase_roas: row.purchase_roas || null,\n      actions: Array.isArray(row.actions) ? row.actions : []\n    }\n  };\n});"}},{"id":"3f1a0c10-0013-4a01-8b01-000000000013","name":"Normalize Yesterday Row","type":"n8n-nodes-base.code","typeVersion":2,"position":[400,440],"parameters":{"jsCode":"// Same flattening, but every metric is namespaced with yesterday_.\n// Without this the yesterday row's `spend` overwrites the month-to-date\n// `spend` when the two branches combine, and all pacing maths goes wrong.\nreturn $input.all().map((item) => {\n  const row = (Array.isArray(item.json.data) ? item.json.data[0] : null) || item.json;\n  return {\n    json: {\n      yesterday_spend: Number(row.spend || 0),\n      yesterday_impressions: Number(row.impressions || 0),\n      yesterday_clicks: Number(row.clicks || 0),\n      yesterday_cpm: Number(row.cpm || 0)\n    }\n  };\n});"}},{"id":"3f1a0c10-0005-4a01-8b01-000000000005","name":"Merge Spend Windows","type":"n8n-nodes-base.merge","typeVersion":3,"position":[520,300],"parameters":{"mode":"combine","combineBy":"combineByPosition","options":{}}},{"id":"3f1a0c10-0006-4a01-8b01-000000000006","name":"Attach Account Config","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[780,300],"parameters":{"assignments":{"assignments":[{"id":"a1","name":"account_id","value":"={{ $('Load Guarded Accounts').item.json.account_id }}","type":"string"},{"id":"a2","name":"platform","value":"={{ $('Load Guarded Accounts').item.json.platform }}","type":"string"},{"id":"a3","name":"label","value":"={{ $('Load Guarded Accounts').item.json.label }}","type":"string"},{"id":"a4","name":"monthly_budget","value":"={{ $('Load Guarded Accounts').item.json.monthly_budget }}","type":"number"},{"id":"a5","name":"daily_cap","value":"={{ $('Load Guarded Accounts').item.json.daily_cap }}","type":"number"},{"id":"a6","name":"min_daily","value":"={{ $('Load Guarded Accounts').item.json.min_daily }}","type":"number"},{"id":"a7","name":"days_in_month","value":"={{ $('Load Guarded Accounts').item.json.days_in_month }}","type":"number"},{"id":"a8","name":"days_elapsed","value":"={{ $('Load Guarded Accounts').item.json.days_elapsed }}","type":"number"},{"id":"a9","name":"elapsed_fraction","value":"={{ $('Load Guarded Accounts').item.json.elapsed_fraction }}","type":"number"},{"id":"a10","name":"yesterday_spend","value":"={{ $json.yesterday_spend }}","type":"number"},{"id":"a11","name":"yesterday_impressions","value":"={{ $json.yesterday_impressions }}","type":"number"}]},"includeOtherFields":true,"options":{}}},{"id":"3f1a0c10-0007-4a01-8b01-000000000007","name":"Score Pacing & Detect Drift","type":"n8n-nodes-base.code","typeVersion":2,"position":[1040,300],"parameters":{"jsCode":"// ---------------------------------------------------------------\n// Budget guardian: pacing projection + runaway / stall detection.\n// Inputs are merged month-to-date + yesterday rows per account.\n// ---------------------------------------------------------------\nconst RUNAWAY_PACE   = 1.15; // projected >115% of monthly budget = runaway\nconst STALL_PACE     = 0.70; // projected <70% of budget = under-delivering\nconst BURST_MULT     = 1.60; // yesterday spend vs. the pace it should run at\nconst STALL_SPEND    = 0.25; // yesterday spend below 25% of target daily = stalled\nconst ZERO_IMPR      = 200;  // fewer impressions than this = delivery is dead\nconst ROAS_FLOOR     = 1.20; // burning money below this ROAS is never \"fine\"\nconst COOLDOWN_HOURS = 12;   // do not re-act on the same account inside this window\n\nconst store = $getWorkflowStaticData('global');\nstore.lastAction = store.lastAction || {};\nconst nowMs = Date.now();\n\nconst out = [];\n\nfor (const item of $input.all()) {\n  const r = item.json;\n\n  // ---- pull the metrics off the merged row ---------------------\n  const mtd_spend = Number(r.spend || 0);\n  const yday_spend = Number(r.yesterday_spend || 0);\n  const conversions = Number((Array.isArray(r.actions) ? r.actions : []).find((a) => a.action_type === 'purchase')?.value || 0);\n  const reported_roas = Number((Array.isArray(r.purchase_roas) ? r.purchase_roas[0]?.value : r.purchase_roas) || 0);\n  const revenue = reported_roas * mtd_spend;\n  const impressions = Number(r.impressions || 0);\n  const yday_impressions = Number(r.yesterday_impressions || 0);\n  const frequency = Number(r.frequency || 0);\n  const roas = mtd_spend > 0 ? revenue / mtd_spend : 0;\n\n  // ---- pacing maths -------------------------------------------\n  const budget = Number(r.monthly_budget);\n  const elapsed = Math.max(0.01, Number(r.elapsed_fraction));\n  const remaining_days = Math.max(0.5, r.days_in_month - r.days_elapsed);\n  const target_spend_to_date = budget * elapsed;\n  const projected_month_spend = mtd_spend / elapsed;\n  const pace_index = projected_month_spend / budget;        // 1.0 = perfectly on pace\n  const budget_remaining = Math.max(0, budget - mtd_spend);\n  const target_daily = budget / r.days_in_month;\n  const safe_daily = budget_remaining / remaining_days;      // what we CAN spend from here\n  const burst_ratio = target_daily > 0 ? yday_spend / target_daily : 0;\n\n  // ---- verdict scoring ----------------------------------------\n  // Positive score = spending too fast, negative = stalling.\n  let score = 0;\n  const reasons = [];\n\n  if (pace_index >= RUNAWAY_PACE) {\n    score += Math.min(40, (pace_index - 1) * 100);\n    reasons.push(`projected ${Math.round(pace_index * 100)}% of monthly budget (${projected_month_spend.toFixed(0)} vs ${budget})`);\n  }\n  if (burst_ratio >= BURST_MULT) {\n    score += 25;\n    reasons.push(`yesterday spent ${yday_spend.toFixed(0)} = ${burst_ratio.toFixed(2)}x the ${target_daily.toFixed(0)} daily target`);\n  }\n  if (mtd_spend > target_spend_to_date * 1.25) {\n    score += 15;\n    reasons.push(`MTD spend ${mtd_spend.toFixed(0)} is 25%+ ahead of the ${target_spend_to_date.toFixed(0)} it should be`);\n  }\n  if (roas > 0 && roas < ROAS_FLOOR && pace_index > 1) {\n    score += 20;\n    reasons.push(`overspending at ROAS ${roas.toFixed(2)} (floor ${ROAS_FLOOR})`);\n  }\n  if (frequency >= 3.5 && pace_index > 1) {\n    score += 10;\n    reasons.push(`frequency ${frequency.toFixed(2)} while overspending`);\n  }\n\n  if (yday_impressions < ZERO_IMPR && r.days_elapsed >= 1) {\n    score -= 45;\n    reasons.push(`delivery stalled: only ${yday_impressions} impressions yesterday`);\n  }\n  if (target_daily > 0 && yday_spend < target_daily * STALL_SPEND && r.days_elapsed >= 1) {\n    score -= 30;\n    reasons.push(`yesterday spent ${yday_spend.toFixed(0)} vs ${target_daily.toFixed(0)} target (${Math.round(burst_ratio * 100)}% of pace)`);\n  }\n  if (pace_index <= STALL_PACE && r.days_elapsed >= 3) {\n    score -= Math.min(30, (1 - pace_index) * 60);\n    reasons.push(`projected to underspend by ${(budget - projected_month_spend).toFixed(0)} this month`);\n  }\n\n  // ---- translate score into a concrete budget move -------------\n  let verdict = 'healthy';\n  let new_daily_budget = null;\n  if (score >= 35) {\n    verdict = 'runaway';\n    // throttle to what is actually safe for the rest of the month, floored at min_daily\n    new_daily_budget = Math.max(r.min_daily, Math.min(r.daily_cap, Math.round(safe_daily * 0.85)));\n  } else if (score <= -35) {\n    verdict = 'stalled';\n    // push back up toward the pace needed to land on budget, capped\n    new_daily_budget = Math.max(r.min_daily, Math.min(r.daily_cap, Math.round(safe_daily * 1.10)));\n  }\n\n  // ---- dedupe: one action per account per cooldown window ------\n  const key = `${r.account_id}:${verdict}`;\n  const last = store.lastAction[key] || 0;\n  const hours_since = (nowMs - last) / 3.6e6;\n  const suppressed = verdict !== 'healthy' && hours_since < COOLDOWN_HOURS;\n  if (verdict !== 'healthy' && !suppressed) store.lastAction[key] = nowMs;\n\n  out.push({\n    json: {\n      account_id: r.account_id,\n      platform: r.platform,\n      label: r.label,\n      monthly_budget: budget,\n      mtd_spend: Number(mtd_spend.toFixed(2)),\n      yesterday_spend: Number(yday_spend.toFixed(2)),\n      target_spend_to_date: Number(target_spend_to_date.toFixed(2)),\n      projected_month_spend: Number(projected_month_spend.toFixed(2)),\n      pace_index: Number(pace_index.toFixed(3)),\n      burst_ratio: Number(burst_ratio.toFixed(2)),\n      safe_daily: Number(safe_daily.toFixed(2)),\n      target_daily: Number(target_daily.toFixed(2)),\n      budget_remaining: Number(budget_remaining.toFixed(2)),\n      remaining_days: Number(remaining_days.toFixed(2)),\n      impressions,\n      yesterday_impressions: yday_impressions,\n      frequency: Number(frequency.toFixed(2)),\n      conversions,\n      roas: Number(roas.toFixed(2)),\n      guard_score: Math.round(score),\n      verdict,\n      suppressed,\n      new_daily_budget,\n      reason: reasons.length ? reasons.join(' • ') : 'on pace, no action needed',\n      checked_at: new Date().toISOString()\n    }\n  });\n}\n\nreturn out;"}},{"id":"3f1a0c10-0008-4a01-8b01-000000000008","name":"Action Needed?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1300,300],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.verdict }}","rightValue":"healthy","operator":{"type":"string","operation":"notEquals"}},{"id":"c2","leftValue":"={{ $json.suppressed }}","rightValue":"","operator":{"type":"boolean","operation":"false","singleValue":true}}]},"options":{}}},{"id":"3f1a0c10-0009-4a01-8b01-000000000009","name":"Runaway Spend?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1560,140],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.verdict }}","rightValue":"runaway","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"3f1a0c10-000a-4a01-8b01-00000000000a","name":"Throttle Daily Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[1820,20],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.account_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: Math.round($json.new_daily_budget * 100), status: 'ACTIVE' }) }}","options":{}}},{"id":"3f1a0c10-000b-4a01-8b01-00000000000b","name":"Stalled Account?","type":"n8n-nodes-base.if","typeVersion":2,"position":[1820,240],"parameters":{"conditions":{"options":{"caseSensitive":true,"version":2},"combinator":"and","conditions":[{"id":"c1","leftValue":"={{ $json.verdict }}","rightValue":"stalled","operator":{"type":"string","operation":"equals"}}]},"options":{}}},{"id":"3f1a0c10-000c-4a01-8b01-00000000000c","name":"Resume & Rebalance Budget","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[2080,160],"parameters":{"method":"POST","url":"=https://graph.facebook.com/v21.0/{{ $json.account_id }}","sendBody":true,"specifyBody":"json","jsonBody":"={{ JSON.stringify({ daily_budget: Math.round($json.new_daily_budget * 100), status: 'ACTIVE' }) }}","options":{}}},{"id":"3f1a0c10-0014-4a01-8b01-000000000014","name":"Record Throttle Action","type":"n8n-nodes-base.code","typeVersion":2,"position":[2080,-60],"parameters":{"jsCode":"// The HTTP node replaced the scored row with the Graph API reply, so pull the\n// scored rows back (index-aligned with what we sent) and stamp what we did.\nconst scored = $('Runaway Spend?').all();\n\nreturn $input.all().map((item, i) => {\n  const row = scored[i] ? scored[i].json : {};\n  const ok = item.json && item.json.error ? false : true;\n  return {\n    json: {\n      ...row,\n      action_taken: ok\n        ? `throttled daily budget to $${row.new_daily_budget} (was pacing to $${row.projected_month_spend})`\n        : `THROTTLE FAILED: ${item.json.error?.message || 'unknown API error'}`,\n      action_applied: ok,\n      acted_at: new Date().toISOString()\n    }\n  };\n});"}},{"id":"3f1a0c10-0015-4a01-8b01-000000000015","name":"Record Resume Action","type":"n8n-nodes-base.code","typeVersion":2,"position":[2340,160],"parameters":{"jsCode":"// Same restore for the stalled branch: the API reply carries no account context.\nconst scored = $('Stalled Account?').all();\n\nreturn $input.all().map((item, i) => {\n  const row = scored[i] ? scored[i].json : {};\n  const ok = item.json && item.json.error ? false : true;\n  return {\n    json: {\n      ...row,\n      action_taken: ok\n        ? `resumed and rebalanced daily budget to $${row.new_daily_budget} (was under-delivering at ${Math.round((row.pace_index || 0) * 100)}% pace)`\n        : `RESUME FAILED: ${item.json.error?.message || 'unknown API error'}`,\n      action_applied: ok,\n      acted_at: new Date().toISOString()\n    }\n  };\n});"}},{"id":"3f1a0c10-000d-4a01-8b01-00000000000d","name":"Flag For Manual Review","type":"n8n-nodes-base.set","typeVersion":3.3,"position":[2080,380],"parameters":{"assignments":{"assignments":[{"id":"b1","name":"verdict","value":"watch","type":"string"},{"id":"b2","name":"action_taken","value":"no automated move — needs a human look","type":"string"}]},"includeOtherFields":true,"options":{}}},{"id":"3f1a0c10-000e-4a01-8b01-00000000000e","name":"Log Healthy Pacing","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[1560,520],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=0","mode":"list","cachedResultName":"Pacing Log"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"3f1a0c10-000f-4a01-8b01-00000000000f","name":"Build Guardian Alert","type":"n8n-nodes-base.code","typeVersion":2,"position":[2340,300],"parameters":{"jsCode":"// Compose one alert line per acted-on account and a single summary block.\nconst rows = $input.all().map((i) => i.json);\n\nconst ICON = { runaway: '🔴', stalled: '🟡', watch: '👀', healthy: '🟢' };\nconst money = (n) => Number(n || 0).toLocaleString('en-US', { maximumFractionDigits: 0 });\n\n// Highest-severity first: runaway, then stalled, then watch.\nconst rank = { runaway: 0, stalled: 1, watch: 2, healthy: 3 };\nrows.sort((a, b) => (rank[a.verdict] ?? 9) - (rank[b.verdict] ?? 9) || Math.abs(b.guard_score) - Math.abs(a.guard_score));\n\nconst lines = rows.map((r) => {\n  const action = r.action_taken || (r.verdict === 'healthy' ? 'no change' : 'logged only');\n  return [\n    `${ICON[r.verdict] || '•'} *${r.label}* (${r.platform})`,\n    `   pace ${Math.round(r.pace_index * 100)}% • MTD $${money(r.mtd_spend)} / $${money(r.monthly_budget)} • projected $${money(r.projected_month_spend)}`,\n    `   yesterday $${money(r.yesterday_spend)} vs $${money(r.target_daily)} target • ROAS ${r.roas}`,\n    `   why: ${r.reason}`,\n    `   action: ${action}${r.new_daily_budget ? ` → daily budget $${money(r.new_daily_budget)}` : ''}`\n  ].join('\\n');\n});\n\nconst runaway = rows.filter((r) => r.verdict === 'runaway').length;\nconst stalled = rows.filter((r) => r.verdict === 'stalled').length;\nconst overspend_at_risk = rows\n  .filter((r) => r.verdict === 'runaway')\n  .reduce((s, r) => s + Math.max(0, r.projected_month_spend - r.monthly_budget), 0);\n\nconst header = `*Budget guardian* — ${runaway} runaway, ${stalled} stalled` +\n  (overspend_at_risk > 0 ? ` • $${money(overspend_at_risk)} of projected overspend caught` : '');\n\nreturn [{\n  json: {\n    header,\n    runaway_count: runaway,\n    stalled_count: stalled,\n    overspend_at_risk: Number(overspend_at_risk.toFixed(2)),\n    accounts_touched: rows.length,\n    slack_text: [header, '', ...lines].join('\\n\\n'),\n    detail: rows,\n    run_at: new Date().toISOString()\n  }\n}];"}},{"id":"3f1a0c10-0010-4a01-8b01-000000000010","name":"Alert Buyers In Slack","type":"n8n-nodes-base.slack","typeVersion":2.2,"position":[2600,200],"parameters":{"select":"channel","channelId":{"__rl":true,"value":"#ads-budget-guard","mode":"name"},"text":"={{ $json.slack_text }}","otherOptions":{}}},{"id":"3f1a0c10-0011-4a01-8b01-000000000011","name":"Log Guard Actions","type":"n8n-nodes-base.googleSheets","typeVersion":4.5,"position":[2600,420],"parameters":{"operation":"append","documentId":{"__rl":true,"value":"SHEET_ID","mode":"id"},"sheetName":{"__rl":true,"value":"gid=1","mode":"list","cachedResultName":"Guard Actions"},"columns":{"mappingMode":"autoMapInputData","value":{},"matchingColumns":[]},"options":{}}},{"id":"3f1a0c10-0100-4a01-8b01-000000000100","name":"Note: Pull","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[-300,20],"parameters":{"content":"## 1 · PULL SPEND\nEvery 3 hours, load the guarded accounts (monthly budget, daily cap, floor) and pull two windows from the Insights API: month-to-date and yesterday.\n\nMTD tells us pacing. Yesterday tells us whether delivery just broke or just exploded.","height":320,"width":460,"color":4}},{"id":"3f1a0c10-0101-4a01-8b01-000000000101","name":"Note: Decide","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[1000,-40],"parameters":{"content":"## 2 · PACE & SCORE\nprojected_month_spend = mtd_spend / elapsed_fraction\n\nPositive guard score = burning too fast (>115% pace, 1.6x daily burst, ROAS under 1.2).\nNegative score = stalled (dead impressions, <25% of target daily spend).\n\nNew daily budget is derived from budget_remaining / remaining_days, clamped to the account cap and floor. A 12h cooldown per account+verdict stops flip-flopping.","height":360,"width":480,"color":3}},{"id":"3f1a0c10-0102-4a01-8b01-000000000102","name":"Note: Act","type":"n8n-nodes-base.stickyNote","typeVersion":1,"position":[2300,20],"parameters":{"content":"## 3 · ACT & TELL\nRunaway → throttle. Stalled → resume and rebalance. Anything else scored → flag for a human.\n\nEvery alert carries the reason string that produced it, so nobody has to reverse-engineer why the budget moved. Healthy runs still land in the pacing log.","height":300,"width":440,"color":5}}],"connections":{"Every 3 Hours":{"main":[[{"node":"Load Guarded Accounts","type":"main","index":0}]]},"Load Guarded Accounts":{"main":[[{"node":"Fetch Month-To-Date Spend","type":"main","index":0},{"node":"Fetch Yesterday Delivery","type":"main","index":0}]]},"Fetch Month-To-Date Spend":{"main":[[{"node":"Normalize Month-To-Date Row","type":"main","index":0}]]},"Fetch Yesterday Delivery":{"main":[[{"node":"Normalize Yesterday Row","type":"main","index":0}]]},"Normalize Month-To-Date Row":{"main":[[{"node":"Merge Spend Windows","type":"main","index":0}]]},"Normalize Yesterday Row":{"main":[[{"node":"Merge Spend Windows","type":"main","index":1}]]},"Merge Spend Windows":{"main":[[{"node":"Attach Account Config","type":"main","index":0}]]},"Attach Account Config":{"main":[[{"node":"Score Pacing & Detect Drift","type":"main","index":0}]]},"Score Pacing & Detect Drift":{"main":[[{"node":"Action Needed?","type":"main","index":0}]]},"Action Needed?":{"main":[[{"node":"Runaway Spend?","type":"main","index":0}],[{"node":"Log Healthy Pacing","type":"main","index":0}]]},"Runaway Spend?":{"main":[[{"node":"Throttle Daily Budget","type":"main","index":0}],[{"node":"Stalled Account?","type":"main","index":0}]]},"Throttle Daily Budget":{"main":[[{"node":"Record Throttle Action","type":"main","index":0}]]},"Record Throttle Action":{"main":[[{"node":"Build Guardian Alert","type":"main","index":0}]]},"Stalled Account?":{"main":[[{"node":"Resume & Rebalance Budget","type":"main","index":0}],[{"node":"Flag For Manual Review","type":"main","index":0}]]},"Resume & Rebalance Budget":{"main":[[{"node":"Record Resume Action","type":"main","index":0}]]},"Record Resume Action":{"main":[[{"node":"Build Guardian Alert","type":"main","index":0}]]},"Flag For Manual Review":{"main":[[{"node":"Build Guardian Alert","type":"main","index":0}]]},"Build Guardian Alert":{"main":[[{"node":"Alert Buyers In Slack","type":"main","index":0},{"node":"Log Guard Actions","type":"main","index":0}]]}},"settings":{"executionOrder":"v1"},"pinData":{}}