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
- 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
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
- 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
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
- REPLAY_CONFLICT
- ALREADY_REVOKED
- COMPANY_MONITORING_ADMISSION_EVIDENCE_STALE
- Account changed while creating the embed key. Try again.
- Account changed while revoking the embed key. Try again.
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)