{"record":{"id":"fbf152b153c9ee71","repo":"koala73/worldmonitor","slug":"removed-company-is-terminal","errorCode":"REMOVED_COMPANY_IS_TERMINAL","errorMessage":"REMOVED_COMPANY_IS_TERMINAL","messagePattern":"REMOVED_COMPANY_IS_TERMINAL","errorType":"exception","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/companyMonitoring/companies.ts","lineNumber":361,"sourceCode":"export const setCompanyStateForOwner = internalMutation({\n  args: {\n    ownerUserId: v.string(),\n    companyId: v.string(),\n    state: v.union(v.literal(\"active\"), v.literal(\"paused\"), v.literal(\"removed\")),\n  },\n  handler: async (ctx, args) => {\n    const account = await requireActiveAccount(ctx, args.ownerUserId);\n    const company = await ctx.db\n      .query(\"companyMonitoringCompanies\")\n      .withIndex(\"by_account_companyId\", (q) =>\n        q.eq(\"ownerAccountId\", account.logicalAccountId).eq(\"companyId\", args.companyId),\n      )\n      .unique();\n    if (!company) throw new ConvexError(\"NOT_FOUND\");\n    try {\n      assertLifecycleTransition(\"company\", company.lifecycle, args.state);\n    } catch {\n      throw new ConvexError(\"REMOVED_COMPANY_IS_TERMINAL\");\n    }\n    if (company.lifecycle === \"removed\") {\n      if (args.state === \"removed\") return { status: \"already_removed\", companyId: company.companyId };\n      throw new ConvexError(\"REMOVED_COMPANY_IS_TERMINAL\");\n    }\n    if (company.lifecycle === args.state) return { status: \"unchanged\", companyId: company.companyId };\n\n    const now = Date.now();\n    if (args.state !== \"removed\") {\n      if (args.state === \"paused\") {\n        await cancelCompanyScanWork(ctx, {\n          ownerAccountId: account.logicalAccountId,\n          companyId: company.companyId,\n          reason: \"superseded\",\n        });\n      }\n      await ctx.db.patch(company._id, {\n        lifecycle: args.state,","sourceCodeStart":343,"sourceCodeEnd":379,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/convex/companyMonitoring/companies.ts#L343-L379","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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)"],"exampleFix":"// before\nawait api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state: \"active\" }); // may be invalid from current state\n\n// after\nconst company = await api.companyMonitoring.getCompany({ ownerUserId, companyId });\nif (!company) throw new UserFacingError(\"Company not found\");\nif (company.lifecycle === \"removed\") throw new UserFacingError(\"Removed companies are terminal.\");\nif (company.lifecycle !== \"active\") {\n  await api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state: \"active\" });\n}","handlingStrategy":"type-guard","validationCode":"const company = await api.companyMonitoring.getCompany({ ownerUserId, companyId });\nif (!company) throw new UserFacingError(\"Company not found.\");\nif (!isAllowedTransition(company.lifecycle, state)) {\n  throw new UserFacingError(`Cannot move a ${company.lifecycle} company to ${state}.`);\n}\nawait api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state });","typeGuard":"const ALLOWED_COMPANY_TRANSITIONS: Record<string, string[]> = {\n  active: [\"paused\", \"removed\"],\n  paused: [\"active\", \"removed\"],\n  removed: [], // terminal\n};\nfunction isAllowedTransition(from: string, to: string): boolean {\n  return from === to || (ALLOWED_COMPANY_TRANSITIONS[from] ?? []).includes(to);\n}","tryCatchPattern":"try {\n  await api.companyMonitoring.setCompanyState({ ownerUserId, companyId, state });\n} catch (err) {\n  if (err instanceof ConvexError && err.data === \"REMOVED_COMPANY_IS_TERMINAL\") {\n    const company = await api.companyMonitoring.getCompany({ ownerUserId, companyId });\n    if (company?.lifecycle === state) return { status: \"unchanged\", companyId };\n    ui.notify(\"This company can no longer change state (removed or invalid transition).\");\n    return;\n  }\n  throw err;\n}","preventionTips":["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"],"tags":["convex","state-machine","lifecycle","terminal","mutation"],"backgroundTag":"invalid-state-transition","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}