{"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/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/alertRules.ts#L71-L107","documentation":"assertCompatibleDeliveryMode (convex/alertRules.ts:87) enforces the tightened 2026-04-27 rule: digestMode 'realtime' is compatible only with sensitivity 'critical'. The pairs (realtime, all) and (realtime, high) throw ConvexError { code: 'INCOMPATIBLE_DELIVERY' } so that high-frequency events reach users through a digest cadence (daily / twice_daily / weekly) instead of flooding them. The checked pair is the EFFECTIVE pair: resolveEffectivePair merges incoming args with the stored row and falls back to realtime/critical, so a patch can trip this even when sensitivity was not passed.","triggerScenarios":"setAlertRules passing sensitivity 'all' or 'high' while the stored digestMode is 'realtime' (or on insert where digestMode defaults to 'realtime'); setDigestSettings switching digestMode to 'realtime' while the stored sensitivity is 'all'/'high'; setNotificationConfigForUser resolving any input + stored combination to a forbidden pair; a UI saving digest mode and sensitivity in two sequential calls where the first call alone already violates the rule.","commonSituations":"Settings UI with independent controls for digest cadence and sensitivity — switching one without the other trips the validator mid-flow (this two-call race is why the atomic setNotificationConfigForUser mutation exists, per docs/archive/plans/forbid-realtime-all-events.md); a user downgrading from digest to realtime without changing sensitivity; migration scripts replaying pre-2026-04-27 settings.","solutions":["Change both fields atomically via setNotificationConfigForUser: digestMode 'realtime' together with sensitivity 'critical' in one call.","Or keep sensitivity 'all'/'high' and choose a digest cadence: digestMode 'daily' | 'twice_daily' | 'weekly'.","Or pass sensitivity 'critical' whenever digestMode is or will be 'realtime'.","When patching, remember the effective pair merges with the stored row — send both fields explicitly instead of relying on defaults."],"exampleFix":"// before — switching to realtime while stored sensitivity stays 'all'\nawait mutateAPI.alertRules.setDigestSettings({ variant: 'default', digestMode: 'realtime' });\n// ConvexError: INCOMPATIBLE_DELIVERY\n\n// after — atomic update with a compatible pair (server-side ctx.runMutation)\nawait ctx.runMutation(internal.alertRules.setNotificationConfigForUser, {\n  userId,\n  variant: 'default',\n  digestMode: 'realtime',\n  sensitivity: 'critical',\n});","handlingStrategy":"validation","validationCode":"// Mirror the server invariant before any mutation.\ntype DigestMode = 'realtime' | 'daily' | 'twice_daily' | 'weekly';\ntype Sensitivity = 'all' | 'high' | 'critical';\n\nfunction assertDeliveryCompatible(digestMode: DigestMode, sensitivity: Sensitivity): void {\n  if (digestMode === 'realtime' && sensitivity !== 'critical') {\n    throw new RangeError(`realtime requires sensitivity 'critical', got '${sensitivity}'`);\n  }\n}\nassertDeliveryCompatible(nextDigestMode, nextSensitivity);\nawait saveNotificationConfig(nextDigestMode, nextSensitivity);","typeGuard":"const isCompatibleDelivery = (d: DigestMode, s: Sensitivity): boolean =>\n  d !== 'realtime' || s === 'critical';","tryCatchPattern":"try {\n  await mutateAPI.alertRules.setDigestSettings(args);\n} catch (err) {\n  if (err instanceof ConvexError && (err.data as { code?: string }).code === 'INCOMPATIBLE_DELIVERY') {\n    // Resync the form from getAlertRules and force a compatible pair (realtime+critical, or digest+any).\n    return;\n  }\n  throw err;\n}","preventionTips":["Model digest cadence and sensitivity as one coupled control; never two independent saves.","Always send sensitivity: 'critical' in the same payload that sets digestMode: 'realtime'.","Read the stored rule before patching — pre-migration rows may hold forbidden pairs.","Prefer the atomic setNotificationConfigForUser flow over chained setDigestSettings + setAlertRules."],"tags":["convex","validation","business-rule","notifications","cross-field"],"backgroundTag":"conflicting-option-combination","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}