koala73/worldmonitor · error · ConvexError

REMOVED_COMPANY_IS_TERMINAL

REMOVED_COMPANY_IS_TERMINAL

Error message

REMOVED_COMPANY_IS_TERMINAL

What it means

Thrown by the company set-state mutation (convex/companyMonitoring/companies.ts:361) when assertLifecycleTransition("company", currentLifecycle, targetState) rejects the requested transition; the catch block maps any invalid transition to REMOVED_COMPANY_IS_TERMINAL. Note the mapping is broader than the name implies: any disallowed transition (not only ones involving removed) surfaces under this code.

Solutions

  1. Re-read the company's current lifecycle and compute the next allowed transition with the same assertLifecycleTransition helper from the shared contract before calling
  2. If the company is removed, stop issuing state changes — removal is terminal; create a new company instead
  3. Make state actions idempotent-aware: treat "already in target state" as success (the mutation itself returns unchanged for equal states)
  4. Serialize concurrent state edits per company (disable the toggle while a request is in flight)

Example fix

// before
await api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state: "active" }); // may be invalid from current state

// after
const company = await api.companyMonitoring.getCompany({ ownerUserId, companyId });
if (!company) throw new UserFacingError("Company not found");
if (company.lifecycle === "removed") throw new UserFacingError("Removed companies are terminal.");
if (company.lifecycle !== "active") {
  await api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state: "active" });
}
Defensive patterns

Strategy: type-guard

Validate before calling

const company = await api.companyMonitoring.getCompany({ ownerUserId, companyId });
if (!company) throw new UserFacingError("Company not found.");
if (!isAllowedTransition(company.lifecycle, state)) {
  throw new UserFacingError(`Cannot move a ${company.lifecycle} company to ${state}.`);
}
await api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state });

Type guard

const ALLOWED_COMPANY_TRANSITIONS: Record<string, string[]> = {
  active: ["paused", "removed"],
  paused: ["active", "removed"],
  removed: [], // terminal
};
function isAllowedTransition(from: string, to: string): boolean {
  return from === to || (ALLOWED_COMPANY_TRANSITIONS[from] ?? []).includes(to);
}

Try / catch

try {
  await api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state });
} catch (err) {
  if (err instanceof ConvexError && err.data === "REMOVED_COMPANY_IS_TERMINAL") {
    const company = await api.companyMonitoring.getCompany({ ownerUserId, companyId });
    if (company?.lifecycle === state) return { status: "unchanged", companyId };
    ui.notify("This company can no longer change state (removed or invalid transition).");
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: Requesting a transition the lifecycle machine forbids (as enforced by assertLifecycleTransition in the shared contract); asking a removed company to become active or paused (covered again by the explicit removed check); duplicated/out-of-order state writes where a concurrent transition already moved the company.

Common situations: Clients racing each other (one pauses, another resumes, a third removes); replayed state-change webhooks; UI toggle buttons built from stale lifecycle values; state machines extended with new rules during an upgrade while old clients keep sending now-forbidden transitions.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


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

Appendix: source

Thrown at convex/companyMonitoring/companies.ts:361

export const setCompanyStateForOwner = internalMutation({
  args: {
    ownerUserId: v.string(),
    companyId: v.string(),
    state: v.union(v.literal("active"), v.literal("paused"), v.literal("removed")),
  },
  handler: async (ctx, args) => {
    const account = await requireActiveAccount(ctx, args.ownerUserId);
    const company = await ctx.db
      .query("companyMonitoringCompanies")
      .withIndex("by_account_companyId", (q) =>
        q.eq("ownerAccountId", account.logicalAccountId).eq("companyId", args.companyId),
      )
      .unique();
    if (!company) throw new ConvexError("NOT_FOUND");
    try {
      assertLifecycleTransition("company", company.lifecycle, args.state);
    } catch {
      throw new ConvexError("REMOVED_COMPANY_IS_TERMINAL");
    }
    if (company.lifecycle === "removed") {
      if (args.state === "removed") return { status: "already_removed", companyId: company.companyId };
      throw new ConvexError("REMOVED_COMPANY_IS_TERMINAL");
    }
    if (company.lifecycle === args.state) return { status: "unchanged", companyId: company.companyId };

    const now = Date.now();
    if (args.state !== "removed") {
      if (args.state === "paused") {
        await cancelCompanyScanWork(ctx, {
          ownerAccountId: account.logicalAccountId,
          companyId: company.companyId,
          reason: "superseded",
        });
      }
      await ctx.db.patch(company._id, {
        lifecycle: args.state,

View on GitHub (pinned to 7d06c8633d)