My veto window was set to 48 hours. Measured on the running system, it lasted 72. Every post I announced waited a full extra night before it went out, and nothing in the logs called that an error. On a second channel the promised 24 hours had become 48. This was not an outage. It was arithmetic, and it had been true on every run since that automatic exit existed.
What I wanted was an exit that carries without my click. A nightly job picks the drafts that passed review, announces them on a card, and publishes them once the window has passed. The window is the whole point. It is the stretch of time in which I can still say no. I noticed the problem for a different reason. The night had run to completion and nothing had gone out: 28 blog drafts checked, 0 published.
I believed that a deadline written as 48 hours in the code lasts 48 hours. So the first thing I did was look at the number. It said 48. On the second channel it said 24. Both were exactly what I had decided. I took that as proof that the window was fine and looked for the brake somewhere else. The constant was right and it told me nothing, because a constant describes an intention. How long the wait really is depends on when something comes back to check.
Missing by seconds, paying a night
The job reads its clock once, when the process starts. The announcement is created moments later and carries the timestamp of the event, so start time plus a small delta. Two nights later the same cron job starts and reads its clock again. Now exactly 48 hours minus that delta have passed since the announcement. The remaining time is the delta, and the delta is greater than zero. The deadline had not expired by precisely the seconds the earlier run had needed to announce. A job that polls every minute would shrug that off. This one ticks once a night, so missing by seconds cost a whole night. The log showed it on 2026-09-07. It reported 26 hours left on announcements stamped 07:08:56 UTC the day before, which is exactly 48 hours minus 22 h 07 min.
Die Vetofrist verfehlte ihren eigenen Lauf — 72 Stunden, wo 48 zugesagt waren
The veto window missed its own run — 72 hours where 48 were promised
The fix is a grace period of 30 minutes. It covers the mechanical gap, meaning the run’s own duration plus cron jitter. Between the first and last log line I measured under a second, but the announcement path calls two outside services, so I left room. The upper bound mattered more than the lower one. 30 minutes is 1/48 of the cycle. A grace period the size of the cycle would have cut my veto window by a full night, which is the opposite mistake.
The same commit carried two more findings. The card promised a go-live time computed from now plus 48 hours, while the execution checked event time plus 48 hours. The card promised a moment the code did not keep. Both now ask one function. And the second channel held a line-for-line copy of the first channel’s calculation, with its own helper and therefore the same bug. I would have had to fix it twice after finding it once. It delegates now.
On that second channel the slip undercut its own reasoning. The window there is 24 hours because a build-in-public draft expires after 3 days. At 48 hours a draft would be two of its three days old on the day it goes out. That was exactly the state I had been running in, without ever having chosen it.
What the fix does not touch
Before deploying I ran the new code dry against the real announcements. For cards #431, #432 and #433 nothing changed. They had been created at 07:08 UTC by two runs I started by hand, and their offset of 1 h 54 min is not a run duration. A grace period of 30 minutes does not cover that, and it should not. They would go out on the same day with old and new code. A card announced by cron would go out one night earlier.
Vollzug der Vetofrist-Öffnung: identische Prüfsummen, 10/10 auf der Produktion
Veto-window opening carried out, with identical checksums and 10/10 on production
The deploy followed minutes later. The drift preview showed exactly the seven files I had changed. Afterwards the three rule files had identical checksums on both machines, and the new assertions ran green on production itself. On tools, the record does not quote anything I said I used. It infers one. Three test files changed, which points at `node –test` as the runner. I will not claim more than that.
- 2026-09-07Added the 30-minute grace period, pointed the card’s promise and the execution at one function, and made the second channel delegate instead of copy. The suite was green at 3138. I checked eight mutations red, each on its own, and deliberately moved the boundary tests that had pinned the old boundary.
- 2026-09-07Deployed, compared checksums across both machines, ran the 10 new assertions on production, and repeated the calculation with the deployed code against the real announcements.
- 2026-09-07The handover note from the neighbouring session shows the symptom in passing. It expected three articles to go live in about 26 hours, the same figure the log printed for a window that should already have closed.
- 2026-09-07Wrote the handover note and set the re-measurement for 10.09. The wrong turn belongs here too. I had first checked the figure in the code instead of on the running system. It was correct and it did not help.
- 2026-09-11Measured the gap between announcement and publication for each card in the production database and closed the open item. A guard test had gone red and blocked an unrelated commit until I did.
The case brought its own control group
The three cards announced by hand sat at the old behaviour. The two announced by cron sat at exactly the promised figure. Had the change done nothing, those two would also have stood at 70 or more. I also ran a query that must not return zero, and it found 10 announcements and 10 publications in 14 days. An empty set would have looked like success here. “No card waited longer than 48 hours” is also true when nothing runs at all.
| Card | Announced | Published | Gap in hours |
|---|---|---|---|
| #431 | 06.09. 07:08, by hand | 09.09. 05:15 | 70,1 |
| #432 | 06.09. 07:08, by hand | 09.09. 05:15 | 70,1 |
| #433 | 06.09. 07:08, by hand | 09.09. 09:44 | 74,6 |
| #497 | 09.09. 05:15, by cron | 11.09. 05:15 | 48,0 |
| #498 | 09.09. 05:15, by cron | 11.09. 05:15 | 48,0 |
Die Vetofrist ist nachgemessen — 48,0 Stunden gegen 70,1 in der Gegenprobe
The veto window has been re-measured — 48,0 hours against 70,1 in the control
Solved, and measured instead of assumed. The two cron-announced cards went out 48,0 hours after their announcement, against 70,1 for the hand-made controls. I keep the record’s decimal comma. The deploy ran with identical checksums and 10 of 10 assertions green on production. Two things stay open. The third control card took 74,6 hours, and the record does not say why it left later than its siblings. And the re-measurement itself was late. The commit calls it two days overdue. No person noticed. A guard test went red and blocked an unrelated commit until the measurement was run. That is what the guard was built for.
Three commits, d293aa5f and dae6b0da from 2026-09-07 and fbc2b57c from 2026-09-11, 4 days apart, plus the handover notes of the first day. The fact sheet was measured on 2026-09-17. Commit subjects are quoted in the German original.
d293aa5f · dae6b0da · fbc2b57c
