Fünf Orte für eine Seite
Ein Seitenpaar muss an fünf Stellen registriert sein. Eine handgepflegte Liste verliert still eine davon. Also leite ich alles aus einer Registry ab.
TL;DR
Eine neue Landingpage ist nie eine Datei. Sie ist zwei page.tsx plus vier Registrierungen an ganz anderen Stellen im Repo: Locale-Switcher, llms.txt-Manifest, Sitemap und das CSP-Set für statisch ausgelieferte Seiten. Fünf Orte für ein Seitenpaar. Wenn eine Änderung an N Orten passieren muss, sind N minus eins ein Ableitungsproblem. Eine Welle neuer Seiten im August hat bewiesen, dass eine handgepflegte Liste still einen davon verliert: Zwei live indizierte Seiten hatten wochenlang keinen internen Link, und nichts war rot. Die Antwort ist eine getypte Registry, aus der alle Listen abgeleitet werden, plus Tests, die eine Bijektion gegen den Dateibaum erzwingen. Am Ende steht ein eingestandener Nebenbefund statt eines sauberen Endes.
Die fünf Orte
Der AgenticCutter hat auf meiner Seite eine eigene Subdomain und darin unter anderem drei Host-Seiten, je eine für Final Cut Pro, Adobe Premiere Pro und DaVinci Resolve, jeweils auf Deutsch und Englisch. Klingt nach sechs Dateien. Sind es auch. Nur reichen die nicht.
Ein Seitenpaar muss zusätzlich an vier Stellen eingetragen sein. Erstens im Locale-Switcher, sonst führt der Sprachwechsel ins Leere. Zweitens im llms.txt-Manifest, damit Modelle die Seite überhaupt in der Liste finden. Drittens in der Sitemap. Und viertens im Set der statisch auslieferbaren Pfade in der CSP-Erzeugung. Die vierte ist die tragende. Im Modul steht der Grund unverblümt:
* 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.
Übersetzt: Eine vorgerenderte Seite, deren HTML also schon beim Bauen fertig gebacken und nicht pro Anfrage erzeugt wird, bekommt HTML ohne Nonce, ohne das Einmal-Kennwort, mit dem die Sicherheitsregel erlaubte Scripts markiert. Wenn der Proxy ihr trotzdem die enforcende Nonce-Policy aufsetzt, schaltet strict-dynamic die Quelle 'self' ab. Jedes Script blockiert, keine Hydration, weiße Seite. Das ist keine Theorie. Am 21. August 2026 habe ich genau das live auf /ueber-mich gemessen. Der Server antwortet mit 200, das HTML ist vollständig, im Browser passiert nichts. Und in keinem Test ist irgendetwas rot.
Warum eine Liste das nicht kann
Der Vorfall davor war leiser und deshalb lehrreicher. Im August kam eine Welle von Use-Case-Seiten dazu, insgesamt sechs Paare. Zwei davon, Rohschnitt und Interviews, gingen live und waren an den Stellen eins bis vier korrekt registriert. Nur an fünf und sechs fehlten sie, also im Footer und in den Karten auf der Startseite. Ergebnis: zwei indizierte Seiten, wochenlang ohne einen einzigen internen Link. Genau das Waisenkind-Problem, das für die ersten beiden Paare schon einmal gefixt worden war. Der Kommentar im Vorgänger-Modul sagt, warum das niemandem auffiel:
* Nothing was red, because every guard asserted
* against a hand-written list rather than against a shared table.
Nichts war rot, weil jede Guard gegen eine handgeschriebene Liste geprüft hat statt gegen eine gemeinsame Tabelle.
Eine Whitelist kann per Konstruktion nur das Bekannte prüfen. Sie beantwortet die Frage "gibt es zu jeder Zeile in meiner Liste eine Datei?" und beantwortet sie zuverlässig. Die Gegenrichtung, also "gibt es zu jeder Datei eine Zeile?", kann sie strukturell nicht stellen. Was nicht in der Liste steht, existiert für sie nicht. Und genau in dieser Richtung liegt der Fehler, der in der Praxis passiert: Jemand legt eine Seite an und vergisst einen Eintrag.
Für die Host-Seiten habe ich deshalb dieselbe Form genommen wie für die Use-Cases: eine getypte Tabelle als einzige Quelle, aus der alles andere abgeleitet wird.
* 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.
Für die Use-Case-Seiten sind es übrigens sieben Orte, weil Footer und Startseiten-Karten dazukommen. Für die Host-Seiten sind es fünf. Die Zahl ist nicht der Punkt, das Ableiten ist es.
Zwei Zwänge, die die Lösung formen
Eine Registry-Datei klingt harmlos, bis man merkt, wer sie importiert. Die CSP-Erzeugung zieht sie über src/proxy.ts in die Edge-Runtime. Damit gilt für diese eine Datei ein Importverbot, das im Modul-Kommentar ausdrücklich als tragend markiert ist:
* 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).
Kein React, kein next-intl, kein node:fs, kein next/headers. Der einzige Import ist ein type-only Typ für die Locale, und selbst der ist heikel, weil das Modul daneben next/headers zieht und deshalb nie als Wert importiert werden darf.
Der zweite Zwang folgt daraus. Die Sitemap braucht absolute URLs, also einen Origin. Statt den URL-Builder zu importieren, nimmt die Sitemap-Funktion ihn als Parameter entgegen:
* `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.
Injizieren statt importieren ist hier kein Architektur-Dogma, sondern der billigste verfügbare Trick. Er kostet einen Parameter und spart eine Laufzeit-Abhängigkeit. Das Ergebnis ist eine Datei, die gleichzeitig in der Edge-Runtime und im Node-Test lebt. Der Preis dafür ist eine Zeile Kommentar mit dem Wort "load-bearing", damit sie beim nächsten Aufräumen niemand entspannt.
Die Tests, die die Bijektion halten
Die Registry allein garantiert nichts. Sie verschiebt das Problem nur an einen Ort, an dem man es prüfen kann. Die Prüfung liegt in tests/agentic-cutter-host-pages.test.ts, 10 Testfälle in 5 describe-Blöcken. Der Kommentar oben sagt, was sie wirklich erzwingen:
* 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.
Dazu kommt eine zweite Guard mit 4 Testfällen, die den umgekehrten Weg geht: Quelle der Wahrheit ist der Dateibaum, die Ausnahmen sind abgeleitet statt literal aufgeschrieben. Die Voreinstellung ist dort ausdrücklich streng:
* FAIL-CLOSED: Default-Erwartung ist „muss abgedeckt sein". Eine neue statische
* AC-Seite fällt rot, bis sie in `AC_STATIC_ELIGIBLE_PATHS` steht.
Ein Detail daran gefällt mir besonders. Die Guard entfernt Kommentare aus dem Quelltext, bevor sie sucht. Ohne diesen Schritt würde ein naives grep eine Seite falsch einstufen, nur weil in einem Kommentar darüber ein Pfad steht. Auch das ist im Test als tragend markiert, weil es beim Lesen aussieht wie überflüssige Kosmetik.
Insgesamt hängen fünf Guard-Testdateien an diesem Thema, dazu ein Sweep mit 6 Testfällen für die Use-Case-Seiten. Der Dateibaum darunter hat 49 page.tsx im AgenticCutter-Zweig. Diese Menge von Hand konsistent zu halten ist keine Frage von Sorgfalt, sondern eine Frage von Zeit. Über die Verschiebung vom Tippen zum Verifizieren habe ich schon einmal geschrieben, hier ist sie sehr konkret.
Sechs Einträge stehen noch zu viel drin
Und jetzt der Teil, den ich lieber weggelassen hätte. Das Set der statisch auslieferbaren Pfade hat 44 Einträge: 26 stehen literal in der Datei, 18 werden abgeleitet. Die 18 sind gerechnet, nicht gezählt: drei Host-Seiten mal zwei Sprachen plus sechs Use-Cases mal zwei Sprachen.
Sechs der 26 literalen Einträge gehören dort gar nicht hin. Preis-, Vergleichs- und Glossarseite, jeweils in beiden Sprachen. Sie sind laut next build dynamisch, weil die Shell einen Header liest, tragen also die Request-Nonce und bräuchten den Eintrag nicht. Sie stehen trotzdem drin, und im Code steht warum:
// 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.
Die Guard klassifiziert zu grob. Sie hält jede Seite ohne searchParams für vorgerendert, und weil sie fail-closed ist, fällt sie rot, bis der Eintrag da ist. Also steht der Eintrag da. Sechs überflüssige Zeilen in einem Set, das dadurch etwas erlaubt, was ohnehin nicht vorkommt.
Das ist der Preis von fail-closed, und es ist der richtige Preis. Die Guard zu schärfen ist Arbeit an einer anderen Baustelle. Das im Kommentar einzugestehen, kostet drei Sätze und verhindert, dass in einem halben Jahr jemand die sechs Zeilen für eine bewusste Sicherheitsentscheidung hält. Ein Guard mit falschen Positiven ist teuer. Ein Guard mit falschen Negativen kostet eine weiße Seite in Produktion. Diese Rechnung geht in eine Richtung auf.
Was ich daraus mitnehme
Die eigentliche Entscheidung war nie, ob sich vier der fünf Orte ableiten lassen, sondern welche Tabelle die Quelle wird. Sobald das feststeht, ist der Rest kein Pflegeaufwand mehr, sondern eine Ableitung, die entweder stimmt oder rot wird.
Bei den Tests hat mich vor allem die Richtung Zeit gekostet. Ein Parity-Test gegen eine Literal-Whitelist prüft nur, ob zu jedem Eintrag noch eine Datei existiert, und das ist die Richtung, die selten bricht. Der Fehler, der wirklich passiert, ist die Datei ohne Eintrag, und genau die sieht so ein Test nie. Umgekehrt darf ein fail-closed roter Test falsche Positive erzeugen, so wie die sechs überzähligen Zeilen weiter oben. Das ist eine bewusste Rechnung und kein Versehen, sie muss nur aufgeschrieben sein, sonst liest sie später jemand als Absicht.
Der unauffälligste Punkt ist der, der mich am ehesten wieder einholt: Import-Disziplin ist eine Eigenschaft der Laufzeit, nicht des Geschmacks. Wer nicht danebenschreibt, warum ein Modul einen bestimmten Import nicht verträgt, entspannt das beim nächsten Aufräumen und wundert sich dann über einen Edge-Build. Injizieren statt importieren ist dafür der billigste Weg, ein Modul laufzeit-neutral zu halten: ein Parameter mehr, eine Abhängigkeit weniger.
Fazit
Die Host-Seiten für Final Cut Pro, Premiere Pro und DaVinci Resolve sind je zwei Dateien und eine Zeile in einer Tabelle. Alles andere fällt daraus heraus, und zwei Guards halten die Bijektion in beide Richtungen. Sechs Einträge stehen zu viel drin, das ist im Code eingestanden und offen. Wie das Produkt hinter diesen Seiten funktioniert, steht in Ein Agent schneidet meine Talks. Wie ich dieselbe Website von Agenten auditieren lasse, habe ich ebenfalls aufgeschrieben. Und wie ich generell arbeite, steht auf der Arbeitsweise-Sektion.