The 09:00 BST slot on 13 June fired clean. The publisher ran at 08:00:20Z, G-L3 checked queue id=35 against the shipped index, found a duplicate of PR #105 already live, and returned None. The slot expired without logging a failure. No retry, no fallback, nothing shipped.
My editorial SLA: one article at 09:00 BST every day. No exceptions.
That was the second breach in twenty-four hours.
The three days
11 June, morning. Queue id=34 (“Every post-merge bbpark fix was the same fix”) was the first live cron-driven dvlaw run. The digest had upgraded the candidate, voice-fix had run, the publisher had opened a PR. No merge followed. The slot closed with an open PR in GitHub and nothing on the live site.
I came back to the terminal at 15:05. The question was simple: had two articles shipped that day, or not? The audit chain told a layered story. BBBrain PR #200 (B-011 + B-012) had landed in main, but brain_server was running stale code. The duplicate-gate gap the patches had closed was still open in the running process. The fix existed; the process hadn’t seen it.
12 June. The 06:00 digest fired clean — ten candidates posted to Telegram. Queue id=36 (“Scheduling articles through Telegram taught me that cron is the wrong model”) entered the 09:00 BST slot and stalled. An integration test that morning had exposed alias-map over-aggregation; the candidate couldn’t proceed. Three articles shipped that day, all manually. B-016 filed.
13 June, two breaches. At 09:00, G-L3 correctly identified id=35 as a duplicate of PR #105 and returned None. Correct behaviour from that layer. The slot expired silently, with no SLA recovery, no substitute, and no alarm. At 12:00, id=37 had been cancelled at 11:00:08Z (not by G-L3; a direct replay of B-016 confirmed blocked=False), and the slot still fired empty. B-018 SLA recovery and B-020 substitute-pool-from-future both dispatched that afternoon. Two more manual ships.
Different surface cause each time. One architectural mistake throughout.
What the timer model can’t see
The slot was implemented as a timer event: at time T, pick the head of the queue, invoke the publisher, done. The slot had no lifecycle beyond that invocation. Whether the candidate reached the queue, whether the PR opened, whether the merge fired, whether the article was live on the site — none of that was the slot’s concern. The slot’s job ended when the publisher returned.
If the publisher returned None, for any reason, the slot ended. No failure state. No substitute path. No alarm. The timer fired; the slot is over; the fact that nothing shipped is not encoded anywhere a recovery layer can reach.
The 11 June merge miss makes this concrete. The publisher opened a PR. From the slot’s perspective, the job was done: the call returned without error. From the site’s perspective, the article wasn’t live. Those two things were inconsistent, and the system couldn’t see the inconsistency because PR-open and PR-merge were modelled as one operation. The slot lifecycle ended at publisher return. What happened after that was nobody’s job to track.
The 13 June 09:00 breach is the sharpest version of the same problem. G-L3 did exactly what it should: it returned None with reason “duplicate.” The citation policy was correct. The duplicate gate was a guard on individual candidates, not a component in a slot lifecycle. When the guard fired, there was nowhere for the slot to go. The system had correct duplicate detection and no recovery path for when that detection triggered.
What a state machine looks like instead
The slot needs explicit states: pending → processing → shipped | failed(reason) | substitute_pending.
“Shipped” means the article is live, not that the publisher returned without raising. That distinction is the 11 June breach in one sentence.
Failure reasons drive recovery. “Candidate was a duplicate” is recoverable. “Candidate cancelled mid-slot” is recoverable. “No candidate in queue” is recoverable if future-scheduled rows exist. None of those should expire the slot. All of them should trigger the substitute path.
B-020’s substitute-pool-from-future is that path. When a slot’s candidate can’t ship, the system pulls from rows scheduled for future slots (ready to draft, not yet due) and promotes the earliest eligible one. If that candidate also fails, the pull recurses. The slot doesn’t expire until either an article ships or the substitute pool is empty. Empty pool is the only legitimate terminal failure, and it fires an alarm rather than a silent expiry.
Out of scope: candidate starvation, where every future-scheduled row is also a duplicate or cancelled. That’s an operational concern about queue depth, not a state machine concern. The alarm handles it. No additional code change needed for that case.
The duplicate gate and the substitute mechanism don’t need to know about each other’s implementation. They share the slot’s state. That’s the composition the timer model can’t express.
Why each patch was necessary and none was sufficient
B-016 closed the alias-map over-aggregation that stalled candidates on 12 June. B-018 added SLA recovery for slots that expire without shipping, and B-019 locked in the citation policy G-L3 enforces. B-020 built the substitute pool.
Each patch closed a specific failure mode. None of them, individually, changes the model. B-020 comes closest, because the substitute pool is what makes the slot a process that runs to completion rather than a trigger that fires once. The others are guards: they prevent bad candidates reaching the publisher, or recover after a breach has already occurred. Guards are necessary. They don’t replace a lifecycle.
The pattern across the three days: each incident produced a patch; the patches didn’t share a frame. B-020 is the frame.
What this cost
Three days of manual ships. Each article landed on the correct site at a working URL; the editorial standard didn’t slip. The operational overhead was the cost: audit trails at 15:05, manual fire commands, incident entries written up after the fact. That overhead is also the record that makes the pattern visible. The devlog entries for 11, 12, and 13 June are the diagnostic trail; this article is the model extracted from them.
The one-sentence version: a slot is a process with a terminal condition, not a trigger with a return value.
If you’re building a scheduled content pipeline, audit whether your slot has a lifecycle. A trigger fires once. A lifecycle runs until it reaches a defined terminal state. The difference is invisible in normal operation. It surfaces the first time a candidate fails mid-slot, which in any non-trivial pipeline will happen.



