Skip to content

ADR-0008: Content models are Wagtail snippets, not Pages

Status

Accepted — implemented alongside ADR-0006 decision 1.

Date

2026-07-09

Context

PR #10 modelled content (Passage, Book, Letter, Essay) as Wagtail Page subclasses under a structural page tree (SiteRoot → per-type index pages), plus a HomePage placeholder. That bought Wagtail's publish workflow, but editors got the page-explorer navigation: content nested inside a tree, reached by drilling through container pages.

The editorial team's CMS mockup wants flat, admin-style listings — grouped sidebar sections (evergreen content / editorial / curation / taxonomy / media) with per-type tables (Title / Kind / Status) — not a tree. And this service is a headless CMS for a mobile app: no content is ever served at a URL by Wagtail's page-routing; the app consumes a hand-built DRF API (ADR-0004). Tree position carried no meaning — every relationship that matters (source, themes, tags) is already an explicit FK/M2M.

The build is at scaffold stage: no environment holds real content, so a destructive schema change is cheap now and expensive later.

Decision Drivers

  • Must: editors get flat per-type listings with status columns (mockup); draft/live + revisions + moderation retained (AI-generated content must stay unreviewable-by-default, constraint #3).
  • Must: no dead structural concepts for editors to trip on (index pages, tree hierarchy).
  • Should: land ADR-0006 decision 1 (unified Writing) in the same schema pass.

Considered Options

Option A: Keep Pages, customize the admin around the tree

  • Pros: no schema change; page URLs if ever needed.
  • Cons: explorer navigation fights the mockup; index/root pages are pure ceremony; slugs are tree-scoped; PageChooserBlocks couple content to wagtailcore.Page.

Option B: Snippets with Wagtail feature mixins (chosen)

  • Pros: flat SnippetViewSet listings + SnippetViewSetGroup sidebar groups map 1:1 to the mockup; DraftStateMixin/RevisionMixin/WorkflowMixin/LockableMixin retain publish workflow; models become plain FK-addressable rows (RAPID-friendly).
  • Cons: no per-item URL routing or Page.copy-style tree tooling (not needed headless); title/slug become explicit model fields.

Decision

  1. Passage, Book, and the new Writing are snippets built on WorkflowMixin + DraftStateMixin + LockableMixin + RevisionMixin + index.Indexed + ClusterableModel, each with explicit title + unique slug.
  2. Writing replaces Letter/Essay with a kind discriminator (letter/essay/ chapter) and per-kind clean() validation — ADR-0006 decision 1, realised as a snippet. collected_in (M2M) is dropped for a single source FK → Book.
  3. Passage carries two nullable source FKs — sourceBook and source_writingWriting (was FK → wagtailcore.Page + content-type validation). A passage is drawn from a single origin — a Book xor a Writing (letter/essay/chapter), never both — matching the documented source chain (data-model plan); clean() enforces the xor. Two explicit FKs rather than a GenericForeignKey keep native per-type snippet choosers, select_related, and admin list filtering. The importer only ever sets source (Book) today.
  4. The page tree dies: SiteRoot, the four index pages, and the HomePage placeholder are deleted. The Home surface will be rebuilt as its own model against the six-slot feed contract (CSL-42) when curation work starts — the placeholder (intro + PageChooserBlocks) matched nothing in that contract and its chooser blocks break once targets aren't Pages.
  5. Admin: content and taxonomy snippets are flat top-level menu items grouped visually by CSS section headers (Content / Taxonomy / Media) — Wagtail's sidebar has no native section header, so each zone's first item carries the label (classname + data-zone-label, styled in admin_theme.css). The page explorer menu item is hidden and the site root / redirects to the CMS sign-in.
  6. Migrations are destructive (0004 deletes the Page models, 0005 creates the snippet tables) — acceptable because nothing beyond re-runnable imports exists in any environment. The Mere Christianity import re-runs cleanly against the new schema.

Consequences

Positive

  • Admin now matches the editorial mockup with stock Wagtail (no custom UI).
  • Content rows are plain models: simpler queries in readers, no page_ptr joins, no .specific casts, slugs globally unique.
  • seed shrinks to dev users only; the importer no longer needs a pre-built tree.

Negative

  • Re-adding page-served content later would mean re-introducing Page models (unlikely: headless).
  • Wagtail's cross-page reference tooling (PageChooserBlock, page search in one index) no longer applies; cross-content references must be explicit FKs or SnippetChooserBlocks.

Risks & Mitigations

  • Risk: future curated surfaces (Home, Journeys) assume Pages. → Build them as snippets/models against their contracts; nothing in the app consumes Wagtail routing.
  • Risk: dropped Letter/Essay tables surprise a stale environment. → Only dev existed, with zero content rows; migrations apply cleanly in sequence.
  • ADR-0004 — headless DRF API (why page routing is unused).
  • ADR-0006 — unified readable type (decision 1 implemented here); review state + Markdown remain open there.
  • ADR-0007 — importer, updated to write snippets.
  • Tickets: CSL-52 (content models), CSL-42 (Home feed contract).