koala73/worldmonitor · error · ConvexError

COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT

COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT

Error message

COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT

What it means

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.

Solutions

  1. On retry, replay the exact same submission payload: keep requestedModelVersion, modelVersion, and modelOutput byte-stable (same fields/values) while reusing the run id
  2. If the payload legitimately changed (different model, regenerated output), allocate a new classificationRunId instead of reusing the old one
  3. Treat the existing row as authoritative: catch the error and adopt the stored decision rather than forcing yours in
  4. Audit retry helpers to confirm they snapshot the payload once and never rebuild modelOutput between attempts

Example fix

// before
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
  classificationRunId: originalRunId,
  modelVersion,
  modelOutput: await model.classify(prompt), // new completion on every retry!
  ...
});

// after
const submission = { requestedModelVersion, modelVersion, modelOutput: await model.classify(prompt) };
for (let attempt = 0; ; attempt++) {
  try {
    await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
      classificationRunId: originalRunId, // same run id ...
      ...submission,                       // ... only with the frozen payload
      ...
    });
    break;
  } catch (err) {
    if (isTransient(err) && attempt < 3) continue; // payload never rebuilt
    throw err;
  }
}
Defensive patterns

Strategy: validation

Validate before calling

// Freeze the submission once and reuse it verbatim on retries so the digest cannot drift.
const submission = {
  requestedModelVersion,
  modelVersion,
  modelOutput, // captured once from the model call
};
// retries must send exactly `submission`; if any field changes, mint a new classificationRunId instead.
const payloadChanged = (previous: typeof submission, next: typeof submission) =>
  JSON.stringify([previous.requestedModelVersion, previous.modelVersion, canonical(previous.modelOutput)]) !==
  JSON.stringify([next.requestedModelVersion, next.modelVersion, canonical(next.modelOutput)]);

Try / catch

try {
  await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, { ...submission, classificationRunId, ... });
} catch (err) {
  if (err instanceof ConvexError && err.data === "COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT") {
    const existing = await fetchExistingDecision(classificationRunId); // authoritative
    return existing; // adopt stored decision; do not force a new one
  }
  throw err;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21). Data as JSON: /api/errors/9636e9fbba27a823. Report an issue: GitHub.

Appendix: source

Thrown at convex/companyMonitoring/admission.ts:442

  const replay = await ctx.db
    .query("companyMonitoringAdmissionDecisions")
    .withIndex("by_replay_fence", (q) =>
      q
        .eq("ownerAccountId", args.ownerAccountId)
        .eq("companyId", args.companyId)
        .eq("occurrenceDedupeKey", args.occurrenceDedupeKey)
        .eq("evidenceRevision", args.expectedEvidenceRevision)
        .eq("classificationRunId", classificationRunId),
    )
    .unique();
  if (replay) {
    const submissionDigest = await fingerprint(canonicalValue({
      requestedModelVersion,
      modelVersion,
      modelOutput: args.modelOutput,
    }));
    if (replay.submissionDigest !== submissionDigest) {
      throw new ConvexError("COMPANY_MONITORING_CLASSIFICATION_REPLAY_CONFLICT");
    }
    return { status: "replayed" as const, decision: replay.decision };
  }
  const candidate = await ctx.db
    .query("companyMonitoringCandidates")
    .withIndex("by_account_company_occurrence", (q) =>
      q
        .eq("ownerAccountId", args.ownerAccountId)
        .eq("companyId", args.companyId)
        .eq("occurrenceDedupeKey", args.occurrenceDedupeKey),
    )
    .unique();
  const now = Date.now();
  if (
    !candidate ||
    candidate.state !== "pending_classification" ||
    candidate.evidenceRevision !== args.expectedEvidenceRevision ||
    candidate.classificationWorkerId !== workerId ||

View on GitHub (pinned to eeab0a219f)