{
  "name": "HarvestLab Amazon qualified price observations - review only",
  "nodes": [
    {
      "id": "5a43f571-30f4-50d9-b98d-c25d6d229bb6",
      "name": "Manual review",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        200
      ],
      "parameters": {}
    },
    {
      "id": "237ebed4-f7e5-5a68-b5ae-52ad0d6d161d",
      "name": "Configure existing run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// Edit only this config object. Never place credentials here.\nconst config = {\n  \"runId\": \"REPLACE_WITH_SUCCEEDED_RUN_ID\",\n  \"expectedActorId\": \"JyRjxUswjrRheOdmh\",\n  \"maxRows\": 1000,\n  \"pageSize\": 100,\n  \"baselineReference\": \"initial-empty\",\n  \"previousSnapshot\": {\n    \"schema_version\": 1,\n    \"policy\": \"retain_last_good_per_product_key\",\n    \"products\": []\n  }\n};\n/* Pure handoff contracts embedded in n8n Code nodes by build_templates.py.\n * No network, persistence, Actor invocation, destination writes or logging.\n */\nconst MERGE_POLICY = 'upsert_by_key_preserve_existing_on_empty';\nconst COMPANY_FIELDS = ['company_key','domain','company_name','website','linkedin_url','contact_page_url','phone','phone_status','tech_stack','scraped_at'];\nconst CONTACT_FIELDS = ['contact_key','company_key','email','email_status','phone','phone_status','role','source_url','scraped_at'];\nconst AMAZON_FIELDS = ['product_key','asin','marketplace','title','price','currency','availability','rating','reviews_count','url','scraped_at'];\nconst AMAZON_POLICY = 'retain_last_good_per_product_key';\nconst EVIDENCE = {published_unverified:0,mx_medium_mailbox_unknown:1,mx_high_mailbox_unknown:2,smtp_deliverable:3};\nconst ID = /^[A-Za-z0-9_-]{1,64}$/;\nconst validId = value => typeof value === 'string' && ID.test(value);\nconst MARKET = /^amazon\\.(?:com|ca|com\\.mx|com\\.br|co\\.uk|de|fr|es|it|nl|pl|se|co\\.jp|in|com\\.au|ae|sa|sg|com\\.tr)$/;\nconst text = v => typeof v === 'string' ? v.trim() : '';\nconst object = v => v !== null && typeof v === 'object' && !Array.isArray(v);\nconst fail = message => { throw new Error(message); };\nconst meaningful = row => Object.fromEntries(Object.entries(row).filter(([k,v]) => k !== 'scraped_at' && v));\nconst stable = row => JSON.stringify(Object.entries(row).sort(([a],[b]) => a.localeCompare(b)));\nconst clean = row => Object.fromEntries(Object.entries(row).filter(([,v]) => v));\n\nfunction validateConfig(config) {\n  if (!object(config) || !validId(config.runId) || config.runId === 'REPLACE_WITH_SUCCEEDED_RUN_ID') fail('Set an exact existing succeeded run ID.');\n  if (!validId(config.expectedActorId)) fail('Invalid expected Actor ID.');\n  if (!Number.isInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 10000) fail('maxRows must be 1..10000.');\n  if (!Number.isInteger(config.pageSize) || config.pageSize < 1 || config.pageSize > 1000) fail('pageSize must be 1..1000.');\n  if (!text(config.baselineReference) || config.baselineReference.length > 128) fail('Set an explicit accepted baseline revision reference.');\n  if (config.baselineReference === 'initial-empty' && object(config.previousSnapshot) && ['companies','contacts','products'].some(kind => Array.isArray(config.previousSnapshot[kind]) && config.previousSnapshot[kind].length)) fail('A nonempty accepted baseline needs its actual revision reference.');\n  return config;\n}\n\nfunction validateRun(response, config) {\n  validateConfig(config);\n  const run = response && response.data;\n  if (!object(run) || run.id !== config.runId || run.actId !== config.expectedActorId || run.status !== 'SUCCEEDED') fail('Run identity, Actor or succeeded status does not match.');\n  if (!validId(run.defaultDatasetId) || !validId(run.defaultKeyValueStoreId)) fail('Run storage identifiers are missing.');\n  return {runId:run.id, actorId:run.actId, datasetId:run.defaultDatasetId, storeId:run.defaultKeyValueStoreId};\n}\n\nfunction validateManifest(manifest, maxRows) {\n  if (!object(manifest) || manifest.status !== 'complete' || manifest.replace_existing_data !== false || manifest.delete_missing_records !== false) fail('CRM export is absent, partial or unsafe.');\n  for (const name of ['exported_companies','exported_contacts','persisted_site_rows','source_rows']) {\n    if (!Number.isInteger(manifest[name]) || manifest[name] < 0 || manifest[name] > maxRows) fail('CRM manifest count is invalid or exceeds maxRows.');\n  }\n  if (manifest.persisted_site_rows !== manifest.source_rows || manifest.exported_companies === 0 || manifest.exported_companies > manifest.source_rows || manifest.exported_contacts > manifest.source_rows || manifest.exported_companies + manifest.exported_contacts > maxRows) fail('CRM manifest counts are incomplete or exceed maxRows.');\n  const required = ['CRM-companies.csv','CRM-contacts.csv','CRM-upserts.json'];\n  if (!Array.isArray(manifest.files) || manifest.files.length !== 3 || required.some(name => !manifest.files.includes(name))) fail('CRM manifest lacks required file references.');\n  if (typeof manifest.require_mx !== 'boolean') fail('CRM manifest has no explicit verification mode.');\n  return manifest;\n}\n\nfunction validateSnapshot(payload, maxRows, native = false) {\n  if (!object(payload) || payload.merge_policy !== MERGE_POLICY || !Array.isArray(payload.companies) || !Array.isArray(payload.contacts)) fail('Invalid CRM snapshot contract.');\n  if (JSON.stringify(payload).length > 10 * 1024 * 1024 || payload.companies.length + payload.contacts.length > maxRows) fail('CRM snapshot exceeds the size or record cap.');\n  if (native && (payload.full_snapshot !== false || payload.allow_deletions !== false || payload.contact_selection !== 'one_eligible_email_per_company_source_row' || payload.association_policy !== 'preserve_existing_on_empty_review_ambiguous')) fail('Native CRM handoff policies do not match.');\n  const result = {merge_policy:MERGE_POLICY,companies:[],contacts:[]};\n  for (const [kind, fields, keyField] of [['companies',COMPANY_FIELDS,'company_key'],['contacts',CONTACT_FIELDS,'contact_key']]) {\n    const seen = new Set();\n    for (const row of payload[kind]) {\n      if (!object(row) || Object.entries(row).some(([k,v]) => !fields.includes(k) || typeof v !== 'string')) fail('Invalid CRM record fields.');\n      const key = row[keyField];\n      if (!key || seen.has(key)) fail('Duplicate or missing CRM key.');\n      seen.add(key);\n      if (kind === 'companies') {\n        if (row.domain !== key || key !== key.toLowerCase() || !key.includes('.') || /[\\s/:@]/.test(key)) fail('Invalid company identity.');\n      } else if (row.email !== key || key !== key.trim().toLowerCase() || !/^[^\\s@,;]+@[^\\s@,;]+\\.[^\\s@,;]+$/.test(key) || !Object.hasOwn(EVIDENCE,row.email_status)) fail('Invalid contact identity or evidence.');\n      if (row.phone && (!/^\\+[1-9]\\d{6,14}$/.test(row.phone) || row.phone_status !== 'format_valid_not_reachability_checked')) fail('Unqualified phone in CRM handoff.');\n      sourceTime(row.scraped_at,'CRM');\n      result[kind].push({...row});\n    }\n  }\n  if (native && result.contacts.some(row => row.company_key && !result.companies.some(company => company.company_key === row.company_key))) fail('Contact refers to a company absent from the native handoff.');\n  return result;\n}\n\nfunction crmHandoff(payload, manifest, config, run) {\n  validateConfig(config);\n  validateManifest(manifest,config.maxRows);\n  const current = validateSnapshot(payload,config.maxRows,true);\n  if (current.companies.length !== manifest.exported_companies || current.contacts.length !== manifest.exported_contacts) fail('CRM payload does not match completion manifest counts.');\n  if (manifest.require_mx && current.contacts.some(row => row.email_status === 'published_unverified')) fail('MX-required export contains an unchecked contact.');\n  const previous = validateSnapshot(config.previousSnapshot,config.maxRows);\n  const next = {merge_policy:MERGE_POLICY}, delta = {merge_policy:MERGE_POLICY};\n  const stats = {verification_downgrades_suppressed:0,association_conflicts_suppressed:0,\n    older_companies_suppressed:0,older_contacts_suppressed:0,same_time_company_conflicts_suppressed:0,same_time_contact_conflicts_suppressed:0};\n  const issues = [];\n  for (const [kind,keyField] of [['companies','company_key'],['contacts','contact_key']]) {\n    const saved = new Map(previous[kind].map(row => [row[keyField],{...row}]));\n    const seen = new Set(); const changes = []; let unchanged = 0;\n    for (const row of current[kind]) {\n      const key = row[keyField], old = saved.get(key) || {};\n      seen.add(key);\n      let merged = {...old,...clean(row)};\n      const weaker = kind === 'contacts' && old.email_status && EVIDENCE[row.email_status] < EVIDENCE[old.email_status];\n      const associationConflict = kind === 'contacts' && old.company_key && row.company_key && old.company_key !== row.company_key;\n      if (weaker) stats.verification_downgrades_suppressed += 1;\n      if (associationConflict) stats.association_conflicts_suppressed += 1;\n      if (old[keyField]) {\n        const observedTime = sourceTime(row.scraped_at,'CRM'), acceptedTime = sourceTime(old.scraped_at,'CRM');\n        if (observedTime < acceptedTime) {\n          stats['older_'+kind+'_suppressed'] += 1;\n          issues.push({record_type:kind,record_key:key,reason:'older_observation',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n          merged = {...old};\n        } else if (observedTime === acceptedTime) {\n          if (stable(meaningful(old)) !== stable(meaningful(merged))) {\n            stats['same_time_'+(kind === 'companies' ? 'company' : 'contact')+'_conflicts_suppressed'] += 1;\n            issues.push({record_type:kind,record_key:key,reason:'same_observation_conflict',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n          }\n          // Exact replays and equivalent timezone spellings preserve the accepted record.\n          merged = {...old};\n        } else if (weaker || associationConflict) merged = {...old};\n      }\n      saved.set(key,merged);\n      if (stable(meaningful(old)) === stable(meaningful(merged))) unchanged += 1;\n      else changes.push(clean(merged));\n    }\n    next[kind] = [...saved.values()]; delta[kind] = changes;\n    stats['unchanged_'+kind] = unchanged;\n    stats['retained_'+kind] = previous[kind].filter(row => !seen.has(row[keyField])).length;\n  }\n  validateSnapshot(next,config.maxRows);\n  return {...reviewEnvelope(run,'contact-crm',config),delta,candidate_baseline:next,stats,review_issues:issues};\n}\n\nfunction reviewEnvelope(run,workflow,config) {\n  return {workflow,handoff_id:workflow+':'+run.actorId+':'+run.runId,source_run_id:run.runId,\n    accepted_baseline_reference:config.baselineReference,\n    acknowledgement_binding:{implemented:false,required:['source_run_id','accepted_baseline_reference','exact_delta_and_candidate_payload'],handoff_id_alone_is_insufficient:true},\n    delivery_status:'REVIEW_ONLY',destination_written:false,acknowledgement_required:true,\n    baseline_advanced:false,replace_existing_data:false,delete_missing_records:false};\n}\n\nfunction datasetRows(pages, config) {\n  if (!Array.isArray(pages) || !pages.length) fail('No dataset page response.');\n  let rows = [], expectedOffset = 0, advertisedTotal;\n  for (const page of pages) {\n    if (!object(page) || page.statusCode !== 200 || !Array.isArray(page.body)) fail('A dataset page failed or was not a JSON array.');\n    const headers = Object.fromEntries(Object.entries(page.headers || {}).map(([key,value]) => [key.toLowerCase(),value]));\n    const readInteger = key => /^\\d+$/.test(String(headers[key])) ? Number(headers[key]) : fail('Dataset pagination headers are missing or invalid.');\n    const offset = readInteger('x-apify-pagination-offset');\n    const count = readInteger('x-apify-pagination-count');\n    const total = readInteger('x-apify-pagination-total');\n    const limit = readInteger('x-apify-pagination-limit');\n    if (offset !== expectedOffset || count !== page.body.length || count > config.pageSize || limit !== config.pageSize) fail('Dataset page is missing, repeated or inconsistent.');\n    if (advertisedTotal !== undefined && advertisedTotal !== total) fail('Dataset changed during the export; retry the same completed run.');\n    advertisedTotal = total;\n    if (total > config.maxRows) fail('Dataset exceeds maxRows; preserve the prior feed.');\n    rows.push(...page.body); expectedOffset += config.pageSize;\n    if (rows.length > config.maxRows) fail('Dataset exceeds maxRows; preserve the prior feed.');\n  }\n  if (rows.length !== advertisedTotal || pages.at(-1).body.length >= config.pageSize) fail('Dataset export is incomplete; preserve the prior feed.');\n  return rows;\n}\n\nfunction amazonSource(row,asin) {\n  const urls = ['url','product_url'].filter(key => text(row[key])).map(key => text(row[key]));\n  let market;\n  for (const url of urls) {\n    // Strict literal Amazon hosts: no credentials, port, host escapes or redirects.\n    const parsed = /^https?:\\/\\/([^/?#]+)([^?#]*)/i.exec(url);\n    if (!parsed) return null;\n    const host = parsed[1].toLowerCase().replace(/^www\\./,'');\n    if (!MARKET.test(host) || (market && market !== host)) return null;\n    market = host;\n    const identities = [...parsed[2].matchAll(/\\/(?:dp|gp\\/product|product)\\/([A-Z0-9]{10})(?=\\/|$)/gi)];\n    if (identities.some(match => match[1].toUpperCase() !== asin)) return null;\n  }\n  return urls.length ? {url:urls[0],marketplace:market} : null;\n}\n\nfunction amazonProducts(rows) {\n  const products = new Map(); let rejected = 0;\n  for (const row of rows) {\n    if (!object(row) || row.diagnostic_item || row.block_detected || row.error || row.billable_contact_found === false || ![undefined,null,'product_data','contact_data'].includes(row.result_type)) { rejected++; continue; }\n    const asin = text(row.asin).toUpperCase(), source = amazonSource(row,asin), currency = text(row.currency).toUpperCase();\n    const price = (typeof row.price === 'number' || typeof row.price === 'string') ? String(row.price).trim() : '';\n    if (!/^[A-Z0-9]{10}$/.test(asin) || !source || !text(row.title) || !text(row.scraped_at) || !/^[A-Z]{3}$/.test(currency) || !/^[+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?$/i.test(price) || !Number.isFinite(Number(price)) || Number(price) <= 0) { rejected++; continue; }\n    const key = source.marketplace+':'+asin;\n    products.set(key,{product_key:key,asin,...source,title:text(row.title),price:price.replace(/^\\+/,''),currency,availability:text(row.availability),rating:row.rating ?? null,reviews_count:row.reviews_count ?? null,scraped_at:text(row.scraped_at)});\n  }\n  return {products:[...products.values()],rejected_rows:rejected};\n}\n\nfunction sourceTime(value,label = 'Amazon') {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.\\d{1,6})?(?:Z|[+-]\\d{2}:\\d{2})$/.exec(value);\n  if (!match) fail(label+' observation needs an ISO source timestamp with a timezone.');\n  const [,year,month,day,hour,minute,second] = match.map(Number);\n  if (year < 1970 || month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year,month,0)).getUTCDate() || hour > 23 || minute > 59 || second > 59) fail('Invalid '+label+' source timestamp.');\n  const parsed = Date.parse(value);\n  if (!Number.isFinite(parsed)) fail('Invalid '+label+' source timestamp.');\n  const fraction = (/\\.(\\d+)(?:Z|[+-])/.exec(value)?.[1] || '').padEnd(6,'0');\n  return BigInt(parsed) * 1000n + BigInt(fraction.slice(3));\n}\n\nfunction decimalIdentity(value) {\n  // Compare equivalent decimal spellings without rounding the persisted price.\n  const match = /^\\+?(\\d*)(?:\\.(\\d*))?(?:e([+-]?\\d+))?$/i.exec(String(value));\n  let coefficient = (match[1] + (match[2] || '')).replace(/^0+/,'');\n  let exponent = Number(match[3] || 0) - (match[2] || '').length;\n  while (coefficient.endsWith('0')) { coefficient = coefficient.slice(0,-1); exponent++; }\n  return coefficient+'e'+exponent;\n}\n\nfunction validateAmazonSnapshot(snapshot,maxRows) {\n  if (!object(snapshot) || snapshot.schema_version !== 1 || snapshot.policy !== AMAZON_POLICY || !Array.isArray(snapshot.products) || Object.keys(snapshot).some(key => !['schema_version','policy','products'].includes(key))) fail('Invalid accepted Amazon snapshot contract.');\n  if (snapshot.products.length > maxRows || JSON.stringify(snapshot).length > 10 * 1024 * 1024) fail('Amazon snapshot exceeds the record or size cap.');\n  const seen = new Set(), products = [];\n  for (const row of snapshot.products) {\n    if (!object(row) || Object.keys(row).some(key => !AMAZON_FIELDS.includes(key))) fail('Invalid Amazon baseline record fields.');\n    const qualified = amazonProducts([row]).products;\n    if (qualified.length !== 1 || row.product_key !== qualified[0].product_key || row.marketplace !== qualified[0].marketplace || row.asin !== qualified[0].asin || seen.has(row.product_key)) fail('Invalid or duplicate Amazon baseline identity.');\n    sourceTime(row.scraped_at);\n    seen.add(row.product_key); products.push(qualified[0]);\n  }\n  return {schema_version:1,policy:AMAZON_POLICY,products};\n}\n\nfunction amazonIncremental(products,previous,maxRows) {\n  const accepted = validateAmazonSnapshot(previous,maxRows);\n  const saved = new Map(accepted.products.map(row => [row.product_key,row]));\n  const seen = new Set(), changes = [], issues = [];\n  const stats = {unchanged_products:0,older_observations_suppressed:0,conflicting_observations_suppressed:0,retained_products:0};\n  for (const row of products) {\n    const time = sourceTime(row.scraped_at), old = saved.get(row.product_key);\n    seen.add(row.product_key);\n    if (old) {\n      const previousTime = sourceTime(old.scraped_at);\n      if (time < previousTime) {\n        stats.older_observations_suppressed++;\n        issues.push({product_key:row.product_key,reason:'older_observation',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n        continue;\n      }\n      if (time === previousTime) {\n        const comparison = item => ({...meaningful(item),price:decimalIdentity(item.price)});\n        if (stable(comparison(row)) === stable(comparison(old))) stats.unchanged_products++;\n        else {\n          stats.conflicting_observations_suppressed++;\n          issues.push({product_key:row.product_key,reason:'same_observation_conflict',scraped_at:row.scraped_at});\n        }\n        continue;\n      }\n    }\n    saved.set(row.product_key,row); changes.push(row);\n  }\n  stats.retained_products = accepted.products.filter(row => !seen.has(row.product_key)).length;\n  const candidate = validateAmazonSnapshot({schema_version:1,policy:AMAZON_POLICY,products:[...saved.values()]},maxRows);\n  return {delta:{products:changes},candidate_baseline:candidate,stats,review_issues:issues};\n}\n\nfunction amazonHandoff(pages,config,run) {\n  validateConfig(config);\n  const rows = datasetRows(pages,config), mapped = amazonProducts(rows);\n  if (!mapped.products.length) fail('No qualified priced products; preserve the last good feed and mark it stale.');\n  const incremental = amazonIncremental(mapped.products,config.previousSnapshot,config.maxRows);\n  return {...reviewEnvelope(run,'amazon-prices',config),source_dataset_complete:true,source_rows:rows.length,\n    source_products:mapped.products,rejected_rows:mapped.rejected_rows,...incremental,\n    observation_key_fields:['product_key','scraped_at'],last_good_feed_policy:'upsert_qualified_observations_keep_missing_keys',\n    product_coverage_complete:null,coverage_note:'Reading every dataset row does not prove all requested products returned.'};\n}\n\nreturn [{json:validateConfig(config)}];"
      }
    },
    {
      "id": "54286c80-1bc0-555a-ac0b-2a474c04473c",
      "name": "Read existing run",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        440,
        200
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $json.runId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": false
            }
          },
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          }
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 1000,
      "notes": "Choose a private HTTP Header Auth credential: Authorization = Bearer <Apify token>. GET only; token never goes in a URL."
    },
    {
      "id": "9fb80a9f-7e40-537b-baf7-844597c1946f",
      "name": "Verify succeeded run",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        660,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/* Pure handoff contracts embedded in n8n Code nodes by build_templates.py.\n * No network, persistence, Actor invocation, destination writes or logging.\n */\nconst MERGE_POLICY = 'upsert_by_key_preserve_existing_on_empty';\nconst COMPANY_FIELDS = ['company_key','domain','company_name','website','linkedin_url','contact_page_url','phone','phone_status','tech_stack','scraped_at'];\nconst CONTACT_FIELDS = ['contact_key','company_key','email','email_status','phone','phone_status','role','source_url','scraped_at'];\nconst AMAZON_FIELDS = ['product_key','asin','marketplace','title','price','currency','availability','rating','reviews_count','url','scraped_at'];\nconst AMAZON_POLICY = 'retain_last_good_per_product_key';\nconst EVIDENCE = {published_unverified:0,mx_medium_mailbox_unknown:1,mx_high_mailbox_unknown:2,smtp_deliverable:3};\nconst ID = /^[A-Za-z0-9_-]{1,64}$/;\nconst validId = value => typeof value === 'string' && ID.test(value);\nconst MARKET = /^amazon\\.(?:com|ca|com\\.mx|com\\.br|co\\.uk|de|fr|es|it|nl|pl|se|co\\.jp|in|com\\.au|ae|sa|sg|com\\.tr)$/;\nconst text = v => typeof v === 'string' ? v.trim() : '';\nconst object = v => v !== null && typeof v === 'object' && !Array.isArray(v);\nconst fail = message => { throw new Error(message); };\nconst meaningful = row => Object.fromEntries(Object.entries(row).filter(([k,v]) => k !== 'scraped_at' && v));\nconst stable = row => JSON.stringify(Object.entries(row).sort(([a],[b]) => a.localeCompare(b)));\nconst clean = row => Object.fromEntries(Object.entries(row).filter(([,v]) => v));\n\nfunction validateConfig(config) {\n  if (!object(config) || !validId(config.runId) || config.runId === 'REPLACE_WITH_SUCCEEDED_RUN_ID') fail('Set an exact existing succeeded run ID.');\n  if (!validId(config.expectedActorId)) fail('Invalid expected Actor ID.');\n  if (!Number.isInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 10000) fail('maxRows must be 1..10000.');\n  if (!Number.isInteger(config.pageSize) || config.pageSize < 1 || config.pageSize > 1000) fail('pageSize must be 1..1000.');\n  if (!text(config.baselineReference) || config.baselineReference.length > 128) fail('Set an explicit accepted baseline revision reference.');\n  if (config.baselineReference === 'initial-empty' && object(config.previousSnapshot) && ['companies','contacts','products'].some(kind => Array.isArray(config.previousSnapshot[kind]) && config.previousSnapshot[kind].length)) fail('A nonempty accepted baseline needs its actual revision reference.');\n  return config;\n}\n\nfunction validateRun(response, config) {\n  validateConfig(config);\n  const run = response && response.data;\n  if (!object(run) || run.id !== config.runId || run.actId !== config.expectedActorId || run.status !== 'SUCCEEDED') fail('Run identity, Actor or succeeded status does not match.');\n  if (!validId(run.defaultDatasetId) || !validId(run.defaultKeyValueStoreId)) fail('Run storage identifiers are missing.');\n  return {runId:run.id, actorId:run.actId, datasetId:run.defaultDatasetId, storeId:run.defaultKeyValueStoreId};\n}\n\nfunction validateManifest(manifest, maxRows) {\n  if (!object(manifest) || manifest.status !== 'complete' || manifest.replace_existing_data !== false || manifest.delete_missing_records !== false) fail('CRM export is absent, partial or unsafe.');\n  for (const name of ['exported_companies','exported_contacts','persisted_site_rows','source_rows']) {\n    if (!Number.isInteger(manifest[name]) || manifest[name] < 0 || manifest[name] > maxRows) fail('CRM manifest count is invalid or exceeds maxRows.');\n  }\n  if (manifest.persisted_site_rows !== manifest.source_rows || manifest.exported_companies === 0 || manifest.exported_companies > manifest.source_rows || manifest.exported_contacts > manifest.source_rows || manifest.exported_companies + manifest.exported_contacts > maxRows) fail('CRM manifest counts are incomplete or exceed maxRows.');\n  const required = ['CRM-companies.csv','CRM-contacts.csv','CRM-upserts.json'];\n  if (!Array.isArray(manifest.files) || manifest.files.length !== 3 || required.some(name => !manifest.files.includes(name))) fail('CRM manifest lacks required file references.');\n  if (typeof manifest.require_mx !== 'boolean') fail('CRM manifest has no explicit verification mode.');\n  return manifest;\n}\n\nfunction validateSnapshot(payload, maxRows, native = false) {\n  if (!object(payload) || payload.merge_policy !== MERGE_POLICY || !Array.isArray(payload.companies) || !Array.isArray(payload.contacts)) fail('Invalid CRM snapshot contract.');\n  if (JSON.stringify(payload).length > 10 * 1024 * 1024 || payload.companies.length + payload.contacts.length > maxRows) fail('CRM snapshot exceeds the size or record cap.');\n  if (native && (payload.full_snapshot !== false || payload.allow_deletions !== false || payload.contact_selection !== 'one_eligible_email_per_company_source_row' || payload.association_policy !== 'preserve_existing_on_empty_review_ambiguous')) fail('Native CRM handoff policies do not match.');\n  const result = {merge_policy:MERGE_POLICY,companies:[],contacts:[]};\n  for (const [kind, fields, keyField] of [['companies',COMPANY_FIELDS,'company_key'],['contacts',CONTACT_FIELDS,'contact_key']]) {\n    const seen = new Set();\n    for (const row of payload[kind]) {\n      if (!object(row) || Object.entries(row).some(([k,v]) => !fields.includes(k) || typeof v !== 'string')) fail('Invalid CRM record fields.');\n      const key = row[keyField];\n      if (!key || seen.has(key)) fail('Duplicate or missing CRM key.');\n      seen.add(key);\n      if (kind === 'companies') {\n        if (row.domain !== key || key !== key.toLowerCase() || !key.includes('.') || /[\\s/:@]/.test(key)) fail('Invalid company identity.');\n      } else if (row.email !== key || key !== key.trim().toLowerCase() || !/^[^\\s@,;]+@[^\\s@,;]+\\.[^\\s@,;]+$/.test(key) || !Object.hasOwn(EVIDENCE,row.email_status)) fail('Invalid contact identity or evidence.');\n      if (row.phone && (!/^\\+[1-9]\\d{6,14}$/.test(row.phone) || row.phone_status !== 'format_valid_not_reachability_checked')) fail('Unqualified phone in CRM handoff.');\n      sourceTime(row.scraped_at,'CRM');\n      result[kind].push({...row});\n    }\n  }\n  if (native && result.contacts.some(row => row.company_key && !result.companies.some(company => company.company_key === row.company_key))) fail('Contact refers to a company absent from the native handoff.');\n  return result;\n}\n\nfunction crmHandoff(payload, manifest, config, run) {\n  validateConfig(config);\n  validateManifest(manifest,config.maxRows);\n  const current = validateSnapshot(payload,config.maxRows,true);\n  if (current.companies.length !== manifest.exported_companies || current.contacts.length !== manifest.exported_contacts) fail('CRM payload does not match completion manifest counts.');\n  if (manifest.require_mx && current.contacts.some(row => row.email_status === 'published_unverified')) fail('MX-required export contains an unchecked contact.');\n  const previous = validateSnapshot(config.previousSnapshot,config.maxRows);\n  const next = {merge_policy:MERGE_POLICY}, delta = {merge_policy:MERGE_POLICY};\n  const stats = {verification_downgrades_suppressed:0,association_conflicts_suppressed:0,\n    older_companies_suppressed:0,older_contacts_suppressed:0,same_time_company_conflicts_suppressed:0,same_time_contact_conflicts_suppressed:0};\n  const issues = [];\n  for (const [kind,keyField] of [['companies','company_key'],['contacts','contact_key']]) {\n    const saved = new Map(previous[kind].map(row => [row[keyField],{...row}]));\n    const seen = new Set(); const changes = []; let unchanged = 0;\n    for (const row of current[kind]) {\n      const key = row[keyField], old = saved.get(key) || {};\n      seen.add(key);\n      let merged = {...old,...clean(row)};\n      const weaker = kind === 'contacts' && old.email_status && EVIDENCE[row.email_status] < EVIDENCE[old.email_status];\n      const associationConflict = kind === 'contacts' && old.company_key && row.company_key && old.company_key !== row.company_key;\n      if (weaker) stats.verification_downgrades_suppressed += 1;\n      if (associationConflict) stats.association_conflicts_suppressed += 1;\n      if (old[keyField]) {\n        const observedTime = sourceTime(row.scraped_at,'CRM'), acceptedTime = sourceTime(old.scraped_at,'CRM');\n        if (observedTime < acceptedTime) {\n          stats['older_'+kind+'_suppressed'] += 1;\n          issues.push({record_type:kind,record_key:key,reason:'older_observation',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n          merged = {...old};\n        } else if (observedTime === acceptedTime) {\n          if (stable(meaningful(old)) !== stable(meaningful(merged))) {\n            stats['same_time_'+(kind === 'companies' ? 'company' : 'contact')+'_conflicts_suppressed'] += 1;\n            issues.push({record_type:kind,record_key:key,reason:'same_observation_conflict',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n          }\n          // Exact replays and equivalent timezone spellings preserve the accepted record.\n          merged = {...old};\n        } else if (weaker || associationConflict) merged = {...old};\n      }\n      saved.set(key,merged);\n      if (stable(meaningful(old)) === stable(meaningful(merged))) unchanged += 1;\n      else changes.push(clean(merged));\n    }\n    next[kind] = [...saved.values()]; delta[kind] = changes;\n    stats['unchanged_'+kind] = unchanged;\n    stats['retained_'+kind] = previous[kind].filter(row => !seen.has(row[keyField])).length;\n  }\n  validateSnapshot(next,config.maxRows);\n  return {...reviewEnvelope(run,'contact-crm',config),delta,candidate_baseline:next,stats,review_issues:issues};\n}\n\nfunction reviewEnvelope(run,workflow,config) {\n  return {workflow,handoff_id:workflow+':'+run.actorId+':'+run.runId,source_run_id:run.runId,\n    accepted_baseline_reference:config.baselineReference,\n    acknowledgement_binding:{implemented:false,required:['source_run_id','accepted_baseline_reference','exact_delta_and_candidate_payload'],handoff_id_alone_is_insufficient:true},\n    delivery_status:'REVIEW_ONLY',destination_written:false,acknowledgement_required:true,\n    baseline_advanced:false,replace_existing_data:false,delete_missing_records:false};\n}\n\nfunction datasetRows(pages, config) {\n  if (!Array.isArray(pages) || !pages.length) fail('No dataset page response.');\n  let rows = [], expectedOffset = 0, advertisedTotal;\n  for (const page of pages) {\n    if (!object(page) || page.statusCode !== 200 || !Array.isArray(page.body)) fail('A dataset page failed or was not a JSON array.');\n    const headers = Object.fromEntries(Object.entries(page.headers || {}).map(([key,value]) => [key.toLowerCase(),value]));\n    const readInteger = key => /^\\d+$/.test(String(headers[key])) ? Number(headers[key]) : fail('Dataset pagination headers are missing or invalid.');\n    const offset = readInteger('x-apify-pagination-offset');\n    const count = readInteger('x-apify-pagination-count');\n    const total = readInteger('x-apify-pagination-total');\n    const limit = readInteger('x-apify-pagination-limit');\n    if (offset !== expectedOffset || count !== page.body.length || count > config.pageSize || limit !== config.pageSize) fail('Dataset page is missing, repeated or inconsistent.');\n    if (advertisedTotal !== undefined && advertisedTotal !== total) fail('Dataset changed during the export; retry the same completed run.');\n    advertisedTotal = total;\n    if (total > config.maxRows) fail('Dataset exceeds maxRows; preserve the prior feed.');\n    rows.push(...page.body); expectedOffset += config.pageSize;\n    if (rows.length > config.maxRows) fail('Dataset exceeds maxRows; preserve the prior feed.');\n  }\n  if (rows.length !== advertisedTotal || pages.at(-1).body.length >= config.pageSize) fail('Dataset export is incomplete; preserve the prior feed.');\n  return rows;\n}\n\nfunction amazonSource(row,asin) {\n  const urls = ['url','product_url'].filter(key => text(row[key])).map(key => text(row[key]));\n  let market;\n  for (const url of urls) {\n    // Strict literal Amazon hosts: no credentials, port, host escapes or redirects.\n    const parsed = /^https?:\\/\\/([^/?#]+)([^?#]*)/i.exec(url);\n    if (!parsed) return null;\n    const host = parsed[1].toLowerCase().replace(/^www\\./,'');\n    if (!MARKET.test(host) || (market && market !== host)) return null;\n    market = host;\n    const identities = [...parsed[2].matchAll(/\\/(?:dp|gp\\/product|product)\\/([A-Z0-9]{10})(?=\\/|$)/gi)];\n    if (identities.some(match => match[1].toUpperCase() !== asin)) return null;\n  }\n  return urls.length ? {url:urls[0],marketplace:market} : null;\n}\n\nfunction amazonProducts(rows) {\n  const products = new Map(); let rejected = 0;\n  for (const row of rows) {\n    if (!object(row) || row.diagnostic_item || row.block_detected || row.error || row.billable_contact_found === false || ![undefined,null,'product_data','contact_data'].includes(row.result_type)) { rejected++; continue; }\n    const asin = text(row.asin).toUpperCase(), source = amazonSource(row,asin), currency = text(row.currency).toUpperCase();\n    const price = (typeof row.price === 'number' || typeof row.price === 'string') ? String(row.price).trim() : '';\n    if (!/^[A-Z0-9]{10}$/.test(asin) || !source || !text(row.title) || !text(row.scraped_at) || !/^[A-Z]{3}$/.test(currency) || !/^[+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?$/i.test(price) || !Number.isFinite(Number(price)) || Number(price) <= 0) { rejected++; continue; }\n    const key = source.marketplace+':'+asin;\n    products.set(key,{product_key:key,asin,...source,title:text(row.title),price:price.replace(/^\\+/,''),currency,availability:text(row.availability),rating:row.rating ?? null,reviews_count:row.reviews_count ?? null,scraped_at:text(row.scraped_at)});\n  }\n  return {products:[...products.values()],rejected_rows:rejected};\n}\n\nfunction sourceTime(value,label = 'Amazon') {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.\\d{1,6})?(?:Z|[+-]\\d{2}:\\d{2})$/.exec(value);\n  if (!match) fail(label+' observation needs an ISO source timestamp with a timezone.');\n  const [,year,month,day,hour,minute,second] = match.map(Number);\n  if (year < 1970 || month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year,month,0)).getUTCDate() || hour > 23 || minute > 59 || second > 59) fail('Invalid '+label+' source timestamp.');\n  const parsed = Date.parse(value);\n  if (!Number.isFinite(parsed)) fail('Invalid '+label+' source timestamp.');\n  const fraction = (/\\.(\\d+)(?:Z|[+-])/.exec(value)?.[1] || '').padEnd(6,'0');\n  return BigInt(parsed) * 1000n + BigInt(fraction.slice(3));\n}\n\nfunction decimalIdentity(value) {\n  // Compare equivalent decimal spellings without rounding the persisted price.\n  const match = /^\\+?(\\d*)(?:\\.(\\d*))?(?:e([+-]?\\d+))?$/i.exec(String(value));\n  let coefficient = (match[1] + (match[2] || '')).replace(/^0+/,'');\n  let exponent = Number(match[3] || 0) - (match[2] || '').length;\n  while (coefficient.endsWith('0')) { coefficient = coefficient.slice(0,-1); exponent++; }\n  return coefficient+'e'+exponent;\n}\n\nfunction validateAmazonSnapshot(snapshot,maxRows) {\n  if (!object(snapshot) || snapshot.schema_version !== 1 || snapshot.policy !== AMAZON_POLICY || !Array.isArray(snapshot.products) || Object.keys(snapshot).some(key => !['schema_version','policy','products'].includes(key))) fail('Invalid accepted Amazon snapshot contract.');\n  if (snapshot.products.length > maxRows || JSON.stringify(snapshot).length > 10 * 1024 * 1024) fail('Amazon snapshot exceeds the record or size cap.');\n  const seen = new Set(), products = [];\n  for (const row of snapshot.products) {\n    if (!object(row) || Object.keys(row).some(key => !AMAZON_FIELDS.includes(key))) fail('Invalid Amazon baseline record fields.');\n    const qualified = amazonProducts([row]).products;\n    if (qualified.length !== 1 || row.product_key !== qualified[0].product_key || row.marketplace !== qualified[0].marketplace || row.asin !== qualified[0].asin || seen.has(row.product_key)) fail('Invalid or duplicate Amazon baseline identity.');\n    sourceTime(row.scraped_at);\n    seen.add(row.product_key); products.push(qualified[0]);\n  }\n  return {schema_version:1,policy:AMAZON_POLICY,products};\n}\n\nfunction amazonIncremental(products,previous,maxRows) {\n  const accepted = validateAmazonSnapshot(previous,maxRows);\n  const saved = new Map(accepted.products.map(row => [row.product_key,row]));\n  const seen = new Set(), changes = [], issues = [];\n  const stats = {unchanged_products:0,older_observations_suppressed:0,conflicting_observations_suppressed:0,retained_products:0};\n  for (const row of products) {\n    const time = sourceTime(row.scraped_at), old = saved.get(row.product_key);\n    seen.add(row.product_key);\n    if (old) {\n      const previousTime = sourceTime(old.scraped_at);\n      if (time < previousTime) {\n        stats.older_observations_suppressed++;\n        issues.push({product_key:row.product_key,reason:'older_observation',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n        continue;\n      }\n      if (time === previousTime) {\n        const comparison = item => ({...meaningful(item),price:decimalIdentity(item.price)});\n        if (stable(comparison(row)) === stable(comparison(old))) stats.unchanged_products++;\n        else {\n          stats.conflicting_observations_suppressed++;\n          issues.push({product_key:row.product_key,reason:'same_observation_conflict',scraped_at:row.scraped_at});\n        }\n        continue;\n      }\n    }\n    saved.set(row.product_key,row); changes.push(row);\n  }\n  stats.retained_products = accepted.products.filter(row => !seen.has(row.product_key)).length;\n  const candidate = validateAmazonSnapshot({schema_version:1,policy:AMAZON_POLICY,products:[...saved.values()]},maxRows);\n  return {delta:{products:changes},candidate_baseline:candidate,stats,review_issues:issues};\n}\n\nfunction amazonHandoff(pages,config,run) {\n  validateConfig(config);\n  const rows = datasetRows(pages,config), mapped = amazonProducts(rows);\n  if (!mapped.products.length) fail('No qualified priced products; preserve the last good feed and mark it stale.');\n  const incremental = amazonIncremental(mapped.products,config.previousSnapshot,config.maxRows);\n  return {...reviewEnvelope(run,'amazon-prices',config),source_dataset_complete:true,source_rows:rows.length,\n    source_products:mapped.products,rejected_rows:mapped.rejected_rows,...incremental,\n    observation_key_fields:['product_key','scraped_at'],last_good_feed_policy:'upsert_qualified_observations_keep_missing_keys',\n    product_coverage_complete:null,coverage_note:'Reading every dataset row does not prove all requested products returned.'};\n}\n\nreturn [{json:validateRun($input.first().json,$('Configure existing run').first().json)}];"
      }
    },
    {
      "id": "62a73e8c-0c26-56d0-9665-a0a565308913",
      "name": "Read all bounded dataset pages",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        200
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/datasets/' + $json.datasetId + '/items' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "responseFormat": "json",
              "fullResponse": true
            }
          },
          "timeout": 30000,
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "pagination": {
            "pagination": {
              "paginationMode": "updateAParameterInEachRequest",
              "parameters": {
                "parameters": [
                  {
                    "type": "qs",
                    "name": "offset",
                    "value": "={{ $pageCount * $('Configure existing run').first().json.pageSize }}"
                  }
                ]
              },
              "paginationCompleteWhen": "other",
              "completeExpression": "={{ !Array.isArray($response.body) || $response.body.length < $('Configure existing run').first().json.pageSize }}",
              "limitPagesFetched": true,
              "maxRequests": "={{ Math.ceil($('Configure existing run').first().json.maxRows / $('Configure existing run').first().json.pageSize) + 1 }}",
              "requestInterval": 200
            }
          }
        },
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "format",
              "value": "json"
            },
            {
              "name": "clean",
              "value": "false"
            },
            {
              "name": "limit",
              "value": "={{ $('Configure existing run').first().json.pageSize }}"
            }
          ]
        }
      },
      "retryOnFail": true,
      "maxTries": 3,
      "waitBetweenTries": 1000,
      "notes": "Choose a private HTTP Header Auth credential: Authorization = Bearer <Apify token>. GET only; token never goes in a URL."
    },
    {
      "id": "0fbfab08-e8a8-56f0-99dc-e6bc961e7b0d",
      "name": "Review qualified prices",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "/* Pure handoff contracts embedded in n8n Code nodes by build_templates.py.\n * No network, persistence, Actor invocation, destination writes or logging.\n */\nconst MERGE_POLICY = 'upsert_by_key_preserve_existing_on_empty';\nconst COMPANY_FIELDS = ['company_key','domain','company_name','website','linkedin_url','contact_page_url','phone','phone_status','tech_stack','scraped_at'];\nconst CONTACT_FIELDS = ['contact_key','company_key','email','email_status','phone','phone_status','role','source_url','scraped_at'];\nconst AMAZON_FIELDS = ['product_key','asin','marketplace','title','price','currency','availability','rating','reviews_count','url','scraped_at'];\nconst AMAZON_POLICY = 'retain_last_good_per_product_key';\nconst EVIDENCE = {published_unverified:0,mx_medium_mailbox_unknown:1,mx_high_mailbox_unknown:2,smtp_deliverable:3};\nconst ID = /^[A-Za-z0-9_-]{1,64}$/;\nconst validId = value => typeof value === 'string' && ID.test(value);\nconst MARKET = /^amazon\\.(?:com|ca|com\\.mx|com\\.br|co\\.uk|de|fr|es|it|nl|pl|se|co\\.jp|in|com\\.au|ae|sa|sg|com\\.tr)$/;\nconst text = v => typeof v === 'string' ? v.trim() : '';\nconst object = v => v !== null && typeof v === 'object' && !Array.isArray(v);\nconst fail = message => { throw new Error(message); };\nconst meaningful = row => Object.fromEntries(Object.entries(row).filter(([k,v]) => k !== 'scraped_at' && v));\nconst stable = row => JSON.stringify(Object.entries(row).sort(([a],[b]) => a.localeCompare(b)));\nconst clean = row => Object.fromEntries(Object.entries(row).filter(([,v]) => v));\n\nfunction validateConfig(config) {\n  if (!object(config) || !validId(config.runId) || config.runId === 'REPLACE_WITH_SUCCEEDED_RUN_ID') fail('Set an exact existing succeeded run ID.');\n  if (!validId(config.expectedActorId)) fail('Invalid expected Actor ID.');\n  if (!Number.isInteger(config.maxRows) || config.maxRows < 1 || config.maxRows > 10000) fail('maxRows must be 1..10000.');\n  if (!Number.isInteger(config.pageSize) || config.pageSize < 1 || config.pageSize > 1000) fail('pageSize must be 1..1000.');\n  if (!text(config.baselineReference) || config.baselineReference.length > 128) fail('Set an explicit accepted baseline revision reference.');\n  if (config.baselineReference === 'initial-empty' && object(config.previousSnapshot) && ['companies','contacts','products'].some(kind => Array.isArray(config.previousSnapshot[kind]) && config.previousSnapshot[kind].length)) fail('A nonempty accepted baseline needs its actual revision reference.');\n  return config;\n}\n\nfunction validateRun(response, config) {\n  validateConfig(config);\n  const run = response && response.data;\n  if (!object(run) || run.id !== config.runId || run.actId !== config.expectedActorId || run.status !== 'SUCCEEDED') fail('Run identity, Actor or succeeded status does not match.');\n  if (!validId(run.defaultDatasetId) || !validId(run.defaultKeyValueStoreId)) fail('Run storage identifiers are missing.');\n  return {runId:run.id, actorId:run.actId, datasetId:run.defaultDatasetId, storeId:run.defaultKeyValueStoreId};\n}\n\nfunction validateManifest(manifest, maxRows) {\n  if (!object(manifest) || manifest.status !== 'complete' || manifest.replace_existing_data !== false || manifest.delete_missing_records !== false) fail('CRM export is absent, partial or unsafe.');\n  for (const name of ['exported_companies','exported_contacts','persisted_site_rows','source_rows']) {\n    if (!Number.isInteger(manifest[name]) || manifest[name] < 0 || manifest[name] > maxRows) fail('CRM manifest count is invalid or exceeds maxRows.');\n  }\n  if (manifest.persisted_site_rows !== manifest.source_rows || manifest.exported_companies === 0 || manifest.exported_companies > manifest.source_rows || manifest.exported_contacts > manifest.source_rows || manifest.exported_companies + manifest.exported_contacts > maxRows) fail('CRM manifest counts are incomplete or exceed maxRows.');\n  const required = ['CRM-companies.csv','CRM-contacts.csv','CRM-upserts.json'];\n  if (!Array.isArray(manifest.files) || manifest.files.length !== 3 || required.some(name => !manifest.files.includes(name))) fail('CRM manifest lacks required file references.');\n  if (typeof manifest.require_mx !== 'boolean') fail('CRM manifest has no explicit verification mode.');\n  return manifest;\n}\n\nfunction validateSnapshot(payload, maxRows, native = false) {\n  if (!object(payload) || payload.merge_policy !== MERGE_POLICY || !Array.isArray(payload.companies) || !Array.isArray(payload.contacts)) fail('Invalid CRM snapshot contract.');\n  if (JSON.stringify(payload).length > 10 * 1024 * 1024 || payload.companies.length + payload.contacts.length > maxRows) fail('CRM snapshot exceeds the size or record cap.');\n  if (native && (payload.full_snapshot !== false || payload.allow_deletions !== false || payload.contact_selection !== 'one_eligible_email_per_company_source_row' || payload.association_policy !== 'preserve_existing_on_empty_review_ambiguous')) fail('Native CRM handoff policies do not match.');\n  const result = {merge_policy:MERGE_POLICY,companies:[],contacts:[]};\n  for (const [kind, fields, keyField] of [['companies',COMPANY_FIELDS,'company_key'],['contacts',CONTACT_FIELDS,'contact_key']]) {\n    const seen = new Set();\n    for (const row of payload[kind]) {\n      if (!object(row) || Object.entries(row).some(([k,v]) => !fields.includes(k) || typeof v !== 'string')) fail('Invalid CRM record fields.');\n      const key = row[keyField];\n      if (!key || seen.has(key)) fail('Duplicate or missing CRM key.');\n      seen.add(key);\n      if (kind === 'companies') {\n        if (row.domain !== key || key !== key.toLowerCase() || !key.includes('.') || /[\\s/:@]/.test(key)) fail('Invalid company identity.');\n      } else if (row.email !== key || key !== key.trim().toLowerCase() || !/^[^\\s@,;]+@[^\\s@,;]+\\.[^\\s@,;]+$/.test(key) || !Object.hasOwn(EVIDENCE,row.email_status)) fail('Invalid contact identity or evidence.');\n      if (row.phone && (!/^\\+[1-9]\\d{6,14}$/.test(row.phone) || row.phone_status !== 'format_valid_not_reachability_checked')) fail('Unqualified phone in CRM handoff.');\n      sourceTime(row.scraped_at,'CRM');\n      result[kind].push({...row});\n    }\n  }\n  if (native && result.contacts.some(row => row.company_key && !result.companies.some(company => company.company_key === row.company_key))) fail('Contact refers to a company absent from the native handoff.');\n  return result;\n}\n\nfunction crmHandoff(payload, manifest, config, run) {\n  validateConfig(config);\n  validateManifest(manifest,config.maxRows);\n  const current = validateSnapshot(payload,config.maxRows,true);\n  if (current.companies.length !== manifest.exported_companies || current.contacts.length !== manifest.exported_contacts) fail('CRM payload does not match completion manifest counts.');\n  if (manifest.require_mx && current.contacts.some(row => row.email_status === 'published_unverified')) fail('MX-required export contains an unchecked contact.');\n  const previous = validateSnapshot(config.previousSnapshot,config.maxRows);\n  const next = {merge_policy:MERGE_POLICY}, delta = {merge_policy:MERGE_POLICY};\n  const stats = {verification_downgrades_suppressed:0,association_conflicts_suppressed:0,\n    older_companies_suppressed:0,older_contacts_suppressed:0,same_time_company_conflicts_suppressed:0,same_time_contact_conflicts_suppressed:0};\n  const issues = [];\n  for (const [kind,keyField] of [['companies','company_key'],['contacts','contact_key']]) {\n    const saved = new Map(previous[kind].map(row => [row[keyField],{...row}]));\n    const seen = new Set(); const changes = []; let unchanged = 0;\n    for (const row of current[kind]) {\n      const key = row[keyField], old = saved.get(key) || {};\n      seen.add(key);\n      let merged = {...old,...clean(row)};\n      const weaker = kind === 'contacts' && old.email_status && EVIDENCE[row.email_status] < EVIDENCE[old.email_status];\n      const associationConflict = kind === 'contacts' && old.company_key && row.company_key && old.company_key !== row.company_key;\n      if (weaker) stats.verification_downgrades_suppressed += 1;\n      if (associationConflict) stats.association_conflicts_suppressed += 1;\n      if (old[keyField]) {\n        const observedTime = sourceTime(row.scraped_at,'CRM'), acceptedTime = sourceTime(old.scraped_at,'CRM');\n        if (observedTime < acceptedTime) {\n          stats['older_'+kind+'_suppressed'] += 1;\n          issues.push({record_type:kind,record_key:key,reason:'older_observation',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n          merged = {...old};\n        } else if (observedTime === acceptedTime) {\n          if (stable(meaningful(old)) !== stable(meaningful(merged))) {\n            stats['same_time_'+(kind === 'companies' ? 'company' : 'contact')+'_conflicts_suppressed'] += 1;\n            issues.push({record_type:kind,record_key:key,reason:'same_observation_conflict',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n          }\n          // Exact replays and equivalent timezone spellings preserve the accepted record.\n          merged = {...old};\n        } else if (weaker || associationConflict) merged = {...old};\n      }\n      saved.set(key,merged);\n      if (stable(meaningful(old)) === stable(meaningful(merged))) unchanged += 1;\n      else changes.push(clean(merged));\n    }\n    next[kind] = [...saved.values()]; delta[kind] = changes;\n    stats['unchanged_'+kind] = unchanged;\n    stats['retained_'+kind] = previous[kind].filter(row => !seen.has(row[keyField])).length;\n  }\n  validateSnapshot(next,config.maxRows);\n  return {...reviewEnvelope(run,'contact-crm',config),delta,candidate_baseline:next,stats,review_issues:issues};\n}\n\nfunction reviewEnvelope(run,workflow,config) {\n  return {workflow,handoff_id:workflow+':'+run.actorId+':'+run.runId,source_run_id:run.runId,\n    accepted_baseline_reference:config.baselineReference,\n    acknowledgement_binding:{implemented:false,required:['source_run_id','accepted_baseline_reference','exact_delta_and_candidate_payload'],handoff_id_alone_is_insufficient:true},\n    delivery_status:'REVIEW_ONLY',destination_written:false,acknowledgement_required:true,\n    baseline_advanced:false,replace_existing_data:false,delete_missing_records:false};\n}\n\nfunction datasetRows(pages, config) {\n  if (!Array.isArray(pages) || !pages.length) fail('No dataset page response.');\n  let rows = [], expectedOffset = 0, advertisedTotal;\n  for (const page of pages) {\n    if (!object(page) || page.statusCode !== 200 || !Array.isArray(page.body)) fail('A dataset page failed or was not a JSON array.');\n    const headers = Object.fromEntries(Object.entries(page.headers || {}).map(([key,value]) => [key.toLowerCase(),value]));\n    const readInteger = key => /^\\d+$/.test(String(headers[key])) ? Number(headers[key]) : fail('Dataset pagination headers are missing or invalid.');\n    const offset = readInteger('x-apify-pagination-offset');\n    const count = readInteger('x-apify-pagination-count');\n    const total = readInteger('x-apify-pagination-total');\n    const limit = readInteger('x-apify-pagination-limit');\n    if (offset !== expectedOffset || count !== page.body.length || count > config.pageSize || limit !== config.pageSize) fail('Dataset page is missing, repeated or inconsistent.');\n    if (advertisedTotal !== undefined && advertisedTotal !== total) fail('Dataset changed during the export; retry the same completed run.');\n    advertisedTotal = total;\n    if (total > config.maxRows) fail('Dataset exceeds maxRows; preserve the prior feed.');\n    rows.push(...page.body); expectedOffset += config.pageSize;\n    if (rows.length > config.maxRows) fail('Dataset exceeds maxRows; preserve the prior feed.');\n  }\n  if (rows.length !== advertisedTotal || pages.at(-1).body.length >= config.pageSize) fail('Dataset export is incomplete; preserve the prior feed.');\n  return rows;\n}\n\nfunction amazonSource(row,asin) {\n  const urls = ['url','product_url'].filter(key => text(row[key])).map(key => text(row[key]));\n  let market;\n  for (const url of urls) {\n    // Strict literal Amazon hosts: no credentials, port, host escapes or redirects.\n    const parsed = /^https?:\\/\\/([^/?#]+)([^?#]*)/i.exec(url);\n    if (!parsed) return null;\n    const host = parsed[1].toLowerCase().replace(/^www\\./,'');\n    if (!MARKET.test(host) || (market && market !== host)) return null;\n    market = host;\n    const identities = [...parsed[2].matchAll(/\\/(?:dp|gp\\/product|product)\\/([A-Z0-9]{10})(?=\\/|$)/gi)];\n    if (identities.some(match => match[1].toUpperCase() !== asin)) return null;\n  }\n  return urls.length ? {url:urls[0],marketplace:market} : null;\n}\n\nfunction amazonProducts(rows) {\n  const products = new Map(); let rejected = 0;\n  for (const row of rows) {\n    if (!object(row) || row.diagnostic_item || row.block_detected || row.error || row.billable_contact_found === false || ![undefined,null,'product_data','contact_data'].includes(row.result_type)) { rejected++; continue; }\n    const asin = text(row.asin).toUpperCase(), source = amazonSource(row,asin), currency = text(row.currency).toUpperCase();\n    const price = (typeof row.price === 'number' || typeof row.price === 'string') ? String(row.price).trim() : '';\n    if (!/^[A-Z0-9]{10}$/.test(asin) || !source || !text(row.title) || !text(row.scraped_at) || !/^[A-Z]{3}$/.test(currency) || !/^[+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:e[+-]?\\d+)?$/i.test(price) || !Number.isFinite(Number(price)) || Number(price) <= 0) { rejected++; continue; }\n    const key = source.marketplace+':'+asin;\n    products.set(key,{product_key:key,asin,...source,title:text(row.title),price:price.replace(/^\\+/,''),currency,availability:text(row.availability),rating:row.rating ?? null,reviews_count:row.reviews_count ?? null,scraped_at:text(row.scraped_at)});\n  }\n  return {products:[...products.values()],rejected_rows:rejected};\n}\n\nfunction sourceTime(value,label = 'Amazon') {\n  const match = /^(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})(?:\\.\\d{1,6})?(?:Z|[+-]\\d{2}:\\d{2})$/.exec(value);\n  if (!match) fail(label+' observation needs an ISO source timestamp with a timezone.');\n  const [,year,month,day,hour,minute,second] = match.map(Number);\n  if (year < 1970 || month < 1 || month > 12 || day < 1 || day > new Date(Date.UTC(year,month,0)).getUTCDate() || hour > 23 || minute > 59 || second > 59) fail('Invalid '+label+' source timestamp.');\n  const parsed = Date.parse(value);\n  if (!Number.isFinite(parsed)) fail('Invalid '+label+' source timestamp.');\n  const fraction = (/\\.(\\d+)(?:Z|[+-])/.exec(value)?.[1] || '').padEnd(6,'0');\n  return BigInt(parsed) * 1000n + BigInt(fraction.slice(3));\n}\n\nfunction decimalIdentity(value) {\n  // Compare equivalent decimal spellings without rounding the persisted price.\n  const match = /^\\+?(\\d*)(?:\\.(\\d*))?(?:e([+-]?\\d+))?$/i.exec(String(value));\n  let coefficient = (match[1] + (match[2] || '')).replace(/^0+/,'');\n  let exponent = Number(match[3] || 0) - (match[2] || '').length;\n  while (coefficient.endsWith('0')) { coefficient = coefficient.slice(0,-1); exponent++; }\n  return coefficient+'e'+exponent;\n}\n\nfunction validateAmazonSnapshot(snapshot,maxRows) {\n  if (!object(snapshot) || snapshot.schema_version !== 1 || snapshot.policy !== AMAZON_POLICY || !Array.isArray(snapshot.products) || Object.keys(snapshot).some(key => !['schema_version','policy','products'].includes(key))) fail('Invalid accepted Amazon snapshot contract.');\n  if (snapshot.products.length > maxRows || JSON.stringify(snapshot).length > 10 * 1024 * 1024) fail('Amazon snapshot exceeds the record or size cap.');\n  const seen = new Set(), products = [];\n  for (const row of snapshot.products) {\n    if (!object(row) || Object.keys(row).some(key => !AMAZON_FIELDS.includes(key))) fail('Invalid Amazon baseline record fields.');\n    const qualified = amazonProducts([row]).products;\n    if (qualified.length !== 1 || row.product_key !== qualified[0].product_key || row.marketplace !== qualified[0].marketplace || row.asin !== qualified[0].asin || seen.has(row.product_key)) fail('Invalid or duplicate Amazon baseline identity.');\n    sourceTime(row.scraped_at);\n    seen.add(row.product_key); products.push(qualified[0]);\n  }\n  return {schema_version:1,policy:AMAZON_POLICY,products};\n}\n\nfunction amazonIncremental(products,previous,maxRows) {\n  const accepted = validateAmazonSnapshot(previous,maxRows);\n  const saved = new Map(accepted.products.map(row => [row.product_key,row]));\n  const seen = new Set(), changes = [], issues = [];\n  const stats = {unchanged_products:0,older_observations_suppressed:0,conflicting_observations_suppressed:0,retained_products:0};\n  for (const row of products) {\n    const time = sourceTime(row.scraped_at), old = saved.get(row.product_key);\n    seen.add(row.product_key);\n    if (old) {\n      const previousTime = sourceTime(old.scraped_at);\n      if (time < previousTime) {\n        stats.older_observations_suppressed++;\n        issues.push({product_key:row.product_key,reason:'older_observation',scraped_at:row.scraped_at,accepted_scraped_at:old.scraped_at});\n        continue;\n      }\n      if (time === previousTime) {\n        const comparison = item => ({...meaningful(item),price:decimalIdentity(item.price)});\n        if (stable(comparison(row)) === stable(comparison(old))) stats.unchanged_products++;\n        else {\n          stats.conflicting_observations_suppressed++;\n          issues.push({product_key:row.product_key,reason:'same_observation_conflict',scraped_at:row.scraped_at});\n        }\n        continue;\n      }\n    }\n    saved.set(row.product_key,row); changes.push(row);\n  }\n  stats.retained_products = accepted.products.filter(row => !seen.has(row.product_key)).length;\n  const candidate = validateAmazonSnapshot({schema_version:1,policy:AMAZON_POLICY,products:[...saved.values()]},maxRows);\n  return {delta:{products:changes},candidate_baseline:candidate,stats,review_issues:issues};\n}\n\nfunction amazonHandoff(pages,config,run) {\n  validateConfig(config);\n  const rows = datasetRows(pages,config), mapped = amazonProducts(rows);\n  if (!mapped.products.length) fail('No qualified priced products; preserve the last good feed and mark it stale.');\n  const incremental = amazonIncremental(mapped.products,config.previousSnapshot,config.maxRows);\n  return {...reviewEnvelope(run,'amazon-prices',config),source_dataset_complete:true,source_rows:rows.length,\n    source_products:mapped.products,rejected_rows:mapped.rejected_rows,...incremental,\n    observation_key_fields:['product_key','scraped_at'],last_good_feed_policy:'upsert_qualified_observations_keep_missing_keys',\n    product_coverage_complete:null,coverage_note:'Reading every dataset row does not prove all requested products returned.'};\n}\n\nreturn [{json:amazonHandoff($input.all().map(item=>item.json),$('Configure existing run').first().json,$('Verify succeeded run').first().json)}];"
      }
    },
    {
      "id": "81b1fcad-9318-5471-a704-7381bbd582e2",
      "name": "Destination adapter placeholder - disabled",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1320,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "throw new Error('Replace this placeholder only after buyer-authorized destination testing. No write or acknowledgement implementation is supplied.');"
      },
      "disabled": true
    },
    {
      "id": "dde80570-7351-58aa-ad44-c7c46634699b",
      "name": "Read before running",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -120
      ],
      "parameters": {
        "content": "## Existing-run review handoff\nInactive, manual only. Set an exact completed run ID and private HTTP Header Auth on each GET.\nAmazon accepts only qualified price observations; missing products retain their last accepted value. Baseline is explicit private input. Acknowledgement must bind run, baseline revision and exact payload.\nNo Actor is started, no destination is written, and no baseline is advanced. Disabled adapter is a placeholder, not a working integration.\nSee examples/n8n/README.md. Treat execution previews as private data.",
        "height": 240,
        "width": 800
      }
    }
  ],
  "connections": {
    "Manual review": {
      "main": [
        [
          {
            "node": "Configure existing run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Configure existing run": {
      "main": [
        [
          {
            "node": "Read existing run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read existing run": {
      "main": [
        [
          {
            "node": "Verify succeeded run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify succeeded run": {
      "main": [
        [
          {
            "node": "Read all bounded dataset pages",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read all bounded dataset pages": {
      "main": [
        [
          {
            "node": "Review qualified prices",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review qualified prices": {
      "main": [
        [
          {
            "node": "Destination adapter placeholder - disabled",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 300,
    "saveManualExecutions": false,
    "saveExecutionProgress": false,
    "saveDataErrorExecution": "none",
    "saveDataSuccessExecution": "none"
  },
  "pinData": {},
  "tags": []
}
