The queue had a planned_publish_at column. The M2 preflight was wired. So was the six-button scheduling sub-menu. The free-text time parser worked. After sprint 4, you could tap ⏰ at…, reply “tomorrow 09:00”, and watch the row appear in article_queue with the correct UTC timestamp. Nothing published.

Sprint 5 had not landed yet. The sweep daemon didn’t exist.

That gap is the actual engineering story of B-schedule. Four sprints of infrastructure recorded intent. One sprint executed it.

What each sprint built

Sprint 2 was schedule_grammar.py. A pure free-text time parser, no Telegram integration, no queue writes. The devlog entry says it plainly: “No external behaviour change yet.” The sprint produced a function that took strings like “tomorrow 09:00”, “+3h”, and “2026-06-15 14:00 UTC” and returned absolute UTC timestamps. That was the entirety of sprint 2.

Sprint 3 added the first user-visible surface: the [📅 schedule] button on every voice-fixed article and a six-button sub-menu behind it — [🚀 now], [⏰ at…], [🪟 next slot], [📥 hold], [🛑 cancel schedule], [« back]. It also landed the M2 queue-aware preflight, the check that runs before presenting scheduling options to confirm the article is in a dispatchable state. From the Telegram side, the UX looked complete.

Sprint 4 closed the ⏰ at… loop sprint 3 had stubbed. Tap ⏰ at…, reply with a free-text time, the grammar parser picks it up, the queue row gets planned_publish_at. Per-reason error replies for invalid input. The conversation contract between Telegram and the queue was fully specified.

After sprint 4, the queue knew exactly when every article was supposed to publish. No article had published on schedule. The executor was missing.

Sprint 5: the verb enters

The schedule sweep daemon starts alongside brain_server lifespan. It sweeps article_queue at a regular interval, finds rows where planned_publish_at is past and state is still scheduled, and dispatches them. The devlog entry is unambiguous about what this means: “Sprints 1–4 wired the mechanism to RECORD schedules. Sprint 5 wires the mechanism to EXECUTE them.”

The other addition was publisher_lock. Without it, a sweep firing while a previous publish was still in flight would double-dispatch — two concurrent publishes of the same article, both writing to the same output path, both committing the same file. publisher_lock is a simple guard: one publish in flight at a time. The sweep checks the lock before dispatching and skips cleanly if it’s held.

Dispatch wiring closed the loop. The sweep finds an eligible row, acquires the lock, calls the publisher, updates the row state. The same publisher that “fire now” called in sprint 3 is what the sweep calls — one dispatch path, not two.

The split between the memory phase and the execution phase shows up cleanly in what the tests assert. Sprints 2–4 tests checked state transitions: does the grammar parser handle UTC correctly? Does M2 preflight block an already-scheduled article? All assertions about rows in article_queue. Sprint 5’s tests were the first to assert that an article had actually published — that a real output existed, not just that the queue row said it would.

Sprint 6: when the clock slips

Sprint 5 handled the happy path. Sprint 6 handled the failures.

Past-due grace policy. If the sweep finds a row with planned_publish_at in the past, it doesn’t reject it immediately. There’s a grace window. Past 48 hours, the row hard-expires. This matters because brain_server can be down for maintenance, launchd can miss a reboot, the machine can sleep. A schedule that slips by four hours is recoverable. One that slips by three days probably isn’t. The 48h threshold is a judgment call; the devlog doesn’t explain its origin.

Slot-exhaustion UX. The scheduler dispatches against cadence slots — the cadence engine controls how many articles can publish in a given window. When all slots are full, a scheduled article can’t dispatch. Without sprint 6, it would skip silently. With it, CJ gets a Telegram notification naming the exhaustion and the next available window. A missed publish without explanation erodes trust in the automated system faster than any bug does.

Security-LOW publisher exception. The devlog flags this as a security finding and calls it a “publisher exception.” The entry doesn’t elaborate, and the detail isn’t in the available devlog entries for this sprint series, so neither will this article.

Why the order matters

Building the memory layer before the execution layer looks like deferred work. It’s also the reason sprint 5 was clean.

The sweep depends on everything around it: a correct state machine, a defined dispatch path, integration points the lock can attach to. Building the sweep in sprint 2 would have meant building all of that in sprint 2, or building a stub that needed unpicking later. Sprint 5 had nothing to negotiate with. The queue was already correct. The dispatch path existed. The lock slotted into a defined integration point. The sweep was a background thread calling an existing function against an existing state machine.

Whether that sequence was deliberate design or the natural consequence of building Telegram UX before the backend that would execute it, the outcome was the same: sprint 5 was small. No state machine rewrites. No concurrency model retrofitted around an existing caller. No renegotiated UX contracts.

That’s the actual return on four sprints of memory work.

Where B-schedule leaves things

Six sprints, one complete feature. DVLAW articles can now be queued, scheduled to a specific time, and published automatically. The test suite distinguishes cleanly between memory-phase coverage (assertions about queue state) and execution-phase coverage (assertions about published outputs). That distinction is worth preserving as the next items on the roadmap land.

The sweep and the lock aren’t complicated. What’s hard is bolting them onto a system whose state machine isn’t stable yet. Sprint 5 stayed small for one reason: the first four sprints had already settled everything the sweep depended on. Every execution layer added on top of this queue will have the same requirement.

All writing