{"record":{"id":"c9cc945151e59682","repo":"thedotmack/claude-mem","slug":"syncapply-invalid-op-seq-op-seq-kind-op-kind-origin-op-c9cc94","errorCode":null,"errorMessage":"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","messagePattern":"SyncApply: invalid op seq=(.+?) kind=(.+?) origin=(.+?)/(.+?): same entity revision has a different canonical operation hash","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"src/services/sync/SyncApply.ts","lineNumber":552,"sourceCode":"\n  /**\n   * Canonical-v2 head ledger. It survives local row deletion and epoch replay,\n   * so a stale live op cannot resurrect a tombstoned entity.\n   */\n  private applyCanonicalRowOp(op: SyncOp, chromaJobs: ChromaJob[]): 'applied' | 'stale' {\n    if (!op.entity_id || !op.entity_rev || !op.operation_sha256) {\n      return this.applyRowOp(op, chromaJobs); // internal legacy fixtures only\n    }\n    const head = this.db.prepare(`\n      SELECT entity_rev, operation_sha256\n      FROM sync_entity_heads WHERE entity_id = ?\n    `).get(op.entity_id) as { entity_rev: string; operation_sha256: string } | undefined;\n    if (head) {\n      const order = compareCanonicalDecimals(op.entity_rev, head.entity_rev);\n      if (order < 0) return 'stale';\n      if (order === 0) {\n        if (op.operation_sha256 !== head.operation_sha256) {\n          throw invalidOp(op, 'same entity revision has a different canonical operation hash');\n        }\n        return 'stale';\n      }\n    }\n\n    let outcome: 'applied' | 'stale';\n    if (op.deleted === true) {\n      const table = op.kind === 'observation'\n        ? 'observations'\n        : op.kind === 'summary'\n          ? 'session_summaries'\n          : 'user_prompts';\n      this.db.prepare(\n        `DELETE FROM ${table} WHERE origin_device_id = ? AND origin_local_id = ?`\n      ).run(op.origin_device, op.origin_id);\n      outcome = 'applied';\n    } else {\n      outcome = this.applyRowOp(op, chromaJobs);","sourceCodeStart":534,"sourceCodeEnd":570,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/d8bc9755e74915e5c3b999181e10a67c889bce2a/src/services/sync/SyncApply.ts#L534-L570","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Investigate the conflicting op: dump the stored head's operation_sha256 vs the incoming one for entity_id=${op.entity_id}.","Fix the producer to always bump entity_rev when content changes — never rewrite a body at an existing rev.","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).","After restoring a device from backup, reset/advance its revision counter before it produces new ops."],"exampleFix":"// before (producer re-emit without rev bump)\npublish({ entity_id, entity_rev: existingRev, body: newBody });\n// after\nconst nextRev = incrementCanonicalDecimal(existingRev);\npublish({ entity_id, entity_rev: nextRev, body: newBody });","handlingStrategy":"try-catch","validationCode":"function willConflict(op, head) {\n  return head && op.entity_rev === head.entity_rev && op.operation_sha256 !== head.operation_sha256;\n}\n// skip or re-emit with bumped rev before applying","typeGuard":"function isReplayOfHead(op, head) { return head != null && op.entity_rev === head.entity_rev && op.operation_sha256 === head.operation_sha256; }","tryCatchPattern":"try {\n  applySyncOp(op);\n} catch (e) {\n  if (String(e.message).includes('different canonical operation hash')) {\n    logger.error({ seq: op.seq, entity: op.entity_id }, 'rev collision — needs operator investigation');\n    alertDivergence(op);\n  } else throw e;\n}","preventionTips":["Always bump entity_rev when body content changes — never rewrite at an existing rev","Advance revision counters after restoring a device from backup","Keep canonicalization/hashing code identical across all node versions"],"tags":["sync","conflict","hash-mismatch","consistency"],"backgroundTag":"checksum-mismatch","analyzedSha":"d8bc9755e74915e5c3b999181e10a67c889bce2a","analyzedAt":"2026-09-17T16:40:26.182Z","contentChangedAt":"2026-09-17T16:40:26.182Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}