thedotmack/claude-mem · error · Error

SyncApply: invalid op seq=

Error message

SyncApply: invalid op seq=${op.seq} kind=${op.kind} origin=${op.origin_device}/${op.origin_id}: same entity revision has a different canonical operation hash

What it means

applyCanonicalRowOp implements last-writer-wins by comparing canonical decimal revisions. When an incoming op has the SAME entity_rev as the stored head but a different operation_sha256, two divergent operations claim the same revision — an invariant violation that would make replicas diverge — so the op is rejected instead of arbitrarily picking one.

Solutions

  1. Investigate the conflicting op: dump the stored head's operation_sha256 vs the incoming one for entity_id=${op.entity_id}.
  2. Fix the producer to always bump entity_rev when content changes — never rewrite a body at an existing rev.
  3. If a version skew changed canonicalization, redeploy the matching version to all devices and re-canonicalize the affected rows (rebuild the sync log with recomputed hashes).
  4. After restoring a device from backup, reset/advance its revision counter before it produces new ops.

Example fix

// before (producer re-emit without rev bump)
publish({ entity_id, entity_rev: existingRev, body: newBody });
// after
const nextRev = incrementCanonicalDecimal(existingRev);
publish({ entity_id, entity_rev: nextRev, body: newBody });
Defensive patterns

Strategy: try-catch

Validate before calling

function willConflict(op, head) {
  return head && op.entity_rev === head.entity_rev && op.operation_sha256 !== head.operation_sha256;
}
// skip or re-emit with bumped rev before applying

Type guard

function isReplayOfHead(op, head) { return head != null && op.entity_rev === head.entity_rev && op.operation_sha256 === head.operation_sha256; }

Try / catch

try {
  applySyncOp(op);
} catch (e) {
  if (String(e.message).includes('different canonical operation hash')) {
    logger.error({ seq: op.seq, entity: op.entity_id }, 'rev collision — needs operator investigation');
    alertDivergence(op);
  } else throw e;
}

Prevention

When it happens

Trigger: Two devices independently generate ops for the same entity with identical rev values but different canonical bodies (rev counter collision), or a producer re-emits a modified body without bumping rev, or canonical hash computation differs between versions (hashing algorithm/normalization change).

Common situations: Clock/counter collision after restoring a device from backup without advancing its rev counter; a bug where edits are written at the same rev as an existing op; a library upgrade changed canonicalization so the same logical body hashes differently.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


AI-assisted analysis of thedotmack/claude-mem@d8bc9755e7 (2026-09-17). Data as JSON: /api/errors/c9cc945151e59682. Report an issue: GitHub.

Appendix: source

Thrown at src/services/sync/SyncApply.ts:552

  /**
   * Canonical-v2 head ledger. It survives local row deletion and epoch replay,
   * so a stale live op cannot resurrect a tombstoned entity.
   */
  private applyCanonicalRowOp(op: SyncOp, chromaJobs: ChromaJob[]): 'applied' | 'stale' {
    if (!op.entity_id || !op.entity_rev || !op.operation_sha256) {
      return this.applyRowOp(op, chromaJobs); // internal legacy fixtures only
    }
    const head = this.db.prepare(`
      SELECT entity_rev, operation_sha256
      FROM sync_entity_heads WHERE entity_id = ?
    `).get(op.entity_id) as { entity_rev: string; operation_sha256: string } | undefined;
    if (head) {
      const order = compareCanonicalDecimals(op.entity_rev, head.entity_rev);
      if (order < 0) return 'stale';
      if (order === 0) {
        if (op.operation_sha256 !== head.operation_sha256) {
          throw invalidOp(op, 'same entity revision has a different canonical operation hash');
        }
        return 'stale';
      }
    }

    let outcome: 'applied' | 'stale';
    if (op.deleted === true) {
      const table = op.kind === 'observation'
        ? 'observations'
        : op.kind === 'summary'
          ? 'session_summaries'
          : 'user_prompts';
      this.db.prepare(
        `DELETE FROM ${table} WHERE origin_device_id = ? AND origin_local_id = ?`
      ).run(op.origin_device, op.origin_id);
      outcome = 'applied';
    } else {
      outcome = this.applyRowOp(op, chromaJobs);

View on GitHub (pinned to d8bc9755e7)