{"record":{"id":"b52b886229eb702b","repo":"koala73/worldmonitor","slug":"invalid-disruption-override","errorCode":null,"errorMessage":"Invalid disruption override","messagePattern":"Invalid disruption override","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"scripts/scenario-worker.mjs","lineNumber":236,"sourceCode":"/** @param {number} score @param {number} severity @param {number} multiplier */\nexport function physicalImpact(score, severity, multiplier) {\n  return score * (severity / 100) * multiplier;\n}\n\n/**\n * @param {string} scenarioId\n * @param {string | null} iso2\n * @param {number | undefined} [disruptionPct]\n */\nexport async function computeScenario(scenarioId, iso2, disruptionPct) {\n  const template = SCENARIO_TEMPLATES.find(t => t.id === scenarioId);\n  if (!template) throw new Error(`Unknown scenario: ${scenarioId}`);\n  const isTariffShock = template.affectedChokepointIds.length === 0;\n  if (iso2 !== null && (typeof iso2 !== 'string' || !/^[A-Z]{2}$/.test(iso2))) {\n    throw new Error('Invalid country');\n  }\n  if (disruptionPct !== undefined && (isTariffShock || !Number.isInteger(disruptionPct) || disruptionPct < 0 || disruptionPct > 100)) {\n    throw new Error('Invalid disruption override');\n  }\n  const severity = disruptionPct ?? template.disruptionPct;\n  const manifest = await redisGet('seed-meta:supply_chain:chokepoint-exposure').catch(() => null);\n  const validIds = (values, pattern, limit) => Array.isArray(values) && values.length > 0\n    && values.length <= limit && values.every(v => typeof v === 'string' && pattern.test(v))\n    && new Set(values).size === values.length;\n  // Deliberately NOT gated on `manifest.status === 'ok'`. The manifest's country/sector\n  // arrays describe the seeder's static universe, not the outcome of its last run — a\n  // failed run leaves them true while invalidating per-key freshness, which the per-record\n  // `missing` state already reports. Requiring 'ok' here turned any single seeder failure\n  // into a total feature blackout even though the exposure keys stay TTL-extended.\n  const manifestKnown = manifest?.manifestVersion === 1\n    && validIds(manifest.countryIds, /^[A-Z]{2}$/, 250)\n    && validIds(manifest.hs2Codes, /^(0[1-9]|[1-9][0-9])$/, 99);\n  const countryIds = iso2 ? [iso2] : manifestKnown ? manifest.countryIds : [];\n  const hs2Codes = template.affectedHs2 ?? (manifestKnown ? manifest.hs2Codes : []);\n  const records = [];\n  const pending = [];","sourceCodeStart":218,"sourceCodeEnd":254,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/scripts/scenario-worker.mjs#L218-L254","documentation":"computeScenario() rejects a disruptionPct override that cannot legally apply to the chosen scenario. An override must be an integer in [0,100], and for 'tariff shock' scenarios (those with no affected chokepoints) overrides are forbidden entirely because severity is driven by tariff logic, not a chokepoint disruption percentage.","triggerScenarios":"Calling computeScenario for a tariff-shock template (affectedChokepointIds empty) with any disruptionPct other than undefined; or for any scenario with a non-integer (e.g. 12.5), out-of-range (<0 or >100), or non-number (string '50') override.","commonSituations":"A slider UI producing fractional values; passing a form string instead of a number; assuming overrides apply to all scenario types; sending 0 or 150 from unbounded input; API callers replaying overrides against a tariff scenario id.","solutions":["For tariff-shock scenarios, do not pass disruptionPct at all — leave it undefined to use the template default.","Clamp and round overrides before the call: Math.min(100, Math.max(0, Math.round(pct))).","Coerce form/query input to a number first (Number(input)) and check Number.isInteger.","If a percentage override is genuinely needed for tariff scenarios, extend the template/validation logic rather than bypassing the check."],"exampleFix":"// before\nawait computeScenario('tariff-shock-2025', null, 40); // tariff scenario rejects overrides\nawait computeScenario('chokepoint-closure', null, 12.5);\n// after\nconst pct = scenarioId.startsWith('tariff') ? undefined : Math.min(100, Math.max(0, Math.round(Number(rawPct))));\nawait computeScenario(scenarioId, null, pct);","handlingStrategy":"validation","validationCode":"const isTariffShock = template.affectedChokepointIds.length === 0;\nfunction sanitizeOverride(rawPct, isTariff) {\n  if (rawPct === undefined || rawPct === null || isTariff) return undefined;\n  const n = Number(rawPct);\n  if (!Number.isInteger(n) || n < 0 || n > 100) throw new Error(`Override must be an integer 0-100, got ${rawPct}`);\n  return n;\n}","typeGuard":"const isValidOverride = (v) => v === undefined || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= 100);","tryCatchPattern":"try {\n  await computeScenario(id, iso2, pct);\n} catch (e) {\n  if (e.message === 'Invalid disruption override') {\n    log.warn(`Override ${pct} rejected for ${id}; falling back to template default`);\n    return computeScenario(id, iso2, undefined);\n  }\n  throw e;\n}","preventionTips":["Clamp and round slider/form values (Math.round, min 0, max 100) before sending.","Coerce strings from query params/forms with Number() and validate Number.isInteger.","Know which scenario templates are tariff-shock and disable the override control in the UI for them.","Add a shared validator used by both UI and worker so rules never drift."],"tags":["validation","arguments","range-check","scenario-worker"],"backgroundTag":"value-out-of-range","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-15T18:17:12.389Z"}