Five Phase B fixes in 48 hours. They were all patching the same missing type.

The Saturday 18:00 BST audit trail, opened to verify B-020 had worked, showed this: id=51 → id=53 → id=54 → id=55 rejected → id=58 → breach. All resolved in three seconds. Sunday’s 09:00 BST audit trail showed the same sequence: same ids, same shape, same three seconds. The pattern had the look of parallel evaluation. Five candidates probed simultaneously, one selected, the rest falling away. That’s not what it was.

That was the sixth time I’d looked at the same shape. The five fixes that preceded this investigation had each addressed a different surface. The shape kept returning.

What happened in 48 hours

Five changes shipped between Friday and Sunday, all tagged phase-b.

Substitute pool recursion. Thursday’s 09:00 and 12:00 BST slots both missed. Fix C’s alarm fired twice. The 12:00 walkthrough: schedule_sweep fired the publisher at 11:00:08Z, the publisher cancelled the row (logging publisher_cancelled_row), and the substitute pool reached into future-scheduled rows to fill the gap. That pull propagated. The substitute itself had a scheduled successor, and the pool tried to pull it too. The fix was to make the recursion explicit. The pool follows the chain until it reaches a row with no scheduled successor. No guards, no depth cap. The structure terminates naturally.

G-L3 alias-map over-aggregation. Friday’s integration test exposed three articles blocked-or-blockable in one morning. Queue id=36 (“Scheduling articles through Telegram taught me cron is the wrong model”) was one of them. The alias map was grouping platform-tag matches into the same topic_key as the article’s substantive topic. G-L3, the deduplication layer, was treating that grouping as a conflict and blocking the row. The fix: G-L3 ignores platform-tag matches when deriving topic_key. Three rows unblocked.

SLA recovery. The id=35 cancellation at 08:00 UTC was the third in 72 hours, after id=36, id=39, and id=50 the day before. Autonomous success across the last five scheduled slots was 20%. Four of those failures were the same upstream failure mode that B-016 had already addressed. SLA recovery added three mechanisms: sibling cancel (cancel a failed article occupying the slot), slot fallback (pull a substitute from the pool), missed-slot alarm (surface the event to Telegram). B-016 fixed the upstream. SLA recovery fixed what happens after the upstream fails despite that fix.

Citation policy. The drafter now refuses sibling-rehash at the draft stage, not after. A draft whose only relation to a published article is recapping the same source material is rejected before the drafter writes anything. The substitute filter respects sequel_to frontmatter. If a queued article declares a relation to a published one, the filter won’t substitute across that sequence. Two layers, one at the drafter, one at the picker, because the rule is probabilistic at the prompt layer and deterministic at the filter layer.

Slot state machine. Made slots first-class entities with declared state and explicit transitions.

The pattern

Set the five fixes against each other and the shared shape becomes visible.

The substitute pool recursion added ordering rules to a pull that had none. The G-L3 fix added a category distinction (topic vs. platform) to a key derivation that treated them as equivalent. SLA recovery added an observer to a transition (scheduled to missed) that had no one watching it. The citation filter added a sequencing constraint to a substitution that had no memory of what had already shipped.

Each fix was adding structure to something that had been left implicit. The slot was the thing that had been left most implicit of all.

A slot, the scheduled position in the publication calendar, existed only as a column on a queue row. Its state was inferred. If the row was cancelled, the slot was empty; if shipped, filled; if drafted, in progress. Nothing declared what states a slot could occupy, what transitions were valid between them, or what should happen at each boundary.

The audit trail from Saturday makes this concrete. The id=51 → id=53 → id=54 → id=55 rejected → id=58 → breach sequence was sequential thrashing, not parallel evaluation. The substitute pool was cycling through candidates one at a time because nothing prevented it from re-entering a slot already under evaluation. The slot had no state to lock. The sweep couldn’t tell whether the slot was being evaluated, had already been evaluated, or had been abandoned mid-evaluation. It inferred from queue rows and got it wrong.

The same absence explains SLA recovery’s shape. The missed-slot alarm had to reconstruct whether a slot had been missed by scanning queue history. There was no missed_at timestamp to read, no MISSED state to query. Recovery logic had to walk backward through rows to determine forward state. The state machine was implicit and running in the wrong direction.

The fix

The slot became its own entity.

Each slot now has a row, a state column, and a defined state machine. States are declared, not inferred. A slot in evaluating cannot be pulled as a substitute elsewhere. A slot in missed triggers the alarm directly, because MISSED is a first-class state with an observer attached, not an absence the sweep has to detect. The substitute pool reads slot state before pulling. SLA recovery reads slot state before deciding whether recovery is needed.

The audit trail shape, five ids in three seconds, cannot recur in the current model. The first candidate evaluation locks the slot. Subsequent sweeps see the lock and exit. The sequence becomes a single id with a single transition.

What 48 hours of fixes pointed at

Improving on four-expired-slots-and-the-sla-recovery-rule-we-learned-in-sequence: that piece documented the SLA recovery rule as a sequence learned through production failures across four expired slots. The rule was correct. What it didn’t yet have was the model that makes the rule unnecessary to reason about at runtime.

Each of the five fixes was locally correct. Substitute pool recursion was the right behaviour for an unordered pull. G-L3’s category distinction handled an over-aggregating alias map. SLA recovery watched an unwatched transition. Citation policy gave the filter the memory it lacked. They were all correct, and they were all guarding against the consequences of implicit slot state.

The state machine made them redundant one by one. Not by being a better patch, but by removing the substrate those patches were defending against. When a slot knows what state it’s in, the code that was inferring that state has nothing left to do.

All writing