koala73/worldmonitor · error · ConvexError

COMPANY_MONITORING_CLASSIFICATION_FENCED

COMPANY_MONITORING_CLASSIFICATION_FENCED

Error message

COMPANY_MONITORING_CLASSIFICATION_FENCED

What it means

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.

Solutions

  1. Re-acquire: re-run the claim flow to get a fresh leaseToken + classificationRunId and a current expectedEvidenceRevision, then re-classify
  2. Keep the model call plus submission well under 5 minutes, or checkpoint and complete inside the lease window
  3. Always read workerId/leaseToken/runId from the claim response and pass them back verbatim — never from a cache or previous attempt
  4. Treat FENCED as a normal loss-of-lease outcome (drop the result), not an error to hammer on

Example fix

// before
const decision = await callModel(candidate); // may take > 5 min
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
  workerId, leaseToken, classificationRunId, // from an old claim
  ...
});

// after
let decision = await callModel(candidate);
try {
  await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
    workerId, leaseToken, classificationRunId, expectedEvidenceRevision,
    ...
  });
} catch (err) {
  if (err instanceof ConvexError && err.data === "COMPANY_MONITORING_CLASSIFICATION_FENCED") {
    const reclaimed = await claimClassification({ ... }); // fresh lease + runId + revision
    decision = await callModel(reclaimed.candidate);
    await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
      ...reclaimed, modelOutput: decision, ...
    });
  } else throw err;
}
Defensive patterns

Strategy: retry

Validate before calling

// Before the model call, confirm the lease you hold is current and unexpired.
const candidate = await loadCandidate(ownerAccountId, companyId, occurrenceDedupeKey);
const leaseValid =
  candidate?.state === "pending_classification" &&
  candidate.classificationWorkerId === workerId &&
  candidate.classificationLeaseToken === leaseToken &&
  candidate.classificationRunId === classificationRunId &&
  candidate.classificationLeaseExpiresAt !== undefined &&
  candidate.classificationLeaseExpiresAt > Date.now() + SAFETY_MARGIN_MS;
if (!leaseValid) {
  const reclaimed = await claimClassification({ ownerAccountId, companyId, occurrenceDedupeKey });
  Object.assign(args, reclaimed); // fresh workerId/leaseToken/runId/revision
}

Type guard

function holdsLiveLease(candidate: Doc<"companyMonitoringCandidates"> | null, fence: { workerId: string; leaseToken: string; classificationRunId: string; requestedModelVersion: string; evidenceRevision: number }): candidate is Doc<"companyMonitoringCandidates"> {
  return (
    !!candidate &&
    candidate.state === "pending_classification" &&
    candidate.evidenceRevision === fence.evidenceRevision &&
    candidate.classificationWorkerId === fence.workerId &&
    candidate.classificationLeaseToken === fence.leaseToken &&
    candidate.classificationRunId === fence.classificationRunId &&
    candidate.classificationRequestedModelVersion === fence.requestedModelVersion &&
    candidate.classificationLeaseExpiresAt !== undefined &&
    candidate.classificationLeaseExpiresAt > Date.now()
  );
}

Try / catch

try {
  await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, args);
} catch (err) {
  if (err instanceof ConvexError && err.data === "COMPANY_MONITORING_CLASSIFICATION_FENCED") {
    const reclaimed = await claimClassification({ ownerAccountId, companyId, occurrenceDedupeKey });
    if (reclaimed) {
      const modelOutput = await callModel(reclaimed.candidate);
      await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, { ...reclaimed.args, modelOutput });
    }
    return; // if reclaim failed, another worker owns it — stand down
  }
  throw err;
}

Prevention

When it happens

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

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

Related errors


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

Appendix: source

Thrown at convex/companyMonitoring/admission.ts:467

      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 ||
    candidate.classificationLeaseToken !== leaseToken ||
    candidate.classificationRunId !== classificationRunId ||
    candidate.classificationRequestedModelVersion !== requestedModelVersion ||
    candidate.classificationLeaseExpiresAt === undefined ||
    candidate.classificationLeaseExpiresAt <= now
  ) {
    throw new ConvexError("COMPANY_MONITORING_CLASSIFICATION_FENCED");
  }
  if (!await admissionScopeIsActive(ctx, candidate)) {
    throw new ConvexError("COMPANY_MONITORING_CLASSIFICATION_FENCED");
  }
  if (candidate.expiresAt <= now) {
    await terminalizeSystemDecision(
      ctx,
      candidate,
      "expire",
      "candidate_expired",
      "hold_expired",
      now,
    );
    return { status: "recorded" as const, decision: "expire" as const };
  }
  const submissionDigest = await fingerprint(canonicalValue({
    requestedModelVersion,
    modelVersion,

View on GitHub (pinned to eeab0a219f)