ADR-0006: Content Model & Serialization — unified readable type, source, review state, Markdown¶
Status¶
Proposed — decision 1 is settled with the client (Slack, 2026-07-08) and implemented as the
Writing snippet model (see ADR-0008, 2026-07-09), superseding the
separate Letter/Essay models from PR #10 (CSL-52). Decisions 2 and 3 are recommended and
awaiting sign-off.
Date¶
2026-07-08
Context¶
PR #10 (CSL-52) introduces the first content models on Wagtail following RAPID conventions
(ADR-0004): Passage (the atom), separate Letter and Essay
page types, Book, and taxonomy (Tag/TagCategory/Theme). No content API endpoints are
wired — content will be reached through the Home / Journey / Chapter surfaces to be built, so the
read/serialization layer is deferred until those views land.
Three decisions arrived from the client review call and FE during PR #10 development, all touching the content layer before the schema hardens:
- How to model the full readable text units (Letter/Essay and the incoming Chapter), and how they link to their source. (Client review call / Slack, 2026-07-08.)
- A publication state separating AI-generated (draft) from editorially-reviewed content. (CSL-71.)
- The rich-text wire format FE consumes. (FE preference, 2026-07-08.)
This ADR records all three so the schema can be migrated in one pass before merging. (A related curation/featuring change and the "Theme as world vs snippet" question — CSL-64 / CSL-42 — are tracked separately.)
Decision Drivers¶
- Match the client's editorial model without over-engineering (the "CMS is not a bibliography").
- Keep Wagtail-native where it helps (publish workflow, admin), RAPID-clean elsewhere.
- Keep the mobile contract stable and simple; logic in
readers(ADR-0004/0005). - Don't let AI-generated content reach the app unreviewed (constraint #3).
Decision¶
1. Unified readable-unit model + single source → Work — settled (client)¶
Merge Letter and Essay (and the new Chapter) into one model differentiated by a kind
field (essay / letter / chapter). Kind-specific fields are optional on the model with
required-ness enforced per-kind in validation (recipient → letter; order → chapter;
original_date → letter/essay). Shared spine: body, themes, tags, reflection_prompt,
optional audio, plus the review state (below).
All three link to a single source FK → Work (= our Book); this replaces the collected_in
M2M — editors pick one main edition. Passage (the atom) stays its own type. Because source is
an FK (not tree containment), a Chapter need not be a tree child of its Work, so one shared index
parent works.
2. Publication review state (AI-generated → editorially-reviewed) — recommended¶
Add a review state distinct from Wagtail's live (which is piece-level publish only). AI-
generated content defaults to a draft/AI state and is never served; an editor transitions it to
reviewed in the Wagtail admin as QA. For the mixed case (trusted canon body + AI
interpretation on one Passage), use a per-AI-field review flag so canon content isn't blocked
by an unreviewed interpretation; the readers layer omits unreviewed AI fields/pieces from the API.
Pipeline (CSL-22) sets the default. (Open: confirm per-field vs also piece-level.)
3. Rich-text API format: Markdown — authoring side implemented¶
Serve rich-text fields as Markdown (FE renders with a markdown widget), scoped to bold,
italic, links, lists only. Lock each RichTextField to that feature allowlist
(features=["bold","italic","link","ol","ul"]) and convert HTML→Markdown in the readers
serialization layer (built when content endpoints land). With the feature set constrained the
conversion is lossless. (Alternative: wagtail-markdown for markdown-native authoring — choose
only if editors want to write Markdown.)
Implemented 2026-07-09 (ADR-0008): every content body is a
RichTextField locked to the allowlist via the shared data.richtext.MARKDOWN_FEATURES constant —
Passage.body/interpretation, Writing.body, Book.synopsis. Passage.body was a StreamField
(speculative quote/scripture_ref/image/audio blocks) but the passage body is verbatim canon
prose, so it collapsed to a single RichTextField and the block set was dropped. No StreamFields
remain on content models; the HTML→Markdown projector is the only piece still outstanding.
Consequences¶
Positive¶
- One readable model → the reading screens (which share a spine) map to one shape; less duplication.
- Single
sourcekeeps the source ladder simple and unambiguous. - AI content can't leak to the app; review state is visible/actionable in-admin.
- FE gets a clean Markdown contract; because serialization is centralized in
readers, it's a one-place change (a payoff of the hand-serialized API).
Negative¶
- Migrating the two live
Letter/Essaymodels into onekindmodel is a schema change on an unmerged branch (regenerate migrations; low risk, nothing deployed). - Per-kind validation logic lives in the model/
clean()rather than the type system. - Markdown is a lossy target for arbitrary HTML — mitigated by the restricted feature set.
Risks & Mitigations¶
- Risk:
kindmodel accretes many nullable fields. → Keep the spine small; only two kind-specific fields today (recipient,order); validate per kind. - Risk: review-state gating missed on a new field. → Enforce in the
readerslayer, one gate.
Related¶
- ADR-0004 (DRF + Wagtail), ADR-0005 (RAPID API conventions).
- Tickets: CSL-52 (PR #10), CSL-71 (review state), CSL-22 (Content Pipeline), CSL-42 (Home), CSL-64 (Theme curation). Supporting research/decisions are kept in the internal working notes (not linked here — external docs stay free of internal paths).