Voice-Tutor-Ballon — Architektur

Abgeleitet aus dem Code, nicht aus der Doku: Monorepo /home/marc/docker-projects/Voicetutor_ballon, drei npm-Workspaces (apps/web, functions, packages/shared), 303 TypeScript-Dateien davon 133 Testdateien. Gegengelesen gegen CLAUDE.md, firestore.rules und firebase.json — Abweichungen stehen unten in der Drift-Tabelle. Quelldateien im Repo: docs/architecture/.

Person System Container Datenspeicher Komponente Extern — durchgezogen: Aufruf · gestrichelt: Rückkanal

Ebene 1System-Kontext

Vier Rollen, drei fremde Dienste. Die architektonisch wichtigste Kante ist die zwischen Kind und Gemini Live: sie geht am eigenen System vorbei.

flowchart TB
    kind["Kind
A2-Englisch
kein Konto, kein Login"]:::person lehr["Lehrkraft
baut Uebungen, gibt frei,
liest die Fehlerkurven"]:::person owner["Owner
legt Lehrkraefte an,
pflegt den Einwilligungstext"]:::person eltern["Eltern
lesen die Information,
unterschreiben auf Papier"]:::person sys["Voice-Tutor-Ballon
Klassenraum-Demonstrator
A2-Sprechuebung als Rollenspiel
Erfolgskriterium: sinkende Fehler je 100 Worte"]:::system subgraph google["Google — je nach Pfad andere Region"] direction LR live["Gemini Live API
gemini-3.1-flash-live-preview
Developer API, global"]:::ext vertex["Vertex AI
gemini-2.5-flash
europe-west3"]:::ext fbauth["Firebase Auth + App Check
Lehrer-Konten, reCAPTCHA v3"]:::ext end smtp["SMTP IONOS
Zugangs-Mails, DE"]:::ext kind -->|"oeffnet die Capability-URL
aus QR-Karte oder Mail"| sys lehr -->|"Klassen, Uebungen,
Freigabe, Fortschritt"| sys owner -->|"Konten und
Einwilligungstext"| sys eltern -->|"liest /eltern"| sys sys -->|"mintet
Ephemeral-Token"| live kind <-->|"Audio direkt
dieser Strom beruehrt
den Server nie"| live sys -->|"Grammatik,
Buchseite"| vertex sys -->|"Konto und App"| fbauth sys -->|"Zugangslink
je Kind"| smtp smtp -.->|"Mail"| eltern classDef person fill:#08427b,stroke:#052e56,color:#fff classDef system fill:#1168bd,stroke:#0b4884,color:#fff classDef ext fill:#8a8a8a,stroke:#6b6b6b,color:#fff style google fill:none,stroke:#b9bec7,stroke-dasharray:4 4
Kerninvariante: Der Server ist ein Credential-Broker, kein Medien-Proxy. Er mintet ein kurzlebiges Token, in dem der System-Prompt eingesperrt ist (liveConnectConstraints) — danach spricht das Kind direkt mit Google. Zwei Folgen, die man kennen muss: die Datenresidenz endet an dieser Kante (Interims-Schutz ist die Elterneinwilligung, nicht die Technik), und es gibt serverseitig keine Token-Zahl abzugreifen. Die Owner-Übersicht rechnet deshalb in Gesprächssekunden.

Ebene 2Container

Eine PWA für alle vier Rollen, elf einzeln deployte gen2-Functions mit je eigener URL, ein Firestore, das für jeden Client verschlossen ist.

flowchart TB
    kind["Kind"]:::person
    lehr["Lehrkraft"]:::person

    subgraph geraete["Geraete"]
        direction LR
        pwa["Voice-Tutor-PWA
React 18 · Vite 5 · TS strict
vite-plugin-pwa
EINE App, Rollen-Routing:
/k/:code · /lehrer/*
/eltern · /aufnahme/:id"]:::container handy["Buchfoto-Seite
dasselbe Bundle
Handy ohne Anmeldung"]:::container end subgraph fbprj["Firebase-Projekt voicetutor-ballon — alles in europe-west3"] host["Hosting
liefert das Bundle
SPA-Rewrite"]:::container fkind["Kind-Pfad
3 Functions gen2
sessionStart
sessionEnd
grammarEval"]:::container flehr["Lehrer-Pfad
6 Functions gen2
klassen · children
uebungen · lehrwerke
publishRelease
teacherProgress"]:::container fowner["Owner-Pfad
2 Functions gen2
lehrkraefte
einwilligung"]:::container fs[("Firestore
Native Mode
deny-by-default
fuer jeden Client")]:::store auth["Firebase Auth
E-Mail/Passwort
Lehrkraft-Verzeichnis"]:::container end live["Gemini Live API
global"]:::ext vertex["Vertex AI
europe-west3
ADC ohne Key"]:::ext smtp["SMTP IONOS
587, STARTTLS"]:::ext kind --> pwa lehr --> pwa lehr -->|"QR-Code"| handy pwa -->|"HTTPS"| host pwa -->|"App Check"| fkind pwa -->|"App Check
+ ID-Token"| flehr pwa -->|"+ Owner"| fowner handy -->|"aufnahmeId aus dem QR-Code
kein ID-Token"| flehr pwa -->|"Anmeldung"| auth pwa <-.->|"WSS, Ephemeral-Token"| live fkind -->|"mintet Token"| live fkind -->|"firebase-admin ADC"| fs fkind --> vertex flehr --> fs flehr --> vertex flehr --> smtp fowner --> fs fowner -->|"legt Konten an, liest sie"| auth classDef person fill:#08427b,stroke:#052e56,color:#fff classDef container fill:#438dd5,stroke:#2e6295,color:#fff classDef store fill:#3c7fbf,stroke:#2a5c8a,color:#fff classDef ext fill:#8a8a8a,stroke:#6b6b6b,color:#fff style geraete fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style fbprj fill:none,stroke:#b9bec7,stroke-dasharray:4 4
Kein API-Gateway. gen2 gibt jeder Function eine eigene Cloud-Run-URL, deshalb steht im Bundle für jede eine eigene VITE_*_BASE_URL (elf Stück in apps/web/.env.local). Der Preis ist Konfigurationsfläche; gekauft wird dafür, dass jede Function eigene Secrets, eigene maxInstances und eine eigene Deploy-Einheit hat.

Ebene 3Komponenten der PWA

Die UI des Kindes kennt den Live-Treiber nicht — zwischen beiden liegt die SessionDriver-Naht. Dahinter steckt wahlweise der echte Gemini-Treiber oder ein Skript ohne Netz; das ist der Grund, warum der Gesprächsablauf ohne Mikrofon testbar ist.

flowchart TB
    app["App.tsx — Rollen-Routing
/k/:code · /lehrer/* · /eltern · /aufnahme/:id"]:::comp subgraph kindb["Kind-Bereich — src/child, 27 Quelldateien"] cview["Oberflaeche
ChildSessionView · ConversationView
MicControls · ScenarioPicker · Protokollfenster"]:::comp naht["SessionDriver-Naht
Die UI kennt nur dieses Interface.
Turns und Chips als Callback-Strom"]:::comp subgraph treiber["Treiber hinter der Naht"] direction LR gl["geminiLiveDriver
orchestriert Token, Mikrofon,
WSS und Auswertung"]:::comp fake["fakeSessionDriver
Skript, ohne Netz"]:::comp end subgraph audiok["Audio-Kette"] direction LR mic["MicCapture
Worklet -> 16 kHz PCM16"]:::comp play["PcmPlayback
24 kHz, flush bei Barge-in"]:::comp ta["TranscriptAssembler
Chunks -> ganze Turns"]:::comp end pcm["pcm.ts — reine Zahlenlogik,
ohne Web-Audio testbar"]:::comp clients["Broker-Clients
sessionStartClient
sessionEndClient
grammarEvalClient · appCheck"]:::comp end subgraph lehrb["Lehrer-Bereich — src/teacher, rund 60 Quelldateien"] direction LR lauth["auth/
Provider · Guard · Login
einziger Ort mit firebase/auth"]:::comp lscreens["Bildschirme
classes/ · children/
exercises/ · publish/
onboarding/ · admin/"]:::comp lapi["teacherApi.ts
ein Kern fuer alle Aufrufe:
App Check + ID-Token"]:::comp end elt["parents/ + einwilligung/
Lese- und Druckseite ohne Login"]:::comp design["design/
tokens/ · components.css
BrandMark · BuildMarke"]:::comp pwam["pwa/
neueFassung · laufendeSitzung
zoomSperre"]:::comp shared["@vt/shared
Contracts · Metrik · Taxonomie
Prompts · Szenario-Modell"]:::comp livex["Gemini Live"]:::ext fnx["Cloud Functions"]:::ext app --> cview app --> lauth app --> elt app --- design app --- pwam cview --> naht naht --> gl naht --> fake gl --> mic gl --> play gl --> ta mic --> pcm play --> pcm gl --> clients gl -->|"WSS"| livex clients --> fnx lauth --> lscreens lscreens --> lapi lapi --> fnx elt --> fnx naht -.-> shared lapi -.-> shared classDef comp fill:#85bbf0,stroke:#5d82a8,color:#0b1620 classDef ext fill:#8a8a8a,stroke:#6b6b6b,color:#fff style kindb fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style lehrb fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style treiber fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style audiok fill:none,stroke:#b9bec7,stroke-dasharray:4 4
Ein Ort je Fremdsystem. firebase/auth wird ausschließlich in teacher/auth/ berührt, das ID-Token holt allein teacherIdToken.ts, und alle fünf Lehrer-Clients laufen durch teacherApi.ts. Das ist keine Ästhetik: die EU-souveräne Zielarchitektur aus KB-4 soll ein lokalisierter Eingriff bleiben, kein Rewrite.

Ebene 3Komponenten des Brokers

35 Quelldateien in functions/src, in fünf Schichten geordnet. Jede Schicht ist eine eigene Datei-Familie, weil sie einzeln ohne Netz und ohne Emulator prüfbar sein soll — der Verifier ist überall injiziert.

flowchart TB
    subgraph schale["HTTP-Schale — firebase-functions v2 onRequest, CORS, europe-west3"]
        h1["11 Endpunkt-Module
Methode und Pfad-Suffix trennen die Vorgaenge:
POST /uebungen · /uebungen/freigabe · /uebungen/entfernen
GET ?klasseId= vs. ?stufe="]:::comp end subgraph gates["Gates — in dieser Reihenfolge"] direction LR g1["appCheck.ts
X-Firebase-AppCheck
v2 onRequest hat kein
eingebautes Enforcement"]:::comp g2["teacherAuth.ts
Bearer-ID-Token -> lehrerUid
+ ownerGate gegen OWNER_EMAIL"]:::comp g3["Eigentumspruefung
gehoert die klasseId dieser uid?
404 statt 403, wenn es sie nicht gibt"]:::comp end subgraph parser["Grenz-Parser — parse, don't validate"] direction LR p1["childrenRequest · klassenRequest
uebungenRequest + uebungenGrenzen
publishRequest"]:::comp end subgraph modell["Modellwissen — die einzigen Stellen mit Prompt und Schema"] direction LR m1["liveToken.ts
sperrt den System-Prompt in
liveConnectConstraints ein"]:::comp m2["grammarEval.ts
Aeusserung -> Fehler je Kategorie"]:::comp m3["aufnahmeEntwurf.ts
Buchseite -> Entwurf,
feldweiser Rueckfall"]:::comp end subgraph naehte["Naehte — Repository-Interfaces aus @vt/shared"] direction LR r1["Firestore-Repositories
Progress · Klassen · Exercises
Lehrwerke · Aufnahmen · Einwilligung
alle ueber den schmalen Port FirestoreLike"]:::comp r2["sessionStore.ts
transaktionale Zaehler
der Kernmetrik
+ staleSessions"]:::comp r3["lehrkraefteAuth
mailTransport
Verzeichnis und Versand"]:::comp end fsx[("Firestore")]:::ext vx["Vertex AI europe-west3"]:::ext lx["Gemini authTokens, global"]:::ext ax["Firebase Auth"]:::ext sx["SMTP IONOS 587"]:::ext h1 --> g1 g1 --> g2 g2 --> g3 g3 --> p1 p1 --> r1 h1 --> m1 h1 --> m2 h1 --> m3 m1 --> lx m2 --> vx m3 --> vx m2 --> r2 r1 --> fsx r2 --> fsx r3 --> ax r3 --> sx classDef comp fill:#85bbf0,stroke:#5d82a8,color:#0b1620 classDef ext fill:#8a8a8a,stroke:#6b6b6b,color:#fff style schale fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style gates fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style parser fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style modell fill:none,stroke:#b9bec7,stroke-dasharray:4 4 style naehte fill:none,stroke:#b9bec7,stroke-dasharray:4 4
Die Gate-Kette ist nicht überall vollständig. App Check gilt für jeden Aufruf. Das ID-Token entfällt auf dem Kind-Pfad (dort ist der Zugangscode der Nachweis), auf GET /einwilligung (die Elternseite hat keine Anmeldung) und auf dem Bild-Upload der Buchfoto-Übergabe (das fotografierende Handy war nie angemeldet). Diese drei Ausnahmen sind Entwurfsentscheidungen mit je eigener Begründung im Dateikopf — keine Lücken.

AblaufSitzung und Live-Gespräch

Der Pfad, der das Produkt ausmacht. Zwei Invarianten stecken darin, die kein Strukturbild zeigt: die Freigabe der Lehrkraft schlägt jede Client-Wahl, und ein fehlgeschlagener Buchhaltungsschritt darf das Gespräch nicht verhindern.

sequenceDiagram
    autonumber
    actor K as Kind
    participant P as PWA /k/:code
    participant S as sessionStart
    participant F as Firestore
    participant G as Gemini Live (global)
    participant E as grammarEval
    participant V as Vertex AI (EU)

    K->>P: oeffnet die Capability-URL
    P->>S: POST /session/start ohne scenarioId
    S->>F: aktive Freigaben der Klasse des Kindes
    F-->>S: Uebung(en) + Lehrwerktitel
    S-->>P: freigegebene Szenarien
    Note over P: Liegt eine Uebung an, gibt es
gar keine Auswahl. Sonst die
Standard-Situationen. P->>S: POST /session/start mit scenarioId S->>G: authTokens.create — Prompt in liveConnectConstraints G-->>S: Ephemeral-Token S->>F: Sitzung offen anlegen, Zaehler auf null Note over S,F: Schlaegt das fehl, spricht das Kind trotzdem.
Prio 1 ist Sprechen, Prio 2 Messbarkeit. S-->>P: sessionId + Ephemeral-Token + Modell P->>G: live.connect ueber WSS — ab hier ohne Server loop je Gespraechsrunde P->>G: PCM16 16 kHz vom Mikrofon G-->>P: Audio 24 kHz + Transkripte P->>E: POST /grammar/eval mit dem Kind-Turn E->>V: gemini-2.5-flash, EU-resident V-->>E: Fehler je Kategorie E->>F: Worte, Turns, Fehler transaktional hochzaehlen E-->>P: GrammarChips end
Warum die Zähler serverseitig entstehen: Fehler und Wortzahl sind Zähler und Nenner der Kernmetrik, und diese Kurve ist das Erfolgskriterium des Projekts. Käme sie aus dem Browser, könnte jeder mit einem Zugangscode eine beliebige Lernkurve behaupten. /grammar/eval sieht den Turn ohnehin im Klartext — dort zu zählen kostet nichts extra. Transaktional, weil sich Turns überlappen können.

AblaufFreigeben und Verschicken

Der missbrauchsanfälligste Endpunkt des Projekts: er versendet Mails an Kinderadressen. Deshalb prüft die Grenze mehr als Typen, und deshalb ist die Reihenfolge von Versand und Freigabe umgekehrt zur Intuition.

sequenceDiagram
    autonumber
    actor L as Lehrkraft
    participant P as PWA /lehrer
    participant R as publishRelease
    participant F as Firestore
    participant M as SMTP IONOS

    L->>P: "Freigeben und verschicken"
    P->>P: baut je Kind die Capability-URL
    Note over P: Genau eine Link-Quelle — sonst
koennten Mail und gedruckte Karte
auseinanderlaufen. P->>R: POST /release/publish + App Check + ID-Token R->>R: Grenz-Parser: Empfaengerzahl, keine Steuerzeichen,
Link muss die Form dieser App haben R->>F: gehoert die Klasse dieser lehrerUid? F-->>R: ja R->>F: gibt es die Uebung ueberhaupt? F-->>R: ja loop je Kind R->>M: reine Textmail mit persoenlichem Link end alt Versand vollstaendig R->>F: Freigabe schreiben, aktiviertAm = Serverzeit R-->>P: ExerciseRelease else Versand fehlgeschlagen R-->>P: Fehler — und KEINE Freigabe end
„Freigeben und Verschicken sind ein Vorgang." Früher schrieb der Client die Freigabe nach der Antwort; schlug das fehl, hatten die Kinder Links zu einer Übung, die das Dashboard als nicht freigegeben führte. Jetzt schreibt sie derselbe Vorgang, der die Mails verschickt — die Lehrkraft sieht nie ein „vielleicht gesendet". aktiviertAm ist Serverzeit, weil eine Client-Uhr die Laufzeit verlängern könnte.

AblaufÜbung aus dem Buchfoto

Der Rechner erzeugt einen kurzlebigen Vorgang, das Handy speist Bilder ein. Die Auth ist bewusst asymmetrisch — und genau das ist die Invariante, die man beim Erweitern kennen muss.

sequenceDiagram
    autonumber
    actor L as Lehrkraft am Rechner
    participant D as PWA /lehrer/uebungen
    participant U as uebungen-Function
    participant F as Firestore aufnahmen
    participant H as Handy /aufnahme/:id
    participant V as Vertex AI (EU)

    L->>D: "Buchseite fotografieren"
    D->>U: POST /uebungen/aufnahme, App Check + ID-Token + Eigentum
    U->>F: Vorgang anlegen: ID = 128 Bit Zufall, ablaufAm = +10 min
    U-->>D: aufnahmeId
    D->>D: QR-Code der Capability-URL zeigen
    L->>H: scannt mit dem Handy
    H->>U: POST /uebungen/aufnahme/:id/bild — NUR App Check
    Note over H,U: Das fotografierende Geraet war nie angemeldet.
Der Code erlaubt einspeisen, nicht abgreifen:
wer ihn abfilmt, liest nichts Fremdes. U->>V: alle gesammelten Seiten in EINEM Aufruf V-->>U: Entwurf — feldweiser Rueckfall auf den Referenztext U->>F: Entwurf ablegen, Vorgang verbraucht D->>U: GET /uebungen/aufnahme/:id, Polling alle 2 s, mit ID-Token U-->>D: Entwurf L->>D: gegenlesen, korrigieren, erst dann speichern
Drei Schranken statt einer Anmeldung: einmal verwendbar, Verfall nach zehn Minuten, und nichts wird als Übung gespeichert, bevor die Lehrkraft es gelesen hat. Aufgeräumt wird über die native TTL-Policy auf ablaufAm, nicht über einen Job — ein Cleanup-Job, der ausfällt, hinterlässt Lehrbuchtext in der Datenbank. Weil Firestore verzögert löscht, prüft der Handler die Gültigkeit zusätzlich selbst. Das Bild wird nirgends gespeichert: es geht in den Modellaufruf und danach nirgendwohin.

AblaufAbschluss und liegengebliebene Sitzungen

Der Regelfall ist unspektakulär. Interessant ist der andere Zweig: wenn niemand das Ende meldet.

sequenceDiagram
    autonumber
    actor K as Kind
    participant P as PWA
    participant E as sessionEnd
    participant S as sessionStart
    participant F as Firestore

    alt regulaeres Ende
        K->>P: beendet, oder die Zeit ist um
        P->>E: POST /session/end, App Check + Kind-Lookup
        E->>F: endedAt, durationS, reason
        E-->>P: gezaehlte Worte, Turns, Fehler
    else Tab geschlossen oder App-Umschalter
        Note over P: Kein Signal moeglich: sendBeacon kann den
App-Check-Header nicht setzen, und pagehide
feuert auf Mobilgeraeten oft gar nicht. K->>P: irgendwann der naechste Besuch P->>S: POST /session/start S->>F: neue Sitzung anlegen S->>F: eigene Altlasten des Kindes schliessen, ueber lastActivityAt Note over S,F: Kein Scheduler, keine Collection-Group-Abfrage,
kein zusaetzlicher Index. Der Preis: die Garantie
haengt am naechsten Besuch DESSELBEN Kindes. end
Warum das wichtig ist: Eine offene Sitzung hat kein endedAt, keine Dauer, keinen Grund — und wird damit nie ein Kurvenpunkt. Ohne den Sweeper fehlte in der Auswertung womöglich genau die Sitzung, in der ein Kind am längsten gesprochen hat.

Firestore-Sammlungen

Sieben Pfade, alle über firestore.rules für jeden Client verschlossen. Erreichbar ausschließlich über firebase-admin aus den Functions.

PfadSchlüsselBesonderheit
klassen/{klasseId}servergeneriert, 96 Bit, kein Geheimnisträgt das Eigentum: lehrerUid. Felder abiturJahr + zug; der Anzeigename wird gerechnet, nicht gespeichert.
klassen/{id}/uebungen/{exerciseId}scenario.id ist die exerciseIdenthält den Lehrbuch-Auszug — fremdes Urheberrecht, EU-resident abgelegt.
klassen/{id}/freigabe/{exerciseId}Dokument-ID = exerciseIdje freigegebener Übung ein Dokument; listReleases liefert alle, istFreigabeAktiv filtert.
children/{childId}Dokument-ID = Zugangscode, 128 Bitder Code wird bewusst nicht zusätzlich als Feld geschrieben — zwei Kopien desselben Secrets könnten auseinanderlaufen.
children/{id}/sessions/{sessionId}Sitzungs-UUIDdrei Zustände: offen anlegen, je Turn hochzählen, abschließen. Nichts Abgeleitetes wird gespeichert.
lehrwerke/{lehrwerkId}96 Bit, kein Geheimnisgehört der Lehrkraft, nicht der Klasse — dasselbe Buch trägt mehrere Klassen.
aufnahmen/{aufnahmeId}Dokument-ID = Geheimnis aus dem QR-Codeoberste Ebene und flüchtig; native TTL-Policy auf ablaufAm.
einstellungen/einwilligung + fassungen/{nr}feste IDkein Eigentum: der Text gilt für alle. Archiv, weil eine Unterschrift sich auf den gelesenen Stand bezieht.

Doku-Drift

Die Diagramme oben bilden den Code ab. Folgende Dokumente widersprechen ihm. Nichts davon wurde still korrigiert — das Nachziehen ist eine eigene Entscheidung.

DokumentAussageRealität im Code
CLAUDE.md
Datenmodell-Tabelle
klassen/{klasseId} trägt name, schuljahr, lehrerUid, erstelltAm Felder sind abiturJahr (Zahl) und zug. name und schuljahr sind bewusst weg — beide folgen aus Abiturjahr und Uhr (schuljahr.ts), gespeichert wären sie ab dem nächsten Jahr falsch. Beleg: packages/shared/src/klasse.ts:42, functions/src/firestoreKlassen.ts:toKlasse. Der Deploy-Abschnitt derselben Datei nennt den Wechsel, die Tabelle darüber zieht nicht nach.
CLAUDE.md
Datenmodell-Tabelle
klassen/{id}/freigabe/aktuell — „feste ID — je Klasse kann es nur eine geben" Die Dokument-ID ist die exerciseId (firestoreExercises.ts:205), es gibt kein Dokument aktuell. listReleases liest die ganze Sammlung, sessionStart filtert die aktiven und gibt dem Kind eine Liste (ReleasedScenarioResponse.scenarios). Mehrere gleichzeitige Freigaben je Klasse sind damit möglich. Der Dateikopf von firestoreExercises.ts widerspricht sich in derselben Sache selbst: Zeile 11 nennt {exerciseId}, Zeile 13 „feste Dokument-ID".
CLAUDE.md
Datenmodell-Tabelle
listet sechs Pfade lehrwerke/{lehrwerkId} und aufnahmen/{aufnahmeId} fehlen — beide sind eigene Top-Level-Sammlungen mit eigenem Repository und eigenem Eintrag in firestore.rules.
CLAUDE.md
Struktur-Abschnitt
zählt src/child/, src/teacher/, src/parents/, src/design/ auf src/aufnahme/ (Handy-Route der Buchfoto-Übergabe) und src/einwilligung/ (Client + Druckansicht) fehlen.
CLAUDE.md
Functions-Liste
elf Functions, korrekt aufgezählt Stimmt — verschweigt aber, dass die Buchfoto-Übergabe keine eigene Function ist: POST /uebungen/aufnahme, POST /uebungen/aufnahme/:id/bild und GET /uebungen/aufnahme/:id hängen unter uebungen. Der Bild-Upload ist damit der einzige Pfad einer Lehrer-Function ohne ID-Token-Prüfung.
firestore.rules Regel für teachers/{teacherUid} mit Kommentar über das „eigene Profildokument" Die Sammlung teachers kommt im gesamten Quellcode nicht vor — weder schreibend noch lesend. Toter Pfad samt Begründungstext. Harmlos (die Regel erlaubt nur Lesen des eigenen Dokuments), aber irreführend beim Lesen des Regelwerks.

Strukturelle Beobachtungen

  • Zwei Modellfamilien, zwei Residenzen, zwei Auth-Verfahren. Der Live-Loop läuft über die Developer API am global-Endpunkt mit einem echten Key (GEMINI_API_KEY als Secret); Grammatik-Auswertung und Buchfoto laufen über Vertex AI in europe-west3 per ADC, ganz ohne Key. Das ist kein Zufall, sondern die Grenze der Datenresidenz-Zusage — sie steht wörtlich im Lehrer-Login und in der Elterninformation und gilt für alles außer dem Live-Strom.
  • Genau eine Stelle entscheidet über Eigentum. Firestore ist deny-by-default; es gibt bewusst keine Regel „lies, was dir gehört", weil das eine zweite Entscheidungsstelle wäre. teacherProgress ist das saubere Gegenbeispiel: es nimmt gar keine klasseId entgegen, sondern liest über listByLehrer. Der Umfang der Antwort folgt aus dem Eigentum statt aus einem Filter, den man vergessen kann.
  • Die Testbarkeit ist ins Design eingebaut, nicht drangeschraubt. 133 von 303 TypeScript-Dateien sind Tests, und jeder Zugriff auf ein Fremdsystem läuft durch einen injizierten Port: FirestoreLike statt des Firestore-Typs, AuthTokenClient und GenaiModelsClient statt des genai-SDK, MailTransport statt nodemailer, AudioContextLike statt Web Audio. Das ist die Antwort auf „kein Emulator, direkt Produktiv": was man nicht lokal starten kann, muss man ohne es prüfen können.
  • Die Naht in der Mitte trägt zwei Treiber. Die Kind-UI kennt nur SessionDriver. Dahinter steht der echte Gemini-Treiber oder ein Skript ohne Netz — deshalb ist der Gesprächsablauf ohne Mikrofon und ohne Kontingent testbar, und deshalb liegt die gesamte Live-SDK-Kenntnis in einer einzigen dünnen Datei (liveConnection.ts).
  • Nichts Abgeleitetes wird persistiert. Ob eine Sitzung in die Kurve zählt, folgt aus spokenWords und METRIC_CONFIG — es steht in keinem Dokument. Kurven entstehen ausschließlich in @vt/shared (deriveCurve), nie im Backend. Ein Nachjustieren der Kalibrierung ist damit eine Konstantenänderung, keine Migration.
  • Die Dokument-ID als Geheimnis kommt zweimal vor — beim Kind (childId == accessCode) und beim Aufnahme-Vorgang. Beide Male ist die Folge dieselbe: kein Client-Lesepfad auf die Sammlung, weil ein Lesepfad ein Lesepfad auf die Geheimnisse wäre. Beim Kind ist das ausdrücklich vertagt (keine Token-Rotation möglich, der Code steht in jeder Dashboard-Antwort, weil er der Navigationsschlüssel der Kachel ist).
  • Elf Functions, elf URLs, elf Env-Variablen. Der Preis von gen2 ohne Gateway. Er kostet Konfigurationsfläche und war am 2026-07-27 an einem halb kaputten Produktionsstand beteiligt — daher die Regel „deployt wird ausschließlich aus main" und der Fünf-Endpunkt-Smoke-Test nach jedem Deploy.
  • Aufräumen hängt am Verkehr, nicht an der Zeit. Zweimal wurde bewusst gegen einen Hintergrundjob entschieden: liegengebliebene Sitzungen schließt der nächste /session/start desselben Kindes, Aufnahme-Vorgänge räumt die native TTL-Policy weg. Beide Male ist die Begründung gleich — ein Job, der ausfällt, fällt lautlos aus. Beide Male ist der Preis auch gleich: ein Kind, das nie wiederkommt, hinterlässt eine dauerhaft offene Sitzung.
  • Der Buildstempel ist Teil der Architektur, nicht der Kosmetik. __BUILD_ID__ wird in das Bundle eingebacken und in der App angezeigt, weil der Service Worker sonst eine alte Fassung serviert und jeder Gerätetest zweideutig macht — ein ausgerollter Fix ließ sich nicht von einem wirkungslosen unterscheiden.