Skip to content
bflo.sh

ADR-0008: Scheduled publishing via read-time gating, with a scheduler for warm-up

ADR-0008 — Accepted 2026-07-05. Part of the platform's decision record.

Adapted from the repo's docs/adr/0008-scheduled-publishing-read-time-gating.md — Part of Internals: the platform's design set, published as content on the platform it describes.

  • Status: Accepted
  • Date: 2026-07-05
  • Deciders: contract-owner, lead
  • Tiers affected: backend, contract, infra
  • Related: ADR-0005 (index queried at read time), ADR-0006 (unpublished ⇒ 404), ../master-blueprint.md §3.4, §5.4, §6.2

Context

Articles declare a publication schedule in front-matter; a SCHEDULED article must stay invisible until its go-live instant, then appear automatically. Visibility must be correct even if a background job is late or never runs — a missed scheduler tick must not leak a still-private article or hide a due one. The precedence between published_at and scheduled_at must be unambiguous so they never contradict. This is the single most important Domain invariant and shapes both the index queries and the emitted events, so it is decided before the Domain layer is built.

Decision

We will make publication visibility a pure function of front-matter and the current time, enforced at read time (correctness), and run a scheduler that emits content.article-published when an item crosses into PUBLISHED (cache-warming + notification, not a correctness dependency). Precedence is fixed:

  • go-live instant = scheduled_at ?? published_at — when the article becomes public.
  • display date = published_at ?? scheduled_at — the date shown to readers.
  • draft: true or a null go-live instant ⇒ DRAFT; go-live > nowSCHEDULED; go-live <= nowPUBLISHED.

Every public list/fetch query filters go_live_at <= now(); only PUBLISHED items surface.

Alternatives considered

  1. Scheduler-only visibility (no read-time filter) — flip a published flag on a tick. Rejected: a missed or delayed tick leaks a still-scheduled article or hides a due one; correctness must not depend on a job firing on time.
  2. Publish purely by file presence, no schedule — a file in the tree is live. Rejected: removes scheduling entirely, which is a core goal (blueprint §1.1); authors could not stage future posts.

Consequences

  • Easier: the API is correct with no running scheduler; the scheduler becomes a pure optimisation (re-warm caches, notify the front-end for on-demand revalidation).
  • Harder / now forbidden: no query may return content without the go_live_at <= now() filter; visibility logic lives only in PublicationPolicy, not scattered in controllers. Unpublished items return 404 (ADR-0006).
  • Follow-up: domain-engineer owns PublicationPolicy + Clock; data-engineer applies the filter in PostgresContentIndex and wires the scheduler tick; platform-engineer runs the scheduler service; contract-owner publishes content.article-published per EventEnvelope.

Compliance

  • Policy unit tests: PublicationPolicy is tested across DRAFT/SCHEDULED/PUBLISHED with an injected Clock, covering both precedence rules and the draft: true override.
  • Read-time filter scan: every index list/fetch query includes go_live_at <= now() (no unfiltered content read path).
  • Boundary test: a SCHEDULED article returns 404 before its go-live instant and 200 at/after it, driven by a controllable Clock.
  • Event conformance: the emitted content.article-published payload validates against contract/asyncapi.yaml's EventEnvelope.