Push the channel_keys membership filter into the blob read query

Problem

Since !251344 (merged) the reconstruction filters channel_values to the header's channel_keys — but in Ruby, after the query. Workflow#reconstructed_channel_values fetches every channel ever blobbed on the ancestor chain, then #select_live_channels drops the dead ones post-decode. For chat workflows that means fetching and decompressing every branch:to:* routing channel, each re-seeded as a full snapshot at every group start, only to discard them.

Kibana p95 db_duration_s for getLatestCheckpoint (the gateway resume payload): 0.107s legacy vs 0.339s on blobs (dw_read_blobs_graphql:1) — the 3.2× matches IC-D29's "full reconstruction ≈ 3.3× a header read". Much of that gap is TOAST reads for channels the membership filter throws away.

Proposal

Push the membership into the SQL. The plumbing exists: accumulated_blobs_for(checkpoint, channels:) adds WHERE channel IN (...), served by the dedup index (project_id, workflow_id, thread_ts, channel, …).

def reconstructed_channel_values(checkpoint, channels: nil)
  channels ||= channel_membership(checkpoint)
  ...
  • A NULL membership (pre-channel_keys header) keeps today's unfiltered read.
  • An explicit channels: argument intersects with the membership rather than bypassing it.
  • Keep #select_live_channels as the final guard — the query filter is an optimization, not the correctness boundary.
  • Same treatment in #blobs_by_thread_ts_for (the batched page read): filter by the union of the page headers' channel_keys, skipped when any header declares none.

Acceptance criteria

  • The blob query for a membership-bearing header restricts channel to channel_keys.
  • NULL membership folds unfiltered (unchanged behavior, spec-pinned).
  • Explicit channels: callers (scoped trace reads) intersect with membership.
  • Query plans on the MR (before/after) from a chain with dead branch:to:* channels.
  • getLatestCheckpoint p95 re-measured after deploy.