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.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.
- 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".
- 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.
- 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.