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
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.
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.
Example fix
// before — switching to realtime while stored sensitivity stays 'all'
await mutateAPI.alertRules.setDigestSettings({ variant: 'default', digestMode: 'realtime' });
// ConvexError: INCOMPATIBLE_DELIVERY
// after — atomic update with a compatible pair (server-side ctx.runMutation)
await ctx.runMutation(internal.alertRules.setNotificationConfigForUser, {
userId,
variant: 'default',
digestMode: 'realtime',
sensitivity: 'critical',
}); Defensive patterns
Strategy: validation
Validate before calling
// Mirror the server invariant before any mutation.
type DigestMode = 'realtime' | 'daily' | 'twice_daily' | 'weekly';
type Sensitivity = 'all' | 'high' | 'critical';
function assertDeliveryCompatible(digestMode: DigestMode, sensitivity: Sensitivity): void {
if (digestMode === 'realtime' && sensitivity !== 'critical') {
throw new RangeError(`realtime requires sensitivity 'critical', got '${sensitivity}'`);
}
}
assertDeliveryCompatible(nextDigestMode, nextSensitivity);
await saveNotificationConfig(nextDigestMode, nextSensitivity); Type guard
const isCompatibleDelivery = (d: DigestMode, s: Sensitivity): boolean => d !== 'realtime' || s === 'critical';
Try / catch
try {
await mutateAPI.alertRules.setDigestSettings(args);
} catch (err) {
if (err instanceof ConvexError && (err.data as { code?: string }).code === 'INCOMPATIBLE_DELIVERY') {
// Resync the form from getAlertRules and force a compatible pair (realtime+critical, or digest+any).
return;
}
throw err;
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- COUNTRIES_LIMIT_EXCEEDED
- quietHoursStart and quietHoursEnd must differ (same value =…
- COMPANY_MONITORING_ADMISSION_QUERY_VERSION_INVALID
- COMPANY_MONITORING_EVIDENCE_REVISION_INVALID
- COMPANY_MONITORING_ _INVALID
AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21).
Data as JSON: /api/errors/7842f8209a65a463.
Report an issue: GitHub.
Appendix: 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 eeab0a219f)