feat(deploy): EYT-126 — Web und API als OCI-Workloads, Container-Smoke an den Head gebunden - #89
Merged
Merged
Conversation
Confluence 30998530 stellt das Staging auf VPS + Coolify + Docker um. Damit brauchen beide Workloads eine Buildbeschreibung im Repository; bisher war der Deploymentpfad ausschliesslich ueber Dashboard-Variablen konfiguriert. Beide Dockerfiles bauen aus dem Wurzelverzeichnis mit `pnpm install --frozen-lockfile` und `corepack`, laufen als `node` statt als root und tragen den Commit als OCI-Label `org.opencontainers.image.revision`. Der Outbox-Worker benutzt dasselbe API-Image und ueberschreibt nur das Kommando. In `docker-compose.yml` veroeffentlicht nur `web` einen Port; die API haengt am internen Netz, damit der Browser genau eine Origin sieht. `EASYTREE_API_PROXY_TARGET` ist dabei BAUZEIT-Konfiguration, und das ist jetzt gemessen statt offen: gebaut mit `http://buildtime-marker.invalid:9999` und gestartet mit `EASYTREE_API_PROXY_TARGET=http://127.0.0.1:3999` antwortet `/health` mit HTTP 500 und protokolliert `ENOTFOUND buildtime-marker.invalid`. `next start` liest die Weiterleitungen aus dem gebauten routes-manifest.json. Der Kommentar in `lib/api-proxy-target.ts` nannte das bisher ausdruecklich ungeprueft. `output: "standalone"` ist ein Schalter und keine Konstante: der Cloudflare-Bundlebau in `build-web` erwartet die Standardausgabe, und sein Abbau ist EYT-149. `resolveNextOutput` ist fail-closed — ein unbekannter Wert wirft, statt still auf die Standardausgabe zurueckzufallen. Gegenmutation ausgefuehrt: `throw` durch `return {}` ersetzt macht `test/next-output.test.ts` rot ("expected function to throw an error, but it didn't"), Ruecknahme-Diff leer.
Ein Image, das baut, ist noch kein Image, das laeuft. Der Smoke startet beide Container als zusammenhaengende Topologie und prueft, was ein Deploy spaeter voraussetzt: OCI-Label gegen den Head, nicht-privilegierter Benutzer, `/health`, `/ready` mit ECHTER Datenbank, den Weg Web -> API ausschliesslich ueber den internen Dienstnamen, dass derselbe Dienstname vom Host aus NICHT aufloesbar ist (sonst bewiese der vorige Punkt nichts), dass das ausgelieferte HTML die interne Adresse nicht nennt, geheimnisfreie Protokolle und geordnetes Herunterfahren. Dazu die Gegenprobe, ohne die die Konfigurationspruefung auch dann gruen waere, wenn sie nichts prueft: mit `NODE_ENV=production` und ohne `DATABASE_SSL_ROOT_CERT` MUSS der API-Container den Start verweigern und die Variable benennen. Der Smoke haengt am Ende von `db-gates` und bekommt bewusst keinen eigenen Job — ein neuer Job waere kein Pflichtcheck, bis jemand mit Adminrechten das Ruleset neu anwendet, und beide Kopien der Pflichtcheck-Liste muessten mitgezogen werden. Nur dort gibt es ausserdem eine echte PostgreSQL-Instanz mit der Rolle `easytree_app`. Er meldet `[container-smoke] mode=required executed=… passed=… skipped=0`; ein separater Schritt prueft die Zeile, weil ein weggeduckter Smoke sonst wie ein bestandener aussaehe. `pruefeContainerDeployAutoritaet` zieht die Regel gegen eine zweite Migrationsautoritaet auf Dockerfiles und Compose nach: Coolify und Railway duerfen so wenig Schemawerkzeuge aufrufen wie ein Cloudflare-Build. Kommentarzeilen bleiben aussen vor, sonst erzieht der Waechter zum Verschweigen. Lokal gemessen (arm64, colima, Postgres 17 mit easytree_app): `[container-smoke] mode=required executed=15 passed=15 skipped=0`. Drei Gegenmutationen ausgefuehrt und wieder zurueckgenommen: GIT_SHA=deadbeef… setzt das Label auf deadbeef… (Bindungspruefung waere rot); `USER node` entfernt ergibt `user=''` (Nicht-root-Pruefung waere rot); Web-Image auf `http://127.0.0.1:3001` gebaut liefert ueber den Web-Container HTTP 500 statt der API-Antwort (Weiterleitungspruefung waere rot).
Das Runbook beschrieb zwei Cloudflare-Worker als Deployziel. Seit der Entscheidung vom 21.08.2026 (Confluence 30998530) ist das falsch, und ein falsches Runbook ist schlechter als keines — es wird beim ersten Deploy befolgt. Neu beschrieben: die beiden Images und ihre Startbefehle, die Bauzeitnatur von `EASYTREE_API_PROXY_TARGET` samt Messung, der Container-Smoke unter den Pflicht-Smokes, das Rollback ueber Imagetag und OCI-Revision, was in Coolify konkret angelegt wird und woran die Railway-Kompatibilitaet haengt. Unveraendert bleibt, was unveraendert gilt: das Gate aus §1. Die Messung vom 21.08.2026 ist eingetragen — `list_projects` zeigt kein EasyTree-Projekt, `list_branches` auf inypnrvpawvhgiyagxbd zeigt genau eine Branch, deren project_ref und parent_project_ref identisch sind. Es gibt also weiterhin keine Non-Production-Datengrenze, und jeder Schritt unterhalb von §1 bleibt gesperrt. Was nicht ausgefuehrt ist, steht weiterhin als "nicht gemessen" da: der Coolify-Abschnitt, das Rollback, der Railway-Deploy und das Testdaten-Bootstrapskript. Das Dokument behauptet keinen Vollzug.
Reviewer's GuideAdds first-class Docker/OCI support for the API and Web apps plus a container smoke test wired into CI, while enforcing that container-based deployments cannot run schema/migration tools and keeping deployment documentation aligned with a container-first strategy. Sequence diagram for CI container smoke test flowsequenceDiagram
participant GH as GitHubActions_db-gates_job
participant Smoke as scripts/smoke-container.sh
participant Docker as Docker_Daemon
participant API as ApiContainer
participant Web as WebContainer
participant DB as PostgreSQL_easytree_app
participant SA as Supabase_Auth
GH->>Smoke: run smoke-container.sh\n(EASYTREE_CONTAINER_SMOKE=required, GIT_SHA)
Smoke->>Docker: docker build apps/api/Dockerfile\n--build-arg GIT_SHA
Smoke->>Docker: docker build apps/web/Dockerfile\n--build-arg GIT_SHA\n--build-arg EASYTREE_API_PROXY_TARGET
Smoke->>Docker: docker inspect easytree-api:smoke\n(check OCI revision, user=node)
Smoke->>Docker: docker inspect easytree-web:smoke
Smoke->>Docker: docker run ApiContainer\n(NODE_ENV=test, DATABASE_URL,...)
Docker-->>API: start dist/main.js
API->>DB: connect using DATABASE_URL
API->>DB: /ready health probe
Smoke->>API: HTTP GET /health
API-->>Smoke: 200 OK
Smoke->>API: HTTP GET /ready
API-->>Smoke: 200 OK {database:true}
Smoke->>Docker: docker run WebContainer\n(NODE_ENV=production)
Docker-->>Web: start apps/web/server.js
Web->>API: /health, /ready via\nEASYTREE_API_PROXY_TARGET
Smoke->>Web: HTTP GET /health
Web-->>Smoke: proxied API /health
Smoke->>Web: HTTP GET /ready
Web-->>Smoke: 200 OK
Smoke->>Web: HTTP GET /
Web-->>Smoke: HTML (no internal address)
Smoke->>Docker: docker logs ApiContainer, WebContainer
Docker-->>Smoke: logs (checked for secrets)
Smoke->>Docker: docker run --rm ApiContainer\n(NODE_ENV=production, no DATABASE_SSL_ROOT_CERT)
Docker-->>Smoke: non-zero exit, error\nmentions DATABASE_SSL_ROOT_CERT
Smoke->>Docker: docker stop WebContainer
Docker-->>Smoke: exit code 0 or 143
Smoke->>Docker: docker stop ApiContainer
Docker-->>Smoke: exit code 0
Smoke-->>GH: [container-smoke] mode=required\nexecuted=… passed=… skipped=0
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
…absichern Der Bericht vom 20.08.2026 zitiert EYT-142 mit "primaerem Cloudflare-Staging". Seit dem 21.08.2026 ist Cloudflare kein Zielruntime mehr — und genau daraus laesst sich der falsche Schluss ziehen, mit der Plattform sei auch der Blocker erledigt. Ist er nicht. Die fehlende Voraussetzung ist keine Eigenschaft der Hostingplattform, sondern der Datengrenze. Der Nachtrag sagt das und traegt die Wiederholungsmessung vom 21.08.2026 ein: list_projects zeigt weiterhin kein EasyTree-Projekt, list_branches auf inypnrvpawvhgiyagxbd weiterhin genau eine Branch, deren project_ref und parent_project_ref identisch sind. Der Rest des Dokuments bleibt unangetastet — es ist ein datierter Messbericht, kein lebendes Dokument.
…ngsreif machen Der PRIL-Scope-Gate meldet PRIL_POLICY_VIOLATION (exit 3). Die Ursache ist keine Nachlaessigkeit im Slice, sondern eine ausdrueckliche Festlegung: Revision 1 des Sprint-6-Manifests schliesst "Infrastruktur-, API- und supabase-Pfade fuer Slice 0/3 bewusst" aus. Ein Dockerfile kann diese Regel nicht einhalten, ohne aufzuhoeren ein Dockerfile zu sein. Gemessen und getrennt, damit die Entscheidung auf Zahlen steht statt auf einer Fehlermeldung mit 102 Dateien: * Oberflaeche A (bereits auf origin/master, gegen die armierte Base f3b427d): 82 Dateien, davon 28 ausserhalb der Scope. Die Base liegt 78 Commits zurueck, der Gate war also VOR diesem Slice bereits rot. * Oberflaeche B (nur dieser Slice): 16 Dateien, davon 11 ausserhalb — genau die elf Deploymentpfade. Das Manifest wird von hier aus NICHT geaendert, obwohl docs/scope/** in der Governance-Scope steht. Die Bindung unter .plumbline/scope-authority/ ist scharf (Digest und shasum stimmen ueberein, gemessen), und jede der vier bestehenden Revisionen traegt den PO namentlich mit confirmed:true. Eine fuenfte zu schreiben, ohne dass die Entscheidung gefallen ist, waere eine gefaelschte Freigabe, keine Dokumentation. Das Dokument enthaelt deshalb den fertigen Revisionsblock mit confirmed:false und offenem decision_maker, die Alternative "EYT-126 eigenstaendig armieren", und den Hinweis, dass der Gate ein lokaler Stop-Hook ist und nicht in ci.yml steht — die 11 Pflichtchecks sind gruen, PR #89 ist mergeState=CLEAN.
…e nachziehen PO-Entscheidung vom 21.08.2026: Weg 1 aus docs/plans/2026-08-21-eyt-126-scope-autoritaet.md. Revision 5 nimmt die Deploymentpfade auf, die Revision 1 ausdruecklich ausgeschlossen hatte — der Containerbau existiert ausschliesslich, um das PENDING-Gate "deploy" desselben Features passierbar zu machen. Die Digest-Konvention wurde vorher gegen ALLE VIER bestehenden Revisionen verifiziert statt erraten: sha256(JSON.stringify(scope, Object.keys(scope).sort())) — vier von vier stimmen. Revision 5 rechnet nach derselben Regel, und der Top-Level-scope ist identisch mit dem der letzten Revision. Abweichung vom freigegebenen Entwurf, laut benannt statt still: zusaetzlich ist docs/architecture/** aufgenommen. Grund ist Konsistenz — sieben andere docs/**-Pfade stehen bereits in der Governance-Scope, docs/architecture/ traegt ADR-001/002/003. Folge: damit faellt auch die vorbestehende, UNGETRACKTE Datei "docs/architecture/EasyTree – Softwaredokumentation.md" in die Scope; sie stammt nicht aus diesem Slice. supabase/** bleibt ausgeschlossen. Zweitens die Base. docs/context/.scope-base stand auf f3b427d und lag 78 Commits hinter origin/master; der Gate meldete deshalb dauerhaft 21 bereits gemergte, bereits reviewte Dateien mit. Das Plandokument hatte das Verschieben zunaechst abgelehnt, weil es die gepruefte Oberflaeche verkleinert und ausgerechnet der Slice davon profitiert haette. Dieser Einwand ist mit Revision 5 gegenstandslos: gemessen ist der Befund dieses Slice gegen BEIDE Basen null. Die Ruecknahme steht im Dokument, nicht nur im Ergebnis. Gate danach: "PRIL scope check passed … (32 changed files)", Exit 0. Nicht versioniert: .plumbline/ steht in .gitignore, die neu gezogene Bindung auf den Manifest-Hash d5b483614d8e81c7… existiert nur lokal.
…he Annahme zuruecknehmen Nach PO-Freigabe wurde versucht, ein zweites Projekt easytree-staging anzulegen (get_cost fuer diese Org: 0 $/Monat). Der Aufruf wurde abgelehnt: BadRequestException: The following organization members have reached their maximum limits for the number of active free projects within organizations where they are an administrator or owner: DYAI2025 (2 project limit). Korrektur: die erste Lesart fuehrte das pausierte Projekt "Bazodiac" (status INACTIVE) als einen der beiden belegten Slots. Das ist falsch. Die Supabase-Doku sagt an zwei Stellen woertlich das Gegenteil — Billing FAQ: "Paused projects do not count towards your quota"; About billing: "Paused projects do not count towards your free project limit". Bazodiac zu loeschen oder zu pausieren ist damit gegenstandslos, und das steht jetzt so da, statt als stille Streichung. Die fehlende Voraussetzung ist damit zweistufig: (1) es gibt keine EasyTree-Datengrenze mit abweichendem project_ref, unveraendert; (2) der KOSTENLOSE Weg dorthin ist versperrt, weil die Free-Projekt-Quota pro NUTZER zaehlt und DYAI2025 sie mit zwei aktiven Projekten ausgeschoepft hat — eines ist die Produktion, das zweite ist ueber diesen Zugang nicht sichtbar. Punkt 2 ist belegt und nicht vermutet: list_projects liefert reproduzierbar genau ein Projekt und enthaelt inypnrvpawvhgiyagxbd NICHT, obwohl get_project darauf antwortet; list_organizations enthaelt EASTree nicht, obwohl get_organization darauf antwortet. Die Aufzaehlung ist nachweislich unvollstaendig, deshalb steht im Bericht ausdruecklich KEINE daraus abgeleitete Projektzaehlung. Der abgelehnte Aufruf belegt nebenbei, dass die Autorisierung durchlaeuft: die Antwort war ein Quota-Fehler, kein 403. Der Vorbehalt dazu ist notiert. Kein Schreibzugriff hat stattgefunden. Der abgelehnte create_project-Aufruf hat nichts angelegt, alles Uebrige war read-only, Produktionsdaten wurden nicht beruehrt.
…eit-Proxy
Der PO hat den Sprint-6-Scope am 22.08.2026 um genau sechs Pfade erweitert:
apps/web/app/{api,health,ready}/**, apps/web/instrumentation.ts,
apps/api/test/architecture/secret-surface-rules.ts und
apps/api/test/container-bindung.test.ts. Keine weitere Erweiterung.
Der Grund ist gemessen, nicht bevorzugt: der Same-Origin-Proxy muss aus
next.config.ts-rewrites() heraus, weil Next das Ergebnis beim Bauen nach
routes-manifest.json schreibt und ein fertiges Image damit an sein Ziel
gebunden waere. Eine Next-16-proxy.ts scheidet zweifach aus — sie sendet
x-middleware-rewrite mit der internen Adresse an den Browser, und
@opennextjs/cloudflare bricht mit "Node.js middleware is not currently
supported" ab, was den Pflichtjob build-web rot machte. Uebrig bleiben Route
Handler unter apps/web/app/, wo Revision 5 nur planung/** fuehrte.
Nichts weiter war noetig: apps/web/lib/** und apps/web/test/** stehen seit
Revision 1 als Platzhalter in der Scope, die Container- und CI-Pfade kamen mit
Revision 5.
Die Digest-Konvention wurde vor dem Schreiben gegen alle fuenf bestehenden
Revisionen nachgerechnet (5/5), nicht aus dem Dokument uebernommen, das sie
behauptet. Revisionen 1-5 sind unveraendert, kein Pfad ging verloren. Die
Bindung unter .plumbline/scope-authority/ wurde neu gezogen
(d5b483614d8e81c7... -> 2741c0ec0935d01d...), geprueft gegen shasum -a 256.
Im Nachtrag zusaetzlich festgehalten, in diesem Lauf gemessen: .plumbline/ ist
per .gitignore:35 ausgeschlossen, der Feature-Marker docs/context/.active-feature
dagegen versioniert. Fehlt die Bindung — etwa in einem frischen Worktree —
meldet der Gate trotzdem "passed" und nicht SCOPE_AUTHORITY_CHANGED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…acken
Der Same-Origin-Proxy bekommt eine eigene Durchreiche, die
EASYTREE_API_PROXY_TARGET bei JEDER Anfrage neu aus der Umgebung liest.
Bisher loeste next.config.ts-rewrites() den Wert beim Bauen auf und schrieb
ihn nach .next/routes-manifest.json — ein fertiges Web-Image war damit an sein
Ziel gebunden, was Confluence 30998530 (dieselben Images fuer Coolify/VPS und
Railway) nicht erfuellt.
Die Datei schliesst zugleich drei Wege, auf denen die interne API-Adresse den
Browser erreichen koennte:
* kein x-middleware-rewrite — anders als bei einer Next-16-proxy.ts baut ein
Route Handler die Antwort selbst und bestimmt jeden Kopf;
* ein absoluter location-Kopf auf das konfigurierte Ziel wird in einen
relativen Pfad uebersetzt, Pfad, Query und Fragment bleiben erhalten. Ein
EXTERNES Weiterleitungsziel bleibt unveraendert;
* ein Verbindungsfehler wird zu einem eigenen 502, statt die undici-Meldung
(sie nennt den Zielhost) durch Nexts Fehlerseite laufen zu lassen.
Gegenmutation ausgefuehrt und zurueckgenommen: friert man das Ziel beim
Modulladen in einer Modulkonstante ein, faellt "liest das Ziel bei JEDER
Anfrage neu aus der Umgebung" mit
`AssertionError: expected [ Array(2) ] to deeply equal [ …(2) ]`, dazu sechs
Folgefaelle — darunter die drei Weiterleitungsfaelle, weil ein eingefrorenes
Ziel den location-Vergleich gegen die falsche Origin fuehrt und die interne
Adresse dann leckt. Nach der Ruecknahme ist die Datei hashgleich
(sha256:ddfcba34…) und die Suite wieder 17/17 gruen.
Drei Route Handler ersetzen den Bauzeit-Rewrite: /api/[[...pfad]] als optionaler Catch-all (damit auch /api selbst nicht als 404 aus der Web-App zurueckkommt), /health und /ready lesend. Alle drei zeigen auf dieselbe Durchreiche und setzen `dynamic = "force-dynamic"` — ohne das koennte eine Antwort aus dem Cache kommen und das alte Ziel weiterreichen. Der Test prueft die Routen selbst und nicht nur die Durchreiche: eine fehlende Route oder eine nicht exportierte Methode waere im Browser ein 404 auf /ready statt eines Fehlers, und ein Test auf die Durchreiche allein bliebe dabei gruen.
…oxyziel register() laeuft einmal beim Hochfahren des Next-Servers. Wirft es, meldet Next "Failed to prepare server" und beantwortet danach JEDE Route mit 500 — auch / und /anmelden. Ohne diese Sperre antworteten nur /api, /health und /ready mit einem Fehler; die Anwendung selbst saehe gesund aus und zeigte im Browser eine leere Woche. Gemessen 22.08.2026 auf Next 16.2.11, sowohl im Container als auch mit `next start`: ohne Wert /=500, /anmelden=500, /health=500 und im Protokoll "EASYTREE_API_PROXY_TARGET ist in production Pflicht. Es gibt hier bewusst keinen Default."; mit ftp:-Ziel dieselben Codes und "erlaubt nur http: oder https:, erhalten: \"ftp:\"". Ehrliche Grenze, die auch in CLAUDE.md und im Runbook so steht: der Prozess bleibt am Leben und haelt den Port. Er bedient aber keinen normalen Anwendungsverkehr, und der Compose-Healthcheck auf / schlaegt fehl. Die Behauptung "der Container startet nicht" waere falsch. Die Datei liest die Umgebung NICHT selbst, sondern ruft aktuellesProxyziel() auf — es soll genau eine Laufzeitstelle geben, nicht zwei.
…tivliste Der Geheimnis-Waechter meldete vor dieser Zeile genau EINEN Fund: "apps/web/lib/proxy-durchreichen.ts:63 [runtime-reads-no-environment]". Dass es genau einer war und nicht zwei, ist die eigentliche Aussage — instrumentation.ts liest die Umgebung nicht selbst. Gegenmutation ausgefuehrt und zurueckgenommen: liest instrumentation.ts versuchsweise selbst per normalizeProxyTarget(process.env…), meldet der Waechter "apps/web/instrumentation.ts:24 [runtime-reads-no-environment]" und geht rot. Die Positivliste gibt also nicht pauschal apps/web frei. Nach der Ruecknahme ist die Datei hashgleich und der Waechter wieder 39/39 gruen.
…ad entfernen
next.config.ts traegt keinen rewrites()-Block mehr. Damit verlieren
resolveBuildProxyTarget, CLOUDFLARE_API_FALLBACK_TARGET und ProxyBuildEnvironment
ihren einzigen Aufrufer; toter Code mit gruenen Tests behauptet eine Zusage,
die niemand mehr einloest, also gehen sie samt ihrem describe-Block mit.
turbo.json braucht EASYTREE_API_PROXY_TARGET, WORKERS_CI und CF_PAGES nicht
mehr im Cache-Schluessel der build-Aufgabe.
Zwei Aussagen, die dieser Commit ueberholt, jeweils an ihrer Stelle korrigiert
statt stehengelassen: der Dateikopf von api-proxy-target.ts nannte den Wert
Bauzeitkonfiguration, und next-output.ts nannte ihn als gleichartig zu seinem
eigenen Bauzeitschalter.
Gemessen 22.08.2026:
* `env -u EASYTREE_API_PROXY_TARGET pnpm --filter @easytree/web... build` →
exit 0, Routentabelle mit `ƒ /api/[[...pfad]]`, `ƒ /health`, `ƒ /ready`,
keine Zeile `ƒ Proxy (Middleware)`. Vor dieser Aenderung brach derselbe
Befehl mit "EASYTREE_API_PROXY_TARGET ist in production Pflicht" ab.
* `env -u EASYTREE_API_PROXY_TARGET pnpm build` (Wurzel, ueber Turbo) →
exit 0, `Tasks: 6 successful`, `Cached: 0 cached, 6 total`. Der in
CLAUDE.md dokumentierte Wurzelbau-Bruch ist damit weg, nicht bloss
umgangen.
* `pnpm --filter @easytree/web run build:cf` → "Worker saved in
.open-next/worker.js" / "OpenNext build complete."; cf:dry-run meldet
gzip 1184.28 KiB gegen das 3072-KiB-Limit.
* Web-Suite 294 Faelle (273 + 25 neue - 4 entfernte).
…wingen
apps/web/Dockerfile traegt kein `ARG EASYTREE_API_PROXY_TARGET` mehr. Das
Image ist damit an kein Ziel und an keinen Anbieter gebunden; dieselbe Datei
und derselbe Startpfad liefern es fuer Coolify/VPS wie fuer Railway.
GIT_SHA verliert in BEIDEN Dockerfiles seinen Vorgabewert und wird geprueft.
Ein leeres ARG faellt sonst still auf ein Label ohne Inhalt zurueck, und
"das Image ist gruen" waere eine Aussage ueber irgendein Image.
Gegenprobe ausgefuehrt (22.08.2026, colima):
* `docker build -f apps/web/Dockerfile .` ohne --build-arg → exit 1 mit
"GIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse HEAD)."
* dasselbe fuer apps/api/Dockerfile → exit 1, gleiche Zeile.
* mit --build-arg GIT_SHA=<head> gebaut, danach
`docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}'`
→ exakt der uebergebene Head.
Zwei fail-open-Stellen geschlossen. `:?` statt `:-`, und das Proxyziel wandert
vom Build-Arg in die Laufzeitumgebung des Web-Dienstes. Ein Deploy ohne
Herkunft und ein geratenes Proxyziel waren bis hierher ueber genau diesen Pfad
erzeugbar; ein geratenes Ziel sieht im Browser aus wie eine leere Woche, nicht
wie ein Fehler.
Gemessen 22.08.2026, alle drei Faelle:
* ohne GIT_SHA → exit 1, "error while interpolating
services.api.build.args.GIT_SHA: required variable GIT_SHA is missing a
value: GIT_SHA fehlt - baue mit GIT_SHA=$(git rev-parse HEAD)"
* mit GIT_SHA, ohne Ziel → exit 1, "error while interpolating
services.web.environment.EASYTREE_API_PROXY_TARGET: required variable
EASYTREE_API_PROXY_TARGET is missing a value: …"
* mit beidem → exit 0, und `docker compose config` zeigt GIT_SHA am Head
sowie EASYTREE_API_PROXY_TARGET unter web.environment. Ohne diese dritte,
positive Probe waeren die ersten beiden nicht von "kaputte Datei" zu
unterscheiden.
…ausschliessen
Die Handproben aus den beiden vorigen Commits belegen dasselbe schaerfer,
laufen aber nie wieder, wenn sie niemand wiederholt. Dieser Waechter braucht
kein Docker und laeuft im Pflichtjob unit-tests mit.
Der Bauabschnitt des Web-Dienstes wird eng geschnitten und nicht die ganze
Datei durchsucht: der Kopfkommentar NENNT die Variable (er erklaert, warum sie
keinen Vorgabewert hat), eine dateiweite Suche waere daran haengengeblieben.
Drei Eingangsbremsen halten den Test davon ab, vakuos gruen zu werden — eine
Mindestlaenge je Datei, ein Schnitt-Check auf den Bauabschnitt und die
Gegenrichtung "das Ziel steht in der Laufzeitumgebung".
Gegenmutation ausgefuehrt und zurueckgenommen, in beiden Haelften:
* `${GIT_SHA:?…}` → `${GIT_SHA:-unknown}` in docker-compose.yml → rot in
"gibt GIT_SHA keinen Vorgabewert" mit
`AssertionError: expected '# easyTree — Container-Topologie fuer…' not to
match /\$\{GIT_SHA:-/`
* die RUN-Pruefung in apps/web/Dockerfile → `ARG GIT_SHA=unknown` → rot in
"apps/web/Dockerfile erzwingt GIT_SHA statt es vorzubelegen"
Beide Dateien sind nach der Ruecknahme hashgleich
(d7fcb173… bzw. 65df23db…), der Waechter wieder 9/9 gruen.
…ontainer belegen
Der Kern der PO-Forderung: EIN Image-Digest, ZWEI Starts, ZWEI Ziele, KEIN
Neubau dazwischen. Der Digest zaehlt und nicht der Tag — ein Tag laesst sich
zwischen zwei Starts umhaengen, ein Digest nicht, und genau "kein Neubau
dazwischen" ist die Aussage. Zwei Stub-Container statt der echten API, weil
der Nachweis ueber die UNTERSCHEIDUNG laeuft; zwei echte APIs waeren nicht
unterscheidbar.
Neu ausserdem, weil x-middleware-rewrite zu vermeiden NICHT genuegt: derselbe
Stub liefert `Location: http://<interner-host>:3001/foo?x=1`. Diese Adresse
darf den Browser nicht erreichen, und ein EXTERNES Weiterleitungsziel darf
nicht spekulativ veraendert werden. Die Leckpruefung deckt jetzt HTML,
jeden referenzierten Client-Chunk, die Antwortkoepfe und den location-Kopf ab.
Zwei Reparaturen am Skript, die beim Ausfuehren aufgefallen sind:
* Die Namen der Ein-Image-zwei-Ziele-Probe stehen bei den Konstanten und
nicht erst in Abschnitt 8. Das Skript laeuft mit `set -Eeuo pipefail` und
`aufraeumen()` haengt am EXIT-Trap; waeren sie erst weiter unten gesetzt,
erzeugte ein Abbruch davor einen "unbound variable"-Folgefehler, der den
echten Grund verdeckt. Zusaetzlich liest das Cleanup jeden Namen ueber
`${VAR:-}`. Gemessen: ein Abbruch vor Abschnitt 8 (fehlende
EASYTREE_SMOKE_DATABASE_URL) meldet 0 Treffer "unbound variable".
* Acht `printf|grep -q`-Pruefungen auf `case`-Vergleiche umgestellt.
`grep -q` beendet sich beim ERSTEN Treffer, die schreibende Seite bekommt
EPIPE, und unter `pipefail` ist die Pipeline dann fehlgeschlagen — obwohl
grep gefunden hat. Gemessen an `docker logs … | grep -qF
EASYTREE_API_PROXY_TARGET`: die Pruefung ging rot, obwohl das Protokoll
die Variable nannte. Bei einer LECK-Pruefung ist dieselbe Mechanik die
gefaehrliche Richtung — das gefundene Leck saehe aus wie keines.
Lokal ausgefuehrt 22.08.2026 gegen colima und eine echte PostgreSQL-Instanz
(Rolle easytree_app, NOSUPERUSER/NOBYPASSRLS, damit das EYT-45-Startgate
greift):
[container-smoke] mode=local executed=24 passed=24 skipped=0
ziel=http://easytree-stub-a:3001 -> {"stub":"easytree-stub-a","pfad":"/health"}
ziel=http://easytree-stub-b:3001 -> {"stub":"easytree-stub-b","pfad":"/health"}
vorher=sha256:191a1af9bd22422634586cdb460bd832781e1e8f6fcf96528eddcdc4ab5d8a88
nachher=sha256:191a1af9bd22422634586cdb460bd832781e1e8f6fcf96528eddcdc4ab5d8a88
location=/foo?x=1
location=https://login.example.org/oauth?state=xyz
Ziel fehlt: / -> HTTP 500
Ziel ungueltig: / -> HTTP 500
10 Client-Chunks geprueft
Lokal ist nicht CI: der Nachweis in mode=required kommt aus db-gates.
Solange CI die Variable beim Bauen setzt, koennte niemand bemerken, dass der
Build sie nicht mehr braucht. Sie dort zu entfernen ist die staerkste
dauerhafte Gegenprobe: brauchte der Build sie noch, waere der Pflichtjob
build-web rot.
* build-web: der env-Block entfaellt ersatzlos.
* auth-journey: der Bauschritt setzt sie nicht mehr. Das Ziel kommt aus
apps/web/e2e/auth-journey/config.ts:136 in der webServer-Umgebung, also
zur Laufzeit — womit genau dieser Job zugleich belegt, dass Login,
POST-Koerper und mehrere HttpOnly-Set-Cookie durch die neue Durchreiche
gehen.
* read-through-harness.sh: die Bauzeile verliert sie; Zeile 109 (`next
start`) behaelt sie und ist ab jetzt die einzige Stelle, an der sie zaehlt.
* web-smoke: NICHT angefasst. Dort steht sie auf Job-Ebene, damit `next
start` sie erbt — seit dieser Aenderung braucht der Server sie zum
Hochfahren, nicht nur zum Weiterleiten. Der Kommentar sagt das jetzt, weil
ein Entfernen "als Bauzeit-Ueberbleibsel" den Job komplett faellen wuerde.
Beide Dokumente behaupteten wortreich das Gegenteil des neuen Verhaltens. Jede Fundstelle einzeln entschieden statt gesucht-und-ersetzt: Absolutaussagen ueberleben sonst in Zusammenfassungen, Tabellen und Fehlersuchspalten. CLAUDE.md: der BAUZEIT-Absatz im Deployment-Abschnitt, der rewrites()-Satz in "Web never talks to Supabase", die Container-Smoke-Aufzaehlung, und der Wurzelbau-Absatz. Letzterer wurde NACHGEMESSEN statt umformuliert: `env -u EASYTREE_API_PROXY_TARGET pnpm build` → exit 0, `Tasks: 6 successful`, `Cached: 0 cached, 6 total`. docs/runbooks/staging-deploy.md: §3 "Die achte Groesse", das Bauexempel in §5, die Zeile "Leere Woche statt Fehler" in der Fehlersuchtabelle, das Coolify-Build-Argument in §9, die Railway-Kompatibilitaet in §10 und der Stand in §11. Die frueheren Messungen werden nicht geloescht, sondern als das gekennzeichnet, was sie waren: fuer den rewrites()-Weg korrekt. Ebenso bleibt die ehrliche Grenze der Startsperre stehen — der Prozess haelt den Port, der Container gilt als unhealthy, aber er "startet" durchaus. Keine Staging-Evidenz: BLOCKER_ENVIRONMENT_SEPARATION bleibt unberuehrt, und ein CI-echter Stack belegt Funktion, nicht Bereitstellung.
… korrigieren
Beide beschreiben einen Mechanismus, den dieser Slice ersetzt hat, und beide
stehen an Stellen, die als Autoritaet gelesen werden:
* Der Grund fuer apps/web/next.config.ts in ERLAUBTE_UMGEBUNGSLESER nannte
EASYTREE_API_PROXY_TARGET. Die Datei liest die Variable nicht mehr; sie
liest EASYTREE_NEXT_OUTPUT. Der Eintrag bleibt noetig, seine Begruendung
war falsch — in einer Sicherheits-Positivliste ist das kein Schoenheits-
fehler.
* CLAUDE.md beschrieb den belegten Lesepfad als "same-origin Next rewrite".
Nicht angefasst und darum hier benannt statt stillschweigend gelassen: der
Ausdruck "Same-Origin-Rewrite" steht weiter als Wegbeschreibung in
apps/web/e2e/**, scripts/read-through-harness.sh, .github/workflows/ci.yml und
zwei Runbooks; und apps/web/app/providers.tsx, apps/web/open-next.config.ts
sowie apps/web/playwright.harness.config.ts verweisen weiter auf
"das Rewrite in next.config.ts". Diese drei liegen ausserhalb der
Scope-Revision 6 und werden hier nicht angefasst.
…rne Adresse Der Same-Origin-Proxy schloss bisher drei Lecks: `x-middleware-rewrite` entsteht gar nicht erst, ein `location` auf das interne Ziel wird relativ, und ein Verbindungsfehler wird ein eigener 502 ohne Hostnamen. Jeder UEBRIGE Antwortkopf wurde dagegen unveraendert weitergereicht. Damit blieb genau ein Weg nach draussen offen: `X-Upstream-Url: http://<intern>:3001/private` oder `Link: <http://<intern>:3001/foo>; rel="self"` erreichen den Browser, sind fuer same-origin-JavaScript ueber `response.headers` lesbar, und `Link` wertet der Browser sogar selbst aus. Der Riegel laesst solche Koepfe WEG statt sie umzuschreiben. Ein Kopf ohne festgelegte Bedeutung traegt keine Struktur, aus der sich eine Uebersetzung ableiten liesse; eine erfundene waere schlimmer als der Verlust. Erkannt wird das Ziel in drei Formen — volle Adresse, Autoritaet, nackter Dienstname —, schreibungsunabhaengig, und der Kopfname zaehlt mit. `location` bleibt unberuehrt: er hat eine Bedeutung und seine eigene Uebersetzung. Fremde Adressen bleiben stehen; ein leckendes `Set-Cookie` faellt einzeln weg, ohne die uebrigen zusammenzufalten. Gemessen, nicht behauptet: * Unit: sieben neue Faelle in `apps/web/test/proxy-durchreichen.test.ts`. Vor dem Riegel 5 rot / 19 gruen, danach 24/24. Die zwei Erhaltungsfaelle (harmloser Kopf, externe URL) waren schon vorher gruen — sie sichern, dass der Riegel nicht pauschal alles wegwirft. * Gegenmutation ausgefuehrt: den Riegel entfernt -> genau dieselben 5 Faelle wieder rot; zurueckgenommen, `shasum -a 256` gleich (3690b3cf…). * Container: `scripts/smoke-container.sh` Abschnitt 8c. Der Stub sendet den leckenden Kopf nachweislich (direkt aus dem Containernetz gelesen), hinter dem Proxy ist er fort, der harmlose `x-stub-marke` kommt durch. `[container-smoke] mode=local executed=26 passed=26 skipped=0`. * Gegenmutation im Container ausgefuehrt: ohne den Riegel faellt 8c mit exit 1 und zeigt die Koepfe woertlich — `x-upstream-url: http://easytree-stub-b:3001/private`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Der Riegel aus 18f5004 schloss das echte Leck, war aber zu breit gefasst. Er verglich Teilstrings gegen drei Formen — volle Adresse, Autoritaet UND nackter Dienstname — und zwar ueber Kopfname und Wert zusammen. Fuer die vorgesehene Containertopologie `http://api:3001` ist der Dienstname das Wort "api". Damit fiel jeder harmlose Kopf, in dem diese drei Buchstaben irgendwo vorkommen: X-Api-Version: 1 X-Documentation: https://api.example.org/public Set-Cookie: api_session=abc123; Path=/; HttpOnly Das ist kein Schutz, sondern ein stiller Ausfall — und es verletzt die Zusage, dass legitime Koepfe unveraendert bleiben. Gesucht wird jetzt die ADRESSE. Drei Formen, jede mit einer Grenze: 1. eine echte URL im WERT, deren Origin die des Ziels ist — gefunden mit `new URL()` statt mit einem Textvergleich, deshalb ist `https://api.example.org/public` kein Treffer; 2. die Autoritaet ohne Schema (`api:3001`), nur bei ausgewiesenem Port und nur an einer Zeichengrenze — `capitalized-api` und `api:30011` nicht; 3. der Wert BESTEHT aus dem Host — `X-Upstream-Host: api` bleibt gedeckt, als Teilstring wird der nackte Hostname nicht mehr gesucht. Der Kopf-NAME wird gar nicht mehr geprueft: ein Name traegt keine Adresse. Fuer `set-cookie` kommt das `Domain`-Attribut hinzu, strukturell ausgelesen und verglichen statt im Text gesucht; das erste Segment ist das Name/Wert-Paar und damit kein Attribut. `location` ist unveraendert. Gemessen, nicht behauptet: * Sieben neue Faelle mit dem Ziel woertlich auf `http://api:3001` — mit `api-a.invalid` waere der Fehler unsichtbar geblieben. Vorher `7 failed | 25 passed (32)`, danach 32/32. Alle bisherigen Leck-Nachweise bleiben gruen, keiner wurde entschaerft. * Gegenmutation ausgefuehrt: die ueberbreite Fassung woertlich wiederhergestellt (nackter Hostname als Teilstring, Kopfname im Vergleich, Cookie ueber denselben Weg) -> dieselben 7 Faelle wieder rot; zurueckgenommen, `cmp` leer, `shasum -a 256` = f38874cb… * Container: der Stub sendet jetzt neben dem leckenden Kopf auch `x-api-version`, eine fremde Doku-URL und zwei Cookies. Beide Richtungen belegt, beide Eingangsbremsen scharf. `[container-smoke] mode=local executed=27 passed=27 skipped=0`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Was dieser PR tut
Er macht EasyTree containerfähig — und zwar genau so weit, wie es ohne eine
Non-Production-Datengrenze zulässig ist. Grundlage ist Confluence
„EasyTree – Deployment-Entscheidung 21.08.2026: VPS + Coolify + Docker"
(Seite 30998530): primär eigener VPS mit Coolify, sekundär Railway, Cloudflare
Workers ist kein Zielruntime mehr.
Er deployt nichts.
BLOCKER_ENVIRONMENT_SEPARATIONgilt unverändert — siehe unten.Fachlicher Inhalt
apps/api/Dockerfile,apps/web/Dockerfile,docker-compose.yml,.dockerignore:reproduzierbare OCI-Builds aus dem gepinnten
pnpm-lock.yaml,corepack-aktiviertespnpm@10.28.0, beide Laufzeit-Images als
nodestatt root, beide mit dem Commit alsOCI-Label
org.opencontainers.image.revision. Der Outbox-Worker benutzt dasselbeAPI-Image und überschreibt nur das Kommando.
webveröffentlicht einen Port. Die API hängt am internen Netz — der Browsersieht genau eine Origin und niemals eine interne Adresse.
scripts/smoke-container.sh+ CI-Schritte am Ende vondb-gates.apps/api/test/deploy-authority.test.tsdeckt jetzt auch Dockerfiles und Compose ab:weder Coolify noch Railway dürfen eine zweite Migrationsautorität werden.
docs/runbooks/staging-deploy.mdauf den Containerpfad umgeschrieben;CLAUDE.mdbekommt den Abschnitt dazu.
Die Messung, die diesen PR zuerst getragen und dann gedreht hat
Frühere Fassung dieses PR-Textes sagte:
EASYTREE_API_PROXY_TARGETistBauzeit-Konfiguration. Diese Messung war korrekt — für den Weg, den es nicht mehr gibt.
Gemessen am 21.08.2026 auf Next 16.2.11: Web gebaut mit
http://buildtime-marker.invalid:9999, gestartet mit einem anderen Wert → HTTP 500 mitgetaddrinfo ENOTFOUND buildtime-marker.invalid.next startlas die Weiterleitungen ausdem gebauten
.next/routes-manifest.json. Das galt demrewrites()-Block innext.config.ts— und genau dieser Block ist jetzt weg.Seit EYT-126 ist der Wert LAUFZEIT-Konfiguration. Der Same-Origin-Proxy liegt in Route
Handlern (
apps/web/app/api/[[...pfad]],app/health,app/ready), die auf einegemeinsame Durchreiche zeigen. Route Handler sind dynamischer Servercode: sie lesen die
Variable bei jeder Anfrage neu;
apps/web/instrumentation.tsprüft sie zusätzlicheinmal beim Serverstart.
Der Gegenbeleg: EIN Digest, ZWEI Ziele, KEIN Neubau
Der Container-Smoke baut ein Web-Image, hält seinen Digest fest und startet denselben
Digest zweimal gegen zwei verschiedene Stub-APIs. Zwei Stubs und nicht zwei echte APIs,
weil der Nachweis über die Unterscheidung läuft.
Der Digest zählt und nicht der Tag: ein Tag lässt sich zwischen zwei Starts umhängen,
ein Digest nicht — und „kein Neubau dazwischen" ist die eigentliche Aussage.
Konsequenz: das Web-Image ist weder an ein Ziel noch an einen Anbieter gebunden.
Dieselbe Datei und derselbe Startpfad liefern es für Coolify/VPS wie für Railway, und es
gibt kein
--build-argmehr, das sich unterscheiden könnte.Fail-closed, an zwei Stellen
Nicht irgendein 500: der Smoke verlangt, dass das Containerprotokoll
EASYTREE_API_PROXY_TARGETnennt. Geprüft wird/, nicht/health— die Zusagelautet, dass kein normaler Anwendungsverkehr bedient wird.
Ehrliche Grenze, die auch im Code, in
CLAUDE.mdund im Runbook so steht: der Prozessbleibt am Leben und hält den Port. Der Compose-Healthcheck auf
/schlägt fehl und derContainer gilt als
unhealthy— aber „der Container startet nicht" wäre falsch.Die interne Adresse erreicht den Browser auf keinem Weg
x-middleware-rewritezu vermeiden genügt nicht. Antwortet die API mitLocation: http://<interner-host>:3001/foo?x=1, trägt der Browser die interne Adresse indie Adresszeile und ruft sie als nächstes selbst auf. Die Durchreiche übersetzt deshalb
einen
location-Kopf, der auf das konfigurierte Ziel zeigt, in einen relativen Pfad —Pfad, Query und Fragment bleiben erhalten. Ein externes Weiterleitungsziel bleibt
unangetastet; es spekulativ umzuschreiben wäre schlimmer als das Leck, das es nicht ist.
Geprüft werden HTML, jeder referenzierte Client-Chunk, die Antwortköpfe und der
location-Kopf.Zwei Sackgassen, die niemand wiederholen sollte
Beide gemessen, beide in
lib/proxy-durchreichen.tsals Kommentar konserviert:proxy.ts(Node-Middleware) funktioniert zur Laufzeit, sendet aberx-middleware-rewrite: <interne Adresse>an den Browser. Der Kopf lässt sich nichtentfernen, ohne die Weiterleitung abzuschalten — er ist der Mechanismus.
@opennextjs/cloudflarebricht mitNode.js middleware is not currently supportedab.Das lief im Pflichtjob
build-webund hätte EYT-149 in diesen Slice gezogen.Edge-Middleware scheidet aus, weil
process.envdort beim Bauen eingebacken wird — genaudas Problem, das dieser Slice löst.
Was der Container-Smoke prüft
24 Prüfungen,
skipped=0. Er läuft am Ende vondb-gates, weil es nur dort eine echtePostgreSQL-Instanz mit der Rolle
easytree_appgibt — bewusst kein eigener Job: derwäre kein Pflichtcheck, bis jemand mit Adminrechten das Ruleset neu anwendet, und beide
Kopien der Pflichtcheck-Liste müssten mitgezogen werden.
node./health= 200,/ready= 200 mit{"name":"database","ready":true}./healthund/readyüber den internen Dienstnamen an die API weiter.Prüfung bewiese (3) nichts über den internen Weg.
location-Kopf nennt die interne Adresse; keinx-middleware-rewrite.NODE_ENV=productionund ohneDATABASE_SSL_ROOT_CERTverweigert der Container den Start und benennt die Variable.und der Digest ist vorher wie nachher identisch. Ohne diesen Punkt bliebe
„anbieterneutral" eine Behauptung.
bedient und das Protokoll nennt
EASYTREE_API_PROXY_TARGET.nicht 137 ist (SIGKILL nach Fristablauf).
Evidenz
Lokal ausgeführt (arm64/colima, Postgres 17 mit
easytree_appNOSUPERUSER NOBYPASSRLSNOINHERIT), zuletzt am 22.08.2026 mit dem erweiterten Smoke:
mode=local, weil der lokale Lauf keine CI-Zusage ist — die maßgebliche Zahl steht unten imAbschnitt zum finalen Head und lautet dort
mode=required.Lokale Gates —
pnpm exec turbo run lint typecheck test --force --concurrency=2:Cached: 0steht hier absichtlich: ein Turbo-Replay wäre kein Lauf.Gegenmutationen aus der ersten Runde dieses PR — ausgeführt und wieder
zurückgenommen, nicht ausgedacht. Die vier Gegenmutationen zum Laufzeit-Proxyziel
stehen in der Tabelle im Abschnitt zum finalen Head.
GIT_SHA=deadbeef…beim Bauenlabel=deadbeef…≠ HeadUSER nodeaus dem runner entferntuser=''http://127.0.0.1:3001gebaut{"status":"ok"}throwinresolveNextOutput→return {}next-output.test.tsrotJede Rücknahme mit leerem
diffgegen die Sicherungskopie belegt.Was hier NICHT belegt ist: die lokalen Images sind arm64. Der amd64-Build ist Sache
des CI-Laufs an diesem Head — er ist die maßgebliche Evidenz, nicht der lokale Lauf.
Der Blocker bleibt
BLOCKER_ENVIRONMENT_SEPARATION, nachgemessen am 21.08.2026 read-only über dieControl-Plane:
list_projects→ genau ein Projekt sichtbar (ykoijifgweoapitabgxx, „Bazodiac"),kein EasyTree-Projekt.
list_branchesaufinypnrvpawvhgiyagxbd→ genau eine Branchmainmitproject_ref == parent_project_ref == inypnrvpawvhgiyagxbd. Die einzige Branch istdie Produktion.
Die exakte fehlende Voraussetzung: es existiert keine EasyTree-Datengrenze, deren
project_refvoninypnrvpawvhgiyagxbdverschieden ist.Solange sie fehlt, sind Deploy, reale Browserreise gegen Staging, Staging-Screenshots und
Rollback-Evidenz gesperrt — das ist eine Owner-Entscheidung mit Kosten- und
Infrastrukturwirkung, und eine Supabase-Branch hängt an derselben GitHub-Integration, die
beim Merge auf
masterProduktionsmigrationen fährt. Optionen unverändert indocs/plans/2026-08-20-sprint-6-staging-blocker.md.Nicht in diesem PR
Cloudflare-Cleanup (EYT-149) — er kommt erst nach belegter Container-Parität.
wrangler.jsonc,open-next.config.tsund die Cloudflare-Schritte inbuild-web/build-apibleiben deshalbunangetastet und grün.
Refs EYT-126, EYT-142, EYT-137.
🤖 Generated with Claude Code
Summary by Sourcery
Enable provider-neutral OCI deployment for the web and API while proving runtime proxy portability and enforcing container safety through CI smoke tests.
New Features:
Bug Fixes:
Enhancements:
Build:
CI:
Documentation:
Tests:
CI-Evidenz am damaligen Head
ea9a392ca3e6755659e8b93f69078fd44e434f85Lauf 32498237919 — alle 11
Pflichtjobs grün,
conclusion=success. Der Container-Smoke lief dort auf amd64 gegen denechten Supabase-Stack:
Alle 14 Gate-Zeilen aus
db-gates, jedemode=required,passed=executed,skipped=0:Der Schritt „Container-Smoke" lief 15:40:00Z→15:41:00Z, der Gate-Zeilen-Check danach — beide
success. Damit ist der amd64-Build belegt; der lokale arm64-Lauf war nur die Vorprüfung.Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung. Dieser PR bringt keine
Migration mit, aber der Merge auf
masterist der Pfad, den die Supabase-GitHub-Integrationfür Produktionsmigrationen benutzt — das entscheidet niemand nebenbei.
Stand am damaligen Head
7cf698c42b1f5832aef0f9e53346dc8dd4486a14CI-Lauf 32516908989 — 11/11 grün,
[container-smoke] mode=required executed=15 passed=15 skipped=0.PRIL-Scope-Gate: grün. Der PO hat am 21.08.2026 Weg 1 aus
docs/plans/2026-08-21-eyt-126-scope-autoritaet.mdgewählt. Revision 5 des Sprint-6-Manifestsnimmt die Deploymentpfade auf, die Revision 1 ausdrücklich ausgeschlossen hatte; die Bindung unter
.plumbline/scope-authority/wurde neu gezogen. Die Digest-Konvention wurde vorher gegen allevier bestehenden Revisionen verifiziert statt geraten. Gate danach:
PRIL scope check passed … (32 changed files), Exit 0.Der Blocker ist eine Stufe genauer — und eine Annahme war falsch. Ein Versuch, ein zweites
Projekt
easytree-staginganzulegen (Kosten für diese Org gemessen: 0 $/Monat), wurde miteinem Quota-Fehler abgelehnt. Die Free-Projekt-Quota zählt pro Nutzer über alle
Organisationen hinweg;
DYAI2025hat sie mit zwei aktiven Free-Projekten ausgeschöpft, und daszweite ist über diesen Zugang nicht sichtbar.
Zurückgenommen: das pausierte „Bazodiac" (
status: INACTIVE) belegt keinen Slot — dieSupabase-Doku sagt an zwei Stellen „Paused projects do not count towards your quota". Es zu
löschen hätte nichts gebracht.
Vollständig, mit den Gegenproben, in
docs/plans/2026-08-20-sprint-6-staging-blocker.md,Nachtrag 2. Kein Schreibzugriff hat stattgefunden.
Stand am damaligen Head
4e16d8c17f505e3db5fa3dd91c5530d9a792fef9— überholt durch18f5004CI-Lauf 32536007202 —
status=completed,conclusion=success, 11 von 11 Jobs grün, direkt übergh run viewgegengelesen und nicht nur über den Exit-Code des Wartewerkzeugs.Alle 14 Gate-Zeilen aus
db-gates, jedemode=required,passed == executed,skipped=0:Der Container-Smoke war vorher bei
executed=15; die neun zusätzlichen Prüfungen sind dieClient-Chunk-Leckprobe, die Antwortkopf-Leckprobe, die beiden Weiterleitungsfälle, die
beiden Ziele, die Digest-Gleichheit und die beiden Fail-closed-Fälle.
Der Nachweis aus genau diesem Lauf (amd64, echter Supabase-Stack):
auth-journeygrün — der einzige Job, der Identität beweist. Er baut Web jetzt ohnedas Proxyziel (der Schritt heißt „Bauen — ECHTE API und Web (das Proxyziel kommt erst beim
Start)") und setzt es erst in der
webServer-Umgebung. Die reale Reise gegen echte GoTrueläuft damit vollständig durch die neue Durchreiche:
2 passed (25.2s), inklusive derZusicherung, dass beide Sitzungscookies (
eyt_access,eyt_refresh) HttpOnly undSameSite=Strict sind — also zwei getrennte
Set-Cookie-Köpfe, die nicht zu einemzusammengefaltet werden dürfen.
build-webgrün ohne gesetztesEASYTREE_API_PROXY_TARGET. Derenv:-Block des Jobsist ersatzlos entfallen; bräuchte der Build die Variable noch, wäre dieser Pflichtjob rot.
Der Cloudflare-Bundlebau bleibt dabei grün (
Worker saved in .open-next/worker.js,OpenNext build complete., gzip 1184.28 KiB gegen das 3072-KiB-Limit) — EYT-149 wird vondiesem Slice nicht angefasst.
Gegenmutationen — ausgeführt, gemessen, zurückgenommen
expected [ Array(2) ] to deeply equal [ …(2) ], dazu sechs Folgefälle — darunter alle drei Weiterleitungsfälle, weil ein eingefrorenes Ziel denlocation-Vergleich gegen die falsche Origin führt und die interne Adresse dann lecktshasum -a 256gleich (ddfcba34…), danach 17/17 grüninstrumentation.tsliest die Umgebung selbstapps/web/instrumentation.ts:24 [runtime-reads-no-environment]— die Positivliste gibt also nicht pauschalapps/webfrei${GIT_SHA:?…}→${GIT_SHA:-unknown}indocker-compose.ymlnot to match /\$\{GIT_SHA:-/d7fcb173…, 9/9 grünRUN test -n "${GIT_SHA}"→ARG GIT_SHA=unknowninapps/web/Dockerfile65df23db…, 9/9 grünFail-closed-Proben außerhalb des Smokes
docker compose config, alle drei Fälle gemessen — die dritte, positive Probe steht dabei,damit die ersten beiden nicht von „kaputte Datei" zu unterscheiden wären:
docker buildohne--build-arg GIT_SHA, für beide Images → exit 1 mitGIT_SHA fehlt. Baue mit --build-arg GIT_SHA=$(git rev-parse HEAD).Zwei Reparaturen am Smoke-Skript, beide beim Ausführen gefunden
set -uim Cleanup. Die Container-Namen der Zwei-Ziele-Probe stehen bei denKonstanten und nicht erst im Abschnitt, der sie benutzt; zusätzlich liest
aufraeumen()jeden über
${VAR:-}. Ein Abbruch vor diesem Abschnitt meldet gemessen 0 Treffer„unbound variable" — vorher hätte er den echten Grund hinter einem Folgefehler versteckt.
grep -qhinter einer Pipe unterpipefail.docker logs … | grep -qF …ging rot,obwohl das Protokoll den gesuchten Text enthielt:
grep -qbeendet sich beim erstenTreffer, die schreibende Seite bekommt EPIPE, und
pipefailmacht daraus einenFehlschlag. Acht solche Stellen sind auf
case-Vergleiche umgestellt. Bei einerLeck-Prüfung ist dieselbe Mechanik die gefährliche Richtung: das gefundene Leck sähe
aus wie keines.
Was dieser Slice ausdrücklich NICHT belegt
BLOCKER_ENVIRONMENT_SEPARATIONsteht unverändert: es gibtkeine EasyTree-Datengrenze mit einem anderen
project_refalsinypnrvpawvhgiyagxbd.Es wurde nichts deployt, nichts konfiguriert und kein Supabase-Projekt angelegt.
Bereitstellung.
build:cfundcf:dry-runbleiben im Pflichtjob; dass siegrün bleiben, ist hier Nebenbedingung, nicht Ziel (EYT-149).
apps/web/e2e/**,scripts/read-through-harness.sh,.github/workflows/ci.ymlund zweiRunbooks;
apps/web/app/providers.tsx,apps/web/open-next.config.tsundapps/web/playwright.harness.config.tsverweisen weiter auf „das Rewrite innext.config.ts". Diese drei liegen außerhalb der Scope-Revision 6 und sind hier bewusst
nicht angefasst.
PRIL-Scope-Gate grün ohne Erweiterung:
PRIL scope check passed … (45 changed files),Exit 0, gegen Revision 6. Es wurde keine Revision 7 gebraucht.
Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung.
Stand am damaligen Head
18f50047fb96529306310e85b8e7717c8146e8ba— überholt durch3b4bbdbCI-Lauf 32540049734 —
status=completed,conclusion=success,headSha=18f50047…, 11 von 11 Jobs grün, direktüber
gh run viewgegengelesen und nicht nur über den Exit-Code des Wartewerkzeugs. DerAbschnitt darüber (
4e16d8c, Lauf 32536007202) bleibt als Historie stehen und ist durchdiesen hier überholt.
Was dazugekommen ist — ein letztes Loch in derselben Zusage.
locationwar übersetzt,x-middleware-rewriteentstand nie, der 502 nannte keinen Host. Jeder übrige Antwortkopfwurde dagegen unverändert weitergereicht. Antwortet die API mit
X-Upstream-Url: http://<intern>:3001/privateoder mitLink: <http://<intern>:3001/foo>; rel="self", dann steht die interne Adresse im Browser —same-origin-JavaScript liest sie über
response.headers, undLinkwertet der Browser sogarselbst aus. Die Zusage lautet „weder HTML, noch Client-JS, noch irgendein Antwortkopf,
noch
location"; sie war an genau dieser Stelle noch nicht eingelöst.Der Riegel lässt weg, statt umzuschreiben. Ein Kopf ohne festgelegte Bedeutung trägt
keine Struktur, aus der sich eine Übersetzung ableiten ließe; eine erfundene wäre schlimmer
als der Verlust. Erkannt wird das Ziel in drei Formen — volle Adresse, Autorität
(
host:port), nackter Dienstname —, schreibungsunabhängig, und der Kopfname zählt mit.Bewusst unangetastet: die Sonderbehandlung von
location(sie hat eine Bedeutung und ihreeigene Übersetzung), fremde Adressen,
Content-Type, gewöhnliche Anwendungsköpfe, Status undKörperstrom. Ein leckendes
Set-Cookiefällt einzeln weg, ohne die übrigenzusammenzufalten.
Drei Dateien am Code, zwei an der Doku:
apps/web/lib/proxy-durchreichen.ts,apps/web/test/proxy-durchreichen.test.ts,scripts/smoke-container.sh,CLAUDE.md,docs/runbooks/staging-deploy.md.Rot vor dem Riegel, grün danach — beides ausgeführt
Die sieben neuen Fälle in
apps/web/test/proxy-durchreichen.test.tswurden vor derReparatur geschrieben und liefen gegen den unreparierten Stand:
Die beiden Erhaltungsfälle („lässt gewöhnliche Köpfe unverändert", „lässt einen Kopf mit
einer EXTERNEN URL unverändert") waren schon vorher grün — sie belegen nicht den Riegel,
sondern dass er nicht pauschal alles wegwirft. Nach der Reparatur:
Tests 24 passed (24).Der letzte Fall ist der schärfste: dieselbe Antwort, nur die Umgebung wechselt auf
api-b.invalid— der Kopf mitapi-bfällt, der Kopf mit dem altenapi-ableibtstehen. Ein beim Modulladen eingefrorenes Ziel drehte genau das um.
Die bestehenden
location-Fälle sind unverändert und weiterhin grün: interne Weiterleitung →relativ (mit Query und Fragment), Basispfad abgestreift, externes Ziel unangetastet, relative
Weiterleitung unangetastet,
redirect: "manual". Ebenso der Set-Cookie-Fall „zwei bleibenzwei".
Gegenmutation — zweimal ausgeführt, nicht ausgedacht
if (nenntInternesZiel(…)) continue;an beiden Stellen entfernt5 failed | 19 passed (24)cmpleer,shasum -a 256=3690b3cf…Die rohe Beobachtung der Container-Gegenmutation, ungekürzt aus dem Protokoll:
Der Container-Nachweis ist nicht vakuos
scripts/smoke-container.shhat einen neuen Abschnitt 8c und dort zwei Prüfungen statteiner. Die erste ist die Eingangsbremse für die zweite: sie liest den Stub direkt aus dem
Containernetz (von außen ist er nicht erreichbar — genau das ist der Punkt) und verlangt,
dass er den leckenden Kopf tatsächlich sendet. Ohne sie bewiese dessen Abwesenheit hinter dem
Proxy nichts. Die zweite verlangt zusätzlich, dass der harmlose
x-stub-markedurchkommt —sonst wäre „der leckende Kopf fehlt" auch dann grün, wenn überhaupt nichts durchgereicht wird.
Aus genau diesem CI-Lauf (amd64, echter Supabase-Stack), Job
db-gates:Alle 14 Gate-Zeilen aus
db-gates, jedemode=required,passed == executed,skipped=0— der Container-Smoke steht jetzt bei 26 statt 24:Die übrigen Gates an diesem Head
pnpm exec turbo run lint typecheck test --force→22 successful, 22 total,Cached: 0 cached, 22 total— also wirklich gelaufen und kein Turbo-Replay.@easytree/web28 Dateien / 301 Tests,@easytree/api76 / 770.PRIL scope check passed … (45 changed files),Exit 0, gegen Revision 6. Auch dieser Nachtrag brauchte keine Revision 7 — alle fünf
Dateien liegen in bestehenden Scope-Pfaden (
apps/web/lib/**,apps/web/test/**,scripts/**,CLAUDE.md,docs/runbooks/**).OpenNext build complete.,Total Upload: 5910.26 KiB / gzip: 1184.39 KiBgegen das 3072-KiB-Limit (vorher 1184.28 KiB). EYT-149 wird weiterhin nichtangefasst.
Doku nachgezogen, keine Evidenz gelöscht
CLAUDE.mdunddocs/runbooks/staging-deploy.mdnannten den Antwortkopf-Schutz bisher nurfür
locationund führtenexecuted=24als gemessene Zahl. Beides ist jetzt korrekt: dieBeschreibung nennt den generischen Fall, die Zahl steht auf
26. Ältere Messungen in diesemPR-Text bleiben unverändert stehen und sind oben als überholt gekennzeichnet.
Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung.
Stand am finalen Head
3b4bbdb5ee88260000be404c86c9dac40e92b591CI-Lauf 32542186429 —
status=completed,conclusion=success,headSha=3b4bbdb5…, 11 von 11 Jobs grün, direktüber
gh run viewgegengelesen. Der Abschnitt darüber (18f5004, Lauf 32540049734) bleibt alsHistorie stehen und ist durch diesen hier überholt.
Der Riegel aus
18f5004war richtig, aber zu breit gefasst. Er verglich Teilstrings gegendrei Formen — volle Adresse, Autorität und nackter Dienstname — und zwar über Kopfnamen und
Wert zusammen. Für die vorgesehene Containertopologie
http://api:3001ist der Dienstname dasWort „api". Damit fiel jeder harmlose Kopf, in dem diese drei Buchstaben irgendwo vorkommen:
Das ist kein Schutz, sondern ein stiller Ausfall — und es verletzt die Zusage im selben
Absatz, dass legitime Köpfe unverändert bleiben. Der echte Leckschutz aus
18f5004bleibtdabei vollständig erhalten; kein einziger Leck-Nachweis wurde entschärft oder gelöscht.
Gesucht wird die ADRESSE, nicht das Wort
Drei Formen, jede mit einer Grenze — und der Kopfname wird gar nicht mehr geprüft, weil ein
Name keine Adresse trägt:
new URL()geparst, über die Origin verglichenX-Upstream-Url: http://api:3001/private,Link: <http://api:3001/foo>; rel="self"https://api.example.org/publicX-Upstream: api:3001api:30011,capitalized-api-responseX-Upstream-Host: apiX-Api-Version: 1Für
set-cookiekommt dasDomain-Attribut hinzu — strukturell ausgelesen und verglichenstatt im Text gesucht. Das erste Segment ist das Name/Wert-Paar und damit kein Attribut, sonst
wäre ein Cookie namens
domainein falscher Treffer.locationist unverändert: interneabsolute Weiterleitung → relativ, externe → unangetastet.
Rot vor der Reparatur — mit dem ECHTEN Ziel gemessen
Die sieben neuen Fälle setzen
EASYTREE_API_PROXY_TARGETwörtlich aufhttp://api:3001. Mitdem sonst benutzten
api-a.invalidwäre der Fehler unsichtbar geblieben, weil dieser Name inkeinem harmlosen Kopf vorkommt — das ist der eigentliche Grund, warum er durchgerutscht ist.
Nach der Reparatur
32 passed (32). Bemerkenswert ist der fünfte Fall: „entfernt trotzdem dieechte interne Adresse" ging rot an seiner Erhaltungszusicherung — der leckende Kopf wurde
korrekt entfernt, aber das harmlose
X-Api-Version: 1gleich mit. Genau diese Kopplung imselben Lauf verhindert, dass ein Riegel grün wird, der einfach alles wegwirft.
Gegenmutation — ausgeführt, nicht ausgedacht
Die überbreite Fassung wurde wörtlich wiederhergestellt — nackter Hostname als Teilstring,
Kopfname im Vergleich, Cookie über denselben generischen Weg — und die Testsuite gefahren:
7 failed | 25 passed (32)cmpleer,shasum -a 256=f38874cb…Der Container misst jetzt BEIDE Fehlerrichtungen
Der Stub sendet neben dem leckenden Kopf vier harmlose, die das Wort „api" tragen und nicht
die interne Autorität:
x-api-version, eine Doku-URL auf einem fremden Host und zwei getrennteCookies (eines davon
api_session). Abschnitt 8c hat dadurch drei Prüfungen statt zwei — dieNicht-Vakuositätsprobe liest den Stub weiterhin direkt aus dem Containernetz und verlangt
jetzt alle vier Eingangsbremsen, nicht nur eine.
Aus genau diesem CI-Lauf (amd64, echter Supabase-Stack), Job
db-gates:Ehrliche Grenze dieses Container-Belegs: die Stub-Hostnamen heißen
easytree-stub-a/bundnicht
api, also ist 8c hier ein Erhaltungsnachweis und keine Gegenmutation derFalsch-positiv-Klasse. Scharf gemessen wird diese Fehlerrichtung in der Unit-Suite, wo das Ziel
wörtlich
http://api:3001heißt. Die Stubs umzubenennen wäre das falsche Mittel gewesen: dieLeckprüfung im Skript sucht den Dienstnamen im Kopf-Dump, und mit dem Namen
apiliefe sieselbst in dieselbe Teilstringfalle.
Alle 14 Gate-Zeilen aus
db-gates, jedemode=required,passed == executed,skipped=0— der Container-Smoke steht jetzt bei 27 statt 26:Die übrigen Gates an diesem Head
pnpm exec turbo run lint typecheck test --force→22 successful, 22 total,Cached: 0 cached, 22 total.@easytree/web28 Dateien,@easytree/api76.PRIL scope check passed … (45 changed files),Exit 0, gegen Revision 6. Weiterhin keine Revision 7.
OpenNext build complete.,Total Upload: 5912.63 KiB / gzip: 1184.94 KiBgegen das 3072-KiB-Limit.Eine Messfalle, die hier gemeldet gehört
Der erste lokale Gesamtlauf nach dem Container-Smoke ging rot: 13 Fälle in sieben
API-Integrationsdateien, alle mit
error: role "authenticated" does not exist. Ursache warnicht die Änderung, sondern die Umgebung — der für den Smoke gestartete nackte
postgres:17belegte Port 54322, worauf die Tenant-Suiten aufhörten zu überspringen und gegeneine Datenbank ohne EasyTree-Schema liefen. Container entfernt, Lauf wiederholt:
22 successful, 0 cached. Festgehalten, weil dieselbe Falle beim nächsten lokalen Smoke wieder zuschnappt.Nicht gemerged, absichtlich. Der Merge ist eine Owner-Entscheidung.
Unabhängige Nachmessung am Head
3b4bbdb(22.08.2026)Diese Runde hat nichts geändert — kein Commit, kein Push, keine Revision 7. Der Auftrag ging
von
18f5004als Head aus; die Reparatur des Falsch-positiv-Riegels lag zu diesem Zeitpunktbereits als
3b4bbdbauf dem Branch. Gemessen wurde deshalb, ob die oben protokolliertenZusagen an diesem Head tatsächlich halten — die Belege oben sind Behauptungen, bis sie jemand
unabhängig nachrechnet.
CI an genau diesem Head
Lauf
32542186429,headSha = 3b4bbdb,conclusion = success, 11 von 11 Jobs grün:format,lint,typecheck,unit-tests,build-web,web-smoke,build-api,secret-scan,db-gates,read-through,auth-journey.Gegenmutation — ein zweites Mal ausgeführt, nicht abgeschrieben
Die überbreite Fassung wurde erneut wörtlich eingespielt (Teilstringvergleich gegen volle
Adresse, Origin, Autorität und nackten Hostnamen;
keksNenntInternesZielauf denselbengenerischen Weg zurückgesetzt; Aufrufstelle wieder auf
`${name}: ${wert}`):Dieselben sieben Fälle, alle im Block „der Riegel trifft die Adresse, nicht das Wort":
Bemerkenswert bleibt, dass die 25 übrigen Fälle unter der Mutation grün blieben — darunter
sämtliche Leck-Nachweise. Das ist der Beleg dafür, dass die Mutation genau eine Fehlerrichtung
trifft: sie schwächt den Schutz nicht, sie verbreitert ihn.
Rücknahme aus einer Sicherungskopie (nicht
git checkout), Beleg auf die mutierte Dateieingegrenzt:
Gates, lokal nachgemessen (macOS, arm64)
turbo run typecheck --force --concurrency=110 successful, 10 total,Cached: 0 cachedturbo run lint --force --concurrency=16 successful, 6 total,Cached: 0 cachedturbo run test --force --concurrency=110 successful, 10 total,Cached: 0 cachedprettier --check apps packages docs scriptsdocs/plans/…) → null verfolgte Verstößeplumbline-scope-guard.shPRIL scope check passed … (45 changed files), Exit 0, gegen Revision 6env -u EASYTREE_API_PROXY_TARGET pnpm --filter @easytree/web... buildDone— der Bau liest die Variable nichtOpenNext build complete.,Total Upload: 5912.63 KiB / gzip: 1184.93 KiB(Grenze 3072 KiB).open-next/.wrangler-dryauf der Plattearchitecture+architecture-red-case40 Fälle grün,no-supabase-import+api-base-path6 Fälle grünDie 95 übersprungenen API-Fälle sind die Tenant-Suiten im Modus
local— auf diesem Rechnerläuft kein Supabase-Stack. Scharf gemessen werden sie ausschließlich in
db-gates.Container-Smoke, lokal wiederholt
Gegen einen nackten
postgres:17mit einer RLS-gebundenen Rolle(
rolsuper=f,rolbypassrls=f,rolcanlogin=t):Ein Image, zwei Ziele, unveränderter Digest:
Abschnitt 8c, beide Fehlerrichtungen im selben Lauf — zuerst die Nicht-Vakuositätsprobe direkt
aus dem Containernetz, dann das Ergebnis hinter dem Proxy:
Die oben protokollierte ehrliche Grenze bleibt bestehen: die Stubs heißen
easytree-stub-a/bund nichtapi, 8c ist also ein Erhaltungs- und keinFalsch-positiv-Gegenmutationsnachweis. Diese Fehlerrichtung wird in der Unit-Suite scharf
gemessen, wo das Ziel wörtlich
http://api:3001heißt.locationist unangetastet:/foo?x=1relativ,https://login.example.org/oauth?state=xyzsemantisch unverändert — im Container wie in den sechs Weiterleitungsfällen der Unit-Suite.
Eine Flakiness, die hier gemeldet gehört
Der erste kombinierte Lauf
turbo run lint typecheck test --force(volle Nebenläufigkeit,22 Aufgaben) ging lokal rot: ein fast-check-Eigenschaftstest lief in den 5-s-Timeout, dazu
Folgefehler in
typecheck/lint/test. Es lief dabei kein Postgres auf 54322, die obenprotokollierte Ursache greift hier also nicht. Die Wiederholung desselben Befehls war grün
(
22 successful, 22 total,Cached: 0 cached), und alle drei nach Aufgabe getrennten,serialisierten Läufe waren beide Male grün. Festgehalten als Messfalle unter Last, nicht als
Befund am Code — eine Ursache wird hier ausdrücklich nicht behauptet.
Weiterhin nicht gemerged. Kein Deploy, keine Supabase-Mutation, kein Jira-Übergang,
keine Revision 7.
BLOCKER_ENVIRONMENT_SEPARATIONbleibt offen.