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_keysheader was dual-written:duo_workflow_write_incremental_onlyhas 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_keysage 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
endThe 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_readsuppression 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.
Related
- #613975 (closed) (the fold filter this gates)
- #613956 (closed) (the column, closed)
- #611971 (removes the fallback together with the legacy read code)