Skip to content

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:

  1. 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.)
  2. A publication state separating AI-generated (draft) from editorially-reviewed content. (CSL-71.)
  3. 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 sourceWorksettled (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.

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: Markdownauthoring 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 source keeps 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/Essay models into one kind model 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: kind model 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 readers layer, one gate.
  • 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).