Page-document contract
Developer notes for backend/src/lib/page-contract. Not a specification. The product
boundary is SPEC.md §4.2 and §10. The exported renderer contract is SPEC.md §10.2. The
live-API renderer boundary is SPEC-RENDERER.md §5.1 and §6.
What it is
Section titled “What it is”One dependency-free, framework-neutral definition of a Dee Wan page document. It exports the format constants, the slide vocabulary, the exact slide and document types, the limits, the parsers, the refusal codes and a conformance fixture corpus.
backend/src/lib/page-contract/ refusal.ts DOCUMENT_REFUSALS, Refusal, Parsed, refuse, accept document.ts PAGE_FORMAT, SLIDE_TYPES, LIMITS, types, parsers fields.ts SLIDES_FIELD_KEY, TITLE_FIELD_KEY, SLIDES_FIELD_TYPE, isPageModel fixtures.ts ACCEPTED_PAGES, REFUSED_PAGES, REFUSED_MODEL_SLIDES, STORED_DELETED_MEDIA format2.ts PAGE_FORMAT_2, PAGE_FORMATS, SECTION_LIMITS, section types renderer.ts renderablePage, parseAnyPageDocument, parsePublicRenderablePage, declaredFormat migrate.ts migrateGroupToFormat2 group-structure.ts structureDrift, requireSameStructure, groupStructure, localeSaveKeepsStructure index.ts barrel — everything except the fixturesThe fixtures are deliberately outside the barrel. They are test data, and a barrel that pulled them in would ship every fixture into every bundle that reads one page.
Who owns what
Section titled “Who owns what”| Layer | Owns |
|---|---|
page-contract |
the document format, the slide vocabulary, the limits, the parsers, the refusal codes |
page-composer |
operations, planning, the model prompt, admin write policy (COMPOSER_OWNED_FIELD_KEYS) |
api/pages.ts |
authorization, media lookup construction, lifecycle writes, HTTP status mapping |
frontend/src/lib/pages/slides.ts |
presentation only — section labels and the outline projections |
| an external renderer | HTML, CSS, layout, routing, response headers |
page-composer/errors.ts builds COMPOSER_ERRORS as [...DOCUMENT_REFUSALS, ...composer-only].
Document codes are never restated there.
Who consumes it
Section titled “Who consumes it”- Backend:
page-composer/operations.ts,page-composer/planner.ts,api/pages.ts,lib/lifecycle/service.ts. - Frontend:
src/lib/pages/slides.tsre-exports it by relative path (../../../../backend/src/lib/page-contract), the same way$prisma/generatedtypes reach the admin app. Nothing is hand-copied.
There is no third handwritten vocabulary, and frontend/cypress/specs/page-composer-parity.test.ts
fails if one appears.
Parser entry points and their trust boundaries
Section titled “Parser entry points and their trust boundaries”All three parse the same document. What differs is the media rule and what an absent document means.
| Entry point | Origin | Media | Absent document | Caller |
|---|---|---|---|---|
parseSlide(raw, id, {origin:'model', media}) |
a language model’s answer | resolved through the site’s MediaLookup, then the resolved values are checked against the recorded-media contract; an unresolved id is unknown_media |
n/a | generation and revision |
parsePageDocument(raw, {origin:'stored', media}) |
a document this product wrote | resolved through the lookup; a resolved row that fails the contract, and a deleted row, both fall back to the recorded reference | empty page | the admin composer |
parsePublicPageDocument(raw) |
the public envelope’s fields.slides |
no lookup at all — the recorded media_id, url and alt are validated as they stand |
unreadable_page |
a renderer |
Every value reaching any of them is untrusted.
The invariant: what the composer writes, a renderer can read
Section titled “The invariant: what the composer writes, a renderer can read”Every document a create or a revise stores must satisfy parsePublicPageDocument. Two independent
defences hold it, and test/page-contract-publishable.test.ts proves each one separately:
- Resolved media is validated. A
MediaLookupanswers frommedia.url, a stored string. An import can write anything there —media-import.tschecks only that asource_urlis non-empty — so a row holdingjavascript:alert(1)used to produce a saved page the public parser refused. Model-origin media is now checked against the same recorded-media contract the public read applies, and the page is refused, never stripped of the image. - The whole document is re-read before every write.
api/pages.tsrunsparsePublicPageDocumentover the built document in both the create and the revise handler, beforesaveContent. Nothing is persisted when it fails.
The second is redundant while the first is correct, and that is the point: mutating the parser alone leaves the route still refusing; mutating both is what lets a bad page through.
Consequence to know about. A page stored before this rule existed, whose media row is unusable, still opens in the admin — otherwise it could never be revised into a valid page — but it will not save until the media is fixed or the slide is removed. The refusal names the field. That is deliberate: the alternative is silently dropping an image the editor did not ask to remove.
Why the public parser takes one argument
Section titled “Why the public parser takes one argument”Rendering a published page must never need the admin database. A media-lookup parameter is a
database in disguise: the moment the signature has one, a renderer needs something to pass.
test/page-contract-boundary.test.ts asserts parsePublicPageDocument.length === 1.
Where model, stored and public deliberately differ
Section titled “Where model, stored and public deliberately differ”- Unknown media. Only
'model'can produceunknown_media. It is the cross-tenant guard for slide media: the lookup is built from this site’s media rows, so an id from anywhere else resolves to nothing. - A deleted media row.
'stored'keeps what the document recorded, so deleting one image does not make an existing page unreadable and therefore unrevisable.'public'reads the same recorded values and produces the same media — the document is the only source either side has once the row is gone. - A malformed recorded media object.
'stored'keeps what the document holds and never refuses, so a damaged page stays openable and therefore fixable in the admin.'public'refuses the whole page. This divergence is intentional and is the one place the two readings differ; the pre-save gate above is what stops it becoming a way to persist an unrenderable page. - An absent document.
'stored'reads it as an empty page — a page waiting to be generated.'public'refuses: rendering an empty document would put a blank, indexable page on a live site under a 200.
Where media lives in a slide
Section titled “Where media lives in a slide”SLIDE_MEDIA_KEYS declares, per slide type, the keys whose value is a SlideMedia, together with
the accessor that reads one. pageMediaReferences(document) is derived from it and returns every
media reference a document carries, with the slide id, slide type and key it was found under.
Graduation is the caller that needs this. Its media-custody check scans the generated <key>_id
columns, and page media is not in a column — it is inside the slides document — so a media-bearing
page read as media-free would have exported a site whose images resolve only against the CMS.
The declaration reaches TOP-LEVEL slide keys. A slide type that nests a media reference inside a
list must extend the shape rather than declare the list key; the corpus test walks parsed documents
for any media_id/url pair the declaration cannot reach.
Media URL rule
Section titled “Media URL rule”A recorded media URL is parsed with new URL, not prefix-matched. startsWith('https://') is
not URL validation: it accepts https:// with no host, https://user:pass@cdn.example/a.jpg, and —
because a browser normalises a backslash to a slash — /\evil.example/a.jpg, which resolves from
https://site.example to https://evil.example/a.jpg.
| Form | Result |
|---|---|
https://cdn.example/a.jpg (query and fragment allowed) |
accepted |
/local-media/abc — exactly one leading slash |
accepted |
http://localhost…, http://127.0.0.1…, http://[::1]… |
accepted |
http:// any other host |
refused — mixed content on a published page |
https:// with no host |
refused |
https://user:pass@host/a.jpg |
refused — credentials leak through referrers and caches |
//evil.example/a.jpg |
refused — protocol-relative is off-origin |
/\evil.example/a.jpg or any backslash |
refused |
javascript:, data:, any other scheme |
refused |
embedded whitespace, markup, over LIMITS.href |
refused |
Loopback HTTP is allowed only because the local rig serves media from
http://localhost:8787/local-media/…; production media must be HTTPS or site-relative.
Alt text is bounded and type-checked but not markup-checked. It is an author-written media caption rather than model output and lands in an escaped attribute; refusing a page over an angle bracket in a caption would break a site over punctuation.
Navigation links are stricter than media URLs: site-relative, https:// or mailto: only.
Reading a document in the admin
Section titled “Reading a document in the admin”frontend/src/lib/pages/slides.ts exports readPageDocument(value), which returns
{ ok: true, document } or { ok: false, code, message }.
It is deliberately not a type predicate. A value is PageDocument guard was unsound: the parser
accepts more shapes than it produces — a serialized JSON string, and a document whose optional
keys are omitted rather than null — and normalises them. Reporting true about the original value
told the compiler a string had .slides.
Only parsed.value has the promised shape. fetch-store/pages.ts therefore replaces the response’s
own document with the parsed one on every page read and write, so no screen ever reads an
un-normalised slide.
Refusals are values
Section titled “Refusals are values”A refusal is { ok: false, error: { code, message, detail } } — a plain object, not a thrown error
and not a class. An instanceof check across a package boundary fails when two copies of the same
class exist, and a thrown error does not survive JSON. api/pages.ts maps the code to a status;
the frontend maps it to copy in COMPOSER_REFUSAL_COPY.
Adding a slide type
Section titled “Adding a slide type”- Add it to
SLIDE_TYPES, add its exact type, add it to theSlideunion. - Add entries to
SLIDE_KEYS,SLIDE_OPTIONAL_KEYS,SLIDE_VARIANT_KEYSandSLIDE_MEDIA_KEYS— all four are keyed bySlideType, so this step is a compile error until it is done.SLIDE_MEDIA_KEYSis checked harder than the others: a slide type that holds aSlideMediamay not declare[], and a key that is not media may not be declared at all. - Add a
casetoparseSlide. - Add fixtures to
ACCEPTED_PAGEScovering the type with every optional value present, every optional value null, and every enumerated value. - Add a label to
SLIDE_LABELSand a branch to each projection infrontend/src/lib/pages/slides.ts(slideOwnHeading,slideLead,slideParagraphs,slideFeatures,slideMedia,slideLink) — every one is an exhaustive switch, so this is also a compile error until it is done. - Add the renderer component in the renderer repository.
Steps 1–5 fail loudly if skipped. test/page-contract-corpus.test.ts fails on a type with no
fixture; frontend/src/lib/pages/slides.test.ts fails on a type the admin draws nothing for;
PagePreview.svelte.test.ts fails on a type that reaches no card;
test/page-contract-media.test.ts fails on a document holding a media reference the declaration
cannot reach, which is the case a top-level-only declaration would otherwise miss.
A new slide type does not need a format bump. Adding a type widens what may be stored; it does not change how an existing document is read.
Changing PAGE_FORMAT
Section titled “Changing PAGE_FORMAT”format is a migration boundary. parsePageDocument refuses anything that is not exactly
PAGE_FORMAT, and parseAnyPageDocument refuses anything outside PAGE_FORMATS — missing and
newer formats fail closed. Accepting a newer format would read a
later build’s fields under today’s rules, drop what it did not recognise, and write that lossy
reading back over the real document.
Bumping it needs, in order:
- a core migration that rewrites stored documents, or an explicit written compatibility decision;
- the contract updated;
- renderer support released;
- conformance fixtures for both formats;
- a documented upgrade order for adopters.
Do not bump PAGE_FORMAT to add a field or a slide type.
PageDocument.format is typed typeof PAGE_FORMAT, not number, so { format: 999, slides: [] }
is a compile error as well as a runtime refusal. test/page-contract-corpus.test.ts holds that with
a @ts-expect-error line that fails to compile if the type ever widens again.
Maintaining the corpus
Section titled “Maintaining the corpus”fixtures.ts is handwritten on purpose. test/page-contract-corpus.test.ts reads SLIDE_TYPES,
SLIDE_OPTIONAL_KEYS, SLIDE_VARIANT_KEYS, LIMITS and DOCUMENT_REFUSALS and fails until the
corpus exercises each one:
- every slide type appears in a parsed accepted document;
- every optional key appears both non-null and null;
- every enumerated key appears with every value;
- every refusal code is produced by some refused fixture;
- every limit is crossed by a refused fixture whose refusal detail carries that exact
max.
Coverage is measured from what the parser returns, not from what a fixture claims. A corpus generated from those lists would agree with them by construction and could never fail — do not write one.
ACCEPTED_PAGES and REFUSED_PAGES are judged by parsePublicPageDocument. Model-origin and
stored-origin behavior is carried separately in REFUSED_MODEL_SLIDES and STORED_DELETED_MEDIA.
workerd compatibility
Section titled “workerd compatibility”The contract must run in workerd with no CMS around it. It may import nothing but its own
siblings — no Hono, no Prisma, no Svelte, no Astro, no node: module, no database code.
test/page-contract-boundary.test.ts asserts this twice and independently:
- it reads every module specifier in the directory and requires each to match
^\./[A-Za-z0-9_-]+$; - it bundles
index.tswith esbuild atplatform: 'neutral'behind a resolver plugin that rejects every non-relative specifier, then checks the output forrequire(andnode:.
Neither runs workerd itself. Together they prove the module is self-contained, which is the
property a workerd build needs.
How an external renderer consumes it later
Section titled “How an external renderer consumes it later”Today the contract lives in this repository and is consumed by relative path. The renderer repository will consume a pinned, immutable artifact of the same directory and must never copy the types by hand. A contract upgrade is then an explicit dependency change.
The artifact name, its location and the publication mechanism are not decided and need owner authority. Nothing in this repository names them. Until they exist, treat the exported surface as if it were already immutable: additive changes are cheap, renames and shape changes are not.
A renderer needs exactly three things from the artifact:
parsePublicPageDocument— the whole validation, one argument, no database;SLIDE_TYPESand the slide types — for a build-time component registry with compile-time exhaustiveness;ACCEPTED_PAGES/REFUSED_PAGES— to prove its own parser agrees with core’s.
It should not import page-composer for anything.