Die Schwerkraft der primären App
Lange Zeit bedeutete ein Projekt hier eine Sache: eine App, ein Stack. Du beschriebst eine App, wir generierten ein React-Projekt (oder React + tRPC + Postgres) — und das war das ganze Universum.
Dann bekamen Projekte eine zweite App. „Füge einen Marketing-Blog neben meinen Finanz-Tracker hinzu." „Gib mir ein Admin-Panel neben dem Storefront." Plötzlich besteht ein einzelnes Projekt aus mehreren Apps, jede mit ihrem eigenen isolierten Vite-Baum und eigenem Stack, plus mehreren Services — teils geteilte Plattform-Infrastruktur, teils Feature-spezifische Add-ons — und das alles lebt in einem einzigen Kubernetes-Namespace und wird komplett aus dem Chat provisioniert.
Dieser Wandel ist klein zu beschreiben und groß, richtig hinzubekommen. Dieser Beitrag handelt davon, wie Provisioning tatsächlich funktioniert, sobald ein Projekt aus mehreren Apps besteht, und von einem Failure-Mode, der sich als ein einziger Bug in drei verschiedenen Kostümen entpuppte. Wir haben heute alle drei Fixes ausgeliefert; das Interessante ist, dass es derselbe Fix ist.
Was ein Projekt tatsächlich ist
Hier ist die Anatomie eines echten Zwei-App-Projekts (qiwykfu39n: ein
Finanz-Tracker, dem ein Blog zuwuchs):
project: qiwykfu39n
┌──────────────────────────────────────────────────────────────────┐
│ git worktree /data/projects/qiwykfu39n │
│ apps/personal-finance/ kind=fullstack is_primary=true │
│ apps/blog/ kind=react is_primary=false │
│ .adorable/spec.json ← the declared desired state │
└──────────────────────────────────────────────────────────────────┘
│ manifests generated from spec
▼
┌─ k8s namespace: adorable-qiwykfu39n ──────────────────────────────┐
│ Deployment app personal-finance (Vite + tRPC) │
│ Deployment app-blog blog (Vite) │
│ Deployment directus catalog service (shared, 1 per ns) │
│ Deployment neon-compute per-project Postgres compute │
│ Ingress personal-finance.preview… blog-… directus-… │
└──────────────────────────────────────────────────────────────────┘
│ DATABASE_URL / S3 keys / tokens via Vault → K8s Secrets
▼
Neon (shared pageserver) Garage S3 (shared) ← platform services
Drei Dinge in diesem Bild sind fürs Provisioning entscheidend:
Apps sind isolierte Bäume. Jede App ist eine eigenständige Vite-App unter
apps/{slug}/. Es gibt keinen geteilten Bundler, keinen App-übergreifenden Import — ein Symbol inapps/blogist niemals ausapps/personal-financeerreichbar. Das ist eine harte Invariante, und wir werden gleich sehen, wie sie zubeißt.Services teilen sich in zwei Klassen. Plattform-Services (Neon Postgres, Garage S3) sind geteilte Instanzen außerhalb des Namespace, erreichbar über
ExternalName. In-Namespace-Services sind entweder der eingebaute Stack (react-frontend,trpc-api,postgres,s3) oder Katalog-Add-ons (directus,redis,strapi, …), definiert alsconfig/services/{id}/service.json. Diese Unterscheidung ist nicht kosmetisch — sie entscheidet, was ein Rollback abreißen darf.Alles wird aus der Spec abgeleitet.
.adorable/spec.jsonist der deklarierte Soll-Zustand. Die K8s-Runtime wird daraus regeneriert; sie ist niemals die Source of Truth.
Die drei Stores und der relationale Klebstoff
Der Zustand eines Projekts verteilt sich über Stores mit unterschiedlichen Identitäts- und Haltbarkeitsmodellen:
| Store | Hält | Identität |
|---|---|---|
| git worktree | die Quellen jeder App unter apps/{slug}/ |
commit SHA |
| K8s namespace | Deployments / Services / Ingress | abgeleitet, regenerierbar |
| Neon branch | Daten aller Apps + Directus-Tabellen + adorable_meta |
Timeline + LSN |
| Postgres (Plattform) | project_apps, projects.services, Plan-Revisionen |
relationale Zeilen |
Die relationalen Zeilen sind der Klebstoff. Zwei davon sind tragend:
-- migration 100 (+108 added fs_dir_name): one row per app
CREATE TABLE project_apps (
id TEXT PRIMARY KEY, -- a_xxx
project_id TEXT NOT NULL,
slug TEXT NOT NULL, -- → apps/{slug}/ on disk, app-{slug} in k8s
kind TEXT CHECK (kind IN ('react','fullstack')),
is_primary BOOLEAN NOT NULL,
fs_dir_name TEXT,
archived_at TIMESTAMPTZ -- soft-archive on rollback
);
-- the project's flat, declared service set (jsonb array on the projects row)
SELECT services FROM projects WHERE id = 'qiwykfu39n';
-- → ["react-frontend","trpc-api","postgres","s3","directus"]
project_apps.kind ist pro App; projects.services ist projektweit. Merk dir
diese Asymmetrie — sie ist die Naht, durch die die Hälfte der heutigen Bugs
schlüpfte.
Von einem Satz zu einer zweiten App
Wie wird aus „füge einen Blog als separate App hinzu" eine
project_apps-Zeile, ein Vite-Baum und ein Deployment? Über den Plan, niemals
über ein Chat-Tool, das direkt in Kubernetes greift. Der Architekt schlägt ein
Feature vor, das eine createsApp-Direktive und ein
$NEW:<featureId>-Sentinel als seine targetAppId trägt:
Das Sentinel ist nötig, weil die App zur Planzeit noch nicht existiert, der Plan
sie aber schon referenzieren muss. Die DB kann $NEW:blog nicht als app_id
speichern (Foreign Key), also schreiben Sentinel-Features NULL — und das
Literal $NEW:blog lebt nur in spec.json. Zur Approve-Zeit macht
resolveCreatesAppSentinels (in state-tools.js) Folgendes:
- erstellt die echte App (
createApp→ Zeile + Scaffold + K8s-Rebuild-Trigger), - schreibt
$NEW:blog→ den echtena_xxxin der Spec um und stempeltapp_idauf die Subtask-Zeilen, die es referenzierten (inklusive Geschwister-Features, die auf dasselbe$NEW:blogzeigen), - übergibt den aufgelösten Plan an
bucketPlanByAppId, das die Arbeit in je einen BullMQ-Job pro eindeutiger App auffächert.
Dieser Fan-out ist der Grund, warum das Per-App-Scoping echt sein muss: Jeder
Job läuft mit dem Producer gegen den Baum genau einer App
(resolveAppDir(projectId, appId)).
Von Services zu einem laufenden Namespace
Services nehmen einen parallelen Pfad. Die Service-Registry
(service-registry.js) hält sowohl die im Code definierten Built-ins als auch
die Katalog-Einträge. Eine ProjectSpec wird zusammengebaut
(project-spec.js) und an k8s-manifest-generator.js übergeben, das ein
Deployment/Service/Ingress pro App plus eines pro In-Namespace-Service
ausgibt. Die Multi-App-Wendung lebt in project-spec.js: Die Registry-Einträge
react-frontend / trpc-api werden übersprungen und stattdessen pro App
abgespielt, wobei der kind jeder App ihre Form bestimmt:
// project-spec.js — the primary app's kind is derived, not trusted
const isTrpcActive = resolved.includes("trpc-api");
const effectiveKind = (app.isPrimary && isTrpcActive) ? "fullstack" : app.kind;
(Der gespeicherte kind der primären App kann driften — Legacy-Projekte
bekamen standardmäßig react und wurden nie aktualisiert, als der Agent
trpc-api hinzufügte —, daher leitet die Manifest-Generierung ihn jedes Mal neu
aus dem aktiven Service-Set ab, statt der Spalte zu vertrauen.)
Die Deploy-Reihenfolge ist nicht hartkodiert. deploy-order.js liest die
requires und readyCheck jedes Services aus dem Katalog und sortiert
topologisch in Tiers, wobei zwischen ihnen Readiness-Probes laufen:
deploy-order.js — tiers from `requires` + `readyCheck` (no hardcoded names)
Tier 0 (no deps) Tier 1 (deps healthy first)
────────────────── ─────────────────────────────
neon-compute ─ pg_isready ──┐
redis ├──► directus requires: [postgres, s3]
app / app-blog │ strapi requires: [postgres]
garage (s3) ─ bucket OK ───┘
Credentials werden den ganzen Weg über Vault gefädelt:
getEnvVarsForCompose aggregiert die Secrets eines Projekts (DATABASE_URL,
S3-Keys, Service-Credentials), und jeder buildProjectSpec-Aufruf muss sie
als vaultEnvVars übergeben — sonst bootet der Container ohne Datenbank.
Katalog-Services wie Directus verbinden sich mit demselben Neon-Branch des
Projekts (DB_CLIENT=pg, eigenes Schema) — weshalb ihre Tabellen und das
adorable_meta-Content-Ledger der Plattform alle auf einer Neon-Timeline reiten.
Diese Tatsache entscheidet, was ein Daten-Rollback zurücknimmt.
Der Bug, der drei Kostüme trägt
Hier wird es interessant. Sobald ein Projekt aus mehreren Apps besteht, wiederholt sich ein bestimmter Failure-Mode: Eine „neue Geschwister-App" kollabiert unbemerkt in die primäre. Wir trafen heute auf drei verschiedene Weisen darauf, behoben jede, und sahen erst danach, dass es ein Bug war.
THE FAILURE CLASS: multi-app intent → silently degrades to "the primary app"
surface where it leaked what fell back to primary fix (commit)
─────────────────────────── ────────────────────────── ───────────
1. producer's READ context saw a sibling app's file in scope context
(gatherContext) the project-wide code index, to targetAppId
wrote a cross-app import (1ce1b58)
2. app MATERIALIZATION createsApp/$NEW never resolved, reconcile at
(approve happy-path only) subtasks (app_id NULL) routed startGeneration,
to the primary app's tree fail closed (c3afd35)
3. ROLLBACK teardown service + checkpoint not reconcile code +
(code+data only) reconciled; blog infra stayed data + INFRA to
live after "undo the blog" one target (bf3edbd)
Kostüm 1 — Kontext-Leck
Dem Producer, der apps/blog generierte, wurden der projektweite Code-Index und
die Vector-Search-Ergebnisse übergeben — ungefiltert nach App. Der
strukturelle Index qualifiziert Pfade korrekt voll aus (code-index.js gibt
apps/personal-finance/src/lib/trpc.tsx mit einem appId-Tag aus), aber der
Producer matchte auf das Symbol, nicht auf die App-Grenze, und schrieb
import { TRPCProvider } from './lib/trpc' in den Directus-only-Blog — eine
Datei, die im Baum einer anderen App existiert. Vite konnte sie nicht
auflösen; die Preview starb.
Apps sind isolierte Bäume, also ist die Datei eines Geschwisters niemals lokal
importierbar. Der Fix beschränkt den gesammelten Kontext auf die Ziel-App des
Laufs — der Dependency-Graph bleibt vollständig (er ist die beabsichtigte
App-übergreifende Oberfläche, und er benennt die Apps), aber der rohe Datei-Dump,
von dem der Producer abschreibt, wird auf targetAppId gefiltert. Es ist ein
Wissens-Bug — der Kontext lag faktisch falsch darüber, welche App die Datei
besitzt — behoben, indem man den Kontext korrigiert, nicht indem man eine Regel
hinzufügt.
Kostüm 2 — Materialisierung nur auf dem Happy Path
Die App-Materialisierung (createApp) lebte an genau einem Ort: im
Sentinel-Resolver von approve_plan. Aber die Generierung erreicht den Producer
durch viele Türen — ein fehlgeschlagener Approval-Schritt,
trigger_generation, Resume, Iterations-Carryover. In einem Trace schlug der
Approval-Schritt nach dem Planen fehl; das $NEW:blog des Blogs wurde nie
aufgelöst, die Subtasks behielten app_id = NULL, und das
rowAppId || defaultAppId von bucketPlanByAppId routete sie direkt in die
primäre App. Der „separate Finance Blog" wurde in den Finanz-Tracker
hineingebaut. Es wurde nie eine App-Zeile erstellt.
Die Services nebenan hatten die Antwort bereits. startGeneration führt auf
jedem Eintrittspfad einen konvergenten Reconcile der Services aus — er
existiert genau dafür, die Türen zu fangen, die den Happy Path umgehen. Apps
waren diesem Vertrag nur noch nicht beigetreten:
Der Reconcile ist idempotent (ein erfolgreicher approve_plan entfernt
createsApp aus der Spec, sodass dieser hier zum No-op wird) und schlägt
geschlossen fehl: Eine deklarierte-aber-nicht-materialisierte App verweigert
die Generierung, statt sie auf die primäre routen zu lassen. Wir haben auch den
Fan-out invertiert: Ein literales $NEW:-Sentinel, das bucketPlanByAppId
erreicht, wirft jetzt, statt zurückzufallen.
Kostüm 3 — Time-Travel, das zwei der drei Stores vergisst
Dieses lohnt sich, näher zu betrachten, denn ein Multi-App-, Multi-Service- Projekt durch die Zeit zurückzurollen ist das Provisioning-Problem rückwärts ausgeführt. „Roll den Blog zurück" muss das Projekt in den Moment zurückversetzen, bevor der Blog existierte — und dieser Moment muss in allen drei Stores gleichzeitig derselbe sein.
Ein Projekt, eine Timeline — warum der Daten-Revert atomar ist
Jeder Generierungs-Commit zeichnet einen leichtgewichtigen Checkpoint auf:
die Dev-Neon-Timeline-ID + ihre Head-LSN + die Commit-SHA (recordCheckpoint in
db-timetravel.js). Es werden keine Daten kopiert — es ist ein
(timeline, lsn, commit)-Marker. Ein kombinierter Restore spult dann drei
Stores auf diesen Marker zurück:
checkpoints (one per generation commit):
● 6fe2a8b ─────────────────────────── ● ac62c29 (= current HEAD)
pre-blog post-blog
"undo the blog" ⇒ restore to 6fe2a8b
│
┌──────────────────────────────┼──────────────────────────────────┐
▼ CODE (git) ▼ DATA (Neon) ▼ INFRA (reconcile)
restoreVersion forkBranch at the reconcileAfterRestore
read-tree --reset checkpoint LSN: • archive post-target
to 6fe2a8b: whole-branch COW fork plan revisions
deletes apps/blog/* reverts EVERY app's • soft-archive + tear
+ any file added schema, Directus down apps/blog (K8s
since target, across tables, AND teardownApp)
ALL app trees, adorable_meta — • deprovision directus
uniformly ATOMICALLY (catalog add-on, via
(one timeline/project) isCodeDefinedService)
repointDevCompute swaps
the compute → 6fe2a8b
└──────── a pre-restore snapshot is captured FIRST → roll-forward point ──────┘
Der Multi-App-Gewinn liegt in der mittleren Spalte. Weil alle Apps eines
Projekts sich einen Neon-Branch teilen (jede App bekommt ihr eigenes Schema;
Directus und das adorable_meta-Content-Ledger leben in demselben Branch),
revertiert ein einziger Copy-on-Write-Timeline-Fork an einer LSN die Daten
jeder App und die Tabellen jedes In-Branch-Services atomar. Es gibt kein
„revertiere die Daten von App A, aber nicht von App B" — es ist eine einzige
physische Operation. Alle App-Daten auf einen Branch zu legen kostet dich
Isolationsgarantien pro App, aber es erkauft dir einen kohärenten,
Alles-oder-Nichts-Daten-Rewind gratis.
Der Code-Revert ist aus einem anderen Grund uniform: Ein git worktree hält jede
App unter apps/{slug}/, sodass ein read-tree --reset auf den Ziel-Commit die
seitdem hinzugefügten Dateien entfernt — über alle App-Bäume hinweg — in einem
Zug. (Ein simples git checkout würde hinzugefügte Dateien zurücklassen; erst
das Reset lässt apps/blog/ tatsächlich verschwinden.)
Infra ist der Store, den keiner der Restores besitzt
Git spult Quellen zurück; Neon spult Daten zurück. Nichts spult die Runtime
zurück — und das ist die Lücke, die wir heute geschlossen haben.
reconcileAfterRestore bringt die abgeleiteten und relationalen Stores wieder in
Einklang mit dem wiederhergestellten Moment:
- relationale Zeilen — archiviert Plan-Revisionen, die nach dem Ziel
erstellt wurden, reaktiviert die Revision der Ziel-Ära und soft-archiviert
project_apps-Zeilen, die nach dem Ziel erstellt wurden (damit die neue App das aktive Set verlässt); - Per-App-K8s —
teardownApplöscht das Deployment der Post-Target-App (abgeleitet, also ist das sicher und regenerierbar); - Katalog-Services — konvergiert
projects.servicesherunter auf das deklarierte Set des Ziels (gelesen aus derspec.jsonim worktree, die der Code-Restore bereits zurückgesetzt hat) und deprovisioniert die Add-ons, die seitdem auftauchten. Das ist es, was schließlich das verwaistedirectus-Deployment abräumt.
Zwei Feinheiten machten das schwer, beide heute behoben:
Wähle den richtigen Moment. Checkpoints sitzen auf Commits, also bedeutet „undo den Blog" den Checkpoint davor wiederherzustellen. Der Agent wählte stattdessen den Post-Blog-Checkpoint — der der aktuelle HEAD war — und machte den ganzen Restore zum No-op. Die Restore-Point-Liste markiert jetzt den am aktuellen HEAD verankerten Punkt (
current: true) und steuert Aufrufer zu dem knapp älteren.Räume nach Taxonomie ab, nicht nach Name. „Welche Services sicher zu entfernen sind" ist keine hartkodierte Liste. Die Registry trennt den eingebauten Stack bereits von Katalog-Add-ons (
codeServiceIds, erfasst bevor der Katalog lädt); der Rollback liest sie überisCodeDefinedService. Built-ins (react-frontend,trpc-api,postgres,s3) werden durch Code und Daten revertiert und in Ruhe gelassen; nur Katalog-Add-ons werden deprovisioniert.
Reversibel bleiben
Time-Travel ist nur vertrauenswürdig, wenn es bidirektional ist. Bevor
restoreToCheckpoint vom aktuellen Zustand wegforkt, erfasst es einen
Pre-Restore-Snapshot am HEAD der ausliefernden Timeline — echtes aktuelles
Schema und Daten, nicht eine Seed-State-LSN —, sodass „undo den Rollback" ein
erstklassiger Restore auf diesen Punkt ist. Und weil ein Daten-Fork auch DDL
revertiert, zählt migrationsSince die Schema-Änderungen nach dem Ziel und warnt,
wenn ein Daten-only-Restore den Code vor seinem Schema stranden ließe, und
lenkt den Nutzer stattdessen zu einem kombinierten Code+Daten-Rollback.
Der Teardown bleibt von Natur aus reversibel: Alles, was er entfernt, ist
entweder aus der git-Historie wiederherstellbar (seitdem hinzugefügte Dateien),
aus der Spec regenerierbar (K8s-Deployments, Katalog-Services) oder
soft-archiviert statt gelöscht (project_apps-Zeilen). Die einzelne Regel
hinter dem Ganzen — was ein Rollback löschen
darf — ist ihr eigener Beitrag.
Das eine Prinzip
Zieh die drei Kostüme ab, und unter allen liegt derselbe Satz:
Jeder Ort, der stillschweigend auf „die primäre App" zurückfallen kann, ist ein Fail-open, das fail-closed sein sollte — und der Multi-App-Soll-Zustand gehört in einen Reconcile am Generierungs-Eintrittspunkt, nicht in einen Happy-Path-Seiteneffekt.
Konkret sind das drei Gewohnheiten, auf die sich die Codebase jetzt stützt:
Konvergiere zum deklarierten Zustand an einem Kontrollpunkt.
startGenerationist die einzige Tür, durch die jede Generierung geht. Services reconcilen dort; Apps reconcilen dort jetzt auch. Die Spec ist der Soll-Zustand; der Eintrittspunkt macht ihn wahr, idempotent, egal welcher Pfad ankam.Fail closed, nicht zurück zur primären. Ein
NULL/Sentinel-app_id, eine nicht-materialisierte App, eine ungelöste Referenz — das sind Vertragsverletzungen, die man verweigert, keine Defaults, die man absorbiert. Das verführerischerowAppId || defaultAppIdriecht genau danach.Klassifiziere aus den Daten, niemals aus einer Liste. Built-in vs. Katalog kommt aus der Registry. App-Identität kommt aus
project_apps. Die App-übergreifende Importierbarkeit kommt aus dem Baum-Layout. Wenn ein Fix „die vier Core-Services" oder „die Blog-App" hartkodieren will, ist das ein Zeichen, dass die Taxonomie bereits irgendwo autoritativ existiert und gelesen, nicht dupliziert werden sollte.
Die primäre App hat Schwerkraft: Jeder mehrdeutige Default, jeder stillschweigende Fallback, jeder nicht-reconcilte Store zieht Arbeit zurück zu ihr. Gutes Multi-App-Provisioning ist großteils die Disziplin, diesen Zug zu verweigern — die deklarierte Vielzahl an einem Punkt real zu machen und „fiel auf die primäre zurück" als einen Bug zu behandeln, an dem man fehlschlägt, statt als einen sicheren Default, auf dem man sich ausruht.