StoryLark
← All guides

StoryLark Content Format v1

Status: normative. Contract version: 1.

This is deliverable D1 of the content standards plan, written against the shipped validator — packages/contracts/content.mjs in the storylark repo — rather than alongside it. Every rule below is one the gate actually enforces, and every error code named here is a real member of CONTENT_ERROR_CODES. Where this document and the validator ever disagree, the validator is right and this document is a bug.

Companion documents: the content API, the repo connection standard, and deployment requirements.


1. What this format is for

StoryLark accepts content through three doors: the admin portal, a connected repository, and the public content API. This document describes the one thing all three carry. It is not three formats with a shared subset — it is one format, and the doors differ only in how a rejection is displayed.

That property is testable, and it is tested: the same malformed file pushed through every door returns the same error codes and the same messages.

2. The governing rule

A file without a storylark: block is not StoryLark content.

Content is opt-in. StoryLark never infers that a markdown file is a chapter because of where it sits, what it is called, or what it contains.

This single rule does the work that a pile of heuristics would otherwise do badly. A repository can hold drafts, notes, a README, a licence, and an entire website alongside its manuscript, and none of it is at risk of being published by accident. Pointing StoryLark at the wrong folder ingests nothing rather than ingesting garbage. And an author's intent is read from a statement rather than guessed from a pattern.

The gate never infers and never repairs. It has exactly two outcomes: a normalised record, or errors. Defaults defined in this document (publish: true, contractVersion: 1) are normalisation — applying a value this specification states — which is a different act from guessing one.

2.1 Where the rule is enforced, and where it is relaxed

requireBlock is set by the repo transport and only by the repo transport.

The portal and the API address content in the request itself: a URL segment, a form field, a position in a submitted array. That is an explicit statement of identity by the caller, not an inference, so those doors accept a file whose identity comes from the transport rather than from a block.

This is also what makes existing StoryLark deployments keep working. Every chapter published before this format existed has no storylark: block, and every one of them remains valid. Grandfathering is a property of the design, not leniency in the gate — the same code path, given a transport-supplied identity, produces a valid record with declared: false.


3. The storylark: block

A nested block in the file's YAML-style front matter:

---
title: The Lantern in the Hall
author: A. N. Author
storylark:
  type: chapter
  book: the-long-road
  chapter: lantern-in-the-hall
  order: 3
---

The hall was longer than she remembered...

The block is additive. Everything outside it is untouched and continues to mean whatever it already meant to whatever else reads the file — a static site generator, another publishing pipeline, a colleague. StoryLark reads exactly one nested key, and the existing front-matter readers deliberately skip nested keys, so adding the block cannot change how any other tool reads the file.

storylark: is the only nested key StoryLark reads. There is one implementation that reads it.

3.1 Syntax

3.2 Fields

Field Type Required Meaning
type book | chapter | story always What this file is. Nothing works without it.
book id for chapter; for book in repo mode The book this belongs to, or the book this declares.
chapter id for chapter This chapter's own id.
order integer for chapter Position within the book.
publish boolean no — defaults true false withholds the file.
title string no Overrides the top-level title.
cover relative path no Cover image. Never a URL.
contractVersion integer no — defaults 1 Which version of this contract the file was written against.

Where a field is marked required "for chapter", the transport may satisfy it: a URL segment, a form field, or an array position is an explicit statement of identity. A block's own value always wins where both exist, and disagreement is an error (id_mismatch) rather than a quiet preference for one of them.

3.3 Ids

^[a-z0-9][a-z0-9-]{0,63}$

One to sixty-four characters of lowercase letters, digits, or hyphens, starting with a letter or a digit. Ids are storage keys and URL segments, so they are kept boring deliberately. The rule has one home: an id supplied by a transport is checked by the same code as an id declared in a block, so a URL segment that could not be a storage key is refused in the same place with the same message (invalid_id).

3.4 order

An integer. Gaps are fine — 10, 20, 30 is a perfectly good way to leave room for an insertion. Ties are an error (order_tie), never silently resolved, because a silently resolved tie means the reading order of a book depends on which file a provider's archive happened to list first.

Filename prefixes are never consulted. A file called 03-lantern.md does not thereby have order 3. Explicit beats implicit, and a prefix is a convention that different authors follow differently.

The stored library joins the tie check: a chapter claiming an order an existing chapter already holds is the same order_tie, with the same message, whether the incumbent arrived in this batch or a month ago. Two carve-outs, both statements rather than inference — a chapter re-declaring the order it already owns is not a tie, and an incumbent that the same arrival re-declares is judged by its new declaration rather than its superseded one.

3.5 publish: false

A withheld file is valid content that says "not yet". It is not an error. Every door accepts it, reports it as withheld, and writes nothing — there is no half-written state to clean up. Remove the flag or set it to true to publish.

3.6 cover

A path relative to the file that names it, never a URL. Images are ingested into the deployment and served from it; they are never hotlinked to someone else's server, which would make the library depend on a host nobody here controls. A value carrying a scheme, or beginning //, is invalid_cover.

3.7 title

storylark.title overrides the top-level title. Where neither is present, a chapter keeps the title it already had, and failing that is labelled with its own chapter id.

That last step is not inference. The chapter id was stated — by a block or by a transport — and using a stated identity as a display label is a different act from reading intent out of prose.

Which is exactly why the # H1 fallback an earlier draft specified does not exist. StoryLark's block model has no heading concept — an # H1 line parses as a paragraph — so that step would have meant inferring a title from the body text, in violation of §2. It was removed once implementation surfaced the contradiction.

A title that is present but empty, or not a string, is invalid_title. Absence is fine; a broken declaration is not.


4. The three types

4.1 type: chapter

A chapter of a book. Requires book, chapter, and order — from the block, or from the transport. Must have prose in the body; front matter alone is no_prose.

4.2 type: book

Book metadata, and metadata only. Prose in the body of a type: book file is type_mismatch, with a message saying to move it into a chapter file.

In repo mode a type: book file must name its own book id. The folder tree is organisation, never meaning, so there is nothing for it to inherit an id from. Through the portal and the API a book is addressed in the request itself, which is why the requirement applies only where no transport identity exists.

4.3 type: story

A standalone piece with no book around it — the shape most of the world's short fiction actually has. Carries its own prose and needs no book.


5. Books and chapters arriving together

An arrival — one repo sync, one batch push — is evaluated as a set, not a stream. File order never matters, and it must not, because it is decided by whatever order a provider's archive happens to list entries in.

A chapter naming a book: that does not exist is rejected (unknown_book), naming the missing book so the message says exactly what to fix — unless the same arrival also carries a type: book file declaring it, in which case both are accepted. Auto-creating a book from a chapter's reference was rejected: it would have to invent a title from something, and a typo'd book: would silently manufacture a junk book for someone to find and delete later.


6. Ownership across sources

The first writer to create a book id owns it. A later arrival claiming the same id from a different source is rejected with book_owned_elsewhere, naming the current owner. The same source writing again — the same repo syncing, the same token pushing — is an ordinary update.

Silent overwrite and last-write-wins were both rejected: the first lets two systems fight forever with nobody told, and the second makes a library's contents depend on cron timing. Both hide a configuration error that a human needs to see, and an error naming the owner is a five-minute fix.


7. Deletion

StoryLark never unpublishes content on its own initiative.

A chapter present in the library and absent from an arrival is reported as missing and surfaced for a human to act on. It is not removed.

A file's absence is not a statement. It is produced equally by an intentional deletion, a partial clone, a fetch that failed halfway, a renamed folder, a branch switch, and a force-push someone regrets. Removing content after observing an absence twice — an earlier proposal — is a heuristic inferring intent from a pattern, which is exactly what §2 forbids; repeating the observation makes it a more confident guess, not a different kind of thing.

Removal, when a human asks for it, is the ordinary recoverable delete: the library entry goes, and the content objects, the source, and the revision history remain.


8. Limits

Limit Value Code
Chapter source size 2 MB too_large
Id length 64 characters invalid_id
Contract version understood 1 unsupported_contract_version

A file declaring a contractVersion higher than the engine understands is refused with a message saying so and naming both numbers — an engine must not guess at a format from the future.


9. The error vocabulary

Stable codes. A transport may choose how to render one; it may never rename one, and it may never invent one. Every code is exercised by packages/worker/test/content-contract.test.mjs, which asserts the set exactly — so a code added without a test, or a test without a code, fails the build.

Code Meaning
empty_chapter The file has no content at all.
too_large Past the 2 MB source ceiling.
unclosed_frontmatter Front matter opens with --- and never closes.
no_prose Front matter only; nothing to read.
missing_storylark_block The §2 rule, in repo mode.
invalid_storylark_block The block is structurally unreadable.
missing_field A required field is absent, and no transport supplied it.
unknown_type type is not book, chapter, or story.
type_mismatch The declared type contradicts the content or the address.
invalid_id An id breaks §3.3, wherever it came from.
id_mismatch The block and the transport disagree about identity.
invalid_order order is not an integer.
order_tie Two chapters claim one position.
invalid_publish publish is not a boolean.
invalid_title title is absent, empty, or not a string.
invalid_cover cover is empty, or is a URL rather than a path.
invalid_contract_version Not a positive integer.
unsupported_contract_version Written for a newer engine.
unknown_book A chapter names a book nothing declares (§5).
book_owned_elsewhere A different source already owns this id (§6).

Every error carries a stable code, a human message, and a location — the file where the transport knows one, the line, and the field where the problem is a specific field's.


10. How a transport uses this

A transport's whole job is to produce candidate records and hand them to the gate. It fetches bytes, works out identity from its own addressing, and calls validateChapterCandidate or validateBookCandidate.

A transport contains no validation logic of its own. If a transport is deciding whether something is valid, the architecture has been broken, and the three-way identical-errors test is what catches it.

The doors differ in exactly one respect — how a rejection is presented:

Door Rejection
Portal Inline, next to the editor, all errors at once
Repo Skipped and listed in the sync report; the rest of the sync proceeds
API 422, with the specific code and the full errors array

The codes and messages are identical across all three.


11. Versioning this contract

contractVersion is stated by content, not by the engine. Version 1 is the version this document describes.


12. A worked example

manuscript/
  the-long-road.md
  chapters/
    opening.md
    lantern.md
  images/
    cover.jpg
  README.md          <- no storylark: block, so not content
  notes/draft-7.md   <- no storylark: block, so not content

the-long-road.md:

---
title: The Long Road
author: A. N. Author
storylark:
  type: book
  book: the-long-road
  cover: images/cover.jpg
---

chapters/lantern.md:

---
storylark:
  type: chapter
  book: the-long-road
  chapter: lantern-in-the-hall
  order: 20
  title: The Lantern in the Hall
---

The hall was longer than she remembered...

Three things this example is meant to make obvious. The README and the drafts are not at risk, because they say nothing. lantern.md is order 20 and not order 2, because the filename is not consulted. And the book file sits beside the chapters rather than above them, because the folder tree is organisation and carries no meaning.


Appendix — conformance status

This specification is normative in full, and as of the repo-sync transport shipping, every rule below has a transport behind it.

Rule Enforced today
§2 opt-in, §3 the block and its fields ✅ portal, API, and repo sync
§3.3 ids, §3.4 order and ties, §3.5 withholding
§3.7 title precedence
§4 the three types ✅ in the gate
§5 arrival-as-a-set, §6 ownership ✅ in the gate and in the repo transport
§7 deletion ✅ the repo sync report surfaces missing entries; nothing is auto-removed
§3.6 cover validated — never a URL; file-relative resolution ships with the repo transport
§2.1 requireBlock ✅ set by the repo transport, and only by the repo transport

Remaining open items are tooling and integrations around the gate — a local conformance CLI for pre-flight checks, and reference integrations for third-party publishing tools — not gaps in the gate itself, which is shipped and tested end to end across all three doors.

A note on how this document was written. It was produced by reading the validator rather than the earlier design prose, which immediately caught a real error: the design claimed a chapter without a title was rejected. It is not — the title falls back to the chapter id. That is the point of writing the specification against the implementation, and it is why any future disagreement between the two should be resolved in the implementation's favour until someone demonstrates otherwise.


Found a gap? StoryLark is open source — improve these docs on GitHub.