{"record":{"id":"b8c2f654a758ca1c","repo":"thedotmack/claude-mem","slug":"partial-canonical-mutation-snapshot","errorCode":null,"errorMessage":"partial canonical mutation snapshot","messagePattern":"partial canonical mutation snapshot","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/services/sync/CloudSync.ts","lineNumber":786,"sourceCode":"      ).all() as MutationOutboxRow[];\n      if (rows.length === 0) break;\n\n      // Same size-bounded packing as drainKind: mutation bodies are usually\n      // tiny, but every page still stays within the request budget.\n      let buf: WireOp[] = [];\n      let bufBytes = 0;\n      const send = async (): Promise<void> => {\n        if (this.stopped || buf.length === 0) return;\n        await this.sendOps(buf);\n        buf = [];\n        bufBytes = 0;\n      };\n      for (const row of rows) {\n        try {\n          let op: WireOp;\n          if (row.canonical_body !== null || row.operation_sha256 !== null) {\n            if (row.canonical_body === null || row.operation_sha256 === null) {\n              throw new Error('partial canonical mutation snapshot');\n            }\n            op = { body: row.canonical_body, operation_sha256: row.operation_sha256 };\n            parseCanonicalOperation(op);\n          } else {\n            let parsed: unknown;\n            try {\n              parsed = JSON.parse(row.body);\n            } catch {\n              throw new Error('mutation body is not JSON');\n            }\n            if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n              throw new Error('mutation body must be an object');\n            }\n            const body = parsed as OpBody;\n            if (body.op === 'set_prompt_session' && typeof body.target === 'object' && body.target !== null) {\n              const target = body.target as Record<string, unknown>;\n              if (target.origin_device_id == null) target.origin_device_id = this.deviceId;\n            }","sourceCodeStart":768,"sourceCodeEnd":804,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/e2d1df569a8f04075d40e92461128ece7cf04c82/src/services/sync/CloudSync.ts#L768-L804","documentation":"drainMutations() requires each sync_outbox row's canonical_body and operation_sha256 to be either both NULL (legacy row, not yet frozen) or both set (frozen canonical wrapper). Exactly one being NULL means the freeze write was torn — the serialized body was stored without its hash or vice versa — so the pair cannot be trusted as an atomic snapshot. The row is moved to sync_dead_letter with this reason and DELETEd from sync_outbox inside one transaction; the drain continues with later rows.","triggerScenarios":"A crash between two separate UPDATE statements in an older non-atomic freeze path; manual SQLite edits to sync_outbox; a migration populating only one of the two columns; disk-level corruption of the DB file.","commonSituations":"Database restored from a backup taken mid-write; a user hand-editing the SQLite file; upgrading from a pre-v42 schema that only had the body column; older app versions partially applying the freeze.","solutions":["Inspect the quarantined payload: SELECT * FROM sync_dead_letter WHERE reason LIKE 'partial canonical%'","If the mutation still matters, re-issue it from the app — it re-enqueues cleanly","Or repair the row by resetting both columns to NULL so the next drain re-freezes it from the raw body: UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL WHERE id = <id>","Scan for other torn rows: SELECT id FROM sync_outbox WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)"],"exampleFix":"-- before: torn row (one column set)\n-- id=7 canonical_body='{...}' operation_sha256=NULL\n\n-- after: let the drain re-derive both from body\nUPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL WHERE id = 7;","handlingStrategy":"validation","validationCode":"// Detect torn freeze pairs before a flush drains them:\nconst torn = db.prepare(`\n  SELECT id, op_uuid FROM sync_outbox\n  WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)\n`).all();\nif (torn.length > 0) {\n  // repair by re-freezing: reset both so the drain rebuilds from body\n  db.prepare(`UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL\n              WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)`).run();\n}","typeGuard":null,"tryCatchPattern":"// Per-row drain failures are quarantined, never thrown. Watch the dead-letter table:\nconst q = db.prepare(`SELECT * FROM sync_dead_letter WHERE reason LIKE 'partial canonical%'`).all();\n// each entry: decide re-issue vs discard, then DELETE the dead-letter row","preventionTips":["Always write canonical_body and operation_sha256 in a single UPDATE/transaction (as current code does)","Never hand-edit sync_outbox; use app APIs so the pair stays atomic","Run the XOR-NULL detector after restoring a database from backup"],"tags":["sync","sqlite","outbox","torn-write","quarantine"],"backgroundTag":"database-corruption","analyzedSha":"e2d1df569a8f04075d40e92461128ece7cf04c82","analyzedAt":"2026-08-20T23:58:13.836Z","contentChangedAt":"2026-08-20T23:58:13.836Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}