Skip to content
Bernhard Götzendorfer
Technical How-tos

Five Places for One Page

One page pair needs registering in five places. A hand-kept list silently drops one of them. So I derive all five from a single typed registry.

TL;DR

A new landing page is never one file. It is two page.tsx files plus four registrations in completely different corners of the repo: the locale switcher, the llms.txt manifest, the sitemap, and the CSP set for statically served pages. Five places for one page pair. If a change has to happen in N places, N minus one of them is a derivation problem. A wave of new pages in August proved that a hand-kept list silently drops one of them: two live, indexed pages had no internal link for weeks, and nothing was red. The answer is a typed registry that every list is derived from, plus tests that force a bijection against the file tree. What it ends with is an admitted side finding rather than a clean ending.

The Five Places

AgenticCutter has its own subdomain on my site, and inside it three host pages, one each for Final Cut Pro, Adobe Premiere Pro and DaVinci Resolve, in German and English. That sounds like six files. It is six files. They are just not enough.

A page pair also has to be registered in four other spots. First in the locale switcher, otherwise the language toggle walks into nothing. Second in the llms.txt manifest, so models find the page in the list at all. Third in the sitemap. And fourth in the set of statically servable paths inside the CSP builder. The fourth is the load-bearing one. The module says so bluntly:

 * The CSP registration is the load-bearing one: a statically prerendered AC
 * page that is NOT in `AC_STATIC_ELIGIBLE_PATHS` (src/lib/csp/build-csp.ts)
 * gets the enforcing `nonce`+`strict-dynamic` policy on nonce-less baked
 * HTML, the #231 "Black Page" class.

In plain words: a prerendered page, one whose HTML is baked at build time instead of being produced per request, gets HTML without a nonce, without the one-off token the security rule uses to mark scripts as allowed. If the proxy still puts the enforcing nonce policy on it, strict-dynamic switches off the 'self' source. Every script is blocked, no hydration, white page. This is not theory. On 21 August 2026 I measured exactly that live on /ueber-mich. The server answers with 200, the HTML is complete, and nothing happens in the browser. And no test anywhere is red.

Why a List Cannot Do This

The incident before that one was quieter and therefore more instructive. In August a wave of use-case pages landed, six pairs in total. Two of them, rough cut and interviews, went live correctly registered in places one through four. They were missing from five and six, meaning the footer and the cards on the homepage. Result: two indexed pages with not a single internal link, for weeks. Precisely the orphan-page problem that had already been fixed once for the first two pairs. The comment in the predecessor module explains why nobody noticed:

 * Nothing was red, because every guard asserted
 * against a hand-written list rather than against a shared table.

Nothing was red, because every guard asserted against a hand-written list rather than against a shared table.

A whitelist can only check what it already knows. It answers the question "is there a file for every row in my list?" and answers it reliably. The opposite direction, "is there a row for every file?", it structurally cannot ask. Whatever is not in the list does not exist for it. And that is exactly the direction the real defect travels: someone adds a page and forgets an entry.

For the host pages I therefore used the same shape as for the use cases: a typed table as the single source, with everything else derived from it.

 * a page pair has to be registered in FIVE independent places
 * (locale switcher, llms.txt manifest, sitemap, CSP static-eligible set, plus
 * the two page.tsx files on disk), and the 2026-08 use-case wave proved that
 * a hand-maintained list silently loses one of them.

For the use-case pages it is seven places, by the way, because footer and homepage cards come on top. For the host pages it is five. The number is not the point, the deriving is.

Two Constraints That Shape the Solution

A registry file sounds harmless until you notice who imports it. The CSP builder pulls it into the Edge runtime through src/proxy.ts. That puts an import ban on this one file, marked as load-bearing in the module comment:

 * IMPORT DISCIPLINE (load-bearing, do not relax): this module is pulled in by
 * `src/lib/csp/build-csp.ts`, which runs in the Edge runtime via
 * `src/proxy.ts`. No React, no next-intl, no `node:fs`, no `next/headers`.
 * The only import is the type-only `AcLocale` (./locale itself imports
 * `next/headers` and must never be imported for value here).

No React, no next-intl, no node:fs, no next/headers. The only import is a type-only locale type, and even that one is delicate, because the module next to it pulls in next/headers and must never be imported for value.

The second constraint follows from the first. The sitemap needs absolute URLs, so it needs an origin. Instead of importing the URL builder, the sitemap function takes it as a parameter:

 * `canonicalUrl` is injected (rather than imported) to keep this module free
 * of runtime imports for the Edge-runtime consumer, see the import-discipline
 * note in the module docblock.

Injecting instead of importing is no architectural dogma here, it is the cheapest trick available. It costs one parameter and saves one runtime dependency. The result is a file that lives in the Edge runtime and in a Node test at the same time. The price is one line of comment carrying the words "load-bearing", so nobody relaxes it during the next cleanup.

The Tests That Hold the Bijection

The registry on its own guarantees nothing. It only moves the problem to a place where it can be checked. The check sits in tests/agentic-cutter-host-pages.test.ts, 10 test cases across 5 describe blocks. The comment on top says what they actually enforce:

 * Der Test leitet ALLES aus `AC_HOST_PAGES` ab und erzwingt eine Bijektion
 * gegen den Dateibaum: Registry-Zeile ohne Datei fällt rot, Datei ohne
 * Registry-Zeile ebenso. Eine Literal-Whitelist könnte die zweite Richtung
 * strukturell nicht prüfen.

In English: the test derives everything from AC_HOST_PAGES and forces a bijection against the file tree. A registry line without a file goes red, and so does a file without a registry line. A literal whitelist could not check that second direction at all.

On top of that comes a second guard with 4 test cases going the other way: the file tree is the source of truth, and the exceptions are derived rather than written out literally. Its default there is explicitly strict:

 * FAIL-CLOSED: Default-Erwartung ist „muss abgedeckt sein". Eine neue statische
 * AC-Seite fällt rot, bis sie in `AC_STATIC_ELIGIBLE_PATHS` steht.

In English: fail-closed is the default expectation. Every page has to be covered, and a new static AgenticCutter page goes red until it appears in AC_STATIC_ELIGIBLE_PATHS.

One detail of it I like a lot. The guard strips comments out of the source before it searches. Without that step a naive grep would misclassify a page just because a path appears in a comment above it. That, too, is marked as load-bearing in the test, because on reading it looks like pointless cosmetics.

In total five guard test files hang off this topic, plus a sweep with 6 test cases for the use-case pages. The file tree underneath holds 49 page.tsx files in the AgenticCutter branch. Keeping that set consistent by hand is not a question of diligence, it is a question of time. I have written about the shift from typing to verifying before, and here it gets very concrete.

Six Entries Still Do Not Belong in the List

The set of statically servable paths holds 44 entries: 26 written out literally in the file, 18 derived. The 18 are calculated, not counted: three host pages times two languages plus six use cases times two languages.

Six of the 26 literal entries do not belong there at all. The pricing, comparison and glossary pages, in both languages. According to next build they are dynamic, because the shell reads a header, so they carry the request nonce and would not need the entry. They sit there anyway, and the code says why:

  // Launch-Politur 2026-09-05: Preis-, Vergleichs- und Glossarseite. Sie sind
  // laut `next build` `ƒ (Dynamic)` (die AC-Shell liest `x-ac-locale`), tragen
  // also die Request-Nonce und braeuchten den Report-Only-Ausweg nicht. Sie
  // stehen hier, weil die Coverage-Guard
  // tests/csp-static-eligibility-ac-coverage.guard.test.ts jede AC-Seite ohne
  // `searchParams` als prerendert einstuft und fail-closed rot faellt. Die
  // Klassifikation dieser Guard gegen die reale Build-Ausgabe zu schaerfen ist
  // ein eigener Follow-up, hier waere es ein stiller Fremdeingriff.

In English: the pricing, comparison and glossary pages are dynamic according to next build, so they carry the request nonce and would not need the entry. They are listed anyway because the coverage guard classifies every page without searchParams as prerendered and fails closed. Sharpening that classification is a separate follow-up; doing it here would be a silent change to someone else's work.

The guard classifies too coarsely. It treats every page without searchParams as prerendered, and because it is fail-closed, it goes red until the entry is there. So the entry is there. Six redundant lines in a set that now permits something which does not occur anyway.

That is the price of fail-closed, and it is the right price. Sharpening the guard is work on a different construction site. Admitting it in a comment costs three sentences and stops someone six months from now reading those six lines as a deliberate security decision. A guard with false positives is expensive. A guard with false negatives costs a white page in production. That calculation only balances in one direction.

What I Take Away From This

The real decision was never whether four of the five places can be derived, but which table becomes the source. Once that is settled, the rest stops being maintenance and becomes a derivation that is either correct or red.

With the tests it was the direction that cost me time. A parity test against a literal whitelist only checks whether every entry still has a file, and that is the direction that rarely breaks. The defect that actually happens is the file without an entry, and that is exactly what such a test never sees. In the other direction, a fail-closed red test is allowed to produce false positives, like the six surplus lines further up. That is a deliberate calculation, not an oversight, and it only has to be written down, otherwise someone later reads it as intent.

The least conspicuous point is the one most likely to catch up with me again: import discipline is a property of the runtime, not of taste. If nobody writes down why a module cannot take a certain import, somebody relaxes it during the next cleanup and then wonders about a broken Edge build. Injecting instead of importing is the cheapest way to keep a module runtime-neutral: one parameter more, one dependency less.

Conclusion

The host pages for Final Cut Pro, Premiere Pro and DaVinci Resolve are two files each plus one row in a table. Everything else falls out of that, and two guards hold the bijection in both directions. Six entries sit there too many, which is admitted in the code and still open. How the product behind those pages works is in An agent cuts my talks. How I have agents audit that same website is written up as well. And how I work in general is on the way-of-working section.