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:

  1. Terminate any pending scheduled-publish instance for the document (best effort — a missing or already-finished instance is tolerated).
  2. Flip the document's status from published to draft through the versioned-write helper.
  3. 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.
  4. 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:

  1. 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.
  2. 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.
  3. Re-render the collection's listing page with the document removed.
  4. 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.
  5. Regenerate the sitemap, feeds, and robots.txt. These queries filter on status = published, so the unpublished document drops out automatically.
  6. 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