On 1 July, the cron produced one draft. The topic: mortgage rate spikes. The ground was the same BBC, Zoopla, Moneyfacts, and Bank of England sources that had shipped the day before, the same US-Iran framing, the same £232 and £66 monthly regional payment figures. The pipeline had already published this cluster. It had no mechanism to know that.
The next morning, the cron fired at 08:22 BST. Triage completed. 150 clusters ranked. Mortgage came top with a score of 20. Candidates fetched, ground pulled. No draft appeared. Clean exit.
The cluster didn’t draft again. A dead zone had opened.
On 6 July, the long-slug IHT file reached its third PR. The filename was uk-inheritance-tax-rate-2026-40-charge-7-75-interest-rate.mdx. PR #171 had created and deleted it within the same squash. PR #176 had merged it live. A third was now in the queue, from a regeneration source that still hadn’t been traced to root-cause.
By 13 July, ten cron cycles (30 June through 13 July) had produced exactly one ready draft and zero auto-publishes. Over that window the skipped counter climbed from 2 to 7. Every morning followed the same shape: triage completed, ground fetched, exit code zero, seven clusters inspected and dismissed cycle after cycle, in complete silence.
Three failure modes. The diagnosis was the same for all of them.
What draft.py was doing
The pipeline index tracked what had been handled. That’s the right design: without it, every cluster drafts on every cycle regardless of whether it’s already published. The index is what separates a pipeline from a fire-hose.
The error was in how “handled” was defined. draft.py consulted the index before initiating a draft and treated any entry as a signal to skip. That included entries prefixed failed-*. A cluster that had attempted to draft and failed was marked the same way as a cluster that had successfully published. From the index’s perspective, both states answered the same boolean: seen before.
A cluster that failed to draft on Monday couldn’t draft on Tuesday, because the index entry persisted. The skip counter incremented. Exit code: zero.
The cascade
On 1 July, the mortgage cluster drafted on ground it had already shipped. The resulting draft was a redundant duplicate: same sources, same framing. When processed, a failed-* entry landed in the index.
On 2 July, the cron reached mortgage, found a failed-* entry, and moved on. Clean exit. Mortgage had scored top in triage. It didn’t matter. The index had already spoken.
The cascade is direct: the duplicate draft on 1 July created the dead zone on 2 July. Both were expressions of the same root cause. The index conflated “attempted and failed” with “successfully handled,” so the pipeline couldn’t recover from its own errors.
The zombie’s different expression
The IHT zombie operated through a different failure path. The cluster had published via PR #176. An index entry existed. But the regeneration source was still running. When it produced a new draft, draft.py looked for an index entry matching the new draft’s lookup path. The entry format had drifted between the original publish and the regeneration. No match found. No guard applied. The zombie reached a third PR.
The dead zone produced too many skips: a failed entry blocking a valid retry. The zombie produced too few: format drift causing a false negative on a cluster that should have been blocked. The index was the single state store for “handled,” and it could fail in both directions.
Why the exits looked clean
The three-weekday gap between 7 and 9 July was invisible from the exit code alone. Zero articles across three weekdays, and nothing surfaced. On 7 July: eight clusters triaged to draft, six packs prepared, zero drafts produced. Exit zero.
Without a broken-down skip counter, that gap looked identical to a healthy pipeline with nothing worth publishing. Same external signal either way: triage complete, exit zero, no drafts. The difference sat in why no drafts appeared. Nothing surfaced it.
The skipped counter climbing from 2 to 7 over ten cycles is what made the 13 July diagnosis possible. The counter wasn’t hidden. It was in the index. But it wasn’t being emitted as part of the heartbeat, so it required active inspection to find. A stuck pipeline and a quiet pipeline produced identical output.
What the fix changed
The 2026-07-13 fix addressed the dead zone first. failed-* entries no longer count as permanently consumed. A cluster with a failed entry is eligible for re-drafting on the next cycle.
The same fix addressed the heartbeat gap. The cron’s exit signal now surfaces ready-draft counts and skip-source breakdowns, so a productive zero is distinguishable from a stuck zero without reading the index directly.
The IHT zombie required a separate resolution: retiring the regeneration source. The entry format drift was a symptom; the source still producing drafts on a published topic was the cause. Removing the source removed the generation pressure.
Out of scope from the July fix: index compaction (failed entries accumulate indefinitely), per-cluster failure reason codes, and retry backoff stratified by failure type. A cluster that fails because ground quality is insufficient should wait longer before re-queuing than one that fails because a fetch timed out. That’s the next iteration.
The common thread
Improving on the dead-zone diagnosis from the ten-cycle analysis, the IHT zombie and the three-weekday gap trace to the same mechanism as the mortgage dead zone. The pipeline had one state store for outcome tracking and it expressed two distinct outcomes identically: success and failure resolved to the same boolean. Seen was seen.
A pipeline that can’t distinguish its own failure states from its success states will produce this cluster of behaviours. The dead zone is invisible without reading the index. The zombie surfaces only when a third PR appears. The zero-draft exit looks clean. None of these signal clearly in isolation. Together, after ten cycles, the skip counter made the diagnosis possible.
“Seen before” is a retrieval state. “Handled” is an outcome state. The index answered the first question and the pipeline acted on the second. That gap is where all three failure modes lived.
The fix was small. Finding it took ten cycles.



