Filter reconstructed channel_values by header channel membership
What does this MR do and why?
GitLab Duo Agent Platform stores LangGraph checkpoints incrementally. Each checkpoint writes a header row plus one compressed blob per changed channel. Read paths rebuild channel_values by folding the blobs along a checkpoint's ancestor chain.
The fold anchors per channel, so the rebuilt key set is the union of every channel ever blobbed in the group. The fold cannot express a channel deletion. A deleted channel keeps its last folded value and lingers in the result. A stale __start__ channel can then re-fire a graph node when updated_channels is empty.
The header already records the live channel set in its channel_keys column. This MR filters the fold to that set. Workflow#reconstructed_channel_values and #reconstructed_channel_values_from now pass their merged fold through a new #live_channels method. #reconstructed_channel returns nil for a channel outside the membership, before it queries blobs. A new #channel_membership method reads channel_keys with try, because a legacy Checkpoint row without that column can still reach these methods through the GraphQL subscription payload.
Because the fix sits in the shared reconstruction layer, it applies to every blob read consumer: trace, GraphQL, notifications, and the internal by_thread_ts and checkpoint list endpoints.
Behavior change
Filtering runs only when the header records a membership. A NULL channel_keys means the header predates the column, so the fold stays unfiltered, matching the behavior before this change. Two cases still produce a NULL membership: a nested subagent lineage header written between 2026-08-06 and 2026-08-18, and an older header in a session that spans the 2026-08-18 deploy. A workflow-level fallback from !250826 (merged) already sends the whole workflow back to legacy reads when the newest top-level header has no membership.
#full_trace_channel_values is unchanged. It uses ChannelValuesReconstructor#channel_changes, a historical record of every recorded change, not a state snapshot. A channel deleted at the end of a session still had changes worth showing in the trace. The trace endpoint's thread=latest and thread=<n> modes go through #reconstructed_channel_values, so they are filtered.
Several existing specs now pass an explicit channel_keys on their headers. The factory derives that value from the header's own channel_values, and those slim test headers carried none, so they read as an empty membership.
How to set up and validate locally
No manual setup is needed. The change is exercised by the specs in ee/spec/models/ai/duo_workflows/workflow_spec.rb, which cover a present membership, an empty membership, a NULL membership, a nested-lineage header with a NULL membership, a mixed session where each header folds by its own membership, and the intersection of the membership with the channels: argument.
Related
- Issue: #613975 (closed)
- Added the
channel_keyscolumn: #613956 (closed) - Gateway now blobs all scalar channels: gitlab-org/modelops/applied-ml/code-suggestions/ai-assist#2709 (closed)
- Workflow-level legacy-read fallback: !250826 (merged)
- Full analysis: !247134 (comment 3652137773)
- Read epic: &22939 (closed)