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: trueor a null go-live instant ⇒DRAFT; go-live> now⇒SCHEDULED; go-live<= now⇒PUBLISHED.
Every public list/fetch query filters go_live_at <= now(); only PUBLISHED items surface.
Alternatives considered
- Scheduler-only visibility (no read-time filter) — flip a
publishedflag 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. - 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 inPublicationPolicy, not scattered in controllers. Unpublished items return404(ADR-0006). - Follow-up:
domain-engineerownsPublicationPolicy+Clock;data-engineerapplies the filter inPostgresContentIndexand wires the scheduler tick;platform-engineerruns the scheduler service;contract-ownerpublishescontent.article-publishedperEventEnvelope.
Compliance
- Policy unit tests:
PublicationPolicyis tested acrossDRAFT/SCHEDULED/PUBLISHEDwith an injectedClock, covering both precedence rules and thedraft: trueoverride. - Read-time filter scan: every index list/fetch query includes
go_live_at <= now()(no unfiltered content read path). - Boundary test: a
SCHEDULEDarticle returns404before its go-live instant and200at/after it, driven by a controllableClock. - Event conformance: the emitted
content.article-publishedpayload validates againstcontract/asyncapi.yaml'sEventEnvelope.