The runtimePart 1 of 2

Die Schwerkraft der primären App

Ein Projekt auf dieser Plattform ist nicht eine App und ein Stack, sondern N isolierte Apps und M Services, die sich einen einzigen Kubernetes-Namespace teilen — alles provisioniert aus einer Chat-Nachricht. Sobald du das zulässt, reimt sich fast jeder Bug: Eine „neue Geschwister-App" kollabiert unbemerkt zurück in die primäre. Das ist die Geschichte, warum das immer wieder passiert, des einen Prinzips, das die ganze Bug-Klasse behebt — am einzigen Kontrollpunkt zum deklarierten Zustand konvergieren und mit Fehler abbrechen, statt auf die primäre App zurückzufallen — und wie dasselbe Prinzip, rückwärts ausgeführt, uns den gesamten Stack durch die Zeit reisen lässt (Code in git, Daten in einem geteilten Neon-Branch und die laufende K8s-Infrastruktur) zurück zu einem kohärenten Moment.

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:

  1. 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 in apps/blog ist niemals aus apps/personal-finance erreichbar. Das ist eine harte Invariante, und wir werden gleich sehen, wie sie zubeißt.

  2. 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 als config/services/{id}/service.json. Diese Unterscheidung ist nicht kosmetisch — sie entscheidet, was ein Rollback abreißen darf.

  3. Alles wird aus der Spec abgeleitet. .adorable/spec.json ist 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:

user approves

chat: add a blog
as a separate app

architect / propose_plan

spec.json feature:
createsApp: {slug: blog, kind: react}
targetAppId: $NEW:blog

resolveCreatesAppSentinels

project_apps row
slug = blog

rewrite spec + subtask rows:
$NEW:blog → a_xxx

bucketPlanByAppId

BullMQ job
app = personal-finance

BullMQ job
app = blog

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:

  1. erstellt die echte App (createApp → Zeile + Scaffold + K8s-Rebuild-Trigger),
  2. schreibt $NEW:blog → den echten a_xxx in der Spec um und stempelt app_id auf die Subtask-Zeilen, die es referenzierten (inklusive Geschwister-Features, die auf dasselbe $NEW:blog zeigen),
  3. ü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:

yes

ok

fail

no

approve_plan

startGeneration

trigger_generation

resume / retry

iteration carryover

spec declares an
unmaterialized app?

resolveCreatesAppSentinels
reconcile mode

syncProjectServices
converge to spec.services

REFUSE:
status → idle, no enqueue

enqueue producer

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-K8steardownApp löscht das Deployment der Post-Target-App (abgeleitet, also ist das sicher und regenerierbar);
  • Katalog-Services — konvergiert projects.services herunter auf das deklarierte Set des Ziels (gelesen aus der spec.json im worktree, die der Code-Restore bereits zurückgesetzt hat) und deprovisioniert die Add-ons, die seitdem auftauchten. Das ist es, was schließlich das verwaiste directus-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 über isCodeDefinedService. 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:

  1. Konvergiere zum deklarierten Zustand an einem Kontrollpunkt. startGeneration ist 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.

  2. 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ührerische rowAppId || defaultAppId riecht genau danach.

  3. 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.

Bau auf der Plattform, die diese Beiträge beschreiben.

Beschreib deine App in einfachem Deutsch — Adorable schreibt den Code, richtet die Datenbank ein und bringt sie live.

Kostenlos loslegen