koala73/worldmonitor · critical · ConvexError

ACCOUNT_OWNER_FENCE_CONFLICT

Error message

ACCOUNT_OWNER_FENCE_CONFLICT

What it means

Thrown by findAccountByOwnerFence (accounts.ts:121-122) when two or more different owner fence hash candidates in ownerFence.all resolve to different companyMonitoringAccounts rows via the by_ownerFenceHash index. This indicates a data-integrity violation: multiple account roots exist for what should be a single owner. The fence keyring is designed so all current+historical hashes map to exactly one account.

Source

Thrown at convex/companyMonitoring/accounts.ts:122

    Math.max(0, delayMs),
    internal.companyMonitoring.accounts.advanceAccountPurge,
    { ownerFenceHash, purgeGeneration },
  );
}

async function findAccountByOwnerFence(
  ctx: MutationCtx,
  ownerFence: CompanyMonitoringOwnerFenceCandidates,
): Promise<{ account: Doc<"companyMonitoringAccounts">; matchedHash: string } | null> {
  let match: { account: Doc<"companyMonitoringAccounts">; matchedHash: string } | null = null;
  for (const ownerFenceHash of ownerFence.all) {
    const account = await ctx.db
      .query("companyMonitoringAccounts")
      .withIndex("by_ownerFenceHash", (q) => q.eq("ownerFenceHash", ownerFenceHash))
      .unique();
    if (!account) continue;
    if (match && match.account._id !== account._id) {
      throw new ConvexError("ACCOUNT_OWNER_FENCE_CONFLICT");
    }
    match = { account, matchedHash: ownerFenceHash };
  }
  return match;
}

async function findAccountByOwnerUserId(
  ctx: MutationCtx,
  ownerUserId: string,
): Promise<Doc<"companyMonitoringAccounts"> | null> {
  const accounts = await ctx.db
    .query("companyMonitoringAccounts")
    .withIndex("by_ownerUserId", (q) => q.eq("ownerUserId", ownerUserId))
    .take(2);
  if (accounts.length > 1) throw new ConvexError("ACCOUNT_OWNER_FENCE_CONFLICT");
  return accounts[0] ?? null;
}

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Inspect the companyMonitoringAccounts table for rows sharing any ownerFenceHash in the user's fence candidate set — identify the duplicate roots.
  2. Determine which row is canonical (correct ownerUserId, non-terminal, latest lifecycleSequence) and delete or terminalize the other(s).
  3. Audit the fence keyring configuration (companyMonitoringOwnerFenceCandidates) for a misconfigured or dropped key that caused the split.
  4. Run a reconciliation script via syncCompanyMonitoringAccountFromEntitlement after dedup to confirm the conflict is resolved.
Defensive patterns

Strategy: try-catch

Try / catch

try {
  await syncCompanyMonitoringAccountFromEntitlement(ctx, userId);
} catch (err) {
  if (err instanceof ConvexError && err.message === "ACCOUNT_OWNER_FENCE_CONFLICT") {
    // data-integrity incident — alert ops, do not auto-retry
    throw err;
  }
  throw err;
}

Prevention

When it happens

Trigger: A fence key was added or removed from the keyring configuration, causing two historical hashes to point at two distinct account rows. A bug in account provisioning created a duplicate root before the owner-binding reconciliation was added. Manual DB edits inserted a second row with a colliding fence hash.

Common situations: Operator rotated or dropped a fence key in the keyring config without running the migration that reconciles roots. Two deployments with different keyring configs wrote to the same table. A prior version of the code (before the owner-binding guard) created duplicate roots that now collide under the current keyring.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/d4ad12d1779ddb4c. Report an issue: GitHub.