Skip to content

Journey access contract

Who may open which journey, and why. One rule set shared by the app, the API and the CMS, so that "can I read this?" has the same answer everywhere it is asked.

Settled for CSL-321; built in CSL-356. Subscription state itself is CSL-355's job — this contract only says what having it means.

The two rules

1. The first journey a reader reads is theirs, for good. Whichever journey they start first becomes free permanently — opening a detail page is browsing, not starting; see "What counts as reading it". Nothing takes it away: not finishing it, not replaying it a year later, not an editor unticking always free, not the end of a subscription.

The one exception is an editor deleting the journey outright, which cannot leave a grant pointing at nothing. That hands the reader their allowance back rather than stranding it — see the edge cases.

2. An editor can mark journeys free for everyone. A journey ticked always free is open to every reader while the tick is there, and closed again when it is removed.

Everything else needs a subscription.

The gate can be suspended per environment

JOURNEY_GATE_ENABLED is on by default. Set it False in an environment — for a demo, a QA pass, or somewhere payments are not ready — and nothing is ever locked there: anything that would have been needs_subscription comes back open with the reason gate_off.

Only the outcome is suspended. Every rule still runs and a reader's free journey is still claimed on their first Start, so switching it back on finds readers already holding the journey they began with rather than all starting again from nothing.

Where it is enforced, and where it is not

Enforced server-side, on the writes that start or advance a journey. POST …/start/ and POST …/chapters/{id}/complete/ both refuse a locked journey with 402. Claiming an emblem is a per-reader write too and is deliberately not among them — see below. The completion endpoint matters as much as Start: nothing obliges a client to call Start first, and a completion posted straight at a locked journey would otherwise create the progress row and put a journey the reader cannot open into their hub.

Collecting an emblem is not gated. A reader who finished a journey earned it, and taking the memento away because a subscription has since lapsed would be punishing them for the wrong thing.

Browsing is open; a locked journey's reading material is not. Detail returns 200 with a preview rather than a 403, because a reader deciding whether to subscribe needs to see what they would get.

The preview keeps the shop window — the journey's name, description, emblem and intro_pages with their audio, and every chapter's id, title, blurb and state.

It withholds everything a reader would actually read: the journey's practice and outro_pages, each chapter's intro_pages, opening_question, pause_line, reveal_line and reflection_prompt, and every other audio track. The practice is part of the course like the rest of it.

The journey's intro_pages are the one exception (CSL-434, settled with Lina on 2026-09-23). The intro is the journey's invitation, written for a reader deciding whether to begin, so it ships in full, text and audio, to a reader without access. The outro_pages are the payoff at the end of a course that reader cannot open, so they are withheld like the practice. A chapter's intro_pages stay withheld: they are the lead-in to a piece, not the journey's pitch.

Withheld means emptied, never absent. Strings come back "", lists [], audio tracks null. The payload shape is the same locked or not, so a client that types the response can still decode it — and it matches the audio contract, which already returns null for a track the reader is not entitled to.

Settled with Lina on 2026-09-16 (CSL-326 / CSL-327): Premium is "the complete Journey library and its narrated experience", so a locked journey's text and audio are both gated, not audio alone.

A journey passage follows its journey. Since CSL-490 a chapter owns its passage, so the passage has exactly one journey, and GET /api/passages/{id}/ applies that journey's access: 402 with required_entitlement (the same refusal as Start) when the journey is locked for the reader, and 404 when the journey is a draft or the passage has no chapter. The response is private, no-store, because it depends on the reader. Send the bearer token so a signed-in reader's access is checked; a guest is open by the rules above.

Writings and articles stay open. They are library pieces a chapter links to, readable from Home and theme pages regardless of any journey, so a locked chapter that points at one gives nothing away. Library passages are unchanged too.

The two rules are independent, and rule 1 does not care which journey it lands on. A reader whose first journey happens to be an always-free one has used their allowance on it — they keep that journey for good under rule 1, and their next journey needs a subscription like anyone else's — unless it too is marked always free, which is open to everybody regardless.

The matrix

Asked once per reader and journey, in this order. The first line that matches wins.

# When Result
1 The reader has a grant on this journey Open. Permanent
2 The journey is ticked always free Open. Only while the tick is there
3 The reader has a subscription Open. Only while it lasts
4 The reader has not used their free journey yet Open. Reading it is what claims it
5 Anything else Locked. The app offers the subscription

A grant is one row saying "this reader's free journey is this one". It is written the first time a reader reads any journey, and each reader has at most one, held by the database rather than by the code that writes it.

Line 4 is why a guest sees no paywall. A reader who has never read a journey has their allowance intact, so every journey is open to them — whichever they pick will be the free one. That is true of every guest by definition, so a guest browsing the catalogue meets the sign-in gate on Start, never a subscription offer. Getting this wrong would put a paywall in front of exactly the readers who have something free waiting.

Order matters at lines 1 and 2. A grant outranks the tick, which is what makes an editor's change of mind safe: remove always free from a journey and readers who merely read it lose access, while the reader who had it as their first journey keeps it.

What counts as reading it

Opening the journey's detail page is not reading it. Browsing is free and always has been — Home, the hub, the full lists and every journey's detail page are open to anyone, signed in or not, and that does not change here.

Reading begins when the reader activates Start Day / Start Chapter. That is already the moment the app asks a guest to sign in (CSL-267), so it is also the first moment there is an account to attach a grant to.

The grant is written by POST /api/account/journeys/{id}/start/, which is safe to repeat: a reader who taps twice, retries after a dropped connection, or comes back through the sign-in link gets the same single grant.

What this means for a guest

Nothing is hidden from guests, and no grant can exist for one. A guest browses everything, taps Start, signs in, and the grant lands on the account they just made. A reader who signs out and into a different account brings nothing with them — grants belong to accounts, not devices, so reinstalling or changing phones changes nothing as long as the account is the same.

Age. Readers under 13 never reach any of this: the app establishes age before account creation and does not create the account, and since reading a journey requires an account, they cannot hold a grant or a subscription. The backend has no age check and no COPPA branch — the whole guard is in the app. Recorded here so that it reads as a decision rather than an oversight.

What the app sees

Journey detail, the hub and the paginated list carry the resolved answer per reader — on the journey itself for detail, and on each card for the hub and the list. All three are already per-reader and uncached, so this adds no new caching problem:

"access": {
  "state": "open",
  "reason": "granted"
}

state is open or locked. reason is granted, always_free, subscription, first_read or needs_subscription, matching the five lines of the matrix in order — plus gate_off, below. An app showing a subscription offer should key on needs_subscription rather than on state, so that first_read reads as the invitation it is.

required_entitlement names which subscription would open it (CSL-479). Present only when reason is needs_subscription — every other reason omits the key, so this is additive and a client reading only state/reason sees no change:

"access": {
  "state": "locked",
  "reason": "needs_subscription",
  "required_entitlement": "journey_premium"
}

Today it is always journey_premium; a locked audiobook title will answer audiobooks once CSL-348 builds that gate, so the app can pick the Journeys paywall or the Audiobooks one without guessing. The 402 from POST …/start/ and POST …/chapters/{id}/complete/ carries the same key at the top level of the error envelope, alongside error_type and errors.

Three fields, and none of them is the others. Journey detail already carries a top-level state (how far this reader has come — not_started, in_progress, completed) and a chapters[].state per chapter (locked, available, completed, about progression: chapter 4 waits on chapter 3). access is the new one, and it is neither.

The word locked therefore already means "finish the chapter before this one". Entitlement must not borrow it, or the app cannot tell that from "this needs a subscription" — two things it draws completely differently. Hence access.state, with its own values.

The shared card shape — what GET /api/journeys/featured/ and the theme carousels return — carries is_always_free and no access. It is an editorial fact, the same for every reader, so those endpoints stay public and cacheable exactly as they are today. The hub and the list build on that same shape and add access on top, which is why they are the per-reader ones. A reader's own access is never inferred from a shared response.

Finding the free ones: GET /api/journeys/?status=not_started&free=true.

free is editorial — the journeys an editor ticked, the same set for everyone. status is not: it is still this reader's group, so the two combine into "always-free journeys I have not started". That is the useful list, and it is worth being exact about, because a reader's own free journey is one they have started and so never appears under not_started whatever free says.

What the pair never means is "journeys I may open". A reader with their allowance intact may open the whole catalogue, and a list saying so would be the catalogue relabelled.

What the editor sees

One tick on the journey: Always free. While it is on, everyone can read the journey without a subscription. Turning it off closes the journey again — but only for readers who have already used their one free journey on something else and are not subscribed. Readers whose first journey this was keep it, subscribers never needed the tick, and anyone who has not started a journey yet can still make this one their free one.

It does not affect Home ordering, Featured selection, or anything else. Editors arrange Featured Journeys by dragging, as they do now.

Edge cases

Situation What happens
Reader's first journey is a paid one It becomes their free journey, permanently
Reader's first journey is an always-free one Same. It becomes their grant, and their allowance is spent
Editor removes always free from a journey It stays open to everyone the other rules already cover: the reader whose first journey it was (rule 1), subscribers (rule 3), and anyone who has not used their free journey yet (rule 4). It closes only for a reader with a grant on a different journey and no subscription
Editor adds always free to a journey Open to everyone from that moment; no grants are written by the change
Subscriber reads their first journey The grant is written. They never notice, until the subscription ends
Subscription ends The granted journey stays open; every other journey locks. Progress is untouched
Reader finishes their free journey, replays it later without a subscription Allowed. The grant is a grant, not a rental
Reader is halfway through a paid journey when the subscription ends Locked. Progress and completions are kept and reappear if they subscribe again
Reader signs out and into another account The new account has its own unused allowance
Reader restores a purchase, moving the subscription to another account The grant does not move with it. It belongs to an account, not to the store — the other account keeps its own unspent allowance, and this one keeps the journey it claimed
Reader reinstalls the app Same account, same grant
Guest browses journeys Everything is visible and open — their allowance is intact, so Start asks them to sign in rather than to subscribe
Reader has an account but has read nothing Same: every journey is open, and the first one they read claims the allowance
Journey is deleted The grant goes with it, so that reader's allowance is unspent again and the next journey they read claims it. Deliberate: only an editor can delete a journey, so no reader can engineer it, and the alternative — a tombstone that keeps the allowance spent — would cost a reader their free journey for something an editor did. Unpublishing, the normal way to retire a journey, keeps the grant
Journey is unpublished Out of the lists and its detail is a 404, so access never arises. The grant row survives untouched, so if an editor publishes it again the reader who had it free still does
Two devices tap Start on different journeys at the same moment One grant. The database decides which, and the other journey is locked

Not in this contract

  • Where subscription state comes from. CSL-355 owns the store, the middleware and the lifecycle, and CSL-327 has settled the shape: a ReaderEntitlement row keyed journey_premium, where having access means expires_at > now() rather than a stored boolean.

This contract asks one question — is this reader subscribed — through one property, and does not care how the answer is produced. Until those land the property answers False, so rule 3 is unreachable outside tests; when they land, only its body changes. Deliberately a property and not a column: CSL-355 says not to hard-code a permanent Premium flag, and an entitlement with an expiry is not something a boolean can hold. - Prices, products, trials, or the paywall screen. CSL-326 and CSL-353. - Audiobooks, which are a separate rights and entitlement problem. - Per-journey purchase. There is one subscription; a reader cannot buy a single journey.