The substitute pool stole from future slots because slots didn't exist as rows

The Sunday 18:00 BST audit trail: id=51 selected; id=53, id=54, id=55 rejected; id=58, breach. Six events in three seconds. Monday’s 09:00 slot produced the same sequence. The pipeline had reached breach across four of the last five scheduled publishing windows. Twenty percent autonomous success by count.

The sequences looked concurrent. They were sequential and recursive, terminating at breach because the substitute pool kept selecting candidates it had no business selecting: articles scheduled for future slots. Nothing in the data model had any mechanism to stop it. The slot that was failing had no identity to reason about. It was a moment in time the code was trying to fill.

Improving on last sprint’s finding that the substitute pool lacked editorial policy, this sprint found the structural cause. Slots had no rows. The pipeline was inferring slot outcomes from queue-row state, and the inference broke down the moment the pool ran recursive.

The theft mechanism

The 12:00 BST walkthrough on 2026-06-13 is the clearest trace. At 11:00:08Z, schedule_sweep fired the publisher against queue id=37. The publisher determined the row wasn’t ready and logged publisher_cancelled_row. Fix B, the substitute filter, ran next. It queried the pool for a replacement. The pool included future-scheduled rows: articles queued but not due until a later window. The filter had no predicate excluding them. It selected one. The publisher encountered it, determined it wasn’t due, logged the same cancel. Fix B ran again. Another future-scheduled row. The recursion terminates at breach.

The Sunday id=51 → id=53 → id=54 → id=55 → id=58 chain completed in three seconds because each cycle is a database read and a state write. Nothing was slow. The speed was a symptom of how structurally unconstrained the selection was: the pool had candidates, the filter had no disqualifying criteria for them, and so it cycled through five before breach.

The same shape appeared at 09:00 Monday. The pool was not random. It was deterministically picking the next eligible candidate by whatever ordering the query returned, and that ordering included future-scheduled rows that were ineligible by date but valid by every other criterion the filter checked. Each cycle was correct by its own logic. The system was wrong by design.

Twenty percent, correctly attributed

The SLA recovery investigation on 2026-06-13 named the pattern. id=35 cancelled at 08:00 UTC, the third cancellation in 72 hours. The previous day saw id=36, id=39, id=50 fall the same way. Four of the five slot expirations traced to the same upstream failure mode, already logged as B-016. On each one the substitute pool cycled, selected candidates from the wrong scheduling window, then converged on breach.

Fix C alarmed twice on 2026-06-13: once for 09:00, once for 12:00. The alarm was correct about the outcome. The signal it was watching, slot failure, was being derived from queue row state. That derivation was right enough to fire the alarm. It wasn’t right enough to prevent the recursion it was alerting on.

Fixing the substitute filter’s selection predicate stopped the recursion. It didn’t change what the system could record about the slot that had just breached. That gap, between stopping the behaviour and capturing the event, is where the slot state machine belongs.

Queue rows as the wrong audit unit

A queue row models content lifecycle: the article exists, has a draft, gets scheduled, ships, or is cancelled. It does not model time-window lifecycle: whether a given publishing slot was filled, substituted, or missed. Those are distinct things. The pipeline had been conflating them.

The consequence is that “this slot breached” was not a stored fact. It was inferred from absence: no published row, no substitute shipped, alarm triggered at the margin. Under normal operation, with one attempt and a clean publish, the inference holds. Under recursion it doesn’t. After five substitute attempts in three seconds, the question “has this slot already tried three times?” has no reliable answer.

Fix B was exactly this problem. It was a filter layer approximating slot awareness without slot identity. It could ask “which queue rows are available?” but not “has the slot we’re filling already cycled through three of them?” The second question requires the slot to exist as a queryable entity.

If you can’t query a breach as a fact, you can’t write SLA recovery logic against it. You write against proxies. Proxies work until the thing they’re approximating changes faster than they can follow.

The slot state machine

Making the publishing window a row changes what can be asked. Each scheduled slot gets its own identity and explicit state transitions. The substitute filter’s question, “is this candidate already held by an active slot?”, becomes a join. “Has this slot already run a substitute cycle?” becomes a column read.

The breach condition becomes a state the slot transitions into rather than a condition the alarm layer derives. The SLA recovery logic reads slot state directly. A slot that has already run a substitute cycle is excluded from the next draw. The pool’s selection query filters out candidates held by slots in progress. Recursion doesn’t terminate at breach. It never starts, because the query feeding the pool returns nothing eligible.

The six-event sequence that previously lived in application logs becomes six state transitions on a single slot row. The sequence is queryable. The breach is a stored fact. The timeline of selections is attached to the entity that experienced it, not scattered across the state changes of six different queue rows.

What explicit transitions add

Explicit transitions are enforcement, not just nomenclature. A slot cannot reach breached without writing a transition record to the slot table. “How many slots breached this week?” reduces to a count query. “Which slots ran more than two substitute cycles?” is a join against the transition table. Neither was possible when breach was inferred rather than stored.

Fix C fires against a slot state transition after this change, not against a derived read from three joined tables. The alarm is simpler and the signal is more reliable. The SLA recovery chain (sibling cancel, slot fallback, missed-slot alarm) reads against slot state now. The model stores what happened rather than requiring the application layer to reconstruct it on every check.

The audit trail that prompted this sprint, six events in three seconds and the same shape two days running, is now a first-class query. It was always a diagnostic signal. It is now a stored fact.

Out of scope

The citation policy changes landed in the same sprint: the drafter refusing sibling-rehash, the substitute filter respecting sequel_to frontmatter when selecting substitutes. Those prevent duplicate-article publishing. The slot state machine prevents the pipeline looping to breach. The two predicates interact in the substitute filter, but the citation logic warrants its own article.

The G-L3 alias-map over-aggregation that blocked three articles on the morning of 2026-06-13 is also out of scope. Queue id=36 (“Scheduling articles through Telegram taught me cron is the wrong model”) was among them. That was a topic-key derivation bug in the alias resolver, not a slot scheduling problem. Separate audit trail.

The slot state machine addresses the audit unit. What gets selected into a slot (editorial policy, cadence constraints, citation rules) is the layer above it.

All writing