Skip to content

CMS Architecture

Accuracy note (2026-07-10). This documents the target content model — much of it is not yet built (Journey, Chapter-as-Journey-slice, Podcast, Reflection, Soul Map, the Content Item bridge). Where it diverges from what ships today: content types are Wagtail snippets, not Pages (ADR-0008); Letter/Essay/Chapter are one Writing model discriminated by a kind field with a single sourceBook (ADR-0006), and a Passage links to a Book xor a Writing (not the polymorphic Page source below). Built content types today: Passage, Book, Writing, Article (editorial, non-canon — schema + CMS only, no API; see the journeys-and-home design), Theme, TagCategory, Tag. For current implementation reality, treat those ADRs and the code as authoritative over the field tables below. In particular, review_status (in the tables below) is not a shipped field — editorial QA rides Wagtail's draft/live publish state (ADR-0008) today.

The content model is a tag graph split into three layers.

  • The Content Library is what editors author — separate content types with distinct field shapes.
  • The Editorial Structure is how editors curate — Themes as navigable worlds, Journeys as sequenced reading paths.
  • The App Layer is what the backend computes on top — the tag-overlap graph, user data, and community reflections.

Bridging them is a polymorphic Content Item — any Passage or Writing (and, when built, Podcast). This is what the graph traverses, what Journeys reference, and what users save. Separate storage, uniform traversal.

See the data-model plan for the design rationale and reconciliation narrative behind this structure. See the glossary for canonical term definitions.

Schema-level constraints:

  1. Accounts ship in V1; no monetization in V1. User identity exists for saves / Soul Map; the tier field ships now but tier-gated reads stay Phase 2.
  2. Tag axes are load-bearing. Never drop tag dimensions without a recorded ADR — traversal queries depend on them.
  3. AI tagging is build-time only. Editor QA gates publish (via Wagtail draft/live today; a dedicated review_status enum is deferred — see the accuracy note above).

Content Library

The readable long-form types — letter, essay, book chapter — are one Writing model discriminated by a kind field (ADR-0006), not three collections; shared fields live on the model and per-kind fields (a letter's recipient/date, a chapter's order) are optional columns validated per kind in clean(). Passage stays a separate model because its body is plain text for word-by-word audio sync. Book is the source/purchase proxy both point at. The Content Item bridge gives uniform traversal without merging storage.

Passage

Atomic unit. Always Lewis's words, always drawn from a source. Primary interaction is cloned-voice audio. Body is plain text because word-by-word audio sync needs a clean string.

Field Type Notes
title string Display label
body plaintext Lewis's exact words — no rich text (audio sync)
content_type enum letter / poem / nonfiction — distinguishes provenance within evergreen Lewis corpus
source_context text Contextual note about where in the source this passage comes from
source ref → Book xor Writing Source ladder anchor. Out-of-corpus → Book (purchase proxy); in-corpus → the Writing (letter/essay/chapter) itself. Never both.
themes refs → Theme · multi Global category axis (Courage, Faith & Doubt, Grief, Hope…)
tags refs · multi Faceted tags — dimensions TBD (candidate: motif, life-stage, register, tone-depth)
thought_provoker ref → Prompt Pre-reading lens; overrideable per Chapter
audio_url url ElevenLabs cloned-voice read-aloud (pre-generated, build-time). No Narnia audio (HarperCollins). Audio model TBD: stored URL vs stream-by-ID.
featured_image media AI-generated; client disclosure pending
tier enum free / premium — field ships now, gating logic Phase 2
front_door bool Narnia entry-wedge flag
review_status enum draftreviewed (AI drafts tags; editor QA before publish)

Writing

The readable long-form unit — a letter, essay, or book chapter — held in full, discriminated by kind (ADR-0006). One model, not three: per-kind fields are optional columns validated per kind in clean(). Can be the source of a Passage. Letters in-app need client confirmation (assumed yes, not validated). Essay contributor attribution (author, Substack source) is not yet modelled on Writing — deferred with the standalone Author type below.

Field Type Notes
title string Display label
slug slug Stable identifier for the API + importer
kind enum letter / essay / chapter — discriminates the unit
body richtext Markdown-restricted rich text (bold / italic / link / list)
recipient string Letters only — who Lewis wrote to
original_date date Letters/essays only — when it was written
order int Chapters only — position within the source Work
source ref → Book The one Work to send readers to (nullable)
reflection_prompt text Optional, singular
audio ref → Document Optional read-aloud (only Passages populated at MVP)
themes refs → Theme · multi
tags refs · multi

Podcast

Longer-form external audio — embedded, not CMS-hosted. Deferred — not built in V1 (scoped as a schema stub only; the fields below are the target shape).

Field Type Notes
title string
subtitle string
cover_image media
embed_link url External audio source
duration int Seconds
transcript text Indexed for search
themes refs → Theme · multi
tags refs · multi
thought_provoker ref → Prompt
review_status enum draftreviewed

Book

The Work — a source/purchase proxy for books not held in full (formerly named Work). Drives the source ladder to purchase; Passages and Writings point at it via their single source FK. Source reference only in V1 — no e-reader; cover, synopsis, publication year, deferred buy CTA.

Field Type Notes
title string
slug slug Stable identifier for the API + importer
cover media
publication_year int
synopsis richtext Markdown-restricted
buy_cta url Bookstore handoff — deferred (no monetization in V1)
themes refs → Theme · multi
tags refs · multi

Author (deferred — not built)

Standalone type for essay contributors (Malcolm Guite, etc.); photo drives the essay card. Not built. Writing (kind=essay) carries no contributor field yet; editorial Article instead attributes via an author FK → User. Revisit if/when contributor essays are ingested. Fields below are the target shape.

Field Type Notes
name string
photo media
bio text Card-level; short

Prompt (deferred — not built)

A standalone, reusable reflection/thought-provoker question, cleanly overrideable per Chapter. Not built as a standalone model. Today each Passage/Writing carries a plain reflection_prompt text field, and a Journey chapter override is snapshotted onto the Reflection at save time. Revisit if prompts need to be shared/reused across pieces. Fields below are the target shape.

Field Type Notes
question_text text The prompt shown to the user
type enum reflection / discussion / personal

Worlds & Taxonomy

Theme

A navigable world — the portal. Each has its own landing page, bespoke colour palette, and portal imagery. Themes are a global vocabulary spanning every content type (not per-book), created once via the sync_themes command. Content pieces and Journeys are assigned to Themes via multi-select (a piece may sit in several).

V1 set (9 themes, from the PM dataset, may grow pre-launch): Courage, Faith & Doubt, Forgiveness, Grief, Hope, Joy, Love, Pride & Humility, Wonder. Book imports link passages to these by name; they never create themes (see ADR-0007).

Because the vocabulary is command-managed and not yet finalised, the Editors role is granted view_theme only — editors see and select themes but cannot add, change, or delete them. Full theme CRUD stays with superusers; the seed command configures this (and re-revokes stray theme grants on every run). Adding themes via the CMS remains architecturally supported for a future editorial-vocabulary phase.

Field Type Notes
title string
description richtext
portal_image ref → Portal Image Bespoke portal imagery
color_palette token Design token — bespoke per theme; switching a theme recolours the UI
content_blocks ordered refs Editor-managed blocks driving the theme landing page
featured bool Surfaces on Home

Portal Image

CMS-managed reusable image library. Editors pick one per Theme. Grows over time.

Field Type Notes
name string Internal label
image media

Tags (deferred)

Granular, multi-dimensional taxonomy distinct from Themes. Powers fine-grained discovery + the graph. Facets TBD — candidate dimensions from the product model:

  • Motif — cross-work, powers surprise jumps
  • Life stage — youth → grief (single per piece)
  • Register — bedtime / morning / anytime
  • Tone depth — meaning (default) / theological (opt-in)

Tag taxonomy is an upstream content-design blocker: facets + controlled vocabulary must be locked before the CMS vocab tool, AI tagging, and the graph engine can be built.


Editorial Structure

Journey

Curated, sequenced reading experience. Belongs to one or more Themes. A Journey holds no content of its own — it's an ordered sequence of Chapters referencing existing library pieces. Same object as a "Portal" viewed as a doorway — don't model as two things.

Field Type Notes
name string
copy richtext Description / overview
colour token
icon media
portal_shape enum From a preset list
opening_question text
final_question text
featured bool Surfaces on Home
themes refs → Theme · multi A Journey can span multiple Themes
chapters ordered refs → Chapter

Journey detail page shows: title, overview, chapter list, and length. Users can have multiple journeys in progress simultaneously; progress is gated behind account creation.

JourneyChapter

Superseded 2026-08-12. The table below is the original design, in which a chapter grouped a primary reading plus supporting content. The updated designs flatten it: a chapter is one piece. opening_passage, supporting_content and prompt_override are gone; the reflection question comes from the piece's own reflection_prompt. See journeys-and-home.md §3 for the built shape.

One chapter of a Journey. References library content — holds no body of its own.

Field Type Notes
opening_question text
opening_passage ref → Content Item Dropped
primary_reading ref → Content Item Superseded — the chapter references exactly one of passage, writing or article (nullable FKs + a DB check). No field of this name exists
supporting_content refs → Content Item · multi Dropped — a chapter holds one piece
prompt_override ref → Prompt Dropped — the prompt belongs to the piece
order int Sequence within the Journey

Any content type can appear in a chapter.

Content Blocks

Editor-managed ordered blocks driving Theme landing pages and the Homepage. Not hardcoded layouts. Block types TBD — at minimum: featured Journey, featured piece, content grid.

Home is likely editorially curated (pending confirmation): hero slot always a passage/quote; below it, featured-theme + featured-journey carousels + a "What's new" module.


Content Item (polymorphic bridge)

The graph, Journeys, Saves, and Reflections all need to reference "any content piece" uniformly. Rather than merging storage (which breaks field-level type differences), a Content Item interface exposes the shared surface:

  • Theme tags (multi)
  • Faceted tags (multi)
  • Thought provoker (Prompt ref)
  • Bookmarking + reflections
  • Review status

Implementation: multi-table inheritance, generic foreign key, or union view — decided at build time per the framework's strengths. The key invariant: one traversal interface, separate storage.


Tag-Overlap Graph (computed, not stored)

Nodes are Content Items; edges are computed from shared Tags + Themes. Two flavours:

  • Further in — same thematic thread, going deeper
  • Connects across — same theme in a different work or format

Tagging pipeline: AI drafts at ingest → editors QA before publish → graph recomputes on publish. No runtime AI in the content API path. No hand-built relations between pieces (except editor hero-pins).

At MVP, tag-overlap ranking runs on Postgres GIN array operators. A Ranker interface keeps the Meilisearch swap mechanical for Phase 2.

Related content surfacing is deferred (IA stand-up 2026-06-30) — the graph engine may still be built in V1, but its UI surface is deprioritized. Confirm scope before investing heavily.


User Data

User

Passwordless email-link (magic link) account via Firebase (Apple/Google Sign-In deferred). Soft delete for App Store compliance (account deletion is a V1 deliverable). The app is fully usable as a guest; accounts persist Soul Map data.

Save (Soul Map entry)

Field Type Notes
user ref → User
content_item ref → Content Item What was saved
prompt_text text Snapshot of the prompt shown — not a ref, because chapters can override the default prompt
user_response text · nullable The user's reflection
saved_at datetime

Journey Progress

Field Type Notes
user ref → User
journey ref → Journey
chapter ref → Chapter Current chapter
last_piece ref → Content Item Resume point
last_opened_at datetime

Journey Completion

Field Type Notes
user ref → User
journey ref → Journey
completed_at datetime

Reflection (community)

Moderated community note. "Publish, not post" — never appears as raw user input. Surfaced as ambient presence ("14 have sat with this"), not a comment thread.

Field Type Notes
content_item ref → Content Item
user ref → User · nullable Nullable for pre-account reflections (if supported)
body text
status enum pending / published
hearts int Aggregate count

Moderation queue UI is Phase 2.

Daily Drop

Scheduled "today's piece" pointer. Schema ships at MVP; scheduling UI is Phase 2.

Field Type Notes
drop_date date
content_item ref → Content Item
register enum Time-of-day variant

Relationships

From → To Cardinality Meaning
Passage Book xor Writing N → 1 Source ladder (out-of-corpus → Book; in-corpus → the Writing)
Writing (essay) Author N → 1 Contributor — deferred (Author not built)
Content Item Theme N ↔ N Multi-axis tagging
Content Item Tags N ↔ N Faceted taxonomy
Content Item Prompt N → 1 Thought provoker (overrideable per Chapter)
Content Item Content Item N ↔ N "Related / go deeper" — computed from shared tags, not stored
Theme Portal Image N → 1 Bespoke portal imagery
Theme Content Blocks 1 → N Theme landing page layout
Journey Theme N ↔ N A Journey can span multiple themes
Journey Chapter 1 → N Ordered sequence
Chapter Content Item N → N Opening passage + primary reading + supporting content
Chapter Prompt N → 1 Prompt override (replaces piece's default)
Save Content Item N → 1 Soul Map entry
Save User N → 1
Reflection Content Item N → 1 Community note
Daily Drop Content Item N → 1 Scheduled "today's piece"

External Integrations

System Touches Role
Substack editorial ingest Syndicates contributor essays via Celery polling — deferred (no Substack field on Writing yet)
Bookstore (affiliate) Book.buy_cta Purchase handoff from the source ladder — deferred (no monetization in V1)
HarperCollins audio availability Rights holder; restricts Narnia audio (rights-gating field TBD)
ElevenLabs Passage.audio_url Cloned-voice read-aloud (pre-generated at build time)

Editor Workflow

  1. Ingest a piece (corpus passage, letter, essay, or editorial).
  2. AI drafts tags across all tag dimensions + themes.
  3. Editor QAs tags in CMS; review_status flips draft → reviewed.
  4. Optional: editor attaches a thought-provoker Prompt (or one is AI-generated).
  5. Publish. Tag-overlap graph recomputes; the new node becomes traversable.

No runtime AI in the content API response path.

Open Items

  • Tags faceted taxonomy — upstream content-design blocker; facets + controlled vocabulary must be locked before CMS build
  • Passage audio model — stream-by-ID from voice provider vs stored pre-generated URL
  • Separate types vs unified traversalresolved (ADR-0006): the long-form types unified into Writing (kind-discriminated); cross-type references use two nullable FKs + an XOR constraint (ContentRefMixin), not the polymorphic Content Item bridge sketched above. See the journeys-and-home design for the implemented shape
  • Content block library — block types for Theme landing pages + Homepage TBD
  • Theme colour palette — CMS reference vs design-token enum
  • Letter + Essay corpora — schemas are provisional until raw content is ingested and validated
  • Letters in-app — needs client confirmation (assumed yes)
  • Deeper meaning — likely deprioritized; client has no editorial team to QA AI interpretations
  • AI-generated featured images — client AI-disclosure conversation pending
  • How many Journeys ship at MVP — still open