{"record":{"id":"1bfaf3dacc87ff3e","repo":"thedotmack/claude-mem","slug":"sync-hub-pull-malformed-changes-response","errorCode":null,"errorMessage":"sync hub pull: malformed /changes response","messagePattern":"sync hub pull: malformed /changes response","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"src/services/sync/SyncClient.ts","lineNumber":554,"sourceCode":"        // Kill-switch mode hint (plan Phase 5 task 2), read BEFORE the\n        // ok-check — the header rides error responses too. Asymmetric on\n        // purpose: header PRESENCE means poll regardless of status, but\n        // header ABSENCE only means \"cleared\" on an OK response. An error\n        // response without the header (a degraded auth upstream 503ing\n        // everything mid-incident — incidents correlate) is ambiguous and\n        // must not exit poll mode, or the client would resume socket\n        // churn for the whole outage.\n        const syncMode = res.headers.get('X-Sync-Mode');\n        if (syncMode !== null || res.ok) {\n          this.onSyncModeHint(syncMode);\n        }\n        if (!res.ok) {\n          const body = (await res.text().catch(() => '')).slice(0, 200);\n          throw new Error(`sync hub pull ${res.status}: ${body}`);\n        }\n        const page = await res.json() as ChangesPage | null;\n        if (!page || page.protocol_version !== 2 || !Array.isArray(page.ops)) {\n          throw new Error('sync hub pull: malformed /changes response');\n        }\n        const epoch = assertCanonicalDecimal(page.epoch);\n        assertCanonicalDecimal(page.head_seq);\n        if (typeof page.more !== 'boolean') throw new Error('sync hub pull: more must be boolean');\n        if (this.stopped) return;\n\n        const decodedOps = decodeChanges(page.ops);\n        const result = this.apply.applyOps(decodedOps, {\n          epoch,\n          requireContiguous: true,\n        });\n        pages++;\n\n        if (result.epochReset) {\n          // applyOps discarded the page and reset the cursor to 0; loop to\n          // re-pull from the start (apply is idempotent by design).\n          if (pages >= this.maxPagesPerCycle) return;\n          continue;","sourceCodeStart":536,"sourceCodeEnd":572,"githubUrl":"https://github.com/thedotmack/claude-mem/blob/e2d1df569a8f04075d40e92461128ece7cf04c82/src/services/sync/SyncClient.ts#L536-L572","documentation":"The hub /changes response was HTTP OK and parsed as JSON, but failed the ChangesPage envelope check: page falsy, protocol_version !== 2, or ops not an array. The client speaks exactly protocol v2, so this is a hard protocol gate — a 200 response with the wrong shape is treated as a contract violation, not an empty page.","triggerScenarios":"Hub running an older or newer protocol_version than 2; a transparent proxy or captive portal returning 200 with an HTML body that happens to parse as JSON null; a load balancer routing to a different service on the same path; a hand-rolled hub missing fields.","commonSituations":"Client and hub upgraded out of sync (new protocol on one side only); hub URL actually pointing at an API gateway that wraps responses; middleware stripping or rewrapping the JSON body.","solutions":["curl the /changes endpoint directly and inspect the raw JSON — check protocol_version and the ops field.","Align client and hub versions so both speak protocol_version 2.","Fix the hub base URL if it points at a gateway/wrapper that mangles the body.","Disable any response-transforming proxy for the hub host."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// Validate the envelope shape yourself before handing it to the client internals.\nfunction isChangesPage(v: unknown): v is { protocol_version: number; ops: unknown[]; more: boolean } {\n  if (!v || typeof v !== 'object') return false;\n  const p = v as Record<string, unknown>;\n  return p.protocol_version === 2 && Array.isArray(p.ops);\n}\nconst raw = await res.json();\nif (!isChangesPage(raw)) throw new Error(`hub protocol mismatch at ${hubUrl} — got protocol_version ${raw?.protocol_version}`);","typeGuard":"function isChangesPage(v: unknown): v is { protocol_version: number; ops: unknown[]; more: boolean } {\n  return !!v && typeof v === 'object' &&\n    (v as any).protocol_version === 2 && Array.isArray((v as any).ops) &&\n    typeof (v as any).more === 'boolean';\n}","tryCatchPattern":"try {\n  await syncClient.start();\n} catch (e) {\n  if (e instanceof Error && e.message.includes('malformed /changes response')) {\n    // Version/endpoint mismatch: inspect raw body with curl against the configured hub URL.\n    logHubDiagnostics(hubUrl);\n  }\n  throw e;\n}","preventionTips":["Pin client and hub to compatible releases; treat protocol_version as a contract in change logs.","Never route hub traffic through response-transforming proxies or gateways.","In hub test suites, assert the exact v2 envelope to catch regressions before deploy."],"tags":["sync","http","protocol-validation","json","api-contract"],"backgroundTag":"response-schema-validation","analyzedSha":"e2d1df569a8f04075d40e92461128ece7cf04c82","analyzedAt":"2026-08-20T23:58:13.836Z","contentChangedAt":"2026-08-20T23:58:13.836Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}