Five slots, twenty percent: the substitute pool had no editorial policy

The audit trail for the Monday 18:00 BST slot read: id=51 → id=53 → id=54 → id=55 rejected → id=58 → breach. Six candidates in three seconds. It looked like parallel evaluation. It was sequential fallback, with each candidate failing for the same reason before the slot expired into breach.

The slot state machine, promoted to a first-class entity with explicit transitions the day before, made that chain readable. Without it, the breach would have been a log line: missed. With it, the full candidate sequence was in the audit trail, and the reason was clear: every candidate the substitute pool presented was a sibling-rehash. The drafter’s citation policy was going to refuse them all.

The numbers

Across the last five scheduled slots: one succeeded autonomously. Four expired. All four expiries traced to the same upstream failure mode.

Twenty percent is not a scheduling indictment. schedule_sweep fired the publisher for the 12:00 BST slot at 11:00:08Z, precisely on time. The publisher cancelled the row (logged as publisher_cancelled_row), and the recovery mechanism activated. The recovery mechanism then pulled candidates from the substitute pool. The substitute pool had no concept of citation policy. Every candidate it offered was one the drafter had standing instructions to refuse.

id=35 was the third cancellation in 72 hours, at 08:00 UTC, after ids 36, 39, and 50 the previous day. The breach count was a citation-policy accumulation, not a scheduling failure.

What the substitute pool was doing

When a slot misses or a row is cancelled, the substitute pool steps in with a different candidate. The early design assumed any queued article was a valid substitute. That assumption is wrong when editorial rules exist.

The pool was stealing from future-scheduled rows (rows already assigned to later slots) and recursing through the queue. Structurally sound. Editorially blind. It had no knowledge of why a candidate might be refused at draft time. It offered what was available.

What was available were articles covering source material that overlapped with already-shipped pieces. The citation policy, wired in at the drafter as a sibling-rehash refusal checked against sequel_to frontmatter, meant none of these candidates were going to reach publication. The substitute pool was presenting disqualified articles as valid recovery options, and the slot was burning its time budget on all of them before expiring.

The fix is structural. The substitute pool recurses through available rows without asking whether a given row is draftable. That is the pool doing its job: it’s a scheduling primitive, not an editorial one. The problem is that an editorial gate, the citation policy, was sitting downstream of it, applying a filter the pool didn’t know existed.

The fix: check sequel_to frontmatter at the substitute filter, before a candidate is offered to a slot. If the overlap relationship isn’t one the citation rules permit (improvement, divergence, or regression against a previously published piece), the candidate is excluded from the pool at selection time. The pool recurses to the next option having already eliminated the ineligible ones.

Two layers

The citation policy as a drafter-level refusal already existed and was working correctly. What was missing was the upstream layer: the substitute filter should apply the same check at selection time, before the slot commits to evaluating a candidate the drafter is going to refuse.

The reason both layers are still necessary: candidates enter the queue from multiple paths. Not all of them pass through the substitute filter. The drafter’s refusal is the backstop. The filter is the optimisation that prevents the backstop from being hit on every recovery attempt.

Wiring the rule in at both points closes the gap. The substitute filter means the drafter is reached only by candidates that stand a chance. The drafter’s check means the rule holds regardless of how a candidate reached the slot.

Out of scope: the breach audit log currently records rejected with a candidate id, but doesn’t capture which citation check caused the rejection. That distinction would have made the Monday trail faster to read. Logging gap, not a correctness gap; deferred.

The G-L3 problem

The same sprint surfaced a related but different failure mode in the platform-tag alias map. G-L3 was excluding platform-tag matches from topic_key derivation. The result was over-aggregation: articles on distinct platform-specific topics were being grouped under the same key and treated as siblings of each other.

Three articles were blocked or blockable in one morning because the topic classifier was wrong, not because the citation relationship was wrong. The fix was to include platform-tag matches in the key derivation so the classifier distinguishes between coverage of the same underlying project on different platforms.

The pattern is structurally identical to the substitute filter problem: editorial logic applied at the wrong precision, producing false sibling signals, causing valid candidates to be rejected. The scheduler registers a miss. The root cause is upstream, in classification.

The slot state machine

Before the slot became a first-class entity, a missed slot was a single event: breach. Now it’s a sequence of state transitions with timestamps and reasons attached to each step: scheduledin_flightsubstitute_search → candidate evaluations → breach. Each transition is explicit. Each candidate attempt leaves a record.

The 18:00 BST audit trail (six candidates, three seconds, one breach) was a sequence, not a mystery. The cancellation chain for id=35 at 08:00 UTC, the third in 72 hours, was equally explicit in the record.

Making the slot an entity with explicit transitions didn’t fix the citation problem. It made the citation problem findable. That matters more than it sounds: a system where every breach is a legible record of what was tried and why it failed is a system where the fix is obvious once you’re looking. Without the state machine, the link between “twenty percent success rate” and “editorial policy not applied at selection time” is a hypothesis. With it, it’s in the log.

The lesson

If your SLA recovery mechanism offers candidates your quality gate will refuse, recovery doesn’t recover. The slot expires. The breach is logged. The scheduler appears to have failed. It didn’t.

The lesson isn’t to weaken the quality gate under recovery pressure. It’s to push the gate upstream to the selection layer. By the time a candidate reaches the drafter, the slot’s time budget is already committed. The substitute filter catches it cheaper, and the slot sees only candidates that might actually survive.

Any pipeline with both a recovery mechanism and a quality gate needs the quality gate at the recovery layer, not downstream of it. Otherwise recovery is a loop that exhausts candidates and reports failure, when the failure was in how the candidates were chosen.

The slot state machine surfaced the pattern. The substitute filter fix closes it. The G-L3 classifier fix is the same shape: get the editorial logic right where selection happens, not downstream where the consequences are logged.

All writing