{"record":{"id":"7842f8209a65a463","repo":"koala73/worldmonitor","slug":"incompatible-delivery","errorCode":"INCOMPATIBLE_DELIVERY","errorMessage":"Real-time delivery is for Critical events only. To receive High or All events, choose a digest cadence (Daily, Twice daily, or Weekly).","messagePattern":"Real-time delivery is for Critical events only\\. To receive High or All events, choose a digest cadence \\(Daily, Twice daily, or Weekly\\)\\.","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/alertRules.ts","lineNumber":89,"sourceCode":"// existing.sensitivity when caller omits the field (no silent narrowing of\n// digest users).\nfunction resolveEffectivePair(args: {\n  incomingDigestMode?: DigestMode;\n  incomingSensitivity?: Sensitivity;\n  existing?: { digestMode?: DigestMode | string; sensitivity?: Sensitivity | string };\n}): { digestMode: DigestMode; sensitivity: Sensitivity } {\n  const digestMode = (args.incomingDigestMode\n    ?? (args.existing?.digestMode as DigestMode | undefined)\n    ?? \"realtime\");\n  const sensitivity = (args.incomingSensitivity\n    ?? (args.existing?.sensitivity as Sensitivity | undefined)\n    ?? \"critical\"); // insert-only default — patch path never includes sensitivity unless caller passed it\n  return { digestMode, sensitivity };\n}\n\nfunction assertCompatibleDeliveryMode(pair: { digestMode: DigestMode; sensitivity: Sensitivity }) {\n  if (pair.digestMode === \"realtime\" && (pair.sensitivity === \"all\" || pair.sensitivity === \"high\")) {\n    throw new ConvexError({\n      code: \"INCOMPATIBLE_DELIVERY\",\n      message:\n        \"Real-time delivery is for Critical events only. \" +\n        \"To receive High or All events, choose a digest cadence (Daily, Twice daily, or Weekly).\",\n    });\n  }\n}\n\n// Defensive ceiling against patched-client abuse — there are ~250 ISO-3166\n// countries; 50 is more than any real user opts into and well below any\n// validator/storage limit.\nconst COUNTRIES_MAX = 50;\n\n/**\n * Shape-validate + normalize an inbound `countries` array.\n *  - trim each entry\n *  - uppercase\n *  - keep only ASCII A-Z 2-letter shapes (`^[A-Z]{2}$`); silently drop the rest","sourceCodeStart":71,"sourceCodeEnd":107,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/alertRules.ts#L71-L107","documentation":"Thrown by assertCompatibleDeliveryMode when an alert rule resolves to digestMode 'realtime' combined with sensitivity 'all' or 'high'. After the 2026-04-27 tightening, real-time delivery is reserved for critical-tier events only because high/all fire too frequently for an inbox. Callers must pick a digest cadence (daily, twice_daily, weekly) for non-critical sensitivities.","triggerScenarios":"Calling setAlertRules with sensitivity 'high' or 'all' while the existing rule (or default) has digestMode 'realtime'; calling setDigestSettings with digestMode 'realtime' on an existing rule whose stored sensitivity is 'high'/'all'; calling setNotificationConfigForUser with both digestMode:'realtime' and sensitivity:'high'/'all'. The check fires in assertCompatibleDeliveryMode via resolveEffectivePair after merging incoming + existing values.","commonSituations":"Migrating a legacy rule that previously allowed realtime+high; a UI that lets the user change sensitivity independently of digest mode without re-validating the pair; an admin/migration script invoking *ForUser internal mutations on pre-tightening rows.","solutions":["When sensitivity is 'high' or 'all', set digestMode to 'daily', 'twice_daily', or 'weekly' instead of 'realtime'.","If real-time is required, set sensitivity to 'critical'.","For atomic UI transitions from daily+all to realtime, use setNotificationConfigForUser which updates both fields in one transaction instead of separate setDigestSettings + setAlertRules calls that trip the validator mid-transition.","Run the migration referenced in docs/archive/plans/forbid-realtime-all-events.md to drain pre-existing forbidden rows."],"exampleFix":"// before\nawait setDigestSettings(ctx, { variant, digestMode: 'realtime' }); // existing.sensitivity === 'all'\n// after\nawait setNotificationConfigForUser(ctx, {\n  userId, variant,\n  digestMode: 'realtime',\n  sensitivity: 'critical', // atomic update keeps the pair consistent\n});","handlingStrategy":"validation","validationCode":"function isValidPair(digestMode, sensitivity) {\n  if (digestMode === 'realtime' && (sensitivity === 'all' || sensitivity === 'high')) return false;\n  return true;\n}\n// before submit:\nif (!isValidPair(effectiveDigestMode, effectiveSensitivity)) {\n  // surface guidance: pick a digest cadence or use 'critical'\n}","typeGuard":"type DigestMode = 'realtime' | 'daily' | 'twice_daily' | 'weekly';\ntype Sensitivity = 'all' | 'high' | 'critical';\nfunction isCompatiblePair(d: DigestMode, s: Sensitivity): boolean {\n  return !(d === 'realtime' && (s === 'all' || s === 'high'));\n}","tryCatchPattern":"try {\n  await setNotificationConfigForUser(ctx, args);\n} catch (e) {\n  if (e instanceof ConvexError && e.data?.code === 'INCOMPATIBLE_DELIVERY') {\n    // show user: choose digest cadence OR set sensitivity to 'critical'\n  } else throw e;\n}","preventionTips":["Always update digestMode and sensitivity together via setNotificationConfigForUser rather than two separate calls.","In the UI, disable the 'realtime' option whenever sensitivity is 'high' or 'all', and vice versa.","Run the migration in docs/archive/plans/forbid-realtime-all-events.md to drain legacy forbidden rows."],"tags":["convex","validation","notifications","business-rule"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}