{
  "name": "Contract Renewal Radar",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * 1-5"
            }
          ]
        }
      },
      "id": "a1f0c2d4-0001-4a10-9b00-000000000001",
      "name": "Daily Radar Run — 07:00 Weekdays",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [-620, 300],
      "notes": "Timezone comes from workflow settings.timezone (America/New_York in this export). Change it there, not here — the notice-deadline math is calendar-date based and a timezone mismatch shifts every deadline by a day."
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT contract_id, last_tier_notified, last_notified_on FROM renewal_radar_log;",
        "options": {}
      },
      "id": "a1f0c2d4-0002-4a10-9b00-000000000002",
      "name": "Load Radar State",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [-400, 300],
      "credentials": {
        "postgres": {
          "id": "PLACEHOLDER_POSTGRES_CRED_ID",
          "name": "Postgres — Contracts DB"
        }
      },
      "alwaysOutputData": true,
      "notes": "Returns one row per contract already in the radar. Empty on first run — alwaysOutputData keeps the branch alive so the first run notifies at whatever tier each contract is currently in."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://ironcladapp.com/public/api/v1/records/metadata",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "httpHeaderAuth",
        "options": {
          "timeout": 30000
        }
      },
      "id": "a1f0c2d4-0003-4a10-9b00-000000000003",
      "name": "Ironclad — Record Schema",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [-180, 180],
      "credentials": {
        "httpHeaderAuth": {
          "id": "PLACEHOLDER_IRONCLAD_CRED_ID",
          "name": "Ironclad — Bearer Token"
        }
      },
      "notes": "Ironclad record metadata field IDs are per-tenant. This call resolves the display names in RENEWAL_FIELD_NAMES to the opaque field IDs your instance actually uses. Requires OAuth scope public.records.readRecords."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "https://ironcladapp.com/public/api/v1/records",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "pageSize",
              "value": "100"
            }
          ]
        },
        "options": {
          "timeout": 60000,
          "pagination": {
            "pagination": {
              "paginationMode": "responseContainsNextURL",
              "nextURL": "={{ $response.body.page !== undefined && ($response.body.page + 1) * $response.body.pageSize < $response.body.count ? 'https://ironcladapp.com/public/api/v1/records?pageSize=100&page=' + ($response.body.page + 1) : '' }}",
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ $response.body.list === undefined || $response.body.list.length === 0 }}",
              "limitPagesFetched": true,
              "maxRequestsFetched": 50
            }
          }
        }
      },
      "id": "a1f0c2d4-0004-4a10-9b00-000000000004",
      "name": "Ironclad — List Records",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [40, 180],
      "credentials": {
        "httpHeaderAuth": {
          "id": "PLACEHOLDER_IRONCLAD_CRED_ID",
          "name": "Ironclad — Bearer Token"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "Pulls the executed-contract repository. maxRequestsFetched caps the walk at 5,000 records; raise it if your repository is larger, and check the Normalize node's dropped-record count after you do."
    },
    {
      "parameters": {
        "method": "GET",
        "url": "=https://api.docusign.net/agreements/v1/accounts/{{ $env.DOCUSIGN_ACCOUNT_ID }}/agreements",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "oAuth2Api",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "limit",
              "value": "100"
            }
          ]
        },
        "options": {
          "timeout": 60000
        }
      },
      "id": "a1f0c2d4-0005-4a10-9b00-000000000005",
      "name": "Docusign — List Agreements",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [40, 400],
      "credentials": {
        "oAuth2Api": {
          "id": "PLACEHOLDER_DOCUSIGN_CRED_ID",
          "name": "Docusign — Agreement Manager OAuth"
        }
      },
      "onError": "continueRegularOutput",
      "notes": "Second repository source. Iris-extracted provisions arrive as expiration_date, renewal_type, renewal_notice_date, total_agreement_value. If you only run one CLM, disable this node — the Merge node passes through whichever branch produces items."
    },
    {
      "parameters": {
        "mode": "append",
        "numberInputs": 2
      },
      "id": "a1f0c2d4-0006-4a10-9b00-000000000006",
      "name": "Merge Contract Sources",
      "type": "n8n-nodes-base.merge",
      "typeVersion": 3,
      "position": [260, 300]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Normalize Ironclad records and Docusign agreements into one contract shape,\n// then compute the notice deadline that actually governs the decision.\n//\n// The date that matters is NOT the expiration date. It is the last day you can\n// serve notice without the term rolling. Everything downstream keys off\n// notice_deadline, never expiration_date.\n\nconst DEFAULT_NOTICE_DAYS = 90;      // conservative when the paper is silent\nconst TIERS = [90, 60, 30, 7];       // days before notice_deadline that trigger a nudge\nconst RADAR_HORIZON_DAYS = 90;       // ignore anything further out than the widest tier\n\n// Display names of the Ironclad record fields holding renewal data. Edit these\n// to match your instance; the schema node resolves them to field IDs.\nconst RENEWAL_FIELD_NAMES = {\n  expiration: 'Expiration Date',\n  noticeDays: 'Notice Period (Days)',\n  renewalType: 'Renewal Type',\n  annualValue: 'Annual Contract Value',\n  owner: 'Business Owner Email',\n};\n\nconst schemaResp = $('Ironclad — Record Schema').first().json;\nconst schemaFields = schemaResp.list || schemaResp.fields || [];\n\n// Map display name -> field id. Fail loudly rather than silently defaulting:\n// a missing notice-period field would make every contract look safe.\nconst fieldId = {};\nfor (const [key, displayName] of Object.entries(RENEWAL_FIELD_NAMES)) {\n  const match = schemaFields.find((f) => (f.name || f.displayName) === displayName);\n  if (!match && (key === 'expiration' || key === 'noticeDays')) {\n    throw new Error(\n      `schema_field_missing: no Ironclad record field named \"${displayName}\". ` +\n      'Fix RENEWAL_FIELD_NAMES in this node to match your instance before enabling the cron.'\n    );\n  }\n  if (match) fieldId[key] = match.id || match.fieldId;\n}\n\n// Calendar-date helpers. All math is on UTC date boundaries — a notice deadline\n// is a legal date, not an instant, and DST must not be able to move it.\nconst toUtcDate = (v) => {\n  if (!v) return null;\n  const d = new Date(String(v).slice(0, 10) + 'T00:00:00Z');\n  return Number.isNaN(d.getTime()) ? null : d;\n};\nconst addDays = (d, n) => new Date(d.getTime() + n * 86400000);\nconst daysBetween = (a, b) => Math.round((b.getTime() - a.getTime()) / 86400000);\nconst iso = (d) => d.toISOString().slice(0, 10);\n\nconst today = toUtcDate(new Date().toISOString());\nconst out = [];\nconst dropped = [];\n\nfor (const item of $input.all()) {\n  const raw = item.json;\n  let c;\n\n  if (raw.properties || raw.recordType) {\n    // ---- Ironclad record ----\n    const p = raw.properties || {};\n    const val = (key) => {\n      const f = fieldId[key] && p[fieldId[key]];\n      return f && typeof f === 'object' ? f.value : f;\n    };\n    c = {\n      contract_id: `ironclad:${raw.id}`,\n      source: 'ironclad',\n      title: raw.name || raw.type || 'Untitled record',\n      counterparty: val('counterparty') || raw.counterpartyName || null,\n      expiration_date: val('expiration'),\n      notice_days: val('noticeDays'),\n      renewal_type: val('renewalType'),\n      annual_value_cents: Math.round(Number(val('annualValue') || 0) * 100) || null,\n      owner_email: val('owner') || null,\n      deep_link: `https://ironcladapp.com/records/${raw.id}`,\n    };\n  } else {\n    // ---- Docusign agreement (Iris-extracted provisions) ----\n    const pr = raw.provisions || {};\n    // Docusign gives the notice DATE directly; derive days so both sources share a shape.\n    const exp = toUtcDate(pr.expiration_date);\n    const noticeDate = toUtcDate(pr.renewal_notice_date);\n    c = {\n      contract_id: `docusign:${raw.id}`,\n      source: 'docusign',\n      title: raw.file_name || raw.type || 'Untitled agreement',\n      counterparty: (raw.parties || []).map((x) => x.name_in_agreement).filter(Boolean).join(', ') || null,\n      expiration_date: pr.expiration_date || null,\n      notice_days: exp && noticeDate ? daysBetween(noticeDate, exp) : null,\n      renewal_type: pr.renewal_type || null,\n      annual_value_cents: Math.round(Number(pr.total_agreement_value || 0) * 100) || null,\n      owner_email: null,\n      deep_link: `https://apps.docusign.com/navigator/agreements/${raw.id}`,\n    };\n  }\n\n  const expiration = toUtcDate(c.expiration_date);\n  if (!expiration) {\n    dropped.push({ contract_id: c.contract_id, reason: 'no_expiration_date' });\n    continue;\n  }\n\n  // A null notice period is the single most dangerous value in this flow.\n  // Defaulting it to 0 would mark the contract safe until the day it renews.\n  // Default to the conservative end and label the assumption so the Slack card\n  // tells the reader the date is not from the paper.\n  let noticeDays = Number(c.notice_days);\n  let noticeSource = 'contract';\n  if (!Number.isFinite(noticeDays) || noticeDays < 0) {\n    noticeDays = DEFAULT_NOTICE_DAYS;\n    noticeSource = 'assumed';\n  }\n\n  const noticeDeadline = addDays(expiration, -noticeDays);\n  const daysToDeadline = daysBetween(today, noticeDeadline);\n\n  // Widest tier that this contract has already reached. Past the deadline is its own tier.\n  let tier = null;\n  if (daysToDeadline < 0) tier = 'passed';\n  else tier = TIERS.filter((t) => daysToDeadline <= t).sort((a, b) => a - b)[0] ?? null;\n\n  if (tier === null || daysToDeadline > RADAR_HORIZON_DAYS) continue;\n\n  out.push({\n    json: {\n      ...c,\n      notice_days: noticeDays,\n      notice_source: noticeSource,\n      expiration_date: iso(expiration),\n      notice_deadline: iso(noticeDeadline),\n      days_to_deadline: daysToDeadline,\n      tier: String(tier),\n      run_date: iso(today),\n    },\n  });\n}\n\nif (dropped.length) {\n  console.warn(`Dropped ${dropped.length} contract(s) with no expiration date`, dropped.slice(0, 20));\n}\n\nreturn out;\n"
      },
      "id": "a1f0c2d4-0007-4a10-9b00-000000000007",
      "name": "Normalize + Compute Notice Window",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [480, 300]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Suppress contracts already nudged at their current tier. Without this the flow\n// re-posts every in-window contract every weekday, the channel gets muted, and\n// the deadline is missed anyway — the exact failure the radar exists to prevent.\n\nconst state = new Map();\nfor (const row of $('Load Radar State').all()) {\n  if (row.json && row.json.contract_id) {\n    state.set(row.json.contract_id, String(row.json.last_tier_notified ?? ''));\n  }\n}\n\nreturn $input.all().filter((item) => {\n  const prev = state.get(item.json.contract_id);\n  return prev !== item.json.tier;\n});\n"
      },
      "id": "a1f0c2d4-0008-4a10-9b00-000000000008",
      "name": "New Tier Only",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [700, 300]
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT licensed_seats, active_seats_30d, last_term_annual_cents, quoted_renewal_annual_cents, open_support_tickets_90d, replacement_effort FROM vendor_context WHERE contract_id = $1;",
        "options": {
          "queryReplacement": "={{ $json.contract_id }}"
        }
      },
      "id": "a1f0c2d4-0009-4a10-9b00-000000000009",
      "name": "Load Spend + Usage",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [920, 300],
      "credentials": {
        "postgres": {
          "id": "PLACEHOLDER_POSTGRES_CRED_ID",
          "name": "Postgres — Contracts DB"
        }
      },
      "alwaysOutputData": true,
      "executeOnce": false,
      "notes": "One row per contract. Missing rows are expected early on — the flow still produces a brief, flagged as context_complete: false."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.anthropic.com/v1/messages",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "anthropicApi",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({\n  model: 'claude-sonnet-5',\n  max_tokens: 8000,\n  thinking: { type: 'adaptive' },\n  output_config: { effort: 'medium' },\n  system: 'You draft vendor-contract renewal briefs for a legal ops team. You are given one contract and its spend/usage context as JSON. Write a brief that a business owner can act on in under two minutes.\\n\\nHard rules:\\n- Use ONLY figures present in the supplied JSON. Never estimate, extrapolate, or introduce a number that is not in the input. If a field is null, say the data is missing rather than guessing.\\n- The decision date is notice_deadline, not expiration_date. Never tell the reader they have until the expiration date.\\n- If notice_source is \"assumed\", state in the first line that the notice period was not on file and the deadline is unverified.\\n- Recommend exactly one of: renew, renegotiate, terminate. Do not hedge across two.\\n\\nReturn ONLY a JSON object, no prose around it, with keys: recommendation (one of renew|renegotiate|terminate), confidence (0-1), rationale (string, max 60 words), leverage (array of up to 3 strings — concrete negotiation points drawn from the supplied data, empty array if none are supported), missing_data (array of strings naming fields you needed and did not get).',\n  messages: [{ role: 'user', content: JSON.stringify($json) }]\n}) }}",
        "options": {
          "timeout": 120000,
          "batching": {
            "batch": {
              "batchSize": 4,
              "batchInterval": 1000
            }
          }
        }
      },
      "id": "a1f0c2d4-0010-4a10-9b00-000000000010",
      "name": "Claude — Renewal Brief",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [1140, 300],
      "credentials": {
        "anthropicApi": {
          "id": "PLACEHOLDER_ANTHROPIC_CRED_ID",
          "name": "Anthropic — Messages API"
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 5000,
      "onError": "continueRegularOutput",
      "notes": "Batched 4-per-second to stay inside standard rate limits. retryOnFail handles 429/529 with backoff. onError keeps a single failed brief from killing the whole run — Score + Route falls back to the deterministic recommendation."
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Compute the recommendation deterministically from spend and usage, then treat\n// the model's answer as advisory. The model writes the brief; arithmetic decides\n// the route. This is what stops a confidently-worded brief from driving a\n// terminate on a contract whose usage data never loaded.\n\nconst LOW_UTILIZATION = 0.40;        // below this, the seats are not being used\nconst SOFT_UTILIZATION = 0.70;       // below this, there is a downsize argument\nconst UPLIFT_RENEGOTIATE = 0.10;     // quoted increase above this is worth pushing back on\nconst ESCALATION_VALUE_CENTS = 5000000; // $50,000/yr and up goes to legal, not just the owner\n\nconst contract = $('New Tier Only').item.json;\nconst ctx = $('Load Spend + Usage').item.json || {};\n\nconst licensed = Number(ctx.licensed_seats);\nconst active = Number(ctx.active_seats_30d);\nconst lastTerm = Number(ctx.last_term_annual_cents);\nconst quoted = Number(ctx.quoted_renewal_annual_cents);\n\nconst utilization = Number.isFinite(licensed) && licensed > 0 && Number.isFinite(active)\n  ? active / licensed\n  : null;\nconst uplift = Number.isFinite(lastTerm) && lastTerm > 0 && Number.isFinite(quoted)\n  ? (quoted - lastTerm) / lastTerm\n  : null;\n\nconst contextComplete = utilization !== null && uplift !== null;\n\nlet deterministic;\nif (utilization !== null && utilization < LOW_UTILIZATION) deterministic = 'terminate';\nelse if ((utilization !== null && utilization < SOFT_UTILIZATION) || (uplift !== null && uplift > UPLIFT_RENEGOTIATE)) deterministic = 'renegotiate';\nelse if (contextComplete) deterministic = 'renew';\nelse deterministic = 'renegotiate'; // no data is not a reason to sign the same paper again\n\n// Parse the model brief. A malformed or missing body must degrade to the\n// deterministic path, never abort the contract.\nlet brief = null;\nlet briefError = null;\ntry {\n  const body = $json.content;\n  const text = Array.isArray(body)\n    ? body.filter((b) => b.type === 'text').map((b) => b.text).join('')\n    : null;\n  if (!text) throw new Error('no_text_block');\n  brief = JSON.parse(text.trim().replace(/^```(?:json)?/, '').replace(/```$/, '').trim());\n} catch (e) {\n  briefError = e.message;\n}\n\nconst modelRec = brief && brief.recommendation ? brief.recommendation : null;\nconst agrees = modelRec === deterministic;\n\nconst escalate =\n  (Number(contract.annual_value_cents) || 0) >= ESCALATION_VALUE_CENTS ||\n  contract.tier === '7' ||\n  contract.tier === 'passed' ||\n  contract.notice_source === 'assumed' ||\n  !contract.renewal_type ||\n  !agrees;\n\nreturn {\n  json: {\n    ...contract,\n    utilization,\n    uplift,\n    context_complete: contextComplete,\n    recommendation: deterministic,\n    model_recommendation: modelRec,\n    model_agrees: agrees,\n    confidence: brief && Number.isFinite(brief.confidence) ? brief.confidence : null,\n    rationale: brief ? brief.rationale : 'Model brief unavailable — deterministic recommendation only.',\n    leverage: brief && Array.isArray(brief.leverage) ? brief.leverage.slice(0, 3) : [],\n    missing_data: brief && Array.isArray(brief.missing_data) ? brief.missing_data : [],\n    brief_error: briefError,\n    route: escalate ? 'legal_escalation' : 'owner_nudge',\n  },\n};\n"
      },
      "id": "a1f0c2d4-0011-4a10-9b00-000000000011",
      "name": "Score + Route",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [1360, 300]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "version": 2
          },
          "conditions": [
            {
              "id": "route-check",
              "leftValue": "={{ $json.route }}",
              "rightValue": "legal_escalation",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "a1f0c2d4-0012-4a10-9b00-000000000012",
      "name": "Needs Legal Escalation",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [1580, 300]
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "#legal-ops-renewals",
          "mode": "name"
        },
        "text": "=:rotating_light: *Renewal escalation — {{ $json.counterparty || $json.title }}*",
        "otherOptions": {
          "includeLinkToWorkflow": false
        },
        "blocksUi": "={{ JSON.stringify([\n  { type: 'header', text: { type: 'plain_text', text: `Notice deadline in ${$json.days_to_deadline} days — ${$json.counterparty || $json.title}` } },\n  { type: 'section', fields: [\n    { type: 'mrkdwn', text: `*Notice deadline*\\n${$json.notice_deadline}${$json.notice_source === 'assumed' ? ' :warning: _assumed, not on file_' : ''}` },\n    { type: 'mrkdwn', text: `*Expires*\\n${$json.expiration_date}` },\n    { type: 'mrkdwn', text: `*Renewal type*\\n${$json.renewal_type || ':warning: unknown'}` },\n    { type: 'mrkdwn', text: `*Annual value*\\n${$json.annual_value_cents ? '$' + ($json.annual_value_cents / 100).toLocaleString() : 'not on file'}` },\n    { type: 'mrkdwn', text: `*Seat utilization*\\n${$json.utilization !== null ? Math.round($json.utilization * 100) + '%' : 'no usage data'}` },\n    { type: 'mrkdwn', text: `*Quoted change*\\n${$json.uplift !== null ? (($json.uplift > 0 ? '+' : '') + Math.round($json.uplift * 100) + '%') : 'no quote on file'}` }\n  ] },\n  { type: 'section', text: { type: 'mrkdwn', text: `*Recommendation: ${$json.recommendation.toUpperCase()}*\\n${$json.rationale}` } },\n  ...($json.leverage.length ? [{ type: 'section', text: { type: 'mrkdwn', text: '*Leverage*\\n' + $json.leverage.map((l) => '• ' + l).join('\\n') } }] : []),\n  ...(!$json.model_agrees ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: `:warning: Model recommended *${$json.model_recommendation || 'nothing parseable'}*; rules recommended *${$json.recommendation}*. Human decides.` }] }] : []),\n  ...($json.missing_data.length ? [{ type: 'context', elements: [{ type: 'mrkdwn', text: 'Missing data: ' + $json.missing_data.join(', ') }] }] : []),\n  { type: 'actions', elements: [{ type: 'button', text: { type: 'plain_text', text: 'Open contract' }, url: $json.deep_link }] }\n]) }}"
        },
      "id": "a1f0c2d4-0013-4a10-9b00-000000000013",
      "name": "Slack — Legal Escalation",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1800, 200],
      "credentials": {
        "slackApi": {
          "id": "PLACEHOLDER_SLACK_CRED_ID",
          "name": "Slack — Legal Ops Bot"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "select": "channel",
        "channelId": {
          "__rl": true,
          "value": "#vendor-renewals",
          "mode": "name"
        },
        "text": "=*{{ $json.counterparty || $json.title }}* — notice deadline {{ $json.notice_deadline }} ({{ $json.days_to_deadline }} days)",
        "otherOptions": {
          "includeLinkToWorkflow": false
        },
        "blocksUi": "={{ JSON.stringify([\n  { type: 'section', text: { type: 'mrkdwn', text: `*${$json.counterparty || $json.title}* — decide by *${$json.notice_deadline}* (${$json.days_to_deadline} days)${$json.owner_email ? ` · owner: ${$json.owner_email}` : ''}` } },\n  { type: 'section', text: { type: 'mrkdwn', text: `*${$json.recommendation.toUpperCase()}* — ${$json.rationale}` } },\n  ...($json.leverage.length ? [{ type: 'section', text: { type: 'mrkdwn', text: $json.leverage.map((l) => '• ' + l).join('\\n') } }] : []),\n  { type: 'actions', elements: [{ type: 'button', text: { type: 'plain_text', text: 'Open contract' }, url: $json.deep_link }] }\n]) }}"
      },
      "id": "a1f0c2d4-0014-4a10-9b00-000000000014",
      "name": "Slack — Owner Nudge",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.3,
      "position": [1800, 400],
      "credentials": {
        "slackApi": {
          "id": "PLACEHOLDER_SLACK_CRED_ID",
          "name": "Slack — Legal Ops Bot"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "operation": "executeQuery",
        "query": "INSERT INTO renewal_radar_log (contract_id, source, counterparty, notice_deadline, expiration_date, tier, days_to_deadline, notice_source, recommendation, model_recommendation, model_agrees, utilization, uplift, route, rationale, last_tier_notified, last_notified_on)\nVALUES ($1, $2, $3, $4::date, $5::date, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $6, CURRENT_DATE)\nON CONFLICT (contract_id) DO UPDATE SET\n  notice_deadline = EXCLUDED.notice_deadline,\n  expiration_date = EXCLUDED.expiration_date,\n  tier = EXCLUDED.tier,\n  days_to_deadline = EXCLUDED.days_to_deadline,\n  notice_source = EXCLUDED.notice_source,\n  recommendation = EXCLUDED.recommendation,\n  model_recommendation = EXCLUDED.model_recommendation,\n  model_agrees = EXCLUDED.model_agrees,\n  utilization = EXCLUDED.utilization,\n  uplift = EXCLUDED.uplift,\n  route = EXCLUDED.route,\n  rationale = EXCLUDED.rationale,\n  last_tier_notified = EXCLUDED.last_tier_notified,\n  last_notified_on = CURRENT_DATE;",
        "options": {
          "queryReplacement": "={{ $json.contract_id }},{{ $json.source }},{{ $json.counterparty }},{{ $json.notice_deadline }},{{ $json.expiration_date }},{{ $json.tier }},{{ $json.days_to_deadline }},{{ $json.notice_source }},{{ $json.recommendation }},{{ $json.model_recommendation }},{{ $json.model_agrees }},{{ $json.utilization }},{{ $json.uplift }},{{ $json.route }},{{ $json.rationale }}"
        }
      },
      "id": "a1f0c2d4-0015-4a10-9b00-000000000015",
      "name": "Upsert Radar Log",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [2020, 300],
      "credentials": {
        "postgres": {
          "id": "PLACEHOLDER_POSTGRES_CRED_ID",
          "name": "Postgres — Contracts DB"
        }
      },
      "notes": "Upserts on contract_id, so a re-run after a partial failure is safe. last_tier_notified is what the New Tier Only node reads next run to suppress repeat nudges."
    }
  ],
  "connections": {
    "Daily Radar Run — 07:00 Weekdays": {
      "main": [
        [
          {
            "node": "Load Radar State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Radar State": {
      "main": [
        [
          {
            "node": "Ironclad — Record Schema",
            "type": "main",
            "index": 0
          },
          {
            "node": "Docusign — List Agreements",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ironclad — Record Schema": {
      "main": [
        [
          {
            "node": "Ironclad — List Records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ironclad — List Records": {
      "main": [
        [
          {
            "node": "Merge Contract Sources",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Docusign — List Agreements": {
      "main": [
        [
          {
            "node": "Merge Contract Sources",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Merge Contract Sources": {
      "main": [
        [
          {
            "node": "Normalize + Compute Notice Window",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize + Compute Notice Window": {
      "main": [
        [
          {
            "node": "New Tier Only",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "New Tier Only": {
      "main": [
        [
          {
            "node": "Load Spend + Usage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Spend + Usage": {
      "main": [
        [
          {
            "node": "Claude — Renewal Brief",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Claude — Renewal Brief": {
      "main": [
        [
          {
            "node": "Score + Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Score + Route": {
      "main": [
        [
          {
            "node": "Needs Legal Escalation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Needs Legal Escalation": {
      "main": [
        [
          {
            "node": "Slack — Legal Escalation",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Slack — Owner Nudge",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack — Legal Escalation": {
      "main": [
        [
          {
            "node": "Upsert Radar Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack — Owner Nudge": {
      "main": [
        [
          {
            "node": "Upsert Radar Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "timezone": "America/New_York",
    "saveExecutionProgress": true,
    "saveManualExecutions": true,
    "saveDataErrorExecution": "all",
    "saveDataSuccessExecution": "all",
    "executionTimeout": 3600,
    "callerPolicy": "workflowsFromSameOwner"
  },
  "pinData": {},
  "meta": {
    "instanceId": "PLACEHOLDER_N8N_INSTANCE_ID"
  },
  "tags": [
    {
      "name": "legal-ops"
    },
    {
      "name": "contract-renewals"
    }
  ]
}
