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

  1. Change both fields atomically via setNotificationConfigForUser: digestMode 'realtime' together with sensitivity 'critical' in one call.
  2. Or keep sensitivity 'all'/'high' and choose a digest cadence: digestMode 'daily' | 'twice_daily' | 'weekly'.
  3. Or pass sensitivity 'critical' whenever digestMode is or will be 'realtime'.
  4. 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

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


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 rest

View on GitHub (pinned to eeab0a219f)