Fall back to the legacy checkpoint row for headers written before channel_keys existed

Problem

duo_workflow_incremental_checkpoints has been writing headers globally since 2026-07-30. channel_keys only exists since !250189 (merged) (merged 2026-08-18), so every header written before that MR deployed has channel_keys = NULL.

Once #613975 (closed) filters the fold to the header's channel membership, those older headers reconstruct incorrectly: a NULL membership filters out every channel (or forces the filter to guess), and the reconstructed channel_values come back wrong or empty for any session that spans the deploy boundary.

A backfill is not viable: deriving the correct membership for an old header means folding its whole blob chain, which is the work the column exists to avoid, across every retained partition.

Proposal

Treat a header without channel_keys as malformed for reconstruction purposes and fall back to the legacy p_duo_workflows_checkpoints row for that checkpoint.

This is safe by construction:

  • Every pre-channel_keys header was dual-written: duo_workflow_write_incremental_only has never been enabled, so the full legacy row exists for each of them.
  • The fallback self-expires: partitions drop at 30 days, so headers without channel_keys age out within a month of the !250189 (merged) deploy, and the fallback becomes dead code that #611971 removes.

Gate shape — check the header being served, not checkpoint_headers.last: a historical read (by_thread_ts, time-travel) can target a pre-column header even after newer headers carry the column:

if workflow.incremental_checkpoints_enabled? && header.channel_keys.present?
  reconstruct_from_blobs(header)   # filtered fold (#613975)
else
  legacy_row_for(header.thread_ts) # dual-written, so it exists for every pre-column header
end

The ancestor chain needs no check: the filter only consults the served header's membership, so folding over pre-column blobs is fine.

Implementation notes:

  • The check belongs in the reconstruction gate (Workflow/ChannelValuesReconstructor), not in each consumer, so every read path gets the same behavior.
  • Distinguish NULL (pre-column header, use fallback) from [] (written with the column; legitimately empty membership is likely invalid input and should keep the fail-visible behavior).
  • The fallback must go through the sanctioned legacy read path (respect the with_legacy_read suppression once !250618 (closed) lands, and the RuboCop cop from !249745 (merged)).
  • Must land with or before #613975 (closed) — the filter must never run against NULL membership.
Edited by Eduardo Bonet