The publisher cancelled the 12:00 slot. My recovery logic called the publisher again.

At 11:00:08Z on 13 June, schedule_sweep fired the publisher against queue id=37. The article was scheduled for 12:00 BST. The publisher ran, found a problem with the row, and cancelled it. Cancellation note: publisher_cancelled_row. That is the publisher behaving correctly: encounter a problem, write a verdict, exit cleanly.

Fix B, the SLA recovery layer, runs on a separate thread. It saw id=37 in state cancelled. It called publisher against the next substitute candidate.

The next candidate cancelled too. Fix C alarmed. By 12:00 BST the slot was logged as missed.

The 09:00 slot had done the same thing an hour earlier.

The five slots before it

Between 10 and 13 June, five scheduled slots produced one successful publication. Twenty percent autonomous success. Four of those failures traced to B-016, the alias-map over-aggregation the 12 June integration test exposed. The B-016 fix shipped before 13 June started.

The 13 June failures were different. id=35 cancelled at 08:00 UTC, the third cancellation in 72 hours after ids 36, 39, and 50 the day before. By the time schedule_sweep fired id=37 at 11:00:08Z, Fix B had already failed to recover the 09:00 slot. B-016 was not the explanation.

The earlier finding said the substitute pool lacked editorial policy, and that gap explained why five slots produced only one article. The 12:00 failure had a different root cause. The pool’s contents were not the problem. The problem was what Fix B did once it reached them.

What publisher_cancelled_row is not

A publisher cancellation is not a transport failure.

When Telegram delivery fails, when a Google Drive write times out, when an API call returns 503, the publisher never reaches a verdict. The row expires or stays in an intermediate state. Those are recoverable: the primary path did not complete, so substitute and retry.

When the publisher marks a row cancelled with note publisher_cancelled_row, the publisher has reached a verdict. Something in the row itself, whether draft validation, frontmatter state, or queue consistency, triggered a deliberate exit. That note is not a symptom of transient failure. It is a concluded judgment.

Fix B cannot read that distinction. It sees state = cancelled and calls publisher. The publisher applies the same validation to the next candidate, reaches the same conclusion, cancels again. Fix C fires. The alarm count is two. The recovery count is zero.

The structural recursion

The problem is not timing and not concurrency. Fix B and the publisher are not writing to the same row simultaneously. The problem is simpler: Fix B has no model of who cancelled the row.

Recovery logic exists to handle failures the primary actor could not resolve. If the primary actor resolved the row by cancelling it deliberately, calling the primary actor again produces the same resolution. That is not a retry logic error or a race condition. It is a structural gap: two components each behaving correctly, no shared understanding of who holds responsibility when recovery fires.

When schedule_sweep fired publisher against id=37 at 11:00:08Z, the publisher took the row. When publisher cancelled it with publisher_cancelled_row, the publisher released the row to concluded status. Fix B read that as an unclaimed failure. Fix B called publisher. Publisher applied the same validation, cancelled again.

That is not two failures. It is one failure confirmed twice, with Fix B providing the mechanism for its own second confirmation.

The recursion closes only if calling publisher on a publisher-cancelled row sometimes produces a different result. It does not. The publisher is deterministic by design: same row state, same validation path, same note, same cancellation. A deterministic actor will not break a loop it created. Fix B calling it again is a scheduled confirmation, not a recovery attempt.

The substitute pool, separately

A second fix shipped on 13 June alongside this one: the substitute pool was pulling from future-scheduled rows when the immediate candidate pool ran dry. Articles assigned to later slots were being consumed early, leaving those slots without substitutes when their own publisher calls failed.

That is a candidate selection problem, not a call discipline problem. Both fixes landed in the same batch because B-016’s noise had been masking both until it cleared.

Out of scope for 13 June: the em-dash density fix that caught three auto-published articles shipping at 4.5×, 8.6×, and 12.3× the style-guide budget with zero scrubber flags. That fix is on a different subsystem and landed the day before.

What recovery needs to know

A structured cancellation provenance field resolves the lock-ownership gap. The notes column is freetext, unparseable without string matching that will drift as the notes evolve. A cancelled_by field with enumerated values (publisher, timeout, sweep, manual) gives Fix B something to branch on deterministically.

A row with cancelled_by = publisher does not go to publisher recovery. Fix B either routes to a non-publisher filling strategy or raises a different alarm class: “publisher rejected this row” rather than “slot missed.” The distinction matters for triage. A slot-missed alarm might be transient infrastructure. A publisher-rejected alarm is a validation or data problem that needs inspection, not retry.

The two alarms Fix C raised on 13 June looked identical in the log. They were not. Fix B had not given Fix C the information to tell them apart.

What 20% actually means

Five slots produced one article. That set SLA recovery as a priority. With B-016 cleared, the substitute pool selection fixed, and the lock-ownership gap closed, the rate will shift. How far depends on whether the remaining failures share the same class or represent undiagnosed modes.

The recovery system is not broken. It is incomplete. A missed slot that Fix B cannot recover is not a recovery failure. It is a signal that the recovery path has a blind spot for that failure class. The substitute pool’s editorial policy was one blind spot. Lock ownership was another. Each produces correct alarms and zero recoveries, and the two are indistinguishable in the alarm log without provenance data.

The target state after this fix is not “Fix B handles all cancelled rows.” It is “Fix B knows which cancelled rows it can handle.” Those are different specifications. The first is a claim about coverage. The second is a claim about discipline. Only the second is achievable.

All writing