{
  "name": "DataDrivenAEC filing — synthetic review sandbox",
  "active": false,
  "nodes": [
    {
      "id": "filing-trigger",
      "name": "Manual sandbox trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [0, 0],
      "parameters": {}
    },
    {
      "id": "filing-sample",
      "name": "Synthetic input",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [240, 0],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "return [{json: {\n  \"schemaVersion\": 1,\n  \"task\": \"filing\",\n  \"asOf\": \"2026-09-11T12:00:00Z\",\n  \"rows\": [\n    {\n      \"id\": \"rfi-001\",\n      \"revision\": 1,\n      \"projectId\": \"demo-project\",\n      \"documentKind\": \"rfi\",\n      \"sourceUrl\": \"https://example.org/documents/rfi-001\",\n      \"owner\": \"Synthetic coordinator\",\n      \"dueAt\": \"2026-10-01T16:00:00Z\"\n    }\n  ],\n  \"previous\": []\n}}];"
      }
    },
    {
      "id": "filing-queue",
      "name": "Build review queue",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [480, 0],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "function buildAecReviewQueue(input) {\n  const fail = (message) => {\n    throw new Error(message);\n  };\n  const object = (value) =>\n    value !== null &&\n    typeof value === 'object' &&\n    !Array.isArray(value) &&\n    Object.prototype.toString.call(value) === '[object Object]';\n  const fields = (value, allowed, context) => {\n    if (!object(value) || Object.keys(value).some((key) => !allowed.includes(key))) fail(`Invalid ${context} fields.`);\n  };\n  const text = (value, required, context, maximum = 1000) => {\n    if (value === undefined || value === null || value === '') {\n      if (required) fail(`Missing ${context}.`);\n      return null;\n    }\n    if (\n      typeof value !== 'string' ||\n      !value.trim() ||\n      value.length > maximum ||\n      [...value].some((character) => {\n        const code = character.charCodeAt(0);\n        return code < 32 && code !== 9 && code !== 10 && code !== 13;\n      })\n    )\n      fail(`Invalid ${context}.`);\n    return value.trim();\n  };\n  const id = (value, context) => {\n    if (\n      typeof value !== 'string' ||\n      !/^[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(value) ||\n      ['constructor', 'prototype', '__proto__'].includes(value)\n    )\n      fail(`Invalid ${context}.`);\n    return value;\n  };\n  const instant = (value, required, context) => {\n    const v = text(value, required, context, 35);\n    if (v === null) return null;\n    const match =\n      /^(\\d{4})-(\\d{2})-(\\d{2})T([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)(?:\\.\\d{1,3})?(Z|[+-](?:0\\d|1[0-4]):[0-5]\\d)$/.exec(\n        v\n      );\n    if (!match) fail(`Use an explicit ISO timestamp and time zone for ${context}.`);\n    const [year, month, day] = match.slice(1, 4).map(Number);\n    const calendar = new Date(0);\n    calendar.setUTCFullYear(year, month - 1, day);\n    calendar.setUTCHours(0, 0, 0, 0);\n    if (\n      year < 1000 ||\n      calendar.getUTCFullYear() !== year ||\n      calendar.getUTCMonth() !== month - 1 ||\n      calendar.getUTCDate() !== day ||\n      !Number.isFinite(Date.parse(v)) ||\n      /[+-]14:(?!00)/.test(v)\n    )\n      fail(`Invalid ${context} calendar date.`);\n    return new Date(v).toISOString();\n  };\n  const sourceUrl = (value) => {\n    const v = text(value, true, 'source URL', 2000);\n    // Deliberately accept a bounded ASCII HTTPS form. n8n's default Code sandbox\n    // provides neither URL nor prototype inspection; no sandbox changes are needed.\n    const match = /^https:\\/\\/([^/?#]+)([^?#]*)(\\?[^#]*)?$/i.exec(v);\n    if (!match || /[^\\x21-\\x7e]|[\\\\<>\"`{}|^]/.test(v)) fail('Invalid source URL.');\n    const authority = /^([a-z0-9.-]+)(?::([0-9]{1,5}))?$/i.exec(match[1]);\n    if (\n      !authority ||\n      !authority[1].split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)) ||\n      authority[1].length > 253 ||\n      (authority[2] && (Number(authority[2]) < 1 || Number(authority[2]) > 65535))\n    )\n      fail('Source URL must be HTTPS without credentials, sensitive query fields or fragments.');\n    try {\n      const decoded = decodeURIComponent(match[2] + (match[3] ?? ''));\n      if ([...decoded].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127))\n        fail('Invalid source URL.');\n      for (const pair of (match[3] ?? '').slice(1).split('&')) {\n        const key = decodeURIComponent(pair.split('=')[0].replace(/\\+/g, ' '));\n        if (/token|secret|password|signature|api.?key|authorization|session/i.test(key)) fail('Invalid source URL.');\n      }\n    } catch {\n      fail('Invalid source URL.');\n    }\n    const port = authority[2] && Number(authority[2]) !== 443 ? `:${Number(authority[2])}` : '';\n    return `https://${authority[1].toLowerCase()}${port}${match[2] || '/'}${match[3] ?? ''}`;\n  };\n  fields(input, ['schemaVersion', 'task', 'asOf', 'rows', 'previous'], 'envelope');\n  if (input.schemaVersion !== 1 || !['rfp', 'filing', 'meeting-tasks'].includes(input.task))\n    fail('Unsupported schema or task.');\n  const asOf = instant(input.asOf, true, 'asOf');\n  if (!Array.isArray(input.rows) || input.rows.length > 500) fail('Rows must be an array with at most 500 records.');\n  const previous = input.previous ?? [];\n  if (!Array.isArray(previous) || previous.length > 2000) fail('Previous state must contain at most 2000 records.');\n  const state = new Map();\n  for (const row of previous) {\n    fields(row, ['task', 'id', 'revision', 'payload', 'decision'], 'previous-state');\n    if (row.task !== input.task) fail('Previous state belongs to a different task.');\n    const key = id(row.id, 'previous ID');\n    if (!Number.isSafeInteger(row.revision) || row.revision < 0 || state.has(key))\n      fail('Invalid or duplicate previous revision.');\n    state.set(key, {\n      task: input.task,\n      id: key,\n      revision: row.revision,\n      payload: text(row.payload, true, 'previous payload', 16000),\n      decision: text(row.decision, true, 'previous decision', 16000),\n    });\n  }\n  const seen = new Map(),\n    changes = [];\n  let duplicates = 0;\n  for (const row of input.rows) {\n    const common = ['id', 'revision', 'owner', 'dueAt'];\n    const allowed =\n      input.task === 'rfp'\n        ? [...common, 'title', 'sourceUrl', 'noticeStatus']\n        : input.task === 'filing'\n          ? [...common, 'projectId', 'documentKind', 'sourceUrl']\n          : [...common, 'meetingId', 'action', 'sourceRef'];\n    fields(row, allowed, 'record');\n    const key = id(row.id, 'record ID');\n    if (!Number.isSafeInteger(row.revision) || row.revision < 0) fail('Revision must be a nonnegative safe integer.');\n    const owner = text(row.owner, false, 'owner', 200),\n      dueAt = instant(row.dueAt, false, 'dueAt');\n    const normalized = { id: key, revision: row.revision, owner, dueAt };\n    const reasons = [];\n    if (!owner) reasons.push('Assign a human owner.');\n    if (!dueAt) reasons.push('Verify the deadline and its time zone.');\n    if (dueAt && Date.parse(dueAt) <= Date.parse(asOf))\n      reasons.push('Deadline is at or before the comparison time; recheck the source.');\n    let proposal;\n    if (input.task === 'rfp') {\n      if (!['open', 'early-signal', 'unknown'].includes(row.noticeStatus)) fail('Unsupported notice status.');\n      Object.assign(normalized, {\n        title: text(row.title, true, 'title'),\n        sourceUrl: sourceUrl(row.sourceUrl),\n        noticeStatus: row.noticeStatus,\n      });\n      if (row.noticeStatus !== 'open') reasons.push('This record is not a confirmed open solicitation.');\n      proposal = {\n        action: 'review-notice',\n        title: normalized.title,\n        sourceUrl: normalized.sourceUrl,\n        noticeStatus: row.noticeStatus,\n      };\n    } else if (input.task === 'filing') {\n      if (!['rfi', 'submittal'].includes(row.documentKind)) fail('Unsupported document kind.');\n      Object.assign(normalized, {\n        projectId: id(row.projectId, 'project ID'),\n        documentKind: row.documentKind,\n        sourceUrl: sourceUrl(row.sourceUrl),\n      });\n      proposal = {\n        action: 'propose-filing',\n        path: `${normalized.projectId}/${row.documentKind}/${key}/r${row.revision}`,\n        sourceUrl: normalized.sourceUrl,\n      };\n    } else {\n      Object.assign(normalized, {\n        meetingId: id(row.meetingId, 'meeting ID'),\n        action: text(row.action, true, 'structured action', 2000),\n        sourceRef: text(row.sourceRef, true, 'source reference', 200),\n      });\n      proposal = {\n        action: 'review-structured-task',\n        meetingId: normalized.meetingId,\n        text: normalized.action,\n        sourceRef: normalized.sourceRef,\n      };\n    }\n    const payload = JSON.stringify(normalized);\n    if (seen.has(key)) {\n      if (seen.get(key) !== payload) fail('Conflicting records share one ID in this batch; reconcile before retrying.');\n      duplicates++;\n      continue;\n    }\n    seen.set(key, payload);\n    const old = state.get(key);\n    if (old && (row.revision < old.revision || (row.revision === old.revision && payload !== old.payload)))\n      fail('Stale or conflicting source revision; reconcile before retrying.');\n    const result = {\n      id: key,\n      revision: row.revision,\n      status: reasons.length ? 'hold' : 'review',\n      reasons,\n      owner,\n      dueAt,\n      humanApprovalRequired: true,\n      proposal,\n    };\n    const decision = JSON.stringify(result);\n    const change = old\n      ? old.payload === payload\n        ? old.decision === decision\n          ? 'unchanged'\n          : 'recheck'\n        : 'amended'\n      : 'new';\n    changes.push({ ...result, change, recordKey: `${input.task}:${key}` });\n    state.set(key, { task: input.task, id: key, revision: row.revision, payload, decision });\n  }\n  if (state.size > 2000) fail('State capacity exceeded; archive under an approved retention policy.');\n  return {\n    schemaVersion: 1,\n    task: input.task,\n    asOf,\n    duplicates,\n    changes: changes.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),\n    state: [...state.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),\n    sideEffectsPerformed: false,\n  };\n}\nreturn $input.all().map((item, index) => ({json: buildAecReviewQueue(JSON.parse(JSON.stringify(item.json))), pairedItem: {item: index}}));"
      }
    }
  ],
  "connections": {
    "Manual sandbox trigger": { "main": [[{ "node": "Synthetic input", "type": "main", "index": 0 }]] },
    "Synthetic input": { "main": [[{ "node": "Build review queue", "type": "main", "index": 0 }]] }
  },
  "settings": { "executionOrder": "v1" },
  "pinData": {},
  "tags": []
}
