Reconstruction filters folded blobs to the header's channel membership

Problem

Blob reconstruction returns the union of every channel ever blobbed in the group — blobs are an append-only log and fold anchors per channel, so the resulting key set is @blobs.group_by(&:channel). It cannot express channel deletion, so stale channels linger in the reconstructed channel_values (and can re-fire a node when updated_channels is empty). See the full analysis on !247134 (merged) (note).

What

After folding the blobs, select only the channels the header declares in its membership list. Deletion then costs nothing and __start__ stops resurrecting, with no special-casing.

  • Location: Ai::DuoWorkflows::Workflow#reconstructed_channel_values (ee/app/models/ai/duo_workflows/workflow.rb) / Gitlab::DuoWorkflow::ChannelValuesReconstructor.
  • This is the shared reconstruction layer, so the fix applies to all read consumers (trace, GraphQL, notifications, by_thread_ts), not just one endpoint.

Defensive behavior (required)

Only filter when the header actually has a membership list; when it is absent, return the full fold (today's behavior). This degrades gracefully for checkpoints written before the membership/scalar writes landed, and avoids a lockstep-deploy barrier — the filter simply becomes correct for checkpoints written after both writes are live.

Depends on

Implement after both writes are in place and producing data:

Without both, filtering to a membership list is either impossible (no list) or drops live scalars (no blob behind the key).

Sequencing note

If !247134 (merged) (the by_thread_ts read endpoint) is still open when the two writes land, the filter can be added there. Otherwise this issue tracks a dedicated reconstruction MR. !247134 (merged) does not need to block on this — it is flagged and its only consumer today is stop-recovery.

Acceptance criteria for a NULL membership

  • A header whose channel_keys is NULL folds unfiltered, which is the behavior before this filter.
  • Cover both sources of a NULL membership: a nested lineage header written between 2026-08-06 and 2026-08-18, and an older header of a session that spans the 2026-08-18 deploy.
  • Specs assert the unfiltered fold for a NULL membership, and the filtered fold for a present membership, including an empty list.

The workflow-level fallback in !250826 (merged) covers the common case, where the newest top-level header has no membership.

Edited by Eduardo Bonet