{
  "name": "LCBP3 Migration Workflow v4.0.0",
  "nodes": [
    {
      "parameters": {
        "formTitle": "LCBP3 Migration v4 - Safe Sequential Migration",
        "formDescription": "\u0e1b\u0e23\u0e30\u0e21\u0e27\u0e25\u0e1c\u0e25\u0e17\u0e35\u0e25\u0e30\u0e23\u0e32\u0e22\u0e01\u0e32\u0e23 \u0e1e\u0e23\u0e49\u0e2d\u0e21 resumable checkpoint \u2014 \u0e01\u0e23\u0e2d\u0e01 Migration Token \u0e17\u0e35\u0e48\u0e44\u0e14\u0e49\u0e08\u0e32\u0e01 Admin",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Migration Run ID",
              "placeholder": "\u0e40\u0e0a\u0e48\u0e19 C22024-RUN-001",
              "requiredField": true
            },
            {
              "fieldLabel": "Excel File Path",
              "placeholder": "/home/node/.n8n-files/staging_ai/C22024.xlsx",
              "defaultValue": "/home/node/.n8n-files/staging_ai/C22024.xlsx",
              "requiredField": true
            },
            {
              "fieldLabel": "Project Code",
              "placeholder": "\u0e40\u0e0a\u0e48\u0e19 LCBP3 (\u0e23\u0e2b\u0e31\u0e2a\u0e42\u0e04\u0e23\u0e07\u0e01\u0e32\u0e23 \u2014 \u0e23\u0e30\u0e1a\u0e1a\u0e08\u0e30 resolve \u0e40\u0e1b\u0e47\u0e19 UUID \u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34)",
              "requiredField": true
            },
            {
              "fieldLabel": "Contract Code",
              "placeholder": "\u0e40\u0e0a\u0e48\u0e19 C22024 (\u0e40\u0e27\u0e49\u0e19\u0e27\u0e48\u0e32\u0e07\u0e44\u0e14\u0e49\u0e40\u0e21\u0e37\u0e48\u0e2d\u0e44\u0e21\u0e48\u0e21\u0e35 contract scope)"
            },
            {
              "fieldLabel": "Migration Token",
              "placeholder": "Bearer eyJhbGciOiJIUzI1NiIs...",
              "requiredField": true
            },
            {
              "fieldLabel": "Confidence Threshold High (optional)",
              "placeholder": "0.85 (default \u0e15\u0e32\u0e21 ADR-023A)",
              "requiredField": false
            },
            {
              "fieldLabel": "Confidence Threshold Mid (optional)",
              "placeholder": "0.60 (default \u0e15\u0e32\u0e21 ADR-023A)",
              "requiredField": false
            }
          ]
        },
        "options": {}
      },
      "id": "8cf0c7a9-7166-463f-8b8b-b622bf46c605",
      "name": "Form Trigger",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.2,
      "position": [
        -11376,
        6272
      ],
      "notes": "v4: Migration Token \u0e43\u0e2a\u0e48\u0e43\u0e19 form (\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 env var \u0e2b\u0e23\u0e37\u0e2d credential) \u2014 token \u0e44\u0e21\u0e48\u0e16\u0e39\u0e01 persist \u0e43\u0e19 workflow definition"
    },
    {
      "parameters": {
        "jsCode": "// v4: configuration + validation \u2014 \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 env vars (\u0e16\u0e39\u0e01\u0e1a\u0e25\u0e47\u0e2d\u0e01\u0e43\u0e19 n8n 2.21.7+)\n// MIGRATION_TOKEN \u0e43\u0e0a\u0e49\u0e1c\u0e48\u0e32\u0e19 HTTP Header Auth credential \u0e41\u0e17\u0e19\n// AI_THRESHOLD_HIGH/MID \u0e2d\u0e48\u0e32\u0e19\u0e08\u0e32\u0e01 form fields (optional, default \u0e15\u0e32\u0e21 ADR-023A)\nconst formData = $('Form Trigger').first()?.json || {};\n\nconst runId = String(formData['Migration Run ID'] || '').trim();\nconst excelFile = String(formData['Excel File Path'] || '').trim();\nconst projectCode = String(formData['Project Code'] || '').trim();\nconst contractCode = String(formData['Contract Code'] || '').trim();\n\n// \u2500\u2500 Validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nif (!/^[A-Za-z0-9][A-Za-z0-9._-]{2,79}$/.test(runId)) {\n  throw new Error('Migration Run ID must be 3-80 characters: letters, numbers, dot, underscore, hyphen');\n}\nif (!excelFile || excelFile.includes('..') || excelFile.includes('~')) {\n  throw new Error('Invalid Excel file path');\n}\nif (!projectCode) throw new Error('Project Code is required');\nif (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/.test(projectCode)) {\n  throw new Error('Project Code must be 1-50 characters: letters, numbers, dot, underscore, hyphen');\n}\nif (contractCode && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,49}$/.test(contractCode)) {\n  throw new Error('Contract Code must be 1-50 characters or empty');\n}\n\n// \u2500\u2500 Token validation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst migrationToken = String(formData['Migration Token'] || '').trim();\nif (!migrationToken) throw new Error('Migration Token is required');\n// \u0e16\u0e49\u0e32\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49\u0e43\u0e2a\u0e48\u0e41\u0e04\u0e48 token (\u0e44\u0e21\u0e48\u0e21\u0e35 Bearer prefix) \u2192 \u0e40\u0e15\u0e34\u0e21\u0e43\u0e2b\u0e49\u0e2d\u0e31\u0e15\u0e42\u0e19\u0e21\u0e31\u0e15\u0e34\nconst authToken = migrationToken.startsWith('Bearer ') ? migrationToken : 'Bearer ' + migrationToken;\n\n// \u2500\u2500 Thresholds \u0e08\u0e32\u0e01 form (optional) \u0e2b\u0e23\u0e37\u0e2d default \u0e15\u0e32\u0e21 ADR-023A \u2500\u2500\u2500\u2500\nconst confidenceHigh = Number(formData['Confidence Threshold High (optional)'] || 0.85);\nconst confidenceMid = Number(formData['Confidence Threshold Mid (optional)'] || 0.60);\n\nif (isNaN(confidenceHigh) || confidenceHigh < 0 || confidenceHigh > 1) {\n  throw new Error('Confidence Threshold High must be a number between 0 and 1');\n}\nif (isNaN(confidenceMid) || confidenceMid < 0 || confidenceMid > 1) {\n  throw new Error('Confidence Threshold Mid must be a number between 0 and 1');\n}\nif (confidenceMid > confidenceHigh) {\n  throw new Error('Confidence Threshold Mid must not be greater than High');\n}\n\n// \u2500\u2500 Config output \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nreturn [{ json: { config: {\n  BACKEND_URL: 'http://backend:3000',\n  AUTH_TOKEN: authToken,\n  BATCH_ID: runId,\n  DELAY_MS: 2000,\n  AI_JOB_POLL_INTERVAL_MS: 5000,\n  AI_JOB_TIMEOUT_MS: 50000,\n  EXCEL_FILE: excelFile,\n  SOURCE_PDF_DIR: '/home/node/.n8n-files/staging_ai/pdfs',\n  LOG_PATH: '/home/node/.n8n-files/staging_ai/logs',\n  PROJECT_CODE: projectCode,\n  CONTRACT_CODE: contractCode || null,\n  CONFIDENCE_THRESHOLD_HIGH: confidenceHigh,\n  CONFIDENCE_THRESHOLD_MID: confidenceMid,\n  WORKFLOW_VERSION: '4.0.0',\n} } }];"
      },
      "id": "c785a235-4992-4396-884d-4c2137fb2304",
      "name": "Set Configuration",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -11184,
        6272
      ],
      "notes": "v4: Code node \u2014 validation + config build \u0e23\u0e27\u0e21\u0e43\u0e19 node \u0e40\u0e14\u0e35\u0e22\u0e27, \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 $env (token \u0e43\u0e0a\u0e49 credential, thresholds \u0e08\u0e32\u0e01 form)"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/health",
        "options": {
          "timeout": 5000
        }
      },
      "id": "0c23585c-33e3-4eaa-bc54-3cff5079745f",
      "name": "Check Backend Health",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -11008,
        6272
      ],
      "notes": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a Backend \u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/auth/profile",
        "options": {
          "timeout": 5000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "69db8e96-2203-47fc-b8a8-c6573c53178e",
      "name": "Validate Token",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10832,
        6272
      ],
      "notes": "FR-010a: \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a MIGRATION_TOKEN \u0e01\u0e48\u0e2d\u0e19\u0e40\u0e23\u0e34\u0e48\u0e21 Batch \u2014 M4: scope check \u0e17\u0e33\u0e17\u0e35\u0e48 node \u0e16\u0e31\u0e14\u0e44\u0e1b (Verify Migration Scope) \u0e15\u0e32\u0e21 ADR-016"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/master/correspondence-types",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "c9e3959b-4019-4ec6-b254-4a89156ca0d0",
      "name": "Fetch Categories",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -11184,
        6464
      ],
      "notes": "\u0e14\u0e36\u0e07 Categories \u0e08\u0e32\u0e01 Backend"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/master/tags",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "e3ed851e-1ef2-42d4-84bd-df29cfe6e13e",
      "name": "Fetch Tags",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -11008,
        6464
      ],
      "notes": "\u0e14\u0e36\u0e07 Tags \u0e17\u0e35\u0e48\u0e21\u0e35\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27\u0e08\u0e32\u0e01 Backend"
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst config = $('Set Configuration').first().json.config;\n\ntry {\n  if (!fs.existsSync(config.EXCEL_FILE)) {\n    throw new Error(`Excel file not found at: ${config.EXCEL_FILE}`);\n  }\n  if (!fs.existsSync(config.SOURCE_PDF_DIR)) {\n    throw new Error(`PDF Source directory not found at: ${config.SOURCE_PDF_DIR}`);\n  }\n  \n  const files = fs.readdirSync(config.SOURCE_PDF_DIR).filter(f => f.toLowerCase().endsWith('.pdf'));\n  \n  // L4: \u0e15\u0e23\u0e27\u0e08 write permission \u0e42\u0e14\u0e22\u0e44\u0e21\u0e48\u0e17\u0e34\u0e49\u0e07 sentinel file\n  if (!fs.existsSync(config.LOG_PATH)) fs.mkdirSync(config.LOG_PATH, { recursive: true });\n  fs.accessSync(config.LOG_PATH, fs.constants.W_OK);\n  \n  // Grab categories\n  let categories = ['Correspondence','RFA','Drawing','Transmittal','Report','Other'];\n  try {\n    const upstreamData = $('Fetch Categories').first()?.json?.data;\n    if (upstreamData && Array.isArray(upstreamData)) {\n      categories = upstreamData.map(c => c.name || c.type || c);\n    }\n  } catch(e) {}\n  \n  // Grab existing tags\n  let existingTags = [];\n  try {\n    const tagData = $('Fetch Tags').first()?.json?.data || [];\n    existingTags = Array.isArray(tagData)\n      ? tagData.map(t => ({ publicId: t.publicId, tagName: t.tag_name || t.name || '' })).filter(t => t.tagName && t.publicId)\n      : [];\n  } catch(e) {}\n    \n  return [{ json: { \n    preflight_ok: true, \n    pdf_count_in_source: files.length,\n    excel_target: config.EXCEL_FILE,\n    system_categories: categories,\n    existing_tags: existingTags,\n    timestamp: new Date().toISOString()\n  }}];\n} catch (err) {\n  throw new Error(`Pre-flight check failed: ${err.message}`);\n}"
      },
      "id": "6568ed41-da80-4f00-9378-905cc95d86f0",
      "name": "File Mount Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10832,
        6464
      ],
      "notes": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a File System + \u0e23\u0e27\u0e1a\u0e23\u0e27\u0e21 master data \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a AI payload"
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.excel_target }}",
        "options": {}
      },
      "id": "1802f33d-7fc6-42cb-897b-e312dc7c9626",
      "name": "Read Excel Binary",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        -11184,
        6656
      ],
      "notes": "\u0e14\u0e36\u0e07\u0e44\u0e1f\u0e25\u0e4c Excel \u0e02\u0e36\u0e49\u0e19\u0e21\u0e32\u0e44\u0e27\u0e49\u0e43\u0e19\u0e2b\u0e19\u0e48\u0e27\u0e22\u0e04\u0e27\u0e32\u0e21\u0e08\u0e33"
    },
    {
      "parameters": {
        "options": {}
      },
      "id": "af6a69f5-7f04-40f6-9608-1cfe231ee85f",
      "name": "Read Excel",
      "type": "n8n-nodes-base.spreadsheetFile",
      "typeVersion": 2,
      "position": [
        -11008,
        6656
      ],
      "notes": "\u0e41\u0e1b\u0e25\u0e07\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25 Excel \u0e40\u0e1b\u0e47\u0e19 JSON Data"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL + '/api/ai/migration/checkpoint/' + $('Set Configuration').first().json.config.BATCH_ID }}",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "ebddd4ee-0f4e-471b-9f01-179dbcb7f0ba",
      "name": "Read Checkpoint",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10832,
        6672
      ],
      "alwaysOutputData": true,
      "notes": "GET /api/ai/migration/checkpoint/:batchId \u2014 ADR-023A (\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 MySQL \u0e42\u0e14\u0e22\u0e15\u0e23\u0e07) \u2014 H3: continueRegularOutput \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e2b\u0e49 404 (batch \u0e43\u0e2b\u0e21\u0e48) \u0e44\u0e1b\u0e15\u0e48\u0e2d\u0e14\u0e49\u0e27\u0e22 startIndex=0",
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "jsCode": "const cpJson = $input.first()?.json || {};\nconst startIndex = cpJson.data?.lastProcessedIndex ?? cpJson.lastProcessedIndex ?? cpJson.data?.last_processed_index ?? cpJson.last_processed_index ?? 0;\nconst config = $('Set Configuration').first().json.config;\n\nconst allItems = $('Read Excel').all().map(i => i.json);\nconst remaining = allItems.slice(startIndex);\nconst currentBatch = remaining.slice(0, 1); // v4: exactly one row per loop\n\n// Exit condition: \u0e44\u0e21\u0e48\u0e21\u0e35 records \u0e40\u0e2b\u0e25\u0e37\u0e2d\nif (currentBatch.length === 0) {\n  return [{ json: { batch_complete: true, total_processed: startIndex } }];\n}\n\nconst normalize = (str) => {\n  if (!str) return '';\n  return String(str).normalize('NFC').trim();\n};\n\nreturn currentBatch.map((item, i) => {\n  const getVal = (possibleKeys) => {\n    const exactMatch = possibleKeys.find(k => item[k] !== undefined);\n    if (exactMatch) return item[exactMatch];\n    const lowerTrimmedKeys = Object.keys(item).map(k => ({ original: k, parsed: k.toLowerCase().trim() }));\n    for (const pk of possibleKeys) {\n      const found = lowerTrimmedKeys.find(k => k.parsed === pk.toLowerCase().trim());\n      if (found) return item[found.original];\n    }\n    return '';\n  };\n\n  const docNum = getVal(['document_number', 'correspondence_number', 'Document Number', 'Corr. No.']);\n  const excelFileName = getVal(['File name', 'file_name', 'File Name', 'filename']);\n  \n  if (!excelFileName) {\n    // Fix 1: \u0e44\u0e21\u0e48 throw \u2014 \u0e2a\u0e48\u0e07 error item \u0e44\u0e1b\u0e43\u0e19 flow \u0e1b\u0e01\u0e15\u0e34\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e2b\u0e49 checkpoint advance\n    // \u0e16\u0e49\u0e32 throw \u0e08\u0e30\u0e17\u0e33\u0e43\u0e2b\u0e49 execution \u0e2b\u0e22\u0e38\u0e14\u0e41\u0e25\u0e30 checkpoint \u0e44\u0e21\u0e48 advance \u2192 infinite crash loop on resume\n    return {\n      json: {\n        batch_complete: false,\n        document_number: normalize(docNum),\n        title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n        legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n        excel_revision: revVal || 1,\n        revision_missing: !revVal,\n        original_index: startIndex + i,\n        batch_id: config.BATCH_ID,\n        file_name: '',\n        row_key: [config.BATCH_ID, startIndex + i, normalize(docNum), revVal || 1].join(':'),\n        file_valid: false,\n        error: `Missing 'File name' column for row ${startIndex + i + 1}, document: ${docNum}`,\n        error_type: 'MISSING_FILENAME',\n      }\n    };\n  }\n  \n  const revVal = getVal(['revision', 'Revision', 'rev']);\n  // L5: flag missing revision \u0e40\u0e1e\u0e37\u0e48\u0e2d route \u0e44\u0e1b Flagged (\u0e44\u0e21\u0e48 mask \u0e14\u0e49\u0e27\u0e22 default 1)\n  const revisionMissing = !revVal;\n  return {\n    json: {\n      batch_complete: false,\n      document_number: normalize(docNum),\n      title: normalize(getVal(['title', 'Title', 'Subject', 'subject'])),\n      legacy_number: normalize(getVal(['legacy_number', 'Legacy Number', 'Response Doc.'])),\n      excel_revision: revVal || 1, // \u0e43\u0e0a\u0e49 1 \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a row_key stability \u0e41\u0e15\u0e48 flag \u0e41\u0e22\u0e01\n      revision_missing: revisionMissing,\n      original_index: startIndex + i,\n      batch_id: config.BATCH_ID,\n      file_name: normalize(excelFileName),\n      row_key: [config.BATCH_ID, startIndex + i, normalize(docNum), revVal || 1].join(':'),\n    }\n  };\n});"
      },
      "id": "b488f4ac-4864-4a16-ac26-71bd850fa49b",
      "name": "Process Batch + Encoding",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -11184,
        6880
      ],
      "alwaysOutputData": true,
      "notes": "v4: \u0e1b\u0e23\u0e30\u0e21\u0e27\u0e25\u0e1c\u0e25\u0e17\u0e35\u0e25\u0e30\u0e41\u0e16\u0e27 (sequential) + Normalize UTF-8 + Exit condition \u0e40\u0e21\u0e37\u0e48\u0e2d records \u0e2b\u0e21\u0e14 \u2014 BATCH_SIZE \u0e16\u0e39\u0e01\u0e25\u0e1a\u0e2d\u0e2d\u0e01\u0e08\u0e32\u0e01 config (M2)"
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst path = require('path');\nconst config = $('Set Configuration').first().json.config;\n\nconst items = $input.all();\nif (!items || items.length === 0) return [];\n\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a exit condition\nif (items[0]?.json?.batch_complete === true) {\n  return items;\n}\n\nconst validated = [];\nconst errors = [];\n\nfor (const item of items) {\n  // Fix 1: \u0e16\u0e49\u0e32 item \u0e21\u0e32\u0e08\u0e32\u0e01 Process Batch + Encoding \u0e17\u0e35\u0e48\u0e21\u0e35 file_valid: false \u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27 \u2192 \u0e2a\u0e48\u0e07\u0e15\u0e23\u0e07\u0e44\u0e1b errors\n  if (item.json?.file_valid === false) {\n    errors.push(item);\n    continue;\n  }\n  const fileName = item.json?.file_name;\n  if (!fileName) {\n    errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'file_name is missing', error_type: 'MISSING_FILENAME' } });\n    continue;\n  }\n  \n  let safeName = path.basename(String(fileName)).normalize('NFC');\n  if (!safeName.toLowerCase().endsWith('.pdf')) safeName += '.pdf';\n  const filePath = path.resolve(config.SOURCE_PDF_DIR, safeName);\n  \n  if (!filePath.startsWith(path.resolve(config.SOURCE_PDF_DIR))) {\n    errors.push({ ...item, json: { ...item.json, file_valid: false, error: 'Path traversal detected', error_type: 'SECURITY' } });\n    continue;\n  }\n  \n  try {\n    if (fs.existsSync(filePath)) {\n      const stats = fs.statSync(filePath);\n      validated.push({ ...item, json: { ...item.json, file_valid: true, file_path: filePath, file_size: stats.size } });\n    } else {\n      errors.push({ ...item, json: { ...item.json, file_valid: false, error: `File not found: ${safeName}`, error_type: 'FILE_NOT_FOUND' } });\n    }\n  } catch (err) {\n    errors.push({ ...item, json: { ...item.json, file_valid: false, error: err.message, error_type: 'FILE_ERROR' } });\n  }\n}\n\nreturn [...validated, ...errors];"
      },
      "id": "e831cd28-880c-4f93-99fc-901383d1cda3",
      "name": "File Validator",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10640,
        6272
      ],
      "notes": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e44\u0e1f\u0e25\u0e4c PDF \u0e43\u0e19 Directory"
    },
    {
      "parameters": {
        "fileSelector": "={{ $json.file_path }}",
        "options": {}
      },
      "id": "20625b33-17e8-4730-94f1-37c9b6c24df9",
      "name": "Read PDF File",
      "type": "n8n-nodes-base.readWriteFile",
      "typeVersion": 1,
      "position": [
        -10640,
        6464
      ],
      "onError": "continueErrorOutput",
      "notes": "\u0e2d\u0e48\u0e32\u0e19 PDF Binary \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e40\u0e15\u0e23\u0e35\u0e22\u0e21 Upload"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/files/upload",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.row_key + ':upload' }}"
            }
          ]
        },
        "sendBody": true,
        "contentType": "multipart-form-data",
        "bodyParameters": {
          "parameters": [
            {
              "parameterType": "formBinaryData",
              "name": "file",
              "inputDataFieldName": "data"
            }
          ]
        },
        "options": {
          "timeout": 300000
        }
      },
      "id": "2677c8b3-223a-4952-b4e2-41b92bd7f059",
      "name": "Upload PDF to Backend",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        -10416,
        6272
      ],
      "onError": "continueErrorOutput",
      "notes": "ADR-023A: \u0e2d\u0e31\u0e1e\u0e42\u0e2b\u0e25\u0e14 PDF \u0e40\u0e02\u0e49\u0e32 Backend Temp Storage \u2192 \u0e23\u0e31\u0e1a temp_attachment_id \u2014 L8: Backend \u0e21\u0e35 FileCleanupService \u0e23\u0e31\u0e19 cron \u0e17\u0e38\u0e01\u0e40\u0e17\u0e35\u0e48\u0e22\u0e07\u0e04\u0e37\u0e19 \u0e25\u0e1a temp files \u0e17\u0e35\u0e48\u0e2b\u0e21\u0e14\u0e2d\u0e32\u0e22\u0e38 (TTL 24 \u0e0a\u0e21.) \u0e08\u0e36\u0e07\u0e44\u0e21\u0e48\u0e15\u0e49\u0e2d\u0e07 cleanup \u0e40\u0e2d\u0e07\u0e43\u0e19 workflow \u2014 \u0e14\u0e39 backend file-cleanup.service.ts"
    },
    {
      "parameters": {
        "jsCode": "// v3: Build AI Job Payload \u0e1e\u0e23\u0e49\u0e2d\u0e21 contextOverride \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a Master Data filter (ADR-030)\nconst config = $('Set Configuration').first().json.config;\nconst uploadResponse = $json || {};\nconst metaItem = $('File Validator').first()?.json || {};\nconst mountCheckData = $('File Mount Check').first()?.json || {};\n\n// Backend returns { data: { publicId, tempId, ... } } per ADR-019 (id is @Exclude'd)\nconst tempAttachmentPublicId = uploadResponse?.data?.publicId || uploadResponse?.publicId;\nif (!tempAttachmentPublicId) {\n  throw new Error(`Upload failed \u2014 no temp_attachment_public_id returned. Upload response: ${JSON.stringify(uploadResponse)}`);\n}\n\n// Validate required fields per DTO\nconst docNumber = String(metaItem.document_number || '').trim();\nconst docTitle = String(metaItem.title || '').trim();\nif (!docNumber) {\n  throw new Error(`document_number is empty for item: ${JSON.stringify(metaItem)}`);\n}\nif (!docTitle) {\n  throw new Error(`title is empty for document: ${docNumber}`);\n}\n\n// Normalize existingTags to match TagOptionDto (tagName is required)\n// L2: \u0e43\u0e0a\u0e49 null \u0e41\u0e17\u0e19 undefined \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19 JSON.stringify \u0e15\u0e31\u0e14\u0e1f\u0e34\u0e25\u0e14\u0e4c\u0e17\u0e34\u0e49\u0e07\nconst existingTags = (mountCheckData.existing_tags || [])\n  .filter(t => t.tagName && t.tagName.trim())\n  .map(t => ({\n    publicId: t.publicId || null,\n    tagName: String(t.tagName).trim(),\n    colorCode: t.colorCode || null,\n  }));\n\n// v3: contextOverride \u2014 \u0e2a\u0e48\u0e07 public UUID \u0e43\u0e2b\u0e49 backend resolve master data context\n// \u0e15\u0e32\u0e21 ADR-030: filter master data \u0e15\u0e32\u0e21 project/contract scope \u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e25\u0e14 context size\n// null = \u0e44\u0e21\u0e48 filter (\u0e43\u0e0a\u0e49\u0e04\u0e48\u0e32\u0e08\u0e32\u0e01 context_config \u0e02\u0e2d\u0e07 active prompt template)\n// \u0e2d\u0e48\u0e32\u0e19 UUID \u0e08\u0e32\u0e01 Set Resolved UUIDs (resolve \u0e08\u0e32\u0e01 Code \u0e1c\u0e48\u0e32\u0e19 backend API)\nconst resolved = $('Set Resolved UUIDs').first()?.json || {};\nconst contextOverride = {\n  projectPublicId: resolved.projectPublicId || null,\n  contractPublicId: resolved.contractPublicId || null,\n};\n\n// \u0e2a\u0e23\u0e49\u0e32\u0e07 SubmitAiJobDto \u0e15\u0e32\u0e21 Backend DTO (v3)\nconst submitPayload = {\n  type: 'migrate-document',\n  payload: {\n    tempAttachmentId: String(tempAttachmentPublicId),\n    documentNumber: docNumber,\n    title: docTitle,\n    batchId: String(config.BATCH_ID),\n    existingTags: existingTags,\n    systemCategories: mountCheckData.system_categories || [],\n    // v3 NEW: contextOverride \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a ADR-030 Master Data Context Filter\n    contextOverride: contextOverride,\n  }\n};\n\n// Idempotency-Key: deterministic format \u0e15\u0e32\u0e21 FR-001a\nconst idempotencyKey = String(metaItem.row_key || [config.BATCH_ID, metaItem.original_index, docNumber, metaItem.excel_revision || 1].join(':'));\n\nreturn [{\n  json: {\n    ...metaItem,\n    temp_attachment_public_id: tempAttachmentPublicId,\n    submit_payload: submitPayload,\n    idempotency_key: idempotencyKey,\n  }\n}];",
        "mode": "runOnceForEachItem"
      },
      "id": "fad3f363-a595-4a43-a48f-cefb258c2687",
      "name": "Build AI Job Payload",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10400,
        6496
      ],
      "notes": "v4: \u0e40\u0e1e\u0e34\u0e48\u0e21 contextOverride (projectPublicId, contractPublicId) \u0e15\u0e32\u0e21 ADR-019/030 \u2014 Idempotency-Key \u0e15\u0e32\u0e21 FR-001a"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/jobs",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.idempotency_key }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify($json.submit_payload) }}",
        "options": {
          "timeout": 30000
        }
      },
      "id": "7fb24018-9739-4ae9-8650-d118d73e38d7",
      "name": "Submit AI Job",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10400,
        6672
      ],
      "onError": "continueErrorOutput",
      "notes": "ADR-023A: POST /api/ai/jobs \u2014 Backend \u0e08\u0e31\u0e14\u0e01\u0e32\u0e23 Ollama \u0e1c\u0e48\u0e32\u0e19 BullMQ"
    },
    {
      "parameters": {
        "jsCode": "// v3: Parse & Validate AI Response \u2014 \u0e23\u0e2d\u0e07\u0e23\u0e31\u0e1a 11-field schema (ADR-030)\n// recipients \u0e40\u0e1b\u0e47\u0e19 Array<{ organizationPublicId, recipientType }> \u0e41\u0e17\u0e19 parallel arrays\nconst config = $('Set Configuration').first().json.config;\nconst items = $input.all();\n\n// H1: UUIDv7 format validation (ADR-019) \u2014 format-invalid UUIDs treated as missing\nconst UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\nconst isUuidV7 = (s) => typeof s === 'string' && s.trim().length > 0 && UUID_V7_RE.test(s.trim());\nconst results = [];\n\nfor (const item of items) {\n  const data = item.json;\n  \n  // \u0e16\u0e49\u0e32 poll \u0e2a\u0e48\u0e07 error \u0e01\u0e25\u0e31\u0e1a\u0e21\u0e32\u0e42\u0e14\u0e22\u0e15\u0e23\u0e07 (route_index = 3)\n  if (data.route_index === 3 || data.ai_status === 'failed') {\n    results.push({ json: { ...data, route_index: 3 } });\n    continue;\n  }\n  \n  const ai = data.ai_result || {};\n  \n  if (!ai || typeof ai !== 'object') {\n    results.push({ json: { ...data, parse_error: 'Empty or invalid ai_result', error_type: 'PARSE_ERROR', route_index: 3 } });\n    continue;\n  }\n  \n  // ================================================================\n  // v3: 11-field schema validation (ADR-030)\n  // ================================================================\n  \n  // 1. projectPublicId \u2014 UUID string \u0e2b\u0e23\u0e37\u0e2d null\n  const projectPublicId = isUuidV7(ai.projectPublicId) ? ai.projectPublicId.trim() : null;\n  \n  // 2. correspondenceTypeCode \u2014 \u0e23\u0e2b\u0e31\u0e2a\u0e1b\u0e23\u0e30\u0e40\u0e20\u0e17 \u0e40\u0e0a\u0e48\u0e19 RFA, LETTER, TRANSMITTAL\n  const correspondenceTypeCode = typeof ai.correspondenceTypeCode === 'string' && ai.correspondenceTypeCode.trim()\n    ? ai.correspondenceTypeCode.trim().toUpperCase()\n    : null;\n  \n  // 3. disciplineCode \u2014 \u0e23\u0e2b\u0e31\u0e2a\u0e2a\u0e32\u0e02\u0e32\u0e07\u0e32\u0e19 \u0e40\u0e0a\u0e48\u0e19 GEN, STR\n  const disciplineCode = typeof ai.disciplineCode === 'string' && ai.disciplineCode.trim()\n    ? ai.disciplineCode.trim().toUpperCase()\n    : null;\n  \n  // 4. originatorOrganizationPublicId \u2014 UUID string \u0e2b\u0e23\u0e37\u0e2d null\n  const originatorOrganizationPublicId = isUuidV7(ai.originatorOrganizationPublicId) ? ai.originatorOrganizationPublicId.trim() : null;\n  \n  // 5. recipients \u2014 Object Array \u0e15\u0e32\u0e21 spec ADR-030\n  // \u0e23\u0e39\u0e1b\u0e41\u0e1a\u0e1a: Array<{ organizationPublicId: string, recipientType: 'TO' | 'CC' }>\n  // \u0e2b\u0e49\u0e32\u0e21\u0e43\u0e0a\u0e49 parallel arrays (recipientOrganizationPublicIds + recipientTypes)\n  let recipients = [];\n  if (Array.isArray(ai.recipients)) {\n    recipients = ai.recipients\n      .filter(r => r && typeof r === 'object' && isUuidV7(r.organizationPublicId))\n      .map(r => ({\n        organizationPublicId: String(r.organizationPublicId).trim(),\n        recipientType: ['TO', 'CC'].includes(String(r.recipientType || '').trim().toUpperCase())\n          ? String(r.recipientType).trim().toUpperCase()\n          : 'TO',\n      }));\n  }\n  \n  // 6. subject \u2014 \u0e2b\u0e31\u0e27\u0e02\u0e49\u0e2d\u0e40\u0e2d\u0e01\u0e2a\u0e32\u0e23\n  const subject = typeof ai.subject === 'string' && ai.subject.trim()\n    ? ai.subject.trim()\n    : data.title || '';\n  \n  // 7. documentDate \u2014 YYYY-MM-DD\n  const documentDate = typeof ai.documentDate === 'string' && /^\\d{4}-\\d{2}-\\d{2}$/.test(ai.documentDate.trim())\n    ? ai.documentDate.trim()\n    : null;\n  \n  // 8. tags \u2014 string[]\n  const suggestedTags = Array.isArray(ai.tags)\n    ? ai.tags.map(t => {\n        if (typeof t === 'string') return { tagName: t.trim(), isNew: true, colorCode: 'default' };\n        if (typeof t === 'object' && t.tagName) return {\n          tagName: String(t.tagName || t.tag_name || '').trim(),\n          publicId: t.publicId || t.public_id || undefined,\n          isNew: Boolean(t.isNew ?? t.is_new ?? !t.publicId),\n          colorCode: t.colorCode || t.color_code || 'default',\n        };\n        return null;\n      }).filter(t => t && t.tagName)\n    : [];\n  \n  // 9. summary \u2014 \u0e20\u0e32\u0e29\u0e32\u0e44\u0e17\u0e22 4-5 \u0e1b\u0e23\u0e30\u0e42\u0e22\u0e04\n  const summary = typeof ai.summary === 'string' && ai.summary.trim() ? ai.summary.trim() : null;\n  \n  // 10. confidence \u2014 float 0-1\n  const confidence = typeof ai.confidence === 'number' ? Math.max(0, Math.min(1, ai.confidence)) : 0;\n  \n  // ================================================================\n  // Confidence-based routing (\u0e40\u0e2b\u0e21\u0e37\u0e2d\u0e19 v2)\n  // route_index: 0=Auto Ready, 1=Review Flagged, 2=Rejected, 3=Error Log\n  // L10: thresholds \u0e08\u0e32\u0e01 env (ADR-023A: AI_THRESHOLD_HIGH=0.85, AI_THRESHOLD_MID=0.60)\n  // ================================================================\n  const thresholdHigh = Number($('Set Configuration').first().json.config.CONFIDENCE_THRESHOLD_HIGH);\n  const thresholdMid = Number($('Set Configuration').first().json.config.CONFIDENCE_THRESHOLD_MID);\n  let route_index;\n  let review_reason = '';\n  let reject_reason = '';\n  \n  // \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a UUID fields \u0e17\u0e35\u0e48\u0e08\u0e33\u0e40\u0e1b\u0e47\u0e19 \u2014 \u0e16\u0e49\u0e32\u0e44\u0e21\u0e48\u0e21\u0e35 \u2192 Flagged \u0e40\u0e1e\u0e37\u0e48\u0e2d human validation\n  const missingUuids = [];\n  if (!projectPublicId) missingUuids.push('projectPublicId');\n  if (!originatorOrganizationPublicId) missingUuids.push('originatorOrganizationPublicId');\n  if (recipients.length === 0) missingUuids.push('recipients');\n  // H1: \u0e16\u0e49\u0e32 AI \u0e04\u0e37\u0e19 UUID \u0e17\u0e35\u0e48\u0e23\u0e39\u0e1b\u0e41\u0e1a\u0e1a\u0e1c\u0e34\u0e14 \u2192 \u0e16\u0e37\u0e2d\u0e40\u0e2b\u0e21\u0e37\u0e2d\u0e19 missing (route \u0e44\u0e1b Flagged)\n  \n  // L5: missing revision \u2192 Flagged \u0e40\u0e1e\u0e37\u0e48\u0e2d human validation\n  const revisionMissing = Boolean(data.revision_missing);\n  \n  if (missingUuids.length > 0) {\n    route_index = 1;\n    review_reason = `UUID fields \u0e44\u0e21\u0e48\u0e04\u0e23\u0e1a: ${missingUuids.join(', ')} \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23 human validation (ADR-030)`;\n  } else if (revisionMissing) {\n    route_index = 1;\n    review_reason = `Excel revision \u0e02\u0e32\u0e14 \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23 human review (L5)`;\n  } else if (confidence >= thresholdHigh) {\n    route_index = 0; // Auto Ready\n  } else if (confidence >= thresholdMid) {\n    route_index = 1; // Review Flagged\n    review_reason = `confidence \u0e15\u0e48\u0e33 (${confidence.toFixed(2)}) \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23 human review`;\n  } else if (confidence > 0) {\n    route_index = 2; // Rejected\n    reject_reason = `confidence \u0e15\u0e48\u0e33\u0e40\u0e01\u0e34\u0e19 (${confidence.toFixed(2)}) \u2014 \u0e15\u0e48\u0e33\u0e01\u0e27\u0e48\u0e32 threshold ${thresholdMid}`;\n  } else {\n    route_index = 3; // Error\n  }\n  \n  const normalizedAi = {\n    // v3: 11-field schema (ADR-030)\n    projectPublicId,\n    correspondenceTypeCode,\n    disciplineCode,\n    originatorOrganizationPublicId,\n    recipients,\n    subject,\n    documentDate,\n    tags: suggestedTags,\n    summary,\n    confidence,\n  };\n  \n  results.push({\n    json: {\n      ...data,\n      ai_result: normalizedAi,\n      review_reason,\n      reject_reason,\n      route_index,\n    }\n  });\n}\n\nreturn results;"
      },
      "id": "419d8d1a-06c7-4cca-b1ed-53cf0a632c7e",
      "name": "Parse & Validate AI Response",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10208,
        6496
      ],
      "notes": "v4: 11-field schema (ADR-030) \u2014 recipients \u0e40\u0e1b\u0e47\u0e19 Object Array + UUIDv7 validation routing (H1)"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 0,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Auto Ready"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 1,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Review Flagged"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 2,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Rejected"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.route_index }}",
                    "rightValue": 3,
                    "operator": {
                      "type": "number",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Error Log"
            }
          ]
        },
        "options": {}
      },
      "id": "5abd2557-cb16-4102-aca3-db8e8d009286",
      "name": "Route by Confidence",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -10048,
        6560
      ]
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst item = $input.first();\nconst config = $('Set Configuration').first().json.config;\n\nconst csvPath = `${config.LOG_PATH}/reject_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,title,reject_reason,ai_confidence,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\n\nif (!fs.existsSync(csvPath)) {\n  fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nconst line = [\n  new Date().toISOString(),\n  esc(item.json.document_number),\n  esc(item.json.title),\n  esc(item.json.reject_reason),\n  item.json.ai_result?.confidence ?? 'N/A',\n  esc(item.json.job_id || '')\n].join(',') + '\\n';\n\nfs.appendFileSync(csvPath, line, 'utf8');\n\nreturn [$input.first()];"
      },
      "id": "62c48542-9b36-4db3-83ef-23bf1af39875",
      "name": "Log Reject to CSV",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -9808,
        6784
      ],
      "notes": "\u0e1a\u0e31\u0e19\u0e17\u0e36\u0e01\u0e23\u0e32\u0e22\u0e01\u0e32\u0e23\u0e17\u0e35\u0e48\u0e16\u0e39\u0e01\u0e1b\u0e0f\u0e34\u0e40\u0e2a\u0e18\u0e25\u0e07 CSV"
    },
    {
      "parameters": {
        "jsCode": "const fs = require('fs');\nconst items = $input.all();\nconst config = $('Set Configuration').first().json.config;\n\nconst ERROR_TYPE_MAP = {\n  AI_JOB_FAILED: 'API_ERROR',\n  PARSE_ERROR: 'AI_PARSE_ERROR',\n  TOKEN_EXPIRED: 'API_ERROR',\n};\nconst ALLOWED_ERROR_TYPES = new Set([\n  'FILE_NOT_FOUND',\n  'MISSING_FILENAME',\n  'FILE_ERROR',\n  'AI_PARSE_ERROR',\n  'API_ERROR',\n  'DB_ERROR',\n  'SECURITY',\n  'UNKNOWN',\n]);\nconst normalizeErrorType = (type, errorMsg) => {\n  // L7: \u0e15\u0e23\u0e27\u0e08 prefix \u0e43\u0e19 error message \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a errors \u0e17\u0e35\u0e48\u0e21\u0e32\u0e08\u0e32\u0e01 throw (\u0e44\u0e21\u0e48\u0e21\u0e35 error_type field)\n  if (!type && errorMsg) {\n    if (errorMsg.startsWith('AI_JOB_FAILED')) return 'API_ERROR';\n    if (errorMsg.startsWith('AI_JOB_POLL_ERROR')) return 'API_ERROR';\n    if (errorMsg.startsWith('TOKEN_EXPIRED')) return 'API_ERROR';\n  }\n  const mappedType = ERROR_TYPE_MAP[type] || type || 'UNKNOWN';\n  return ALLOWED_ERROR_TYPES.has(mappedType) ? mappedType : 'UNKNOWN';\n};\n\nconst csvPath = `${config.LOG_PATH}/error_log_${config.BATCH_ID}.csv`;\nconst header = 'timestamp,document_number,error_type,error_message,job_id\\n';\nconst esc = (s) => `\"${String(s || '').replace(/\"/g, '\"\"')}\"`;\nconst results = [];\n\nif (!fs.existsSync(csvPath)) {\n  fs.writeFileSync(csvPath, header, 'utf8');\n}\n\nfor (const item of items) {\n  // L6: \u0e44\u0e21\u0e48 mutate input item \u2014 \u0e2a\u0e23\u0e49\u0e32\u0e07 object \u0e43\u0e2b\u0e21\u0e48\n  // L7: \u0e1e\u0e22\u0e32\u0e22\u0e32\u0e21 extract document_number \u0e08\u0e32\u0e01 error message \u0e16\u0e49\u0e32\u0e44\u0e21\u0e48\u0e21\u0e35\u0e43\u0e19 item\n  let documentNumber = item.json.document_number || '';\n  if (!documentNumber && errorMsg) {\n    const m = errorMsg.match(/document=([^,]+)/);\n    if (m) documentNumber = m[1];\n  }\n  if (!documentNumber) documentNumber = 'WORKFLOW';\n  const errorType = normalizeErrorType(item.json.error_type, errorMsg);\n  const errorMsg = item.json.error || item.json.parse_error || item.json.message || '';\n  const jobId = item.json.job_id || '';\n\n  const line = [\n    new Date().toISOString(),\n    esc(documentNumber),\n    esc(errorType),\n    esc(errorMsg),\n    esc(jobId)\n  ].join(',') + '\\n';\n  fs.appendFileSync(csvPath, line, 'utf8');\n\n  // \u0e04\u0e37\u0e19 object \u0e43\u0e2b\u0e21\u0e48\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e04\u0e48\u0e32\u0e17\u0e35\u0e48 normalize \u0e41\u0e25\u0e49\u0e27\n  results.push({ json: { ...item.json, document_number: documentNumber, error_type: errorType, error: errorMsg, job_id: jobId } });\n}\n\nreturn results;"
      },
      "id": "724da26e-5c71-4146-8a4c-a16299dfae17",
      "name": "Log Error to CSV",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10416,
        6864
      ],
      "notes": "\u0e1a\u0e31\u0e19\u0e17\u0e36\u0e01 Error \u0e25\u0e07 CSV"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/errors",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ ($json.batch_id || $('Set Configuration').first().json.config.BATCH_ID) + ':' + ($json.document_number || 'WORKFLOW') + ':error' }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, documentNumber: $json.document_number, errorType: $json.error_type, errorMessage: $json.error, jobId: $json.job_id || '' }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "aa49b719-9133-4ab8-af24-27754c4b4cd2",
      "name": "Log Error to DB",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10208,
        6864
      ],
      "onError": "continueErrorOutput",
      "notes": "POST /api/ai/migration/errors \u2014 ADR-023A"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/checkpoint",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.batch_id + ':checkpoint:' + $json.next_index }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $json.batch_id, lastProcessedIndex: $json.next_index, status: 'RUNNING', outcome: $json.terminal_outcome, documentNumber: $json.document_number }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "702e0d86-786c-4731-89bb-a7b80aa4d425",
      "name": "Save Checkpoint",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -9664,
        6384
      ],
      "onError": "continueErrorOutput",
      "notes": "POST /api/ai/migration/checkpoint \u2014 ADR-023A"
    },
    {
      "parameters": {
        "amount": "={{$('Set Configuration').first().json.config.DELAY_MS / 1000}}",
        "unit": "seconds"
      },
      "id": "f8eadb50-d241-423c-a62e-4c87fd96c3be",
      "name": "Delay",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        -9536,
        6880
      ],
      "notes": "\u0e2b\u0e19\u0e48\u0e27\u0e07\u0e40\u0e27\u0e25\u0e32\u0e23\u0e30\u0e2b\u0e27\u0e48\u0e32\u0e07 Records"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.batch_complete }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Batch Complete"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.batch_complete }}",
                    "rightValue": false,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Continue Loop"
            }
          ]
        },
        "options": {}
      },
      "id": "0ef559e7-8ffc-4bf5-b6ef-2d5e2d44d57a",
      "name": "Check Batch Complete",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -11008,
        6880
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/checkpoint",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $('Set Configuration').first().json.config.BATCH_ID + ':complete' }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $('Set Configuration').first().json.config.BATCH_ID, lastProcessedIndex: $json.total_processed, status: 'COMPLETED', outcome: 'COMPLETED', documentNumber: '' }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "c059f49b-a184-43ff-aef4-a84077c10524",
      "name": "Mark Batch Complete",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10784,
        6864
      ],
      "notes": "Update checkpoint status to COMPLETED when batch finishes \u2014 L9: \u0e40\u0e1e\u0e34\u0e48\u0e21 outcome + documentNumber \u0e43\u0e2b\u0e49\u0e2a\u0e2d\u0e14\u0e04\u0e25\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e1a Save Checkpoint schema"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.file_valid }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Valid PDF"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.file_valid }}",
                    "rightValue": false,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Invalid PDF"
            }
          ]
        }
      },
      "id": "v4-route-file-validation",
      "name": "Route File Validation",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -10640,
        6380
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "// Fix 2: \u0e1b\u0e49\u0e2d\u0e07\u0e01\u0e31\u0e19 Loop History Pollution\n// $('Process Batch + Encoding') \u0e40\u0e1b\u0e47\u0e19 PRIMARY source \u0e40\u0e1e\u0e23\u0e32\u0e30:\n//   1. \u0e23\u0e31\u0e19\u0e17\u0e38\u0e01 iteration (\u0e40\u0e1b\u0e47\u0e19 loop entry point)\n//   2. \u0e21\u0e35 original_index \u0e40\u0e2a\u0e21\u0e2d\n// $('Parse & Validate AI Response') \u0e16\u0e39\u0e01\u0e16\u0e2d\u0e14\u0e2d\u0e2d\u0e01\u0e40\u0e1e\u0e23\u0e32\u0e30\u0e44\u0e21\u0e48\u0e23\u0e31\u0e19\u0e43\u0e19 error paths\n//   (\u0e40\u0e0a\u0e48\u0e19 file not found, upload failed) \u2192 \u0e08\u0e30\u0e04\u0e37\u0e19\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e08\u0e32\u0e01 iteration \u0e01\u0e48\u0e2d\u0e19\u0e2b\u0e19\u0e49\u0e32 (stale)\n// $input.first().json \u0e43\u0e0a\u0e49\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a terminal_outcome \u0e41\u0e25\u0e30 route_index \u0e40\u0e17\u0e48\u0e32\u0e19\u0e31\u0e49\u0e19\n//   \u0e40\u0e1e\u0e23\u0e32\u0e30 HTTP nodes \u0e41\u0e17\u0e19\u0e17\u0e35\u0e48 JSON \u0e17\u0e31\u0e49\u0e07\u0e2b\u0e21\u0e14\u0e14\u0e49\u0e27\u0e22 response\n\nconst currentItem = $json || {};\nconst config = $('Set Configuration').first().json.config;\n\n// PRIMARY: Process Batch + Encoding \u2014 \u0e23\u0e31\u0e19\u0e17\u0e38\u0e01 iteration, \u0e21\u0e35 metadata \u0e04\u0e23\u0e1a\nlet meta = {};\ntry {\n  meta = $('Process Batch + Encoding').first()?.json || {};\n} catch (_) {\n  // \u0e16\u0e49\u0e32\u0e40\u0e02\u0e49\u0e32\u0e16\u0e36\u0e07\u0e44\u0e21\u0e48\u0e44\u0e14\u0e49 \u0e43\u0e0a\u0e49 currentItem \u0e40\u0e1b\u0e47\u0e19 fallback\n  meta = currentItem;\n}\n\nconst originalIndex = Number(meta.original_index);\nif (!Number.isInteger(originalIndex) || originalIndex < 0) {\n  throw new Error(`CHECKPOINT_METADATA_MISSING: original_index unavailable \u2014 currentItem: ${JSON.stringify(currentItem).substring(0, 200)}`);\n}\n\n// terminal_outcome \u0e21\u0e32\u0e08\u0e32\u0e01 currentItem (\u0e0b\u0e36\u0e48\u0e07\u0e2d\u0e32\u0e08\u0e40\u0e1b\u0e47\u0e19 HTTP response \u0e2b\u0e23\u0e37\u0e2d Code node output)\n// \u0e16\u0e49\u0e32 currentItem \u0e21\u0e35 route_index \u0e43\u0e0a\u0e49\u0e2a\u0e34\u0e48\u0e07\u0e19\u0e31\u0e49\u0e19, \u0e16\u0e49\u0e32\u0e44\u0e21\u0e48\u0e21\u0e35\u0e15\u0e23\u0e27\u0e08\u0e08\u0e32\u0e01 meta\nconst routeIndex = currentItem.route_index ?? meta.route_index;\nconst hasError = currentItem.error || currentItem.parse_error || meta.error || meta.parse_error;\nconst terminalOutcome = routeIndex === 2 ? 'REJECTED' : (hasError ? 'FAILED' : 'QUEUED');\n\nreturn { json: {\n  batch_id: meta.batch_id || config.BATCH_ID,\n  original_index: originalIndex,\n  next_index: originalIndex + 1,\n  row_key: meta.row_key || '',\n  document_number: meta.document_number || currentItem.document_number || 'UNKNOWN',\n  terminal_outcome: terminalOutcome,\n} };"
      },
      "id": "v4-prepare-checkpoint",
      "name": "Prepare Terminal Checkpoint",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -9664,
        6600
      ],
      "notes": "Fix 2: \u0e43\u0e0a\u0e49 $('Process Batch + Encoding') \u0e40\u0e1b\u0e47\u0e19 PRIMARY source (\u0e23\u0e31\u0e19\u0e17\u0e38\u0e01 iteration) \u2014 \u0e16\u0e2d\u0e14 $('Parse & Validate AI Response') \u0e2d\u0e2d\u0e01\u0e08\u0e32\u0e01 candidates \u0e40\u0e1e\u0e23\u0e32\u0e30\u0e44\u0e21\u0e48\u0e23\u0e31\u0e19\u0e43\u0e19 error paths \u2192 \u0e01\u0e31\u0e19 Loop History Pollution \u0e08\u0e32\u0e01 iteration \u0e01\u0e48\u0e2d\u0e19\u0e2b\u0e19\u0e49\u0e32"
    },
    {
      "parameters": {
        "jsCode": "// M4: RBAC scope verification (ADR-016)\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e27\u0e48\u0e32 MIGRATION_TOKEN \u0e21\u0e35 permission \u0e17\u0e35\u0e48\u0e08\u0e33\u0e40\u0e1b\u0e47\u0e19\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a workflow \u0e19\u0e35\u0e49\n// Profile response shape: { assignments: [{ role: { roleName, permissions: [{ permissionName }] } }] }\nconst profile = $json || {};\nconst REQUIRED_PERMISSIONS = ['ai.suggest'];\nconst ADMIN_ROLES = new Set(['Superadmin', 'Org Admin']);\n\n// \u0e01\u0e23\u0e13\u0e35 backend \u0e04\u0e37\u0e19 { data: { ... } } \u0e2b\u0e23\u0e37\u0e2d { ... } \u0e42\u0e14\u0e22\u0e15\u0e23\u0e07\nconst user = profile.data || profile;\nconst assignments = Array.isArray(user.assignments) ? user.assignments : [];\n\n// \u0e01\u0e23\u0e2d\u0e07\u0e40\u0e09\u0e1e\u0e32\u0e30 assignments \u0e17\u0e35\u0e48\u0e21\u0e35 role\nconst roles = assignments\n  .map(a => a?.role?.roleName)\n  .filter(Boolean);\n\n// Superadmin \u0e1c\u0e48\u0e32\u0e19\u0e17\u0e38\u0e01 permission check\nconst isAdmin = roles.some(r => ADMIN_ROLES.has(r));\n\n// \u0e23\u0e27\u0e1a\u0e23\u0e27\u0e21 permission \u0e17\u0e31\u0e49\u0e07\u0e2b\u0e21\u0e14\u0e08\u0e32\u0e01\u0e17\u0e38\u0e01 role\nconst allPermissions = new Set();\nfor (const assignment of assignments) {\n  const perms = assignment?.role?.permissions;\n  if (Array.isArray(perms)) {\n    for (const p of perms) {\n      const name = p?.permissionName || p?.name;\n      if (name) allPermissions.add(name);\n    }\n  }\n}\n\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a required permissions\nconst missing = REQUIRED_PERMISSIONS.filter(p => !isAdmin && !allPermissions.has(p));\n\nif (missing.length > 0) {\n  const errorMsg = `UNAUTHORIZED_SCOPE: token \u0e02\u0e32\u0e14 permission \u0e17\u0e35\u0e48\u0e08\u0e33\u0e40\u0e1b\u0e47\u0e19 [${missing.join(', ')}] \u2014 roles: [${roles.join(', ') || 'none'}] (ADR-016)`;\n  throw new Error(errorMsg);\n}\n\nreturn [{\n  json: {\n    scope_verified: true,\n    roles: roles,\n    permissions: Array.from(allPermissions).sort(),\n    is_admin: isAdmin,\n    username: user.username || user.user_id || 'unknown',\n  }\n}];"
      },
      "id": "fb5121f7-8ea0-4972-b1e6-6a1e066d3ca8",
      "name": "Verify Migration Scope",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10832,
        6272
      ],
      "notes": "M4: \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a RBAC scope \u0e08\u0e32\u0e01 /api/auth/profile response \u2014 \u0e15\u0e49\u0e2d\u0e07\u0e21\u0e35 ai.suggest \u0e2b\u0e23\u0e37\u0e2d\u0e40\u0e1b\u0e47\u0e19 Superadmin/Org Admin (ADR-016)"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL}}/api/ai/migration/queue/record",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            },
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.row_key + ':queue' }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ batchId: $json.batch_id, documentNumber: $json.document_number, subject: ($json.ai_result?.subject || $json.title), originalSubject: $json.title, tempAttachmentId: $json.temp_attachment_public_id === undefined ? null : $json.temp_attachment_public_id, confidence: $json.ai_result?.confidence, reviewReason: $json.review_reason || '', status: $json.route_index === 0 ? 'PENDING' : 'PENDING_REVIEW', aiResult: $json.ai_result, idempotencyKey: $json.row_key, sourceRowIndex: $json.original_index, excelRevision: $json.excel_revision }) }}",
        "options": {
          "timeout": 10000
        }
      },
      "id": "e533ac12-d6c4-4f45-b00b-76f1bf3c992e",
      "name": "Insert Review Queue",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -9888,
        6420
      ],
      "onError": "continueErrorOutput",
      "notes": "L3: \u0e23\u0e27\u0e21 Auto+Flagged \u0e40\u0e1b\u0e47\u0e19 node \u0e40\u0e14\u0e35\u0e22\u0e27 \u2014 status \u0e02\u0e36\u0e49\u0e19\u0e01\u0e31\u0e1a route_index (0=PENDING, 1=PENDING_REVIEW) \u2014 ADR-023A"
    },
    {
      "parameters": {
        "jsCode": "// H2: \u0e40\u0e15\u0e23\u0e35\u0e22\u0e21 context \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a native poll loop (\u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 while loop \u0e43\u0e19 Code node)\n// \u0e14\u0e36\u0e07 jobId \u0e08\u0e32\u0e01 Submit AI Job response + metadata \u0e08\u0e32\u0e01 Build AI Job Payload\nconst submitResponse = $('Submit AI Job').first()?.json || {};\nconst originalMeta = $('Build AI Job Payload').first()?.json || {};\n\nconst jobId = submitResponse?.jobId || submitResponse?.data?.jobId;\nif (!jobId) {\n  // FR-010b: \u0e16\u0e49\u0e32 401 \u2192 mark TOKEN_EXPIRED\n  if (submitResponse?.statusCode === 401 || submitResponse?.error?.status === 401) {\n    throw new Error('TOKEN_EXPIRED: 401 Unauthorized \u2014 \u0e01\u0e23\u0e38\u0e13\u0e32 renew MIGRATION_TOKEN \u0e41\u0e25\u0e49\u0e27 resume');\n  }\n  throw new Error(`Submit AI Job failed \u2014 no jobId returned for: ${originalMeta.document_number}`);\n}\n\nreturn [{\n  json: {\n    ...originalMeta,\n    job_id: jobId,\n    poll_start_time: Date.now(),\n  }\n}];"
      },
      "id": "9febc773-8e0b-4012-b401-067044c3278f",
      "name": "Prepare Poll Context",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10208,
        6272
      ],
      "onError": "continueErrorOutput",
      "notes": "H2: \u0e40\u0e15\u0e23\u0e35\u0e22\u0e21 jobId + originalMeta + poll_start_time \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a native poll loop \u2014 \u0e23\u0e31\u0e19\u0e04\u0e23\u0e31\u0e49\u0e07\u0e40\u0e14\u0e35\u0e22\u0e27\u0e01\u0e48\u0e2d\u0e19\u0e40\u0e02\u0e49\u0e32 loop"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL + '/api/ai/jobs/' + $('Prepare Poll Context').first().json.job_id }}",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "524b43a2-992d-4d3a-8064-8793a88b8233",
      "name": "Poll Job Status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10050,
        6272
      ],
      "onError": "continueErrorOutput",
      "notes": "H2: HTTP GET /api/ai/jobs/{jobId} \u2014 \u0e44\u0e21\u0e48\u0e21\u0e35 while loop \u0e43\u0e19 Code node \u0e08\u0e36\u0e07\u0e44\u0e21\u0e48\u0e21\u0e35\u0e1b\u0e31\u0e0d\u0e2b\u0e32 NODE_FUNCTION_TIMEOUT"
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.status || $json.data?.status }}",
                    "rightValue": "completed",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Completed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.status || $json.data?.status }}",
                    "rightValue": "failed",
                    "operator": {
                      "type": "string",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Failed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ Date.now() - $('Prepare Poll Context').first().json.poll_start_time > $('Set Configuration').first().json.config.AI_JOB_TIMEOUT_MS }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Timeout"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ true }}",
                    "rightValue": true,
                    "operator": {
                      "type": "boolean",
                      "operation": "equals",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "Retry"
            }
          ]
        },
        "options": {}
      },
      "id": "043b18bf-5135-4130-b90c-9b1c556cb924",
      "name": "Route Poll Status",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        -9900,
        6272
      ],
      "notes": "H2: \u0e08\u0e31\u0e14 route \u0e15\u0e32\u0e21 poll status \u2014 completed/failed/timeout/retry (fallback \u0e43\u0e0a\u0e49\u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a\u0e22\u0e31\u0e07\u0e44\u0e21\u0e48\u0e40\u0e2a\u0e23\u0e47\u0e08)"
    },
    {
      "parameters": {
        "amount": "={{ $('Set Configuration').first().json.config.AI_JOB_POLL_INTERVAL_MS / 1000 }}",
        "unit": "seconds"
      },
      "id": "a7a331be-4b1a-4eea-aaac-ae6c306dd374",
      "name": "Wait for Poll",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1,
      "position": [
        -9900,
        6600
      ],
      "notes": "H2: \u0e23\u0e2d AI_JOB_POLL_INTERVAL_MS (default 5s) \u0e01\u0e48\u0e2d\u0e19 poll \u0e43\u0e2b\u0e21\u0e48 \u2014 loop \u0e01\u0e25\u0e31\u0e1a\u0e44\u0e1b Poll Job Status"
    },
    {
      "parameters": {
        "jsCode": "// H2: \u0e40\u0e15\u0e23\u0e35\u0e22\u0e21 AI result \u0e08\u0e32\u0e01 poll response \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a downstream processing\n// \u0e2d\u0e48\u0e32\u0e19 metadata \u0e08\u0e32\u0e01 Prepare Poll Context (\u0e23\u0e31\u0e19\u0e04\u0e23\u0e31\u0e49\u0e07\u0e40\u0e14\u0e35\u0e22\u0e27 \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e49 loop)\nconst pollResponse = $json || {};\nconst pollContext = $('Prepare Poll Context').first()?.json || {};\n\nconst aiResult = pollResponse?.result || pollResponse?.data?.result || {};\n\nreturn [{\n  json: {\n    ...pollContext,\n    job_id: pollContext.job_id,\n    ai_result: aiResult,\n    ai_status: 'completed',\n  }\n}];"
      },
      "id": "e6e05b83-5f7f-41a5-8834-e531f46520dd",
      "name": "Prepare AI Result",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -9700,
        6150
      ],
      "notes": "H2: \u0e23\u0e27\u0e21 metadata \u0e08\u0e32\u0e01 Prepare Poll Context + ai_result \u0e08\u0e32\u0e01 poll response \u2192 \u0e2a\u0e48\u0e07\u0e15\u0e48\u0e2d\u0e44\u0e1b Parse & Validate"
    },
    {
      "parameters": {
        "jsCode": "// H2: \u0e08\u0e31\u0e14\u0e23\u0e39\u0e1b\u0e41\u0e1a\u0e1a error \u0e08\u0e32\u0e01 poll loop \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a Log Error to CSV\n// \u0e23\u0e31\u0e1a input \u0e08\u0e32\u0e01: Route Poll Status (failed/timeout) + Poll Job Status (HTTP error)\nconst pollResponse = $json || {};\nconst pollContext = $('Prepare Poll Context').first()?.json || {};\nconst config = $('Set Configuration').first().json.config;\n\nconst documentNumber = pollContext.document_number || 'UNKNOWN';\nconst jobId = pollContext.job_id || 'UNKNOWN';\n\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a 401 token expired (\u0e08\u0e32\u0e01 HTTP error output)\nconst httpStatus = pollResponse?.statusCode || pollResponse?.error?.status;\nif (httpStatus === 401) {\n  return [{\n    json: {\n      document_number: documentNumber,\n      error_type: 'API_ERROR',\n      error: `TOKEN_EXPIRED: renew MIGRATION_TOKEN and resume \u2014 document=${documentNumber}, jobId=${jobId}`,\n      job_id: jobId,\n    }\n  }];\n}\n\n// \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a timeout\nconst elapsed = Date.now() - (pollContext.poll_start_time || Date.now());\nif (elapsed > (config.AI_JOB_TIMEOUT_MS || 50000)) {\n  return [{\n    json: {\n      document_number: documentNumber,\n      error_type: 'API_ERROR',\n      error: `AI Job timeout after ${config.AI_JOB_TIMEOUT_MS}ms for jobId: ${jobId}, document: ${documentNumber}`,\n      job_id: jobId,\n    }\n  }];\n}\n\n// status === 'failed' \u0e2b\u0e23\u0e37\u0e2d HTTP error \u0e2d\u0e37\u0e48\u0e19\u0e46\nconst failDetail = JSON.stringify(pollResponse?.error || pollResponse?.data?.error || pollResponse?.message || 'unknown');\nreturn [{\n  json: {\n    document_number: documentNumber,\n    error_type: 'API_ERROR',\n    error: `AI_JOB_FAILED: document=${documentNumber}, jobId=${jobId}, error=${failDetail}`,\n    job_id: jobId,\n  }\n}];"
      },
      "id": "9b18d120-9706-4f29-998e-3a7067ff4b55",
      "name": "Format Poll Error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -9700,
        6500
      ],
      "notes": "H2: \u0e08\u0e31\u0e14\u0e23\u0e39\u0e1b\u0e41\u0e1a\u0e1a error \u0e08\u0e32\u0e01 poll loop (failed/timeout/HTTP error) \u2192 Log Error to CSV"
    },
    {
      "parameters": {
        "url": "={{ $('Set Configuration').first().json.config.BACKEND_URL + '/api/projects?search=' + encodeURIComponent($('Form Trigger').first().json['Project Code']) + '&isActive=true&limit=50' }}",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "a639084e-b147-4229-8959-74c028b09958",
      "name": "Resolve Project",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10640,
        6272
      ],
      "onError": "continueErrorOutput",
      "notes": "\u0e04\u0e49\u0e19\u0e2b\u0e32 Project \u0e14\u0e49\u0e27\u0e22 projectCode \u0e1c\u0e48\u0e32\u0e19 GET /api/projects?search=... \u2014 \u0e23\u0e30\u0e1a\u0e1a\u0e08\u0e30\u0e01\u0e23\u0e2d\u0e07 exact match \u0e43\u0e19 Set Resolved UUIDs"
    },
    {
      "parameters": {
        "url": "={{ $('Form Trigger').first().json['Contract Code'] ? $('Set Configuration').first().json.config.BACKEND_URL + '/api/contracts?search=' + encodeURIComponent($('Form Trigger').first().json['Contract Code']) + '&limit=50' : $('Set Configuration').first().json.config.BACKEND_URL + '/health' }}",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "={{ 'Bearer ' + ($('Set Configuration').first().json.config.AUTH_TOKEN || '').replace(/^Bearer\\s+/i, '') }}"
            }
          ]
        }
      },
      "id": "9741889b-95d6-4bfc-acf6-ea838242632d",
      "name": "Resolve Contract",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.1,
      "position": [
        -10480,
        6272
      ],
      "onError": "continueErrorOutput",
      "notes": "\u0e04\u0e49\u0e19\u0e2b\u0e32 Contract \u0e14\u0e49\u0e27\u0e22 contractCode \u0e1c\u0e48\u0e32\u0e19 GET /api/contracts?search=... \u2014 \u0e16\u0e49\u0e32 contractCode \u0e40\u0e27\u0e49\u0e19\u0e27\u0e48\u0e32\u0e07\u0e08\u0e30 hit /health \u0e41\u0e17\u0e19 (skip)"
    },
    {
      "parameters": {
        "jsCode": "// Resolve Project/Contract Code \u0e40\u0e1b\u0e47\u0e19 UUIDv7 \u0e1c\u0e48\u0e32\u0e19 backend API\n// \u0e41\u0e17\u0e19\u0e01\u0e32\u0e23\u0e43\u0e2b\u0e49\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49\u0e1e\u0e34\u0e21\u0e1e\u0e4c UUID \u0e22\u0e32\u0e27\u0e46 \u0e43\u0e19 form\nconst formData = $('Form Trigger').first()?.json || {};\nconst projectCode = String(formData['Project Code'] || '').trim();\nconst contractCode = String(formData['Contract Code'] || '').trim();\n\n// H1: UUIDv7 format validation (ADR-019)\nconst UUID_V7_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\nconst isUuidV7 = (s) => typeof s === 'string' && s.trim().length > 0 && UUID_V7_RE.test(s.trim());\n\n// \u2500\u2500 Resolve Project \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nconst projectResponse = $('Resolve Project').first()?.json || {};\nconst projectData = projectResponse?.data || [];\nif (!Array.isArray(projectData) || projectData.length === 0) {\n  throw new Error(`PROJECT_NOT_FOUND: \u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e42\u0e04\u0e23\u0e07\u0e01\u0e32\u0e23\u0e17\u0e35\u0e48 match \"${projectCode}\" \u2014 \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a Project Code \u0e43\u0e19 form`);\n}\n\n// \u0e01\u0e23\u0e2d\u0e07 exact match \u0e17\u0e35\u0e48 projectCode (search API \u0e43\u0e0a\u0e49 LIKE)\nconst projectMatch = projectData.find(p => p.projectCode === projectCode);\nif (!projectMatch) {\n  const found = projectData.map(p => p.projectCode).join(', ');\n  throw new Error(`PROJECT_NOT_FOUND: \u0e44\u0e21\u0e48\u0e1e\u0e1a exact match \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a \"${projectCode}\" \u2014 \u0e1c\u0e25\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32: [${found}]`);\n}\n\nconst projectPublicId = projectMatch.publicId;\nif (!isUuidV7(projectPublicId)) {\n  throw new Error(`PROJECT_UUID_INVALID: publicId \u0e02\u0e2d\u0e07 project \"${projectCode}\" \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e48 UUIDv7: ${projectPublicId}`);\n}\n\n// \u2500\u2500 Resolve Contract (\u0e16\u0e49\u0e32\u0e21\u0e35) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\nlet contractPublicId = null;\nif (contractCode) {\n  const contractResponse = $('Resolve Contract').first()?.json || {};\n  const contractData = contractResponse?.data || [];\n  if (!Array.isArray(contractData) || contractData.length === 0) {\n    throw new Error(`CONTRACT_NOT_FOUND: \u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2a\u0e31\u0e0d\u0e0d\u0e32\u0e17\u0e35\u0e48 match \"${contractCode}\" \u2014 \u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a Contract Code \u0e43\u0e19 form`);\n  }\n\n  // \u0e01\u0e23\u0e2d\u0e07 exact match \u0e17\u0e35\u0e48 contractCode\n  const contractMatch = contractData.find(c => c.contractCode === contractCode);\n  if (!contractMatch) {\n    const found = contractData.map(c => c.contractCode).join(', ');\n    throw new Error(`CONTRACT_NOT_FOUND: \u0e44\u0e21\u0e48\u0e1e\u0e1a exact match \u0e2a\u0e33\u0e2b\u0e23\u0e31\u0e1a \"${contractCode}\" \u2014 \u0e1c\u0e25\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32: [${found}]`);\n  }\n\n  contractPublicId = contractMatch.publicId;\n  if (!isUuidV7(contractPublicId)) {\n    throw new Error(`CONTRACT_UUID_INVALID: publicId \u0e02\u0e2d\u0e07 contract \"${contractCode}\" \u0e44\u0e21\u0e48\u0e43\u0e0a\u0e48 UUIDv7: ${contractPublicId}`);\n  }\n}\n\nreturn [{\n  json: {\n    projectPublicId: projectPublicId,\n    contractPublicId: contractPublicId,\n    projectName: projectMatch.projectName || '',\n    contractName: contractMatch?.contractName || '',\n  }\n}];"
      },
      "id": "d72e2bd0-e7a3-4803-9dca-61f4a8b863c6",
      "name": "Set Resolved UUIDs",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -10320,
        6272
      ],
      "notes": "Resolve Project/Contract Code \u2192 UUIDv7 \u0e1c\u0e48\u0e32\u0e19 backend API + exact match + UUIDv7 validation (ADR-019)"
    }
  ],
  "connections": {
    "Form Trigger": {
      "main": [
        [
          {
            "node": "Set Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Configuration": {
      "main": [
        [
          {
            "node": "Check Backend Health",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Backend Health": {
      "main": [
        [
          {
            "node": "Validate Token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate Token": {
      "main": [
        [
          {
            "node": "Verify Migration Scope",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Categories": {
      "main": [
        [
          {
            "node": "Fetch Tags",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Tags": {
      "main": [
        [
          {
            "node": "File Mount Check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "File Mount Check": {
      "main": [
        [
          {
            "node": "Read Excel Binary",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Excel Binary": {
      "main": [
        [
          {
            "node": "Read Excel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Excel": {
      "main": [
        [
          {
            "node": "Read Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Checkpoint": {
      "main": [
        [
          {
            "node": "Process Batch + Encoding",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Process Batch + Encoding": {
      "main": [
        [
          {
            "node": "Check Batch Complete",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "File Validator": {
      "main": [
        [
          {
            "node": "Route File Validation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read PDF File": {
      "main": [
        [
          {
            "node": "Upload PDF to Backend",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upload PDF to Backend": {
      "main": [
        [
          {
            "node": "Build AI Job Payload",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build AI Job Payload": {
      "main": [
        [
          {
            "node": "Submit AI Job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Submit AI Job": {
      "main": [
        [
          {
            "node": "Prepare Poll Context",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse & Validate AI Response": {
      "main": [
        [
          {
            "node": "Route by Confidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by Confidence": {
      "main": [
        [
          {
            "node": "Insert Review Queue",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Insert Review Queue",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Reject to CSV",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save Checkpoint": {
      "main": [
        [
          {
            "node": "Delay",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Delay": {
      "main": [
        [
          {
            "node": "Read Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Batch Complete": {
      "main": [
        [
          {
            "node": "Mark Batch Complete",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "File Validator",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Reject to CSV": {
      "main": [
        [
          {
            "node": "Prepare Terminal Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Error to CSV": {
      "main": [
        [
          {
            "node": "Log Error to DB",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log Error to DB": {
      "main": [
        [
          {
            "node": "Prepare Terminal Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route File Validation": {
      "main": [
        [
          {
            "node": "Read PDF File",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Terminal Checkpoint": {
      "main": [
        [
          {
            "node": "Save Checkpoint",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify Migration Scope": {
      "main": [
        [
          {
            "node": "Resolve Project",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Insert Review Queue": {
      "main": [
        [
          {
            "node": "Prepare Terminal Checkpoint",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Poll Context": {
      "main": [
        [
          {
            "node": "Poll Job Status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll Job Status": {
      "main": [
        [
          {
            "node": "Route Poll Status",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Poll Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route Poll Status": {
      "main": [
        [
          {
            "node": "Prepare AI Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Poll Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Format Poll Error",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait for Poll",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait for Poll": {
      "main": [
        [
          {
            "node": "Poll Job Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare AI Result": {
      "main": [
        [
          {
            "node": "Parse & Validate AI Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Poll Error": {
      "main": [
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Project": {
      "main": [
        [
          {
            "node": "Resolve Contract",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Resolve Contract": {
      "main": [
        [
          {
            "node": "Set Resolved UUIDs",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Log Error to CSV",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Resolved UUIDs": {
      "main": [
        [
          {
            "node": "Fetch Categories",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1.1",
    "binaryMode": "separate",
    "saveExecutionProgress": true,
    "saveManualExecutions": false
  },
  "versionId": "v4.0.0-sequential-migration",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "id": "4LlPbAKU5BZLgiTg",
  "tags": [
    {
      "updatedAt": "2026-05-27T00:00:00.000Z",
      "createdAt": "2026-05-27T00:00:00.000Z",
      "id": "mGZTyPfxbcsAuFpR",
      "name": "migration"
    },
    {
      "updatedAt": "2026-05-27T00:00:00.000Z",
      "createdAt": "2026-05-27T00:00:00.000Z",
      "id": "v4TagId001",
      "name": "v4"
    },
    {
      "updatedAt": "2026-05-27T00:00:00.000Z",
      "createdAt": "2026-05-27T00:00:00.000Z",
      "id": "adr030TagId001",
      "name": "adr-030"
    }
  ]
}