The audit at 18:00 BST on 14 June showed the same shape it had shown at 09:00. Queue id=51 → id=53 → id=54 → id=55 rejected → id=58 → breach. Five transitions in three seconds. It looked parallel. It was sequential: each row exhausting its options and handing off to the next until there was nothing left to hand to. Four out of five recent scheduled slots had failed to reach a published article. Twenty percent autonomous success.
I spent three days looking at the wrong things.
What I thought the problem was
The substitute pool was the obvious candidate. Fix C, the missed-slot alarm, had fired twice in one morning on 13 June. The 09:00 and 12:00 BST slots both missed. Fix B, the substitute filter, should have caught them. It hadn't. The schedule_sweep had fired the publisher at 11:00:08Z for queue id=37; the publisher had cancelled the row; Fix B's substitute pool had reached into future-scheduled rows and surfaced candidates that weren't exhausted, just unavailable.
Fixed that. The pool was stealing from future-scheduled rows, collapsing the candidate set before those rows' own slots had run. A substitute is only eligible if it isn't already slotted for a future window. That was a real bug. It wasn't the root cause.
SLA recovery was the next candidate. Id=35 had cancelled at 08:00 UTC on 13 June, the third cancellation in 72 hours after ids 36, 39, and 50. All four shared the same upstream failure mode B-016 had fixed. Fix A closed sibling queue rows on expiry. Fix B substituted a fresh candidate into the gap. Fix C raised an alarm when neither layer caught the absence. Three recovery layers, all patching the same gap. The gap wasn't obvious yet.
What the audit trail was actually saying
Five substitutions in three seconds means no artificial latency between attempts. The schedule_sweep was trying, failing, substituting, failing, substituting again, until the slot's window closed and the breach logged. The failure wasn't in any individual substitute. It was that the system had no model of what a “slot” was.
In the schema as it stood, a publication slot was implicit. The article_queue table had a planned_publish_at column. The schedule_sweep used that timestamp to find what to publish. A “missed slot” was an inference the code made: if a row with planned_publish_at in the past hadn't reached shipped state, something had gone wrong. No entity for the slot itself. No state machine. No record of what had happened to it independently of what had happened to the queue row it hosted.
The recovery logic was trying to reason about slot state without slot state existing. Fix A closed sibling rows by reasoning about planned_publish_at overlap. Fix B queried future planned_publish_at timestamps to find substitutes. Fix C counted recent slots that hadn't reached shipped and raised an alarm. Every layer derived slot state from queue row state. That's nearly the same thing, until it isn't.
Id=55 was rejected in the audit trail by the citation policy check, the drafter refusing a sibling-rehash candidate. Correct rejection. But the substitute pool had surfaced it as a candidate anyway, because the pool's eligibility filter didn't know id=55 had already been considered and refused in this slot's recovery chain. The slot had no memory. Nothing tracked the attempts. All four of ids 51, 53, 54, 55 had been tried already.
The missing entity
A slot is a publication window with state. Concretely: a timestamp; a set of candidates already attempted; a terminal outcome of published, breached, or cancelled; a history of what happened between opening and termination. None of that existed as a first-class schema record. Pieces of it lived in article_queue rows. Other pieces lived in triage_decisions timestamps. The rest lived in the sweep's in-memory state.
Adding the slot entity meant giving it explicit transitions:
open → publishing → published (success)
→ substitute_requested
→ substituting → published (substitute success)
→ breached (terminal — no substitute landed)
→ cancelled (terminal — manual or SLA policy)
Each attempt within a slot attaches to the slot record, one row per queue row tried. The substitute pool can now ask: which queue rows haven't been attempted in this slot? Rather than: which queue rows aren't scheduled for a future slot? Those are different questions. The second was the approximation that produced ids 51, 53, 54, 55 in the same three-second window.
The alarm logic simplifies. Instead of counting rows with planned_publish_at in the past that haven't shipped, Fix C queries slot records in breached state. Direct read, not a derivation. Sibling cancel simplifies in the same direction: cancel the slot, not individual rows with overlapping timestamps.
What the G-L3 fix surfaced
While debugging the slot logic, the alias-map over-aggregation in G-L3 came up separately. Three queue rows were blocked or blockable on the morning of 13 June, including id=36 (“Scheduling articles through Telegram taught me cron is the wrong model”). The topic_key derivation was collapsing platform-tag matches into the same bucket as content-tag matches, which made the sibling-rehash check too aggressive: articles on different topics sharing a platform tag were being treated as siblings.
The fix was to exclude platform tags from topic_key derivation. Platform tags scope; content tags identify. Two articles tagged [infra] aren't necessarily siblings, while two articles sharing a content-derived topic key probably are.
Separate bug. It wouldn't have been visible without the slot audit trail showing which rows were being rejected and why. The slot entity creates the audit surface.
What changed
The verification run at 18:00 BST on 14 June was the last audit before the slot state machine landed. It was the same run that showed id=51 through id=58 breaching in three seconds. After the state machine landed, the same scenario produces a different shape: the slot record accumulates attempted ids, the pool filters them out, the alarm fires once at breach rather than after five attempts, and the history is queryable without reconstructing it from queue row timestamps.
Twenty percent autonomous success across five slots wasn't a scheduling bug, or a substitute pool bug, or an SLA bug. Those were real bugs and fixing them was necessary. They were all downstream of the slot not existing. The recovery logic was reconstructing slot state at runtime from first principles on every sweep, which works until the state you need to reconstruct is the history of the current reconstruction attempt. Id=55 being surfaced as a candidate in the same chain that had already refused it is what that failure mode looks like in a log.
The slot entity is the boundary. On one side: code that treats publication windows as derived properties of queue rows. On the other: code that can ask “what happened in this slot?” and get a direct answer. Building the second version requires naming the entity first.



