{"record":{"id":"4d3ad3d87e5bf653","repo":"koala73/worldmonitor","slug":"company-monitoring-classification-fenced","errorCode":"COMPANY_MONITORING_CLASSIFICATION_FENCED","errorMessage":"COMPANY_MONITORING_CLASSIFICATION_FENCED","messagePattern":"COMPANY_MONITORING_CLASSIFICATION_FENCED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/companyMonitoring/admission.ts","lineNumber":467,"sourceCode":"      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 ||\n    candidate.classificationLeaseToken !== leaseToken ||\n    candidate.classificationRunId !== classificationRunId ||\n    candidate.classificationRequestedModelVersion !== requestedModelVersion ||\n    candidate.classificationLeaseExpiresAt === undefined ||\n    candidate.classificationLeaseExpiresAt <= now\n  ) {\n    throw new ConvexError(\"COMPANY_MONITORING_CLASSIFICATION_FENCED\");\n  }\n  if (!await admissionScopeIsActive(ctx, candidate)) {\n    throw new ConvexError(\"COMPANY_MONITORING_CLASSIFICATION_FENCED\");\n  }\n  if (candidate.expiresAt <= now) {\n    await terminalizeSystemDecision(\n      ctx,\n      candidate,\n      \"expire\",\n      \"candidate_expired\",\n      \"hold_expired\",\n      now,\n    );\n    return { status: \"recorded\" as const, decision: \"expire\" as const };\n  }\n  const submissionDigest = await fingerprint(canonicalValue({\n    requestedModelVersion,\n    modelVersion,","sourceCodeStart":449,"sourceCodeEnd":485,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/convex/companyMonitoring/admission.ts#L449-L485","documentation":"Thrown by the classification admission mutation (convex/companyMonitoring/admission.ts:467) when the candidate does not match the submitted fence: candidate missing, state not pending_classification, evidenceRevision different from expectedEvidenceRevision, classificationWorkerId/classificationLeaseToken/classificationRunId/classificationRequestedModelVersion different from the args, or classificationLeaseExpiresAt missing/elapsed. It is lease fencing — only the worker currently holding the 5-minute lease (ADMISSION_LEASE_MS) may record a decision.","triggerScenarios":"Submitting after the 5-minute lease expired and another worker re-claimed the candidate; submitting with a stale lease token read before a re-claim; the candidate terminalized (published/rejected/expired) between claim and submit; passing a modelVersion pair that differs from classificationRequestedModelVersion captured at claim time; retrying an old run after a re-scan bumped evidenceRevision.","commonSituations":"LLM calls slower than the 5-minute lease; worker crashes and retries with cached credentials; two workers processing the same occurrence after a queue redelivery; clock drift; paused/removed company cancelling scan work mid-flight.","solutions":["Re-acquire: re-run the claim flow to get a fresh leaseToken + classificationRunId and a current expectedEvidenceRevision, then re-classify","Keep the model call plus submission well under 5 minutes, or checkpoint and complete inside the lease window","Always read workerId/leaseToken/runId from the claim response and pass them back verbatim — never from a cache or previous attempt","Treat FENCED as a normal loss-of-lease outcome (drop the result), not an error to hammer on"],"exampleFix":"// before\nconst decision = await callModel(candidate); // may take > 5 min\nawait ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {\n  workerId, leaseToken, classificationRunId, // from an old claim\n  ...\n});\n\n// after\nlet decision = await callModel(candidate);\ntry {\n  await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {\n    workerId, leaseToken, classificationRunId, expectedEvidenceRevision,\n    ...\n  });\n} catch (err) {\n  if (err instanceof ConvexError && err.data === \"COMPANY_MONITORING_CLASSIFICATION_FENCED\") {\n    const reclaimed = await claimClassification({ ... }); // fresh lease + runId + revision\n    decision = await callModel(reclaimed.candidate);\n    await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {\n      ...reclaimed, modelOutput: decision, ...\n    });\n  } else throw err;\n}","handlingStrategy":"retry","validationCode":"// Before the model call, confirm the lease you hold is current and unexpired.\nconst candidate = await loadCandidate(ownerAccountId, companyId, occurrenceDedupeKey);\nconst leaseValid =\n  candidate?.state === \"pending_classification\" &&\n  candidate.classificationWorkerId === workerId &&\n  candidate.classificationLeaseToken === leaseToken &&\n  candidate.classificationRunId === classificationRunId &&\n  candidate.classificationLeaseExpiresAt !== undefined &&\n  candidate.classificationLeaseExpiresAt > Date.now() + SAFETY_MARGIN_MS;\nif (!leaseValid) {\n  const reclaimed = await claimClassification({ ownerAccountId, companyId, occurrenceDedupeKey });\n  Object.assign(args, reclaimed); // fresh workerId/leaseToken/runId/revision\n}","typeGuard":"function holdsLiveLease(candidate: Doc<\"companyMonitoringCandidates\"> | null, fence: { workerId: string; leaseToken: string; classificationRunId: string; requestedModelVersion: string; evidenceRevision: number }): candidate is Doc<\"companyMonitoringCandidates\"> {\n  return (\n    !!candidate &&\n    candidate.state === \"pending_classification\" &&\n    candidate.evidenceRevision === fence.evidenceRevision &&\n    candidate.classificationWorkerId === fence.workerId &&\n    candidate.classificationLeaseToken === fence.leaseToken &&\n    candidate.classificationRunId === fence.classificationRunId &&\n    candidate.classificationRequestedModelVersion === fence.requestedModelVersion &&\n    candidate.classificationLeaseExpiresAt !== undefined &&\n    candidate.classificationLeaseExpiresAt > Date.now()\n  );\n}","tryCatchPattern":"try {\n  await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, args);\n} catch (err) {\n  if (err instanceof ConvexError && err.data === \"COMPANY_MONITORING_CLASSIFICATION_FENCED\") {\n    const reclaimed = await claimClassification({ ownerAccountId, companyId, occurrenceDedupeKey });\n    if (reclaimed) {\n      const modelOutput = await callModel(reclaimed.candidate);\n      await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, { ...reclaimed.args, modelOutput });\n    }\n    return; // if reclaim failed, another worker owns it — stand down\n  }\n  throw err;\n}","preventionTips":["Complete model call plus submission well inside the 5-minute lease (ADMISSION_LEASE_MS)","Pass workerId/leaseToken/runId verbatim from the claim response — never from cache","Treat FENCED as loss-of-lease: re-claim and re-classify, do not blind-retry","Renew or shorten work units if model latency approaches the lease window"],"tags":["convex","lease","fencing","concurrency","timeout"],"backgroundTag":"worker-lease-expired","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"}