thedotmack/claude-mem · error
partial canonical mutation snapshot
Error message
partial canonical mutation snapshot
What it means
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.
Source
Thrown at src/services/sync/CloudSync.ts:786
).all() as MutationOutboxRow[];
if (rows.length === 0) break;
// Same size-bounded packing as drainKind: mutation bodies are usually
// tiny, but every page still stays within the request budget.
let buf: WireOp[] = [];
let bufBytes = 0;
const send = async (): Promise<void> => {
if (this.stopped || buf.length === 0) return;
await this.sendOps(buf);
buf = [];
bufBytes = 0;
};
for (const row of rows) {
try {
let op: WireOp;
if (row.canonical_body !== null || row.operation_sha256 !== null) {
if (row.canonical_body === null || row.operation_sha256 === null) {
throw new Error('partial canonical mutation snapshot');
}
op = { body: row.canonical_body, operation_sha256: row.operation_sha256 };
parseCanonicalOperation(op);
} else {
let parsed: unknown;
try {
parsed = JSON.parse(row.body);
} catch {
throw new Error('mutation body is not JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('mutation body must be an object');
}
const body = parsed as OpBody;
if (body.op === 'set_prompt_session' && typeof body.target === 'object' && body.target !== null) {
const target = body.target as Record<string, unknown>;
if (target.origin_device_id == null) target.origin_device_id = this.deviceId;
}View on GitHub (pinned to e2d1df569a)
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)
Example fix
-- before: torn row (one column set)
-- id=7 canonical_body='{...}' operation_sha256=NULL
-- after: let the drain re-derive both from body
UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL WHERE id = 7; Defensive patterns
Strategy: validation
Validate before calling
// Detect torn freeze pairs before a flush drains them:
const torn = db.prepare(`
SELECT id, op_uuid FROM sync_outbox
WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)
`).all();
if (torn.length > 0) {
// repair by re-freezing: reset both so the drain rebuilds from body
db.prepare(`UPDATE sync_outbox SET canonical_body = NULL, operation_sha256 = NULL
WHERE (canonical_body IS NULL) != (operation_sha256 IS NULL)`).run();
} Try / catch
// Per-row drain failures are quarantined, never thrown. Watch the dead-letter table: const q = db.prepare(`SELECT * FROM sync_dead_letter WHERE reason LIKE 'partial canonical%'`).all(); // each entry: decide re-issue vs discard, then DELETE the dead-letter row
Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- mutation body is not JSON
- mutation body must be an object
- SyncApply: could not create or adopt a session for memory_se
- Invalid CLAUDE_MEM_QUEUE_ENGINE=${raw}; expected sqlite or b
- Backfill failed: ${error instanceof Error ? error.message :
AI-assisted analysis of thedotmack/claude-mem@e2d1df569a (2026-08-20).
Data as JSON: /api/errors/b8c2f654a758ca1c.
Report an issue: GitHub.