Scheduling articles through Telegram taught me that cron is the wrong model

The message updated. Voice-fixed article, flagged and ready. That was the first time sprint 3’s [📅 schedule] button appeared on a finished piece. I tapped it. Six options appeared: [🚀 now], [⏰ at…], [🪟 next slot], [📥 hold], [🛑 cancel schedule], [« back].

It felt like scheduling. Not configuring. The distinction matters more than I expected.

Why scheduling needed six sprints

The DVLAW pipeline produces articles through a defined chain: triage → thesis variants → draft → voice pass. By the time an article reaches the schedule step, it’s already written. The question is simple: when does this go out?

The naive implementation is a date-time field. Enter a timestamp. Done. That took about two minutes to reject. The timestamps I actually want to express don’t look like 2026-06-15T14:00:00Z. They look like tomorrow at nine, next Monday, in three hours, whenever there’s a free slot this week. A date-time picker enforces precision where the intention is approximate. It shifts cognitive load from the article to the calendar.

Decision: a free-text grammar parser, not a UI control.

Sprint 2: schedule_grammar.py

dvlaw/schedule_grammar.py is a pure Python module with no external dependencies. It converts free-text time expressions into UTC datetimes. The inputs it targets: tomorrow 09:00, +3h, 2026-06-15 14:00 UTC, relative offsets, day-of-week references. No LLM. This is deterministic parsing, and deterministic parsing should stay deterministic.

The module was built before any UI existed. Sprint 2 produced no user-visible change. That sequencing was deliberate: the grammar layer needs to be correct before you wire it to a live reply listener. Fixing parser bugs in isolation is easier than fixing them under a Telegram callback.

The parser is where the “conversation” framing first becomes concrete. A timestamp is a fact. tomorrow 09:00 is an intention expressed relative to now. The grammar parser translates intentions into facts. Every later sprint builds on that translation.

Sprints 3 and 4: the keyboard and the reply listener

Sprint 3 landed the first user-visible change: a [📅 schedule] button on every voice-fixed article. Tapping it opens the 2×2 scheduling keyboard: a six-option sub-menu.

Four options resolve immediately: [🚀 now] dispatches straight to the queue. [🪟 next slot] picks the next available publication window. [📥 hold] parks the article without committing. [🛑 cancel schedule] kills a pending schedule. [« back] returns to the article card.

One opens a conversation: [⏰ at…].

Sprint 4 wired the reply listener for that button. Tap [⏰ at…], the bot posts a prompt, and the next message in the thread is treated as the time expression. That message goes to schedule_grammar.py. If parsing succeeds, the article is scheduled and the card updates. If it fails (bad input, ambiguous expression, a reference that doesn’t resolve) the bot replies with a per-reason error and the pending entry stays open for a retry.

The reply listener is where scheduling becomes genuinely conversational. No form, no modal, no date-picker. You type what you mean. The parser decides if it can resolve it.

M2 preflight landed in sprint 3. Before showing the schedule sub-menu, the system checks queue capacity. If the queue is full when you tap [📅 schedule], it says so before you pick a time. Surfacing the constraint before the decision rather than after it is the difference between a rejection and a warning.

Sprint 5: articles actually fire

Sprints 1 through 4 wired the mechanism to record schedules. Sprint 5 wired it to execute them.

The schedule sweep daemon starts alongside brain_server’s lifespan. It polls article_queue at a configurable interval, finds rows whose planned_publish_at is in the past and whose state is scheduled, and dispatches them. The publisher lock prevents double-dispatch. If the sweep fires and a concurrent process has the same row, one of them loses the lock and exits cleanly.

The devlog note “Scheduled articles now actually fire” is understated. Sprints 1-4 produced a system that collected intentions. Sprint 5 was where those intentions became actions.

Sprint 6: the grace window

Sprint 5 introduced an edge case. An article scheduled for 09:00 that the sweep picks up at 09:03 is fine. An article scheduled for Monday that I don’t look at until Thursday is not fine in the same way. It’s past due, but not necessarily wrong to publish. Sometimes the schedule slips because I was busy; the article is still valid.

Sprint 6’s past-due grace policy handles this. A past-due article stays publishable for 48 hours. After 48 hours, the hard-expire fires and the article moves to a terminal state: not deleted, but no longer automatically dispatchable. The Telegram notification for an expired article offers one action: reschedule. I decide whether it’s still worth shipping.

The slot-exhaustion UX is the other sprint 6 piece. When the queue fills and a new article tries to schedule, the response isn’t “queue full.” It’s a recovery path: the sub-menu shows how many scheduled slots are occupied and offers to hold the article instead. The constraint is named. The options are explicit. The decision stays with me.

What the six sprints add up to

A cron expression is a machine-readable instruction. It doesn’t know what you meant; it knows what you specified. Specify wrong, it runs wrong. Silently.

The six sprints produced something that works differently. The grammar parser handles imprecise intent. The reply listener keeps the exchange open if the first answer doesn’t resolve. The grace window makes a 48-hour judgment call on behalf of past-me: you probably meant to publish this, but I’m not going to act after two days without checking. The slot-exhaustion UX names the constraint instead of swallowing it.

Each of those choices assumes that the person scheduling articles has approximate intentions, variable availability, and limited appetite for calendar arithmetic at the moment the article is ready to go. The system adapts to those constraints rather than requiring the user to adapt to the system.

That’s the difference between scheduling as a form and scheduling as a conversation. The form captures what you entered. The conversation captures what you meant.

Out of scope: multi-article batch scheduling, calendar integration, optimal-slot recommendations based on engagement data. All tractable. None were in the plan.

The grammar parser is in dvlaw/schedule_grammar.py, no external dependencies. The reply listener, sweep daemon, and grace policy are in the same dvlaw/ module scope. The full build log is at docs/devlog.md under the B-schedule entries. If you’re building something similar, the sequencing (parser first, UI second, execution third, edge cases last) is the part I’d repeat exactly.

All writing