{"record":{"id":"9636e9fbba27a823","repo":"koala73/worldmonitor","slug":"company-monitoring-classification-replay-conflict","errorCode":"COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT","errorMessage":"COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT","messagePattern":"COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/companyMonitoring/admission.ts","lineNumber":442,"sourceCode":"  const replay = await ctx.db\n    .query(\"companyMonitoringAdmissionDecisions\")\n    .withIndex(\"by_replay_fence\", (q) =>\n      q\n        .eq(\"ownerAccountId\", args.ownerAccountId)\n        .eq(\"companyId\", args.companyId)\n        .eq(\"occurrenceDedupeKey\", args.occurrenceDedupeKey)\n        .eq(\"evidenceRevision\", args.expectedEvidenceRevision)\n        .eq(\"classificationRunId\", classificationRunId),\n    )\n    .unique();\n  if (replay) {\n    const submissionDigest = await fingerprint(canonicalValue({\n      requestedModelVersion,\n      modelVersion,\n      modelOutput: args.modelOutput,\n    }));\n    if (replay.submissionDigest !== submissionDigest) {\n      throw new ConvexError(\"COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT\");\n    }\n    return { status: \"replayed\" as const, decision: replay.decision };\n  }\n  const candidate = await ctx.db\n    .query(\"companyMonitoringCandidates\")\n    .withIndex(\"by_account_company_occurrence\", (q) =>\n      q\n        .eq(\"ownerAccountId\", args.ownerAccountId)\n        .eq(\"companyId\", args.companyId)\n        .eq(\"occurrenceDedupeKey\", args.occurrenceDedupeKey),\n    )\n    .unique();\n  const now = Date.now();\n  if (\n    !candidate ||\n    candidate.state !== \"pending_classification\" ||\n    candidate.evidenceRevision !== args.expectedEvidenceRevision ||\n    candidate.classificationWorkerId !== workerId ||","sourceCodeStart":424,"sourceCodeEnd":460,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/companyMonitoring/admission.ts#L424-L460","documentation":"Thrown by the classification admission mutation (convex/companyMonitoring/admission.ts:442) when the replay fence finds an existing decision for the same ownerAccountId + companyId + occurrenceDedupeKey + evidenceRevision + classificationRunId, but the fingerprint of { requestedModelVersion, modelVersion, modelOutput } differs from the stored submissionDigest. The run id is being reused with a different submission payload.","triggerScenarios":"Retrying a timed-out classification with the same classificationRunId but a different modelOutput (model nondeterminism or a re-generated completion); retrying with a different modelVersion/requestedModelVersion while keeping the run id; worker code regenerating modelOutput JSON (key order or whitespace differences after canonicalization are safe, field differences are not); overriding the model after a fallback.","commonSituations":"Retry loops that mint a fresh LLM completion but reuse the original run id; a manual re-submission from an admin tool with edited output; mid-flight model routing changes (fallback model) reusing the fence key.","solutions":["On retry, replay the exact same submission payload: keep requestedModelVersion, modelVersion, and modelOutput byte-stable (same fields/values) while reusing the run id","If the payload legitimately changed (different model, regenerated output), allocate a new classificationRunId instead of reusing the old one","Treat the existing row as authoritative: catch the error and adopt the stored decision rather than forcing yours in","Audit retry helpers to confirm they snapshot the payload once and never rebuild modelOutput between attempts"],"exampleFix":"// before\nawait ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {\n  classificationRunId: originalRunId,\n  modelVersion,\n  modelOutput: await model.classify(prompt), // new completion on every retry!\n  ...\n});\n\n// after\nconst submission = { requestedModelVersion, modelVersion, modelOutput: await model.classify(prompt) };\nfor (let attempt = 0; ; attempt++) {\n  try {\n    await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {\n      classificationRunId: originalRunId, // same run id ...\n      ...submission,                       // ... only with the frozen payload\n      ...\n    });\n    break;\n  } catch (err) {\n    if (isTransient(err) && attempt < 3) continue; // payload never rebuilt\n    throw err;\n  }\n}","handlingStrategy":"validation","validationCode":"// Freeze the submission once and reuse it verbatim on retries so the digest cannot drift.\nconst submission = {\n  requestedModelVersion,\n  modelVersion,\n  modelOutput, // captured once from the model call\n};\n// retries must send exactly `submission`; if any field changes, mint a new classificationRunId instead.\nconst payloadChanged = (previous: typeof submission, next: typeof submission) =>\n  JSON.stringify([previous.requestedModelVersion, previous.modelVersion, canonical(previous.modelOutput)]) !==\n  JSON.stringify([next.requestedModelVersion, next.modelVersion, canonical(next.modelOutput)]);","typeGuard":null,"tryCatchPattern":"try {\n  await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, { ...submission, classificationRunId, ... });\n} catch (err) {\n  if (err instanceof ConvexError && err.data === \"COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT\") {\n    const existing = await fetchExistingDecision(classificationRunId); // authoritative\n    return existing; // adopt stored decision; do not force a new one\n  }\n  throw err;\n}","preventionTips":["Tie classificationRunId to one immutable (modelVersion, requestedModelVersion, modelOutput) triple","Mint a new run id whenever the model, routing fallback, or output regeneration changes the payload","Store the payload next to the run id in your queue so retries replay byte-identical data","On conflict, adopt the stored decision instead of resubmitting"],"tags":["convex","idempotency","replay","fingerprint","retry"],"backgroundTag":"idempotency-replay-conflict","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","contentChangedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}