diff --git a/site/README.md b/site/README.md
index 4358569..6409878 100644
--- a/site/README.md
+++ b/site/README.md
@@ -18,13 +18,98 @@ python3 site/build.py # → site/dist/
```
`site/dist/` is gitignored — it is output, rebuilt on every deploy.
-Deployment is task 32's; this directory only has to produce the files.
`site/` never reaches a host repo: releases ship exactly what
`../manager/core/release-manifest` lists, and it does not list this
directory. `tests/test_release_artifact.py` asserts that out loud rather
than leaving it to inference.
+## Where the site lives
+
+| | |
+| --- | --- |
+| Host | Cloudflare Workers, [static assets](https://developers.cloudflare.com/workers/static-assets/) — no script, no KV, no database |
+| Account | the Cloudflare account holding the `12vectors.com` zone. `npx wrangler whoami` must list it before a deploy will work |
+| Worker | `bench-site` |
+| Route | `bench.12vectors.com`, a **custom domain** — Cloudflare owns the hostname at the zone level and creates the DNS record itself |
+| Served from | `site/dist/`, uploaded whole on every deploy |
+| Config | `site/wrangler.jsonc` (routing) and `site/root/_headers` (caching, security) |
+
+Deploys are run by hand, by a person, exactly as releases are
+(`../release.sh`). There is no deploy pipeline and no Cloudflare token in
+repository secrets; adding a GitHub Action on merge to `main` is a
+separate decision with a separate cost, and a follow-up card.
+
+## Deploying
+
+From a clean checkout, four commands. Re-running the whole sequence is
+safe: the build empties and rewrites `dist/`, and `wrangler deploy`
+replaces the Worker's assets rather than adding to them.
+
+```bash
+python3 -m pip install -r site/requirements.txt # once per machine
+python3 site/fetch-fonts.py # once per checkout
+python3 site/build.py # → site/dist/
+npx wrangler@4 deploy --config site/wrangler.jsonc
+```
+
+`npx wrangler@4 login` first, once per machine, against an account that
+can see the `12vectors.com` zone. Paths inside `wrangler.jsonc` are
+relative to that file, so the command works from anywhere in the repo.
+
+The first deploy is the one that takes the hostname over. It creates the
+DNS record for `bench.12vectors.com` and routes it to the Worker;
+anything else answering on that name stops answering. Every deploy after
+it is an asset upload.
+
+### Preview it locally
+
+```bash
+python3 site/build.py
+npx wrangler@4 dev --config site/wrangler.jsonc # → http://localhost:8787
+```
+
+`dev` serves `dist/` through the same static-assets router as production,
+so trailing-slash redirects and the 404 page behave as they will live.
+The custom domain is ignored locally.
+
+### After a deploy, check these four
+
+The things this repository's tests cannot reach, because they are
+answers rather than files:
+
+1. `https://bench.12vectors.com/` serves the landing page over TLS.
+2. `https://bench.12vectors.com/concepts/claiming-a-card` redirects to
+ the same path with a trailing slash, and no url anywhere ends in
+ `.html`.
+3. A path that does not exist — `/nope/` — renders the site's own 404
+ page **with a 404 status**, not the landing page with a 200.
+4. `curl -sI https://bench.12vectors.com/` shows
+ `x-content-type-options`, `referrer-policy`,
+ `strict-transport-security` and a `cache-control` that revalidates.
+
+## How it is served
+
+- **One url per page.** `html_handling: "force-trailing-slash"` redirects
+ `/concepts/claiming-a-card` to `/concepts/claiming-a-card/`, which is
+ what the pages link and what `` names. Pages are
+ written as `/index.html`, so no url ends in `.html`.
+- **A real 404.** `not_found_handling: "404-page"` serves `dist/404.html`
+ with a 404 status. That page is a normal manifest entry (`/404.html`,
+ layout `notfound`) — the site's own design, its own nav, and a link
+ back to the landing page.
+- **Two caching policies, because there are two kinds of file.** HTML
+ revalidates on every view, so a deploy is visible on the next reload
+ with nobody clearing anything. Everything under `/static/` is kept for
+ a year and never re-checked, which is only safe because the stylesheet
+ and the icon are linked with a `?v=` of their own contents:
+ change the file and the url changes with it.
+- **Baseline headers, no third parties.** `nosniff`, a referrer policy,
+ a year of HSTS, `X-Frame-Options`, and a Content-Security-Policy of
+ `default-src 'none'` with `'self'` for styles, fonts and images. The
+ site collects nothing and loads nothing from anywhere else; the CSP is
+ that promise in a form the browser enforces.
+
## What is where
| Path | What it is |
@@ -33,6 +118,8 @@ than leaving it to inference.
| `build.py` | The generator — slicing, drift detection, rendering, links |
| `templates/` | One `string.Template` per layout (`$name`, `$$` for a literal dollar) |
| `static/` | Copied to `dist/static/` verbatim: the stylesheet, the icon, the fonts |
+| `root/` | Copied to the **top** of `dist/` verbatim: `_headers`, which the host reads and never serves |
+| `wrangler.jsonc` | The Worker: assets directory, url handling, 404, custom domain |
| `requirements.txt` | `markdown-it-py`, pinned. The only dependency |
| `fetch-fonts.py` | Downloads the self-hosted woff2 files, once |
@@ -51,12 +138,15 @@ than leaving it to inference.
}
```
-- **`path`** starts and ends with `/`; `/x/y/` is written to
- `dist/x/y/index.html`.
+- **`path`** starts with `/` and ends with `/`; `/x/y/` is written to
+ `dist/x/y/index.html`. A route that names an `.html` file instead is
+ written to exactly that path — `/404.html` is the only one, and it
+ exists because the host looks for that literal filename.
- **`layout`** names a file in `templates/`.
- **`section`** groups the page in the nav and the sidebar. The IA is
read out of this file in this file's order — nothing is derived from
- the directory layout.
+ the directory layout. A `null` section keeps the page out of both,
+ which is how the 404 page stays off the nav.
- **`source`** is repo-relative, or `null` for a landing page whose body
is authored in its template rather than sliced.
- **`from`** is the heading the slice starts at. It is matched on the
@@ -78,7 +168,8 @@ Each of these exits non-zero with a message naming the route:
- a slice that comes out empty;
- a markdown link to a repo path that does not exist, or that escapes
the repo — a dead relative link must never reach the site;
-- a template placeholder the builder does not supply.
+- a template placeholder the builder does not supply;
+- a file in `root/` that a route would also write.
Repo-relative links that *do* resolve are rewritten: to a site route if
`link_routes` maps the file to one, otherwise to the file on GitHub.
diff --git a/site/root/_headers b/site/root/_headers
new file mode 100644
index 0000000..967d760
--- /dev/null
+++ b/site/root/_headers
@@ -0,0 +1,42 @@
+# Response headers for bench.12vectors.com.
+#
+# Read by Cloudflare Workers static assets at deploy time — the file is
+# consumed, never served — and copied here from site/root/ by
+# site/build.py, because the host looks for it at the root of the build
+# and nowhere else.
+#
+# Rules apply in order and a later rule wins on a header it repeats. The
+# two blocks below are written so that even a host that merged them
+# instead would land on the safe side: HTML would still revalidate.
+
+# Everything, so that no page can ever forget one of these.
+#
+# nosniff a text/plain file must not become a script
+# Referrer-Policy a full url is never sent to another origin
+# HSTS one year, this host and anything below it. No
+# `preload`: that is a submission to browser
+# vendors and a commitment this card did not make
+# X-Frame-Options nothing here is meant to be framed
+# CSP the runtime form of the site's own promise —
+# no analytics, no font CDN, no third-party
+# anything. `default-src 'none'` means an asset
+# must be named below to load at all, and no
+# 'unsafe-inline' anywhere means an injected
+#