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
- Re-read the company's current lifecycle and compute the next allowed transition with the same assertLifecycleTransition helper from the shared contract before calling
- If the company is removed, stop issuing state changes — removal is terminal; create a new company instead
- Make state actions idempotent-aware: treat "already in target state" as success (the mutation itself returns unchanged for equal states)
- 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
- Model the transition table in the client and disable impossible actions
- Treat equal-state requests as success client-side (server returns unchanged)
- Serialize state edits per company to avoid racing transitions
- Remember the error code covers all invalid transitions, not just removed-company ones
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)