The publishing queue ran at 20% success rate. Fixing it took three changes that only work together.

Five queue IDs moved through the 18:00 BST slot audit on 2026-06-14: id=51 → id=53 → id=54 → id=55 rejected → id=58 → breach. Three seconds from first transition to breach event. The chain looked concurrent, candidates competing for a slot. It wasn’t. The slot state machine was processing them in sequence, each failing on different grounds, until the substitute pool ran empty and the SLA clock ran out.

The machine behaved correctly. The problem was what came before it ran.

The number

Queue id=35 was cancelled at 08:00 UTC on 2026-06-13, the third cancellation in 72 hours. Ids 36, 39, and 50 had been cancelled the day before. Across the last five scheduled publishing slots, one article had shipped autonomously. Twenty percent.

Four of those five expirations traced back to the same upstream failure mode. B-016 was identified and supposedly fixed in an earlier sprint. Not fixed well enough. The fix had addressed the primary row for each slot. It hadn’t cancelled siblings. It hadn’t triggered a substitute. It hadn’t alarmed. Each sibling expired on its own schedule. Each breach event fired in isolation. Nothing in the queue surface showed the pattern until I was looking at five rows across three days.

That’s what silent degradation looks like in a queue system. Individual failures look isolated. The pattern is only visible across a window.

The three parts

Sibling cancellation. When a slot’s primary article is cancelled or expires, every sibling in that slot is cancelled at the same moment. Without this, siblings continue to hold queue space in a terminal state. They don’t trigger recovery. They don’t fire an alarm. They sit there, accumulating debt, until the sweep cycle notices them one by one.

Slot fallback. The substitute pool no longer draws only from unscheduled candidates. When a slot fails and a substitute is needed, the pool steals from future-scheduled rows: articles that have a planned slot but haven’t published yet. The steal recurses naturally. If the stolen row itself fails, the pool keeps stealing down the schedule. Queue id=37 showed this on 2026-06-13: schedule_sweep fired the publisher at 11:00:08Z, the publisher cancelled the row with a publisher_cancelled_row note, and the fallback picked up a future-scheduled article without manual intervention.

Missed-slot alarm. When a slot breaches and the substitute pool can’t fill it, the system emits an alert. Before this fix, the breach event was logged. Nothing else happened. On 2026-06-13, both the 09:00 and 12:00 BST slots missed, and the alarm fired twice. Without it, neither miss would have surfaced that morning.

Why each alone fails

The three aren’t independent options. They’re a dependency chain.

Sibling cancellation without slot fallback stops the queue debt from accumulating but doesn’t fill the slot. The cancellation fires, the breach fires, and then nothing happens. The slot stays dark.

Slot fallback without sibling cancellation means the substitute pool fires on the primary row’s failure, but orphaned siblings remain in limbo. When each sibling’s own schedule date arrives, it fires again, same failure mode, same pool invocation, potentially the same substitute candidate. The degradation pattern reproduces.

Sibling cancellation and slot fallback together, without the alarm: when the substitute pool runs dry, the system cancels, attempts the fallback, fails silently, and moves on. The queue is degraded. The next human check is the daily digest. The alarm is the terminal-case visibility layer.

The dependency order: cancellation triggers the fallback path, fallback attempts recovery, alarm fires on fallback failure. Remove any one and the chain breaks at that step.

Slots as first-class entities

The 2026-06-14 slot state machine entry is the architectural prerequisite for the SLA recovery that preceded it in the devlog. First-class slot entities with explicit transitions are what make the audit trail readable: id=51 → id=53 → id=54 → id=55 rejected → id=58 → breach is a decision trail, not a status dump. Each arrow is a named transition. Each rejection is logged with a reason.

When slots are implicit, derived from timestamps on queue rows, the same log looks identical but you can’t distinguish what the machine decided from what it observed. Explicit state makes the distinction legible.

What else surfaced that morning

The 2026-06-13 diagnostic session also exposed a separate issue in G-L3, the topic-key derivation layer. G-L3 was matching platform tags in the alias map and over-aggregating topics. Queue id=36 (“Scheduling articles through Telegram taught me cron is the wrong model”) was blocked because its topic key had collapsed into a broader alias that already had an article in the same publication window. Three articles were blocked or blockable from that one misconfiguration. The fix makes G-L3 ignore platform-tag matches when deriving topic_key.

Out of scope here: the alias-map design and whether the current aggregation model is correct long-term.

The citation policy follows the same logic

The citation policy that shipped the same day has the same dependency structure. The drafter refuses to write a sibling-rehash, an article that recaps the same source material as an already-published piece without improvement, divergence, or regression. The substitute filter checks sequel_to frontmatter before adding a candidate to the pool. Two reinforcing layers for the same editorial rule.

Neither alone is sufficient. The drafter can be bypassed if the variant picker generates a thesis that looks distinct but draws on the same sources. The substitute filter catches the output even when the drafter passed it. Rule enforced at generation, enforced again at consumption. Same dependency structure as the SLA recovery chain.

Where this leaves things

Twenty percent was the forcing function. The fix isn’t a single change. It’s three changes that produce a different failure-mode profile. Cancellations land explicitly instead of expiring silently. The substitute path recurses down the schedule. An alarm fires on the terminal case.

The upstream failure mode B-016 that caused four of those five expirations is a separate problem. What this fix closes is the queue’s ability to absorb failures without visibility. Silent degradation was the finding. Each of the three parts eliminates one layer of that silence.

The devlog entries for 2026-06-13 and 2026-06-14 are the canonical record. The slot state machine entry is the architectural change. The SLA recovery entry is the operational behaviour that runs on top of it.

All writing