Spring til indhold
DAagentic-workflowengineering-judgment

Gaten er dig, del 2: produktionen stemte imod

Vi shippede det 'rigtige' fix og jeg skrev et blogindlæg om at fange AI'en. Næste morgen var produktionen stadig i stykker — og rod-årsagen var myte nummer tre.

Scoret for

I går skrev jeg et indlæg om at fange AI'en i en forkert diagnose. Tre gates, en whoami-test, kildekode-research — og til sidst den "rigtige" rod-årsag: Next.js stoler by default ikke på Host-headeren. Jeg publicerede indlægget om aftenen, vi committede fixet og deployede.

Kl. 22:07: "yes commit push and deploy". Kl. 05:22 næste morgen: "where are we?"

Produktionen redirectede stadig til https://host:3000/en.

Kaninhullet, dag 2

Der er en særlig følelse ved at se sit eget verificerede, kildekode-begrundede, blog-publicerede fix fejle i produktion. Fixet var ellers ikke gætværk: vi havde læst linjen i Next.js' kildekode, runtime-testet hjælperen mod den rigtige NextRequest-constructor, fået grønt fra tsc og eslint. Alt det holdt stadig. Det var bare svaret på det forkerte spørgsmål.

Formiddagen gik med mere graveri i samme session — og det var her, jeg begyndte at mistænke noget andet end koden:

human promptprompt
6 lines2026-07-03 07:57
I am abit conserned here that we really dont undestand nextjs and intl stuff here. can we make a small reproduction where we setup a minimal newest version nextjs and we can show the diffrent cases to make sure we understand. i dont get why experimental.trustHostHeader did not solve the problem as expected? I am wondering if we have mudded our context and we should do a prompt for a fresh agent/session to reporudce it, so can you generate the prompt for me that we want a second agent to run and reproduce our hyposisis.

"Have we mudded our context" er den centrale sætning. Efter halvandet døgn i samme samtale var konteksten fuld af halvsande konklusioner: den modbeviste ACA-teori, den byggede-og-forkastede middleware, den fundne-men-utestede trustHostHeader-linje. Hver ny tur i samtalen stod ovenpå det hele. AI'en ræsonnerede ikke længere frit — den ræsonnerede inde i vores fælles fejlhistorik. Og det gør jeg også selv, når jeg har stirret på det samme problem længe nok.

Gaten denne gang var ikke et spørgsmål til AI'en. Det var at smide konteksten væk.

Clean-room-agenten

I stedet for at fixe videre bad jeg den muddede session om én sidste ting: skriv prompten til en frisk agent, der skal efterprøve alt hvad vi tror vi ved. Resultatet blev en investigator-brief med falsificerbare hypoteser — her er åbningen og de fem påstande (hele briefen ligger som artifact):

human promptprompt
29 lines2026-07-03 11:56
You are a clean-room investigator. Do NOT trust any prior analysis — I will give you a hypothesis to confirm or refute with your own empirical evidence. If you cannot reproduce a claim, say so. Every conclusion must be backed by a command output or a quoted line of source code you actually read, not by reasoning alone. ## Background (the real-world symptom) A Next.js app that uses next-intl for i18n routing is deployed as an output:'standalone' container behind Azure Container Apps (ACA). The container listens on port 3000; ACA's ingress terminates TLS on 443 and reverse-proxies to it. In production, GET / returns a 307 redirect whose Location is: https://<public-host>:3000/en ← the internal port :3000 leaks into the public URL ## Hypothesis to test (confirm or refute each point independently) 1. next-intl builds the locale-redirect's PORT solely from the x-forwarded-port request header (not from Host, not from request.url). 2. In an output:'standalone' server, Next.js BACKFILLS x-forwarded-port with its own listen port (3000) using nullish-assignment (??=) — it only fills the header when the proxy did NOT send one. Proxy sends x-forwarded-port → Next respects it; proxy omits it → Next injects 3000. 3. ACA's ingress sends X-Forwarded-Proto but does NOT send X-Forwarded-Port (nor X-Forwarded-Host). That omission is what triggers the leak. 4. experimental.trustHostHeader does NOT fix this in current Next.js standalone — its code branch is effectively dead when the standalone server sets HOSTNAME+PORT. (This is the point I am most unsure about — investigate it hardest and tell me the truth.) 5. The correct fix is to normalize x-forwarded-port in middleware (proxy.ts) from the trusted x-forwarded-proto (https→443, http→80) — the Next equivalent of ASP.NET Core's UseForwardedHeaders.
See what Claude did next

Læg mærke til formen. Det er ikke "fix min bug". Det er fem nummererede, falsificerbare påstande, et krav om at hver konklusion skal bakkes op af kommando-output eller citeret kildekode, og en eksplicit invitation: "Be skeptical. If any hypothesis is wrong, I specifically want to know — that's the point." Briefen indeholdt endda de gotchas, den gamle session havde betalt for at lære (Turbopack + symlinket node_modules), så den nye agent ikke skulle betale igen.

Så kørte en frisk session med tom kontekst briefen som en videnskabelig protokol: minimal reproduktion fra bunden, fire curl-cases, kildekode-citater, versionsmatrix.

Verdiktet: myte nummer tre

Den nye agent kom tilbage med noget, ingen af os havde på listen. Lækagen er versions-bundet til next-intl 4.10.1–4.11.1. Introduceret i PR #2281 ("Set redirect domain if x-forwarded-host header exists"), reverteret i PR #2322. Vores deployede app pinnede next-intl 4.11.0 — midt i vinduet. Nyeste stack (next 16.2.10 + next-intl 4.13.1) lækker slet ikke; dér kommer Location ud relativ (/en), så en port kan aldrig optræde.

Case-matrixen fra reproduktionen, på præcis den kombination vi havde i produktion (verbatim fra clean-room-rapporten):

┌─────┬───────────────────┬───────────┬──────────┬─────────────────────────────────────────────────────┐
│  #  │       Host        │ X-F-Proto │ X-F-Port │                  observed Location                  │
├─────┼───────────────────┼───────────┼──────────┼─────────────────────────────────────────────────────┤
│ A   │ (direct)          │ —         │ —        │ http://127.0.0.1:3201/en                            │
│ B   │ public.example.io │ https     │ — (ACA)  │ https://public.example.io:3201/en ← leak reproduced │
│ C   │ public.example.io │ https     │ 443      │ https://public.example.io/en                        │
│ D   │ public.example.io │ https     │ 3000     │ https://public.example.io:3000/en                   │
└─────┴───────────────────┴───────────┴──────────┴─────────────────────────────────────────────────────┘

Og så de to ydmygende detaljer.

Ét: experimental.trustHostHeader — gårsdagens publicerede rod-årsag — blev refuteret empirisk. Next 16's build afviser nøglen ("Unrecognized key") og bager false ind i standalone-outputtet. Agenten prøvede endda at force-editere true ind i den genererede server.js og required-server-files.json — lækagen fortsatte. Den knap, jeg var så tilfreds med at have fundet i kildekoden, kan ikke engang slås til.

To: gårsdagens whoami-test var korrekt — og konklusionen vi trak af den var alligevel forkert. whoami beviste, at ACA ikke sender x-forwarded-host til containeren. Men Next.js' standalone-server backfiller selv x-forwarded-host og x-forwarded-port internt, før middleware ser requesten. Én debug-linje fra reproduktionen fortæller hele historien — curl sendte hverken forwarded host eller port, men middleware så begge:

DEBUG_MW {"requestUrl":"https://0.0.0.0:3201/","host":"public.example.io","xfHost":"public.example.io",
          "xfProto":"https","xfPort":"3201","location":"https://public.example.io:3201/en"}

Vi havde målt det rigtige lag og alligevel konkluderet om det forkerte. next-intl 4.11.0 så altid en x-forwarded-host (Next's egen backfill), tog derfor den unconditional forwarded-branch, og satte porten fra den backfillede x-forwarded-port = lytteporten. Derfor virkede gårsdagens normalizeForwardedUrl heller ikke: den omskrev request.url, men den version af next-intl læser headerne, ikke URL'en.

Det verificerede fix: normalisér x-forwarded-port-headeren i proxyen (https → 443, dvs. tom port) før next-intl kører — B, C og D giver alle ren https://public.example.io/en, og POST-body overlever den genskabte request. Eller endnu enklere: opgradér next-intl til ≥ 4.11.2. Nogle gange er den rigtige løsning på ugens kaninhul npm update.

Hvem vandt, runde 2?

I går sluttede jeg med et regnskab: jeg fangede AI'en, AI'en fandt den rigtige linje, Anthropic fik tokens for begge dele. Det regnskab skal revideres. Den "rigtige linje" var myte nummer tre, og min egen sejrsfølelse — publiceret på denne blog samme aften — var en del af muddet.

Læringerne, som jeg ser dem nu:

Konteksten er en del af systemet. Efter mange nok timer i samme samtale er sessionens hukommelse selv blevet teknisk gæld: lag på lag af næsten-rigtige konklusioner, som hver ny tanke bygger ovenpå. Gate-instinktet fra del 1 — "det her lugter" — gælder også for samtalen selv. Når du ikke længere kan skelne hvad I ved fra hvad I har sagt mange gange, er det billigste træk en frisk kontekst.

Falsificerbare hypoteser er overdragelsesformatet. Det, der gjorde clean-room-kørslen værdifuld, var ikke bare den tomme kontekst — det var at den muddede session blev tvunget til at destillere halvandet døgns rod ned til fem påstande, der kunne være forkerte. Skrivearbejdet er debuggingen. Og bed eksplicit om at få at vide, hvis en hypotese falder; ellers får du bekræftelse i stedet for undersøgelse.

"Verificeret" har lag. whoami-testen var god empiri og er stadig sand. Men mellem "hvad proxyen sender" og "hvad biblioteket læser" sad en server, der digter headers til. Hver gang du verificerer, så spørg: målte jeg det lag, konklusionen handler om?

Og så regnskabet: ingeniøren fangede AI'en (del 1). Produktionen fangede ingeniøren (del 2, nat). Clean-room-agenten fangede dem begge (del 2, dag). Anthropic fakturerede alle tre runder — denne gang inklusive en ultracode-kørsel. Jeg begynder at ane et mønster i, hvem der vinder.

Men her er det, jeg faktisk køber for pengene: en fejlproces, der konvergerer. Hver forkert teori blev dyrere at opretholde og billigere at vælte, fordi hvert lag efterlod beviser — en whoami-dump, en versionsmatrix, en debug-linje. Det er ikke ufejlbarlighed. Det er videnskab i småportioner, betalt per token. Og modsat i går tør jeg ikke love, at der ikke kommer en del 3.