Skip to content

Journey import contract

Status: built, sign-off open

The CMS importer reads this contract today and accepts schema version 7 only. It supersedes the Journey Builder's v6 output; What changed from v6 lists exactly what a generator must alter. Still open: sign-off by Lina and Mayank on one sample carrying all three content kinds and every optional chapter field (CSL-242).

Two working examples

content/ingestion/journeys/sample-journey.json is a complete v7 file — four chapters covering all three content kinds, both piece actions, and every optional field. sample-journey-minimal.json is the opposite: the least a valid file can contain. Both are test fixtures, so neither can drift from this page. Their README.md says what each covers.

The Journey Builder produces a file. The CMS reads it and creates one draft Journey, its chapters in order, and the pieces the file asks it to create. That is the whole exchange: the builder never writes to the CMS, and the CMS never writes back.

Two rules shape everything below.

Field names are the CMS model's names. Where the builder's internal vocabulary differs, the builder translates on the way out. A renamed field in the payload is a bug in the payload, not something the importer is expected to absorb.

Import is create-only. Re-importing a Journey whose slug already exists is refused, not merged. Updating a Journey is an editor's job in the CMS.

Envelope

{
  "export": { "kind": "journey", "schema_version": 7, "…": "…" },
  "journey": { "…": "…" }
}

export.schema_version is the version gate: an integer, at the root, and the only one the importer reads. A file whose version it does not support is refused whole, before any field is looked at.

Everything else under export is builder provenance. The importer stores it verbatim on the Journey's ingest_meta and acts on exactly one key:

export key What the importer does
schema_version Gates the import. Integer.
kind Must be "journey".
cms_owned_completion Shown to the editor in the import report as a to-do list.
everything else Stored in Journey.ingest_meta, otherwise ignored.

optional_cms_followup is not read. It has referred to fields the CMS does not have, and a list the importer silently ignores is safer than one it half-understands.

The journey block

Field Required Maps to Notes
slug yes Journey.slug Must not already exist — see Create-only
name yes Journey.name 255 characters at most
description no Journey.description The editorial framing that pitches the journey
foreword_pages no Journey.intro_pages Array of strings, one per page, shown before chapter 1. Old key intro_pages still accepted
afterword_pages no Journey.outro_pages Array of strings, one per page, shown after the final chapter's emblem reveal. Old key outro_pages still accepted
practice no Journey.practice One per journey. See Practice
emblem_title no Journey.emblem_title 255 characters at most
emblem_description no Journey.emblem_description
themes no Journey.themes Array of theme names — see Resolving themes
primary_theme no Journey.primary_theme A theme name; must also appear in themes
related_journeys no RelatedJourney rows Array of journey slugs, in order. Each must already exist
chapters yes JourneyChapter rows Ordered; see below
ingest_meta no merged into Journey.ingest_meta Builder provenance

intro_pages and outro_pages were added within v7 (CSL-434). A v7 file without them is still valid. The importer rejects unknown keys, so a backend without this change refuses a file that carries them.

The CMS labels these pages Foreword and Afterword (CSL-477), so the importer also reads them as foreword_pages and afterword_pages. Send either name for a bookend, not both: a file with intro_pages and foreword_pages, or outro_pages and afterword_pages, is refused. A chapter's intro_pages keeps its name.

The new names need a backend that includes CSL-477. A backend without it refuses them as unknown keys, so keep sending intro_pages and outro_pages until every environment you import into has that change.

There is no colour field on a Journey. Colour comes from the journey's primary theme. A payload carrying colour is rejected rather than ignored, because silently dropping it is how a generator keeps emitting it.

emblem is not in the payload. Artwork is uploaded by an editor in the CMS before publishing; the importer leaves it empty and says so in the report.

The chapter block

Chapters are an ordered array. sort_order is the 1-based position and must be contiguous from 1.

Field Required Maps to
slug yes JourneyChapter.slug — globally unique, so prefix it with the journey slug
title yes JourneyChapter.title
opening_question yes JourneyChapter.opening_question
intro_pages no JourneyChapter.intro_pages — array of strings, one per page
description no JourneyChapter.description — this chapter's own framing
pause_line no JourneyChapter.pause_line
reveal_line no JourneyChapter.reveal_line
piece yes exactly one content reference — see below

A chapter has no practice, no steps array, no second piece, no primary-reading flag, no keystone and no memento. A chapter is one piece plus the editorial words around it.

Practice belongs to the journey

The model is explicit, and so is the reader contract (CSL-246): one practice for the whole course. The chapter's beats are the words around its piece; the practice is what the reader is asked to carry out of the journey.

A practice inside a chapter is a validation error, not a value the importer moves or drops. Four chapter practices cannot honestly collapse into one journey field, and quietly keeping the first would lose three pieces of authored copy without telling anyone.

The piece reference

Exactly one per chapter, discriminated by kind:

"piece": {
  "kind": "passage",
  "action": "create",
  "resolution": { "on_duplicate": "require_editor_choice", "canonical_body_sha256": "…" },
  "create": { "passage": { "…": "…" } }
}
Key Values
kind passage · writing · article
action create: the CMS makes it. Always create for a passage · link: use a writing or an article that already exists
link { "slug": "…" }, when action is link
create { "<kind>": { … } }, when action is create
resolution Optional. Only read for kind: passage. See The body hash

A chapter's passage is always create. The passage belongs to that chapter (CSL-490). A passage with action: link is a validation error at piece.action, even when the library already has the same text. Two chapters that carry the same text get a passage each. Writings and articles can still be linked, and several chapters can link the same one.

action: link resolves by slug against published and draft content. A slug that does not resolve is a validation error naming the chapter — the importer never silently turns a failed link into a create.

Creating a passage

Field Required Maps to
slug, title, body yes Passage.slug / .title / .body
interpretation no Passage.interpretation — editorial, kept separate from the verbatim body
reflection_prompt no Passage.reflection_prompt
source no { "kind": "book"\|"writing", "slug": "…" } → Passage.source xor source_writing
themes, primary_theme no theme names; primary must appear in themes
tags no tag names, resolved against existing tags
ingest_meta no Passage.ingest_meta — attribution, source file/lines, epub_sha256, canonical_body

body is the verbatim quote and nothing else. Attribution, chapter numbers and source offsets belong in ingest_meta; interpretation and the reflection prompt are their own fields. That separation is what lets the CMS treat the body as canon and the rest as editorial.

Every passage the importer creates is a journey passage (Passage.journey_only). Readers reach it through its chapter only. The passage list, Home, theme pages, related quotes and the CMS passage list leave it out.

source.kind: "book" resolves a Book by slug and must already exist — a Book carries a cover, a synopsis and a publication year that no import can invent, so a missing one is a validation error rather than a stub.

Creating a writing or an article

create.writing takes slug, title, kind (letter · essay · chapter) and body, and optionally reflection_prompt, source (a Book slug), recipient, origin, original_date, order, themes, primary_theme and tags.

Three of those are required by kind, not optional:

Kind Must have Must not have
letter recipient order
essay — recipient, order
chapter order (its place in the work) recipient, original_date

The model enforces this too, but the import checks it first so an error names the chapter in your file rather than a field on a record you never see.

A Writing's source can only be a book. Only a Passage can be drawn from another Writing.

create.article takes slug, title and body, and optionally subtitle, reflection_prompt, publication_date, source_url, themes, primary_theme and tags. source_url must be a well-formed URL and unique across all articles — the same address cannot be cited twice, in this file or against one already in the CMS.

Neither takes interpretation or ingest_meta. Those are Passage-only, and deliberately: a passage is the canon-critical unit, so it carries both the editorial layer and the provenance that proves its body verbatim. A Writing or an Article has no field to store either in, so sending them is a validation error rather than something quietly dropped.

Translations

Unavoidable, and therefore written down rather than assumed.

Payload Stored as Rule
intro_pages: ["…", "…"] StreamField of rich-text pages One string becomes one page block, in order
body, interpretation (plain text) RichTextField Blank-line-separated paragraphs become <p> blocks; no other markup is inferred
themes: ["Wonder"] Theme rows By name, case-insensitively
primary_theme: "Wonder" Theme FK Same, and must be one of themes — the model enforces this
source: {kind, slug} Book or Writing FK By slug; both set is a validation error

Themes

Themes are a controlled vocabulary of 14, synced from the design library (sync_themes). A name that does not resolve is a validation error — the importer never creates a theme, because an invented one would have no colour, no portal art and no place in the design system.

What happens when an optional field is absent

Absent means the field is not there, and for text fields an empty string means the same thing — "description": "" and no description at all are both "the editor will fill it in". primary_theme follows that rule too. Neither is an error, and the importer never invents a value, never copies one in from elsewhere, and never skips a check because a hint was missing.

An empty value of the wrong type is not an omission. related_journeys: "", source: "", tags: {} and the like are malformed rather than absent, and are reported as errors: omit the key, or send an empty container of the right type ([]). Treating them as omissions is how a generator bug turns into content quietly missing from an import nobody questioned.

Absent Result
journey.description, practice, emblem_title, emblem_description Stored blank. The editor fills them in, or does not
journey.themes and primary_theme No themes, no primary theme. Colour falls back to the app's neutral treatment
journey.primary_theme with themes present No primary theme. Themes still attach
journey.related_journeys No related rows
journey.ingest_meta {} — the export block is still stored there
journey.foreword_pages, afterword_pages Empty page lists. The reader goes straight into chapter 1, and from the final chapter's emblem reveal straight to collecting it
chapter.intro_pages An empty page list. The reader goes straight from the opening question to the piece
chapter.description, pause_line, reveal_line Stored blank. The reader simply does not see that beat
passage.interpretation, reflection_prompt Stored blank. A piece with no prompt cannot be reflected on (CSL-324), so this is a real editorial consequence, not a cosmetic one
passage.source No source. A passage without an origin is allowed by the model; the reading screen shows no source line
passage.themes, primary_theme, tags None attached
passage.ingest_meta Provenance is empty, but the importer still records canonical_body_sha256 there — see below
piece.resolution No declared hash to check. The importer still records its own. See The body hash

One of those deserves more than a table row.

A missing passage.ingest_meta costs provenance, not correctness. The import succeeds and the passage is identical; what is lost is the record of which file and lines the body came from, and the canonical_body a human would check it against. The fidelity sidecar has nothing to say about such a passage either. Worth avoiding for canon, harmless for editorial content that has no source lines to cite.

One key is always written regardless: canonical_body_sha256, the SHA-256 of the plain-text body the importer stored. It records which verbatim text the passage was made from. The CMS keeps the body as rich text, so nobody can recompute this hash later. It is the importer's own value. A payload that sends a different one under ingest_meta has it overwritten.

That is deliberately not the same as a differing resolution.canonical_body_sha256, which is a validation error. The two fields say different things: resolution is the builder asserting what the body is, and an assertion that disagrees with the text beside it means the file has drifted and should be looked at. ingest_meta is provenance being stored, and there the importer simply knows better.

What is never optional

export.kind, export.schema_version, journey.slug, journey.name, journey.chapters, and per chapter: slug, title, opening_question, piece. A create payload additionally needs slug, title and body; a link needs slug. Anything missing here is a validation error naming the chapter and the field.

The body hash

resolution.canonical_body_sha256 is the builder's SHA-256 of the verbatim body, as UTF-8. The importer hashes the body in the file and compares the two. If they differ, the text changed after the file was made, and the importer reports a validation error. You see that before you import prose that may not be what was reviewed. Upper and lower case hex both pass. Without resolution there is nothing to compare.

resolution.on_duplicate is accepted and ignored. v7 builder files send it, but the importer does not check for duplicate passages (CSL-490). A chapter's passage is new even when the main collection already has the same text, because the journey keeps its own copy of an excerpt on purpose. The CMS duplicate warning (CSL-247) leaves journey passages out for the same reason.

Importing in the CMS

Journeys → Import a Journey in the admin menu. Three steps, and the middle one is the point.

  1. Choose a file. Not valid JSON at all is said plainly, before any contract checking — a list of missing fields is the wrong answer to "this is not a JSON file".
  2. Read what it would do. Every chapter and the piece each one creates or reuses. Nothing has been created at this stage. If the file does not validate, you get the whole error list instead, each naming a chapter and a field.
  3. Commit. The journey, its chapters and any new content are created together, as drafts.

Two things the screen does that are easy to miss:

  • There is no duplicate step. Every passage in the file becomes a new passage for its chapter, even when the library already has the same text. Two chapters with the same text get two passages.
  • The commit re-checks the file from scratch. Between reading a report and pressing the button, a colleague may have imported the same journey or taken one of its slugs. The commit works from the database as it is then, not from the report you read.

Import behaviour

Create-only

A file whose journey.slug already exists is refused. V1 does not merge, update or version an existing Journey.

Content is different. action: link is the supported way to reuse a writing or an article. A create whose slug already exists is a validation error. For a writing or an article, the builder should have sent a link. For a passage, it should have sent another slug, because a passage cannot be linked.

Transactional

Validation runs to completion first and reports every error it finds, each naming the chapter and the field. Nothing is written until an editor commits, and the commit is one transaction: malformed input leaves no partial Journey, no orphan chapters and no stray passages.

Everything arrives as a draft

The Journey, its chapters and any created content are drafts. Publication is the CMS's normal path, with its normal requirements — including the rule that a journey cannot go live around an unpublished piece.

What changed from v6

A v6 file does not validate. To produce v7:

v6 v7
journey.editorial_framing journey.description
journey.emblem_line journey.emblem_description
journey.colour removed — no such field; colour comes from the primary theme
chapter.practice (one per chapter) journey.practice (one per journey)
chapter.ingest_meta removed — a chapter has nowhere to store it
export.schema_version: "6" (string) export.schema_version: 7 (integer)
ingest_meta.artifact_schema_version Kept as provenance; no longer read as a version
— chapter.description now available (optional)
— piece.action: "link" now specified
piece.action: "link" on a passage Refused within v7 (CSL-490). A passage is always create. link applies to writings and articles. resolution.on_duplicate is accepted and ignored

The separate *.fidelity.jsonl sidecar is not an import input. Every field in it already appears in passage.ingest_meta, and an importer reading two files that can disagree is a way to end up with two answers about what the canon says. It stays generated, for a human checking a body against its source lines without opening the CMS. See content/ingestion/journeys/README.md.

One thing the v6 sample could not have demonstrated regardless: its optional chapter copy was placeholder. All four practice values were "Lorem ipsum dolor amet", as were 6 of 7 pause_lines and all 6 reveal_lines. Nothing authored is lost by moving practice to the journey — but a real journey practice now has to be written, and a sign-off sample needs real copy in those fields.

For sign-off, one sample must additionally carry a writing piece, an article piece, at least one action: link on a writing or an article, and a chapter using every optional field including description.