Skip to content
Pre-MVP. The 1.0 codebase is not a released product — see the 1.0 product boundary.

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.

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 fixtures

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

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.

  • Backend: page-composer/operations.ts, page-composer/planner.ts, api/pages.ts, lib/lifecycle/service.ts.
  • Frontend: src/lib/pages/slides.ts re-exports it by relative path (../../../../backend/src/lib/page-contract), the same way $prisma/generated types 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:

  1. Resolved media is validated. A MediaLookup answers from media.url, a stored string. An import can write anything there — media-import.ts checks only that a source_url is non-empty — so a row holding javascript: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.
  2. The whole document is re-read before every write. api/pages.ts runs parsePublicPageDocument over the built document in both the create and the revise handler, before saveContent. 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.

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 produce unknown_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.

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.

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.

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.

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.

  1. Add it to SLIDE_TYPES, add its exact type, add it to the Slide union.
  2. Add entries to SLIDE_KEYS, SLIDE_OPTIONAL_KEYS, SLIDE_VARIANT_KEYS and SLIDE_MEDIA_KEYS — all four are keyed by SlideType, so this step is a compile error until it is done. SLIDE_MEDIA_KEYS is checked harder than the others: a slide type that holds a SlideMedia may not declare [], and a key that is not media may not be declared at all.
  3. Add a case to parseSlide.
  4. Add fixtures to ACCEPTED_PAGES covering the type with every optional value present, every optional value null, and every enumerated value.
  5. Add a label to SLIDE_LABELS and a branch to each projection in frontend/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.
  6. 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.

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:

  1. a core migration that rewrites stored documents, or an explicit written compatibility decision;
  2. the contract updated;
  3. renderer support released;
  4. conformance fixtures for both formats;
  5. 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.

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.

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:

  1. it reads every module specifier in the directory and requires each to match ^\./[A-Za-z0-9_-]+$;
  2. it bundles index.ts with esbuild at platform: 'neutral' behind a resolver plugin that rejects every non-relative specifier, then checks the output for require( and node:.

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_TYPES and 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.