{"record":{"id":"fb77308e6c2942fd","repo":"koala73/worldmonitor","slug":"replay-conflict","errorCode":"REPLAY_CONFLICT","errorMessage":"REPLAY_CONFLICT","messagePattern":"REPLAY_CONFLICT","errorType":"exception","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/companyMonitoring/companies.ts","lineNumber":123,"sourceCode":"    ownerUserId: v.string(),\n    clientRequestId: v.string(),\n    company: monitoredCompanyInputValidator,\n  },\n  handler: async (ctx, args) => {\n    const account = await requireProvisionedAccount(ctx, args.ownerUserId);\n    const clientRequestId = normalizeRequestId(args.clientRequestId);\n    const company = normalizeMonitoredCompanyInput(args.company as MonitoredCompanyInput);\n    const directFingerprint = await fingerprint({ version: \"cm-direct-v1\", company });\n\n    const replay = await ctx.db\n      .query(\"companyMonitoringCompanies\")\n      .withIndex(\"by_account_directRequestId\", (q) =>\n        q.eq(\"ownerAccountId\", account.logicalAccountId).eq(\"directRequestId\", clientRequestId),\n      )\n      .unique();\n    if (replay) {\n      if (replay.directFingerprint !== directFingerprint) {\n        throw new ConvexError(\"REPLAY_CONFLICT\");\n      }\n      return { status: \"replayed\", companyId: replay.companyId };\n    }\n\n    const noop = await findNoopByCustomerReference(\n      ctx,\n      account.logicalAccountId,\n      company.customerReference,\n    );\n    if (noop) return { status: \"noop\", companyId: noop.companyId };\n\n    const companyCount = account.companyCount ?? 0;\n    if (companyCount >= (account.companyLimit ?? COMPANY_LIMIT)) {\n      throw new ConvexError(\"COMPANY_LIMIT_REACHED\");\n    }\n\n    const companyId = await insertNormalizedCompany(ctx, account, company, {\n      directRequestId: clientRequestId,","sourceCodeStart":105,"sourceCodeEnd":141,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/convex/companyMonitoring/companies.ts#L105-L141","documentation":"Thrown by createCompanyForOwner (convex/companyMonitoring/companies.ts:123) when a company record already exists with the same ownerAccountId + clientRequestId (via by_account_directRequestId), but the stored directFingerprint — computed as fingerprint({ version: \"cm-direct-v1\", company }) over the normalized company input — differs from the current request's. clientRequestId is an idempotency key: reusing it with a different company payload is a conflict, not a replay.","triggerScenarios":"A retry framework reusing the same clientRequestId after the user edited the company form (name, domicileCountry, customerReference, or claims changed); two different companies accidentally sent with one shared request id (e.g. a loop variable bug); regenerating request ids from a hash of the user session instead of the submission; copy-pasting an integration test's fixed id across payloads.","commonSituations":"Mobile/web clients with a retry button that does not mint a fresh id after payload edits; queue redelivery where the payload is rebuilt with defaults applied differently; test suites hardcoding clientRequestId; idempotency keys derived from a counter that resets on redeploy.","solutions":["Mint a new clientRequestId whenever the company payload changes (or after a user edit) and retry","Generate ids per submission (uuid) at the moment the user confirms, and store payload+id together so retries replay byte-identical input","If the original creation is what you want, fetch the existing company with the same request id instead of re-submitting different data","Audit retry middleware to confirm it freezes both the id and the payload snapshot"],"exampleFix":"// before\nasync function createCompany(company, clientRequestId) {\n  return api.mutate({ clientRequestId, company }); // id reused after user edits company\n}\n\n// after\nlet lastPayloadHash = null;\nasync function createCompany(company, clientRequestId) {\n  const payloadHash = await fingerprint({ version: \"cm-direct-v1\", company });\n  const id = payloadHash === lastPayloadHash ? clientRequestId : newRequestId();\n  lastPayloadHash = payloadHash;\n  return api.mutate({ clientRequestId: id, company });\n}","handlingStrategy":"validation","validationCode":"// Bind the idempotency key to the exact payload: mint a new id when the payload changes.\nconst payloadKey = await fingerprint({ version: \"cm-direct-v1\", company });\nlet clientRequestId = idStore.get(payloadKey);\nif (!clientRequestId) {\n  clientRequestId = crypto.randomUUID();\n  idStore.set(payloadKey, clientRequestId);\n}\nawait api.companyMonitoring.createCompanyForOwner({ ownerUserId, clientRequestId, company });","typeGuard":null,"tryCatchPattern":"try {\n  await api.companyMonitoring.createCompanyForOwner({ ownerUserId, clientRequestId, company });\n} catch (err) {\n  if (err instanceof ConvexError && err.data === \"REPLAY_CONFLICT\") {\n    clientRequestId = crypto.randomUUID(); // payload genuinely differs from the stored replay\n    await api.companyMonitoring.createCompanyForOwner({ ownerUserId, clientRequestId, company });\n    return;\n  }\n  throw err;\n}","preventionTips":["Generate clientRequestId per submission and persist it with the exact payload sent","On user edits after a failure, mint a fresh id instead of reusing the old one","Never share fixed ids across test cases with different payloads","On REPLAY_CONFLICT, decide deliberately: new id (new intent) or fetch the stored company (same intent)"],"tags":["convex","idempotency","replay","conflict","mutation"],"backgroundTag":"idempotency-replay-conflict","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"}