koala73/worldmonitor · error · ConvexError
INCOMPATIBLE_DELIVERY
INCOMPATIBLE_DELIVERY
Error message
Real-time delivery is for Critical events only. To receive High or All events, choose a digest cadence (Daily, Twice daily, or Weekly).
What it means
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.
Source
Thrown at convex/alertRules.ts:89
// existing.sensitivity when caller omits the field (no silent narrowing of
// digest users).
function resolveEffectivePair(args: {
incomingDigestMode?: DigestMode;
incomingSensitivity?: Sensitivity;
existing?: { digestMode?: DigestMode | string; sensitivity?: Sensitivity | string };
}): { digestMode: DigestMode; sensitivity: Sensitivity } {
const digestMode = (args.incomingDigestMode
?? (args.existing?.digestMode as DigestMode | undefined)
?? "realtime");
const sensitivity = (args.incomingSensitivity
?? (args.existing?.sensitivity as Sensitivity | undefined)
?? "critical"); // insert-only default — patch path never includes sensitivity unless caller passed it
return { digestMode, sensitivity };
}
function assertCompatibleDeliveryMode(pair: { digestMode: DigestMode; sensitivity: Sensitivity }) {
if (pair.digestMode === "realtime" && (pair.sensitivity === "all" || pair.sensitivity === "high")) {
throw new ConvexError({
code: "INCOMPATIBLE_DELIVERY",
message:
"Real-time delivery is for Critical events only. " +
"To receive High or All events, choose a digest cadence (Daily, Twice daily, or Weekly).",
});
}
}
// Defensive ceiling against patched-client abuse — there are ~250 ISO-3166
// countries; 50 is more than any real user opts into and well below any
// validator/storage limit.
const COUNTRIES_MAX = 50;
/**
* Shape-validate + normalize an inbound `countries` array.
* - trim each entry
* - uppercase
* - keep only ASCII A-Z 2-letter shapes (`^[A-Z]{2}$`); silently drop the restView on GitHub (pinned to ffec79ac33)
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.
Example fix
// before
await setDigestSettings(ctx, { variant, digestMode: 'realtime' }); // existing.sensitivity === 'all'
// after
await setNotificationConfigForUser(ctx, {
userId, variant,
digestMode: 'realtime',
sensitivity: 'critical', // atomic update keeps the pair consistent
}); Defensive patterns
Strategy: validation
Validate before calling
function isValidPair(digestMode, sensitivity) {
if (digestMode === 'realtime' && (sensitivity === 'all' || sensitivity === 'high')) return false;
return true;
}
// before submit:
if (!isValidPair(effectiveDigestMode, effectiveSensitivity)) {
// surface guidance: pick a digest cadence or use 'critical'
} Type guard
type DigestMode = 'realtime' | 'daily' | 'twice_daily' | 'weekly';
type Sensitivity = 'all' | 'high' | 'critical';
function isCompatiblePair(d: DigestMode, s: Sensitivity): boolean {
return !(d === 'realtime' && (s === 'all' || s === 'high'));
} Try / catch
try {
await setNotificationConfigForUser(ctx, args);
} catch (e) {
if (e instanceof ConvexError && e.data?.code === 'INCOMPATIBLE_DELIVERY') {
// show user: choose digest cadence OR set sensitivity to 'critical'
} else throw e;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- quietHoursStart and quietHoursEnd must differ (same value =
- COUNTRIES_LIMIT_EXCEEDED
- TICKERS_LIMIT_EXCEEDED
- digestHour must be an integer 0–23
- digestTimezone must be a valid IANA timezone (e.g. America/N
AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12).
Data as JSON: /api/errors/7842f8209a65a463.
Report an issue: GitHub.