koala73/worldmonitor · error · ConvexError

LEGACY_COMP_SOURCE_REQUIRES_AUDIT

LEGACY_COMP_SOURCE_REQUIRES_AUDIT

Error message

LEGACY_COMP_SOURCE_REQUIRES_AUDIT

What it means

During entitlement merge (merging an anonymous/legacy entitlement into an existing user entitlement), both sides have an ACTIVE comp (complimentary) period but the compPlanKey presence differs — meaning one comp came from a legacy source without a plan key. The system cannot safely decide which comp to keep, so it throws to force a manual audit instead of silently picking one.

Solutions

  1. Audit the affected entitlements manually and backfill/normalize compPlanKey (or clear one comp period) before retrying the merge.
  2. Identify the legacy comp grant source and update it to always set compPlanKey.
  3. As an operator, patch one of the two entitlements so only one active comp exists, then re-run the recompute.

Example fix

// before (data)
{ compUntil: 1735689600000, compPlanKey: undefined }  // legacy comp missing plan key
// after
await ctx.db.patch(ent._id, { compPlanKey: "pro_monthly" });  // backfill, then re-run merge
Defensive patterns

Strategy: try-catch

Validate before calling

const conflict = anonCompActive && existingCompActive &&
  Boolean(anonEntitlement.compPlanKey) !== Boolean(existingEntitlement.compPlanKey);
if (conflict) await auditLegacyCompSource(anonEntitlement, existingEntitlement); // resolve before merge

Try / catch

try {
  await mergeEntitlements(args);
} catch (e) {
  if (isConvexError(e) && e.data?.kind === "LEGACY_COMP_SOURCE_REQUIRES_AUDIT") {
    enqueueEntitlementAudit(e.data, args.userId);
  }
}

Prevention

When it happens

Trigger: Recompute/merge runs where anonEntitlement.compUntil and existingEntitlement.compUntil are both > recomputeTimestamp AND Boolean(anonEntitlement.compPlanKey) !== Boolean(existingEntitlement.compPlanKey) — i.e. one active comp has a plan key and the other doesn't.

Common situations: A user claims an account after using an anonymous comp granted before compPlanKey was introduced; data migration mixes pre- and post-planKey comp records; a comp was granted by a legacy script that left compPlanKey null.

Understand the failure class

Background: "This is a bug, please report it": internal invariant violations, unreachable panics, and SNH errors explained — this error's family across 47 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/dd93194277fd110e. Report an issue: GitHub.

Appendix: source

Thrown at convex/payments/billing.ts:3829

    // Move entitlement rows first, then let the shared recompute path derive
    // the final paid/free state from the post-claim subscriptions. If the
    // anonymous row carried a future complimentary floor, transfer it only
    // when it does not undercut stronger current real-user coverage.
    const recomputeTimestamp = Date.now();
    if (anonEntitlement) {
      const existingEntitlement = await ctx.db
        .query("entitlements")
        .withIndex("by_userId", (q) => q.eq("userId", realUserId))
        .first();
      if (existingEntitlement) {
        const anonCompUntil = anonEntitlement.compUntil ?? 0;
        const existingCompUntil = existingEntitlement.compUntil ?? 0;
        const anonCompActive = anonCompUntil > recomputeTimestamp;
        const existingCompActive = existingCompUntil > recomputeTimestamp;
        if (anonCompActive && existingCompActive
          && Boolean(anonEntitlement.compPlanKey) !== Boolean(existingEntitlement.compPlanKey)) {
          throw new ConvexError({ kind: "LEGACY_COMP_SOURCE_REQUIRES_AUDIT" });
        }
        if (anonEntitlement.compPlanKey && anonCompUntil > recomputeTimestamp) {
          const existingCompIsStronger = existingEntitlement.compPlanKey
            && existingCompUntil > recomputeTimestamp
            && compareEntitlementPlans(
              { planKey: existingEntitlement.compPlanKey, validUntil: existingCompUntil },
              { planKey: anonEntitlement.compPlanKey, validUntil: anonCompUntil },
            ) >= 0;
          await ctx.db.patch(existingEntitlement._id, {
            compPlanKey: existingCompIsStronger
              ? existingEntitlement.compPlanKey
              : anonEntitlement.compPlanKey,
            compUntil: Math.max(existingCompUntil, anonCompUntil),
          });
        } else if (anonCompUntil > existingCompUntil && anonCompUntil > recomputeTimestamp) {
          const realSubscriptions = await ctx.db
            .query("subscriptions")
            .withIndex("by_userId", (q) => q.eq("userId", realUserId))

View on GitHub (pinned to 7d06c8633d)