#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
- The block is
storylark:on its own line, followed by indentedkey: valuelines. Any consistent indentation works. - Values are scalars: strings, integers,
true,false. Quotes are stripped. Quote a string that would otherwise read as a number or a boolean. - Unknown fields inside the block are ignored. This is the forward- compatibility rule every StoryLark contract follows: a file written for a newer engine must still load on an older one.
- A value on the
storylark:line itself, an unreadable line, a duplicated key, or a secondstorylark:block are structural errors —invalid_storylark_block, reported with the offending line number.
#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.
- Additive changes — a new optional field, a new
type— do not increment it. Unknown fields are ignored (§3.1), so older engines keep working. - A change that would make previously-valid content invalid increments the version and is a major release.
- An engine reads every version up to and including its own, and refuses anything higher with a message naming both numbers.
#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.