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 Itembridge). Where it diverges from what ships today: content types are Wagtail snippets, not Pages (ADR-0008);Letter/Essay/Chapterare oneWritingmodel discriminated by akindfield with a singlesource→Book(ADR-0006), and aPassagelinks to aBookxor aWriting(not the polymorphic Pagesourcebelow). 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:
- Accounts ship in V1; no monetization in V1. User identity exists for saves / Soul Map; the
tierfield ships now but tier-gated reads stay Phase 2.- Tag axes are load-bearing. Never drop tag dimensions without a recorded ADR — traversal queries depend on them.
- AI tagging is build-time only. Editor QA gates publish (via Wagtail draft/live today; a dedicated
review_statusenum 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 | draft → reviewed (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 | draft → reviewed |
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_contentandprompt_overrideare gone; the reflection question comes from the piece's ownreflection_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¶
- Ingest a piece (corpus passage, letter, essay, or editorial).
- AI drafts tags across all tag dimensions + themes.
- Editor QAs tags in CMS;
review_statusflipsdraft → reviewed. - Optional: editor attaches a thought-provoker Prompt (or one is AI-generated).
- 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 traversal — resolved (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