Accepted
ADR 0017 — UnpublishDocument workflow
Unpublishing 404s the page synchronously, then a workflow refreshes every derived artefact
Publishing a document re-renders the page, refreshes the home and listing pages, regenerates the sitemap and feeds, and purges the edge cache. Unpublishing has to undo all of that. It mirrors publish: a small synchronous part the editor waits on, followed by a workflow for the housekeeping.
Synchronous part: the page is gone immediately
When the editor unpublishes, the admin route does exactly the work the editor needs to see complete before the response returns:
- Terminate any pending scheduled-publish instance for the document (best effort — a missing or already-finished instance is tolerated).
- Flip the document's status from published to draft through the versioned-write helper.
- Delete the rendered HTML from R2 and evict the canonical URL from the edge cache, so the next request for the page hits the not-found path.
- Purge the doc:{id} surrogate key, so any cached representation of this specific document is invalidated.
This is bounded work that completes in milliseconds, so the editor's confirmation is honest: by the time the route returns, the URL really is a 404.
The UnpublishDocument workflow
Everything else moves to the UnpublishDocument workflow, which mirrors the publish flow's render-then-purge shape:
- Remove the document from the search index so public search stops returning it. Done first to shrink the window in which a hit could leak.
- Fire the document.unpublished plugin hook (notification tier). Errors here are swallowed — a misbehaving subscriber can never fail the unpublish, which has already taken effect.
- Re-render the collection's listing page with the document removed.
- Re-render the home page. If the document was the configured homepage, resolution falls back to recent posts; if it was just a post, the recent-posts list is regenerated without it.
- Regenerate the sitemap, feeds, and robots.txt. These queries filter on status = published, so the unpublished document drops out automatically.
- Purge the broader surrogate-key set: listing, sitemap, feed, settings, theme, and nav.
The route creates the workflow instance after the synchronous purge completes. If the instance fails to start, a warning is persisted on the document and surfaced on the admin health page so an operator can re-trigger.
Two ordering invariants
- Re-render before purge. The render steps rewrite the R2 origin, then the final step purges the edge cache. Purging first would let the next request re-cache the stale R2 file.
- Synchronous own-page purge before workflow handoff. The page already 404s before the workflow is created, so a failed workflow start never leaves a live cached page.
Why the split closes a real gap
A purely inline unpublish can purge the sitemap and feed cache without rewriting the R2 origin. The next request then re-caches XML that still advertises the unpublished URL — a permanently listed 404 until the next publish rewrites the origin. Inline work also runs with no retry budget and no observability. Moving the regeneration into a workflow gives it the same retry, observability, and failure-surfacing as publish, and guarantees the origin is rewritten before the cache is purged.
What stays out of the workflow
- Document-level cache invalidation (doc:{id}) stays synchronous, so the page is gone the moment the response returns.
- The DocumentSession durable object is not evicted — an editor with the document open keeps editing the draft body, which is preserved.
- Settings changes keep their own synchronous purge path; they change render inputs rather than the set of published documents, so they need a cache forget, not a multi-step regeneration.
Consequences
- Unpublish has the same architectural shape as publish, and the sitemap, feed, and listing staleness window is closed.
- Background failures are observable and retryable rather than silent.
- The listing, home, and sitemap/feed regeneration logic is shared between the publish and unpublish workflows via src/worker/render/site-artefacts.ts.
- The pattern generalises: bulk operations fan out to the same workflow per document, and the workflow boundary is a natural attachment point for plugin hooks.
Considered and rejected: staying fully synchronous (the regeneration loops over every published document with no retry or observability); one parameterised workflow for both publish and unpublish (conflates two flows with opposite R2 actions); and moving the synchronous own-page purge into the workflow (breaks the page-is-gone-immediately promise).
The surrogate keys and stale-while-revalidate behaviour the purge steps rely on are defined in the cache invalidation decision; the render-then-purge ordering it mirrors is the publish flow.
Read the cache invalidation decisionRead the render and publish decision