koala73/worldmonitor · error · ConvexError

REMOVED_COMPANY_IS_TERMINAL

Error message

REMOVED_COMPANY_IS_TERMINAL

What it means

Thrown by setCompanyStateForOwner when assertLifecycleTransition('company', currentLifecycle, targetState) throws. This is the generic lifecycle-transition guard: it rejects any transition the company state machine does not permit (e.g., paused->removed is allowed, but invalid combinations are not). The catch swallows the specific reason and rethrows this terminal-styled error.

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 ffec79ac33)

Solutions

  1. Check assertLifecycleTransition's allowed transitions in shared/company-monitoring-contract before calling.
  2. Only request transitions to active, paused, or removed from their documented allowed source states.
  3. Re-read the company's current lifecycle and compute a legal target from it.

Example fix

// before
await setState({ ownerUserId, companyId, state: targetState }); // may be illegal

// after
const company = await fetchCompany(companyId);
if (!isLegalCompanyTransition(company.lifecycle, targetState)) {
  throw new UserError(`Cannot transition ${company.lifecycle} -> ${targetState}`);
}
await setState({ ownerUserId, companyId, state: targetState });
Defensive patterns

Strategy: validation

Validate before calling

import { assertLifecycleTransition } from "shared/company-monitoring-contract";
try {
  assertLifecycleTransition("company", current.lifecycle, targetState);
} catch {
  throw new UserError(`Illegal transition ${current.lifecycle} -> ${targetState}`);
}

Type guard

function isLegalCompanyTransition(from: string, to: "active"|"paused"|"removed"): boolean {
  try { assertLifecycleTransition("company", from, to); return true; } catch { return false; }
}

Prevention

When it happens

Trigger: Requesting a state transition the company lifecycle machine disallows, e.g. an illegal direct jump not whitelisted by assertLifecycleTransition.

Common situations: Calling setState with a state value outside active|paused|removed (caught earlier by the validator) or a disallowed source->target pairing; a stale client sending a transition that newer contract rules forbid.

Related errors


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