thedotmack/claude-mem · error

sync hub pull: malformed /changes response

Error message

sync hub pull: malformed /changes response

What it means

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.

Source

Thrown at src/services/sync/SyncClient.ts:554

        // Kill-switch mode hint (plan Phase 5 task 2), read BEFORE the
        // ok-check — the header rides error responses too. Asymmetric on
        // purpose: header PRESENCE means poll regardless of status, but
        // header ABSENCE only means "cleared" on an OK response. An error
        // response without the header (a degraded auth upstream 503ing
        // everything mid-incident — incidents correlate) is ambiguous and
        // must not exit poll mode, or the client would resume socket
        // churn for the whole outage.
        const syncMode = res.headers.get('X-Sync-Mode');
        if (syncMode !== null || res.ok) {
          this.onSyncModeHint(syncMode);
        }
        if (!res.ok) {
          const body = (await res.text().catch(() => '')).slice(0, 200);
          throw new Error(`sync hub pull ${res.status}: ${body}`);
        }
        const page = await res.json() as ChangesPage | null;
        if (!page || page.protocol_version !== 2 || !Array.isArray(page.ops)) {
          throw new Error('sync hub pull: malformed /changes response');
        }
        const epoch = assertCanonicalDecimal(page.epoch);
        assertCanonicalDecimal(page.head_seq);
        if (typeof page.more !== 'boolean') throw new Error('sync hub pull: more must be boolean');
        if (this.stopped) return;

        const decodedOps = decodeChanges(page.ops);
        const result = this.apply.applyOps(decodedOps, {
          epoch,
          requireContiguous: true,
        });
        pages++;

        if (result.epochReset) {
          // applyOps discarded the page and reset the cursor to 0; loop to
          // re-pull from the start (apply is idempotent by design).
          if (pages >= this.maxPagesPerCycle) return;
          continue;

View on GitHub (pinned to e2d1df569a)

Solutions

  1. curl the /changes endpoint directly and inspect the raw JSON — check protocol_version and the ops field.
  2. Align client and hub versions so both speak protocol_version 2.
  3. Fix the hub base URL if it points at a gateway/wrapper that mangles the body.
  4. Disable any response-transforming proxy for the hub host.
Defensive patterns

Strategy: validation

Validate before calling

// Validate the envelope shape yourself before handing it to the client internals.
function isChangesPage(v: unknown): v is { protocol_version: number; ops: unknown[]; more: boolean } {
  if (!v || typeof v !== 'object') return false;
  const p = v as Record<string, unknown>;
  return p.protocol_version === 2 && Array.isArray(p.ops);
}
const raw = await res.json();
if (!isChangesPage(raw)) throw new Error(`hub protocol mismatch at ${hubUrl} — got protocol_version ${raw?.protocol_version}`);

Type guard

function isChangesPage(v: unknown): v is { protocol_version: number; ops: unknown[]; more: boolean } {
  return !!v && typeof v === 'object' &&
    (v as any).protocol_version === 2 && Array.isArray((v as any).ops) &&
    typeof (v as any).more === 'boolean';
}

Try / catch

try {
  await syncClient.start();
} catch (e) {
  if (e instanceof Error && e.message.includes('malformed /changes response')) {
    // Version/endpoint mismatch: inspect raw body with curl against the configured hub URL.
    logHubDiagnostics(hubUrl);
  }
  throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Related errors


AI-assisted analysis of thedotmack/claude-mem@e2d1df569a (2026-08-20). Data as JSON: /api/errors/1bfaf3dacc87ff3e. Report an issue: GitHub.