Journeys & Home — Data Model + API Design¶
Draft 2 · 2026-08-12 · Brainstormed against the INT Figma flows, approved in session
Design draft — Home not yet built
Home is designed here but not implemented. The Journey models + CMS are being built now (no API yet). Where this diverges from what ships, treat the code and ADRs as authoritative.
Draft 2 — the Journey spine flattened (2026-08-13)
The Journey spine changed shape. This document describes the current model; the table records
what it replaced, so a reader who remembers the old design can see what moved. The superseded
version is not kept — git log has it if it is ever needed.
| Draft 1 | Draft 2 (current) |
|---|---|
A chapter grouped ~4 ordered pieces, one flagged is_primary |
A chapter is one piece |
| Reached through a chapter-menu screen | No menu — the landing opens a chapter directly |
| Per-chapter keystone: a distilled line + insight, sealed until the chapter closed | Gone. The reward is the journey's single emblem, revealed progressively by the client |
closing_reflection_question authored on the chapter, sealed until completion |
The prompt belongs to the piece, shown mid-flow, skippable |
| A chapter referenced a Passage or a Writing | May also reference an Article |
A chapter carried its own themes + primary_theme |
Reads them through from its piece |
ChapterPiece, per-piece completion |
Both dropped |
Design for the two surfaces that reach content (per ADR-0006, content has no standalone list endpoints — it is reached via Home and Journeys): the Journey spine (Journeys tab → landing → reading → reflect) and the Home feed (six curated slots). Also records the CMS shape pivot: away from Wagtail Pages/tree, to plain models behind grouped flat navigation.
Sources:
- Figma INT S&D (
RtD5iFi3tWuz1X3Y19dbf2) — updated Journey flow section14864-40694(Draft 2), superseding Journeys flow node12490-5375; Home flow node12155-21902. - Linear: CSL-42 (Home slots), CSL-32 (Journey/Chapter schema), CSL-33 (feed API), CSL-28 (FE shell), CSL-56–61 (Home sections), CSL-64 (Theme curation), CSL-52 (curation & featuring), CSL-71 (review state).
- Notion: Content Model & Curation Spec (
3914dccd9d8b80f2baa0edc1abf95087), Journey Reading Experience Spec (3914dccd9d8b80dfb74bd2216de5ab65).
1. CMS shape: models, not Pages¶
The CMS moves off the Page tree. No index pages, no page-tree routing slugs, no tree ordering (a
unique model slug field stays on every content type — see §2) — every content type is
a plain Django model surfaced through Wagtail ModelViewSets / SnippetViewSets with
DraftStateMixin + RevisionMixin (draft→publish, revisions, review queue all survive), grouped
into flat admin navigation:
| Nav group | Contents |
|---|---|
| evergreen content | Writings (one listing, kind column: letter · essay · chapter), Passages, Books |
| editorial | Articles |
| curation | Journeys, Home, Featured lists |
| taxonomy | Themes, Tags |
| media library | Images (SVG enabled), Documents |
Two status axes appear in listings:
- Publish state — Wagtail draft/live (from
DraftStateMixin). review_status—ai_draft→reviewed(CSL-71): has an editor QA'd this piece?
Separate from both: is_ai_generated — a permanent provenance flag on base content
(Passage, Writing, Book, Article): was this piece originally AI-generated? Unlike review_status
it never changes after creation. CMS-only — excluded from all API payloads.
Status — not yet a uniform field.
review_statusandis_ai_generatedare not implemented as model fields on any content type yet. Passage records AI provenance inPassage.ingest_meta["is_ai_generated"](metadata, not a column); Article ships a dedicatedis_auto_generatedboolean. A uniformreview_status/is_ai_generatedfield across all content is deferred (see the Article row below + ADR-0006 / CSL-71). Field lists in §2 describe the target.
Existing Page models (Passage, Book, Letter, Essay, HomePage, the *Index pages) are
rewritten as plain models in one pass. Pre-content, so migration rewrite is cheap; this is the
last moment it is. Warrants an ADR alongside ADR-0006's unification when implemented.
2. Content models (evergreen + editorial)¶
| Model | Fields | Notes |
|---|---|---|
| Passage | body (RichTextField → Markdown), interpretation, reflection_prompt, audio, source FK → Book/Writing, themes, tags · review_status/is_ai_generated deferred — see §1 (provenance in ingest_meta) |
The atom, unchanged in substance. (Not in What's New — it owns the hero.) |
| Writing | kind (letter · essay · chapter), title, body, single source FK → Book, recipient (letter), original_date (letter/essay), order (position in source Work, chapter), themes, tags, reflection_prompt, optional audio, is_new_eligible · review_status/is_ai_generated deferred — see §1 |
Replaces Letter/Essay per ADR-0006. Kind-specific fields optional on the model; required-ness enforced per kind in clean() |
| Book | title, cover, publication_year, synopsis, themes, tags · is_ai_generated deferred — see §1 |
Work/source proxy; buy CTA deferred (layout leaves room) |
| Article | title, subtitle, author FK → User, featured_image, publication_date, body, themes/tags, primary_theme, is_new_eligible, is_auto_generated (default false; "generated via scraping data"). Deferred, not dropped: review_status, is_ai_generated — added later as a cross-cutting field across all content, not on Article alone |
Built 2026-07-13 (schema + CMS). In What's New since CSL-142 (2026-07-20, per Mayank — reverses "canon-only V1", pending design); still excluded from Further Reading in V1. author uses a searchable User chooser |
Ids + slugs. Every content model and Journey carries a unique slug alongside its id
(adopted from PR #17); the API returns both — paths use id, slug is the stable
human-readable identifier (deep links, analytics, imports).
Themes + primary theme (everywhere). Every content model, plus Journey,
carries themes M2M and a primary_theme FK (validated: primary must be one of themes).
The primary theme is what the FE uses for the reading-screen colour shift, card tinting, and
theme-derived assets — closing the "pieces have multiple themes, none picks the colour" gap from
the reading-experience review.
Cross-type content references. Without the Page tree there is no wagtailcore.Page FK to hide
polymorphism behind. Everywhere a "piece" can be Passage-or-Writing (chapter pieces, What's New
pins, reflections), use two nullable FKs + an XOR check constraint, via one shared abstract
base (ContentRefMixin: passage FK, writing FK, CheckConstraint exactly-one-set, a
content property). Queryable and prefetchable, unlike GenericForeignKey.
3. Journey spine (curation)¶
Screen → endpoint → table flow. Most content reads (solid) are user-agnostic and cacheable, and
per-user reads and writes hit the /account/… endpoints. Journey detail is the exception: it
sits on the public path but carries the reader's progress, so it is private, no-store (see
§5). Edge legend: ──▶ public content read · ┈┈▶ per-user read · ══▶ write.
flowchart LR
subgraph SCR[Screens]
direction TB
s1([Journeys tab])
s2([Journey landing])
s4([Reading screen])
s5(["Reflect / Bookmark"])
end
subgraph PUB[Public endpoints · cacheable]
direction TB
e1["GET /api/journeys/"]
e3["GET /api/journeys/{id}/chapters/{n}/"]
e4["GET /api/content/{kind}/{id}/"]
end
subgraph PRIV[Public path · per-reader · never cached]
direction TB
e2["GET /api/journeys/{id}/"]
end
subgraph ACC[Account endpoints · per-user]
direction TB
m1["GET /api/account/journeys/"]
m3["GET /api/account/journeys/{id}/chapters/{n}/"]
m4["GET /api/account/bookmarks/"]
w1["POST /api/account/journeys/{id}/start/"]
w3["POST …/chapters/{n}/complete/"]
w4["POST /api/account/reflections/"]
w5["PUT·DELETE /api/account/bookmarks/{kind}/{id}/"]
end
subgraph CON[Content tables]
direction TB
t_j[(data_journey)]
t_c[(data_journey_chapter)]
t_rel[(data_related_journey)]
t_ex[(data_passage)]
t_wr[(data_writing)]
t_ar[(data_article)]
t_bk[(data_book)]
end
subgraph USR[User-state tables]
direction TB
u_pr[(data_journey_progress)]
u_cc[(data_chapter_completion)]
u_bm[(data_bookmark)]
u_rf[(data_reflection)]
end
s1 --> e1 & m1
s2 --> e2
s4 --> e3 & e4 & m3 & m4
s5 --> w1 & w3 & w4 & w5
%% public content reads — theme_id only, no data_theme join
e1 --> t_j & t_c
e2 --> t_j & t_c & t_rel
e3 --> t_c & t_ex & t_wr & t_ar
e4 --> t_ex & t_wr & t_bk
%% per-user reads
m1 -.-> u_pr
e2 -.-> u_pr & u_cc
m3 -.-> u_cc
m4 -.-> u_bm
%% writes
w1 ==> u_pr
w3 ==> u_cc & u_pr
w4 ==> u_rf
w5 ==> u_bm
Notes: the cacheable reads (e1, e3, e4) carry no user data and only theme_id (no
data_theme join), so they're identical for everyone and view-cacheable. e2 is not one of
them — it carries the reader's progress and is private, no-store. There is no
chapter-menu screen — the landing carousel opens a chapter directly. w3 writes a
data_chapter_completion and flips data_journey_progress to completed on the final
chapter, in one action. related_journeys (data_related_journey) is always returned by e2;
surfacing it only on the finished state is the client's call, not a filter in the API.
The CMS refuses to unpublish a piece a live journey is built on, so a live journey does not lose a chapter through any route the CMS offers. Journey detail returns every authored chapter and numbers them over that list. This was reversed from the original design: chapter completion is stored per chapter, so a journey that silently dropped one and renumbered the rest would strand saved progress (CSL-246).
The guard is on the unpublish view, not a database constraint, so a direct ORM write
(.update(live=False), a data migration) still can. That is why the readers keep filtering on
openability rather than trusting the invariant.
Naming: base-content chapter (a Writing kind — a book chapter) is distinct from a Journey
slice. The Journey slice is always JourneyChapter in the backend, never bare "Chapter".
Product, the API contract and the FE call it chapter — the serializer is where the product word
belongs.
| Model | Fields | Notes |
|---|---|---|
| Journey | name, slug, central_question (optional), editorial_framing (the card pitch — a paragraph behind "see more"), colour, emblem + emblem_title + emblem_line, themes M2M + primary_theme, related_journeys (ordered through-model, none or 2–4), ingest_meta |
Chapter count + total read time are computed, not stored. related_journeys surfaces on the finished-state landing only. The emblem replaces Draft 1's icon (the ticket's "portal image"): one image that is both the card artwork and the collectible, blurred by the FE and cleared as chapters complete |
| JourneyChapter | journey FK + sort_order, slug, title, one piece (passage xor writing xor article), opening_question, intro_pages (StreamField of rich-text pages), pause_line, practice, reveal_line |
A chapter is one piece. Its themes, primary theme and closing reflection question are all the piece's own, read through — never duplicated onto the chapter. intro_pages is the editor's lead-in, paged: the reader moves through it between the opening question and Lewis's words (~4 typical, no cap). practice and pause_line are optional beats in the arc |
Read time ("4m read", "2h 3m") is computed from body word count in the readers layer — not stored, so it can't drift from the body.
User progress (app layer — not in CMS)¶
Server-persisted (accounts exist in V1 to persist exactly this). Progression gates order only, never pace — no streaks or day-counts (the Figma "Day 2 of 7" reads as "Chapter 2 of 7"; confirm wording with design).
| Model | Fields | Notes |
|---|---|---|
| JourneyProgress | user + journey (unique), started_at, completed_at, emblem_claimed_at |
As built (CSL-180): milestones only. Draft 2 specified a current_chapter FK and a status column; both are derived instead — status from the timestamps, the next chapter as the first with no completion row. A stored pointer is a second source of truth that an edited journey invalidates silently (insert a chapter between two completed ones and the pointer still names the old one). A DB CHECK refuses emblem_claimed_at without completed_at |
| ChapterCompletion | user + chapter (unique), completed_at | The authoritative record. Written when the reader moves from Reflection into the emblem reveal; the row's existence is the unlock, so nothing else has to be advanced |
| Bookmark | user + target (exactly one of: passage / writing / article / journey / journey_chapter), created_at | One model for all bookmarkable things; unique per (user, target). Bookmarks are the "Saved items" surface, separate from Reflections |
| Reflection | user, prompt_snapshot (text at save time), response, nullable chapter FK, nullable content ref |
Prompt is snapshotted so an edited piece doesn't rewrite history; reflections also occur outside journeys (Home quote flow) |
There is no per-piece completion model — a chapter is one piece. Position within a chapter's arc (the 4-segment indicator) is presentation state, not persisted per screen.
Completion cascade. Chapter completion is the explicit user action ("Complete this
chapter"); journey completion is server-derived — when the final chapter's completion is
recorded, JourneyProgress flips to completed (+completed_at) in the same action, and the
response carries the finished state (which is where related_journeys surfaces). Nothing
requires re-deriving on read.
4. Home (curation)¶
Single Home record of fixed, typed slots (CSL-42). Slot order is fixed and known to the FE (not user-reorderable); an empty non-mandatory slot renders nothing and the FE closes the gap; hero failure is a page error, not empty.
Home is served as one endpoint per section, not one aggregate call. Aggregating six slots server-side adds latency with no benefit — most of Home is below the fold, so the FE loads the hero first and the rest lazily / in parallel, and each section carries its own cache TTL. See the API spec below.
Three per-type, editor-sequenced Featured lists exist at entity level (standalone orderable models, not owned by Home): FeaturedJourneysList, FeaturedThemesList, FeaturedBooksList.
Section → endpoint → table flow. Each section is its own public, cacheable call (no aggregate,
no user data); ──▶ solid = reads content, ┈┈▶ dotted = reads a curation pin/list.
flowchart LR
subgraph SEC[Home sections]
direction TB
h1(["1 · Today's Quote — hero"])
h2(["2 · Featured Journeys"])
h3(["3 · Theme of the Week"])
h4(["4 · What's New"])
h5(["5 · Featured Books"])
h6(["6 · Further Reading"])
end
subgraph API[Endpoints · public, cacheable]
direction TB
a1["GET /api/passages/todays-featured/"]
a2["GET /api/journeys/featured/"]
a3["GET /api/theme-of-week/"]
a4["GET /api/whats-new/"]
a5["GET /api/books/featured/"]
a6["GET /api/further-reading/"]
end
subgraph CUR[Curation tables]
direction TB
d_home[(data_home)]
d_hjp[(data_home_journey_pin)]
d_hwn[(data_home_whats_new_pin)]
d_fj[(data_featured_journeys)]
d_ft[(data_featured_themes)]
d_fb[(data_featured_books)]
end
subgraph ENT[Content tables]
direction TB
d_ex[(data_passage)]
d_wr[(data_writing)]
d_ar[(data_article)]
d_bk[(data_book)]
d_jn[(data_journey)]
end
h1 --> a1
h2 --> a2
h3 --> a3
h4 --> a4
h5 --> a5
h6 --> a6
%% 1 Today's Quote — random over ALL eligible passages, no pin
a1 --> d_ex & d_bk & d_wr
%% 2 Featured Journeys — pins → list fill
a2 -.-> d_hjp & d_fj
a2 --> d_jn
%% 3 Theme of the Week — home FK (theme id + framing_line_override), else featured-themes fallback
a3 -.-> d_home & d_ft
%% 4 What's New — pins first, then is_new_eligible auto-fill (Article + Writing; not Passage)
a4 -.-> d_hwn
a4 --> d_ar & d_wr
%% 5 Featured Books — list order only
a5 -.-> d_fb
a5 --> d_bk
%% 6 Further Reading — random over all essays + letters
a6 --> d_wr
Notes: hero (a1) and Further Reading (a6) draw randomly over all eligible rows — no pin
table, freshness is client-side rotation. a1 resolves a passage's source via either data_book
or data_writing. a4 reads its pin list for the front pins, then auto-fills
reverse-chronologically from data_article/data_writing where is_new_eligible (Passage is
not included — it owns the hero; content-set change 2026-07-20, per Mayank, reverses canon-only V1,
pending design). Payloads carry only theme_id — no data_theme join here; the FE resolves
themes from its own cache.
| # | Slot | Population model | Home-record state |
|---|---|---|---|
| 1 | Today's Quote (hero, mandatory) | All Passages eligible — no curated pool/flag (the endpoint still returns a 10-item pool the client rotates). Fresh per session; anti-repetition client-side (guest-safe) | none |
| 2 | Featured Journeys | ≤5. Per-slot precedence: manual pin → Featured Journeys list in order → skip. Deduped | pins (orderable, ≤5) |
| 3 | Theme of the Week | One Theme; framing_line default from Theme, per-placement override wins; supplies page-level wash. Fallback when unpinned: Featured Themes list. Empty → neutral background |
theme FK + framing_line_override |
| 4 | What's New | Article + Writing (not Passage — it owns the hero). is_new_eligible flag (+ filtered CMS view); pins occupy front in pin order; auto-fill reverse-chron by publish date; cap 10 |
pins (orderable) |
| 5 | Featured Book | Featured Books list only, list order, manual-only (no freshness signal); cap 10 | none |
| 6 | Further Reading | Random draw from all essays + letters (no flag), fresh per session, guest-safe; cap 10. Excludes Passages, Books, Articles (canon-only V1) | none |
Theme gains framing_line plus the design asset set the FE needs to build a theme's look:
colour (HEX), icon (SVG), background (SVG), lottie (Lottie JSON, stored as a document),
and home_background (SVG or raster — the Home background blurb shown per featured theme).
These are captured as proper individual CMS fields (image/document choosers + validated hex
input — not a raw JSON blob an editor pastes into), because editors must upload and QA each asset;
but since the backend never consumes them, the API projects them as one design JSON object
on every theme payload. SVGs live in the Wagtail image library with SVG enabled
(WAGTAILIMAGES_EXTENSIONS); the Lottie file in the document library.
To absorb future FE-only design knobs without migrations, Theme also carries a design_extra
JSONField (scalar values only — gradient stops, tints, animation flags); the API merges it into
the same design object. New file assets still become real FK fields (cheap nullable-column
migration) so editors get chooser UX and referential integrity, never hand-pasted URLs.
5. API spec¶
Conventions per ADR-0005. Read endpoints are public; caching is header-driven — the server
sets Cache-Control: public, max-age=<n> per endpoint (TTL matched to that data's volatility) and
the client's HTTP layer honors it. Authenticated reads are private; mutations no-store.
Progress/reflection/bookmark endpoints require Firebase bearer auth. Full request/response
examples for the FE team live in docs/design/api-mobile-flow.md (mock
contract, pending FE confirmation); concrete column-level schema in
database-schema.md.
GET /api/config/ (exists) carries api_version, themes_hash (opaque theme-cache
version — the FE re-fetches /api/themes/ when it changes), and current_theme (the
Theme-of-the-Week id; a shared service backs both this and GET /api/theme-of-week/). The
active theme resolves as the user's preferred_theme → else current_theme. The splash-screen
portal animation is a separate TBD visual, not tied to Themes.
Themes (portals)¶
Themes are the app's navigable portals, reached from the bottom-nav Themes tab (browse all) or the Home Theme-of-the-Week section (the featured one). Selecting a portal sets the user's active theme (the app's skin/wash) — a display choice that doesn't change content.
Themes are static and cached client-side. The FE fetches /api/themes/ once, caches the full
set, and re-fetches only when config.themes_hash changes. Every other payload references a
theme by theme_id only (never the embedded object) — the FE resolves it from this cache. That
is what keeps content payloads small and their endpoints view-cacheable.
GET /api/themes/— every theme as a portal (id, slug, name, framing_line,designobject); Featured Themes list order first, then the rest by name.GET /api/themes/{slug}/— one portal.GET /api/theme-of-week/— the Theme-of-the-Week id + resolvedframing_line(per-placement override → else theme default). The id mirrorsconfig.current_theme(shared service).
The pick persists on the account (decided 2026-07-10): User.preferred_theme FK (nullable,
SET_NULL), returned as an id by GET /api/account/ and set via PATCH /api/account/ — syncs
across devices. Guests have no account, so the FE uses current_theme or a device-local pick.
Active-theme resolution is uniform: user's pick → else Theme-of-the-Week.
Not built this pass: theme-as-content-world (a screen listing a theme's pieces) is CSL-64.
Home feed — one endpoint per section (no /home/ namespace)¶
Split, not aggregated: the FE fetches the hero first (above fold, mandatory) and the rest lazily /
in parallel. Each section is public and independently cacheable, and where a section maps to a
domain it is a sub-resource of it (/api/journeys/featured/, /api/books/featured/). Empty
non-mandatory sections return []/null and the FE hides them; hero failure/empty is a page
error. The six section endpoints with their fill rules and caps are the Home slot table in §4
above; concrete payloads are in api-mobile-flow.md §2.
Journeys, reading & account overlays¶
The cacheable reads — the journey grid and the reading screen
GET /api/content/{kind}/{id}/ (the e1, e3, e4 calls in the §3 flow above; kind ∈ passage ·
letter · essay · chapter · article, resolved internally to Passage/Writing/Article) — are
user-agnostic and view-cacheable. Per-user writes live under /api/account/… (the w* calls in
§3, auth required), which is what keeps those reads cacheable.
Journey detail (e2) is the one read that is not. As built (CSL-180) it carries the reader's
progress in the same response and ships private, no-store, because on that screen the state is
the screen. Consequences:
- Chapter lock state comes back on journey detail itself, not from a separate account overlay.
It was originally specified the other way round; see
.context/08§6 for why that changed. - The reading payload's "Follow the thread" / "More on this topic" rails ship stubbed (empty) until the related-content priority question resolves.
The full endpoint list with request/response payloads is in
api-mobile-flow.md §§3–8. Everywhere a card or reading payload carries
theming it exposes only theme_id — the FE resolves colour/assets from its cached theme set,
never an embedded object.
6. Future changes accommodated¶
- Audio on all types — optional audio capability already on Writing; only Passage populated at MVP.
- Buy CTA on Book — layout + schema leave room; single
buy_linklater. - Podcasts — later phase; not in this design.
- Soul Map — collected emblems + reflections + completions are its inputs; visualization deferred.
- Tag-overlap graph / related content — rails stubbed in the reading payload; engine scope unresolved.
- Theme Home (CSL-64) — Theme-as-destination may need a routable landing; doesn't block this design.
- Review queue gating Journey/Chapter publishing — open in the spec;
DraftStateMixin+ Wagtail workflows leave both answers available.
7. Open items¶
| # | Item | Owner |
|---|---|---|
| 1 | Per-section Home endpoints + header-driven caching (was: single aggregate call) | Decided — FE feedback 2026-07-10 |
| 2 | "Day N" vs "Chapter N" — the Draft 2 flow uses both (landing card says "Chapter 1", every reading screen says "DAY 1"), while the Journey spec forbids day-counts. Backend is unaffected (the ordinal is computed either way), but the copy needs settling | Design |
| 3 | Passages/Books nav placement under "evergreen content" (nav mock omits them) | Lina |
| 4 | Article: schema-now-build-later vs fully in V1 | Product |
| 5 | Theme colour HEX usage beyond the SVGs |
Lina / FE |
| 6 | Review queue gating Journeys or only interpretations | Spec (open) |