A content pipeline was shipping unverified figures: a story would carry three numbers, the checker would verify the correct one, and the writer would publish a different one. The fix extended per-figure verification to the path that had none.
The gate worked exactly as designed. On the first fresh run it rejected all three candidate stories with figure_unverified. Then:
select_trace: { pool_size: 3, llm_ranked: 3, selected: [] }
jobs[].base_inputs.top_stories: ""
jobs[].base_inputs.discussion_links: ""The empty selection propagated as an empty string into the writer's inputs, and the writer — which had no notion of "I was given nothing" — produced two sentences of atmosphere. The published edition's only link was a self-link to the previous period's edition. Zero citations, on a product whose entire proposition is cited figures.
So the release moved the failure from shipping wrong numbers to shipping no product. Strictly worse in the dimension that matters, and it read as green everywhere: no error, no exception, the gate's own trace was a clean and correct rejection, and the three top-level contract checks on the parent artifact all passed.
Three things generalize.
1. A gate and its fallback are one change, not two. A gate is a filter whose failure mode is "everything filtered". Adding rejection logic without deciding what happens at zero survivors is a half-shipped feature. The fallback needs an explicit choice — a degraded-but-labelled tier, hold the previous period's content, or refuse to publish and alert — and "relax the gate" is the one option to reject, because the gate is the fix.
2. Assert on the artifact, after the last generative step. Every check in this pipeline ran BEFORE the writer, so all of them could pass while the published string was empty or wrong. Pre-generation gates cannot see what the writer actually emitted. At least one assertion has to run on the bytes that ship.
3. An abstention must explain itself. The secondary selector also abstained, and its rationale field was the empty string — not a refusal reason, just blank. An honest "no candidate met the bar because X" is debuggable in seconds; a blank rationale is indistinguishable from the field never being populated, and establishing which had happened cost a separate investigation.
One environment-specific trap worth naming: this product's weekly record is a single mutable row rewritten by every scheduled run, so the next run silently overwrote the zero-citation edition. That looks like self-healing and is not — no gate reaches backwards, and the same defect on the immutable daily path would have stayed published. When a bad artifact disappears, state which mechanism removed it before calling anything fixed.
Test to write: feed the selector a pool where every candidate fails verification, and assert the published artifact is either a labelled degraded edition or an explicit refusal — never an empty-input generation.