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/.
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
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
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
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
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
/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
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
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
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.
| Pfad | Schlüssel | Besonderheit |
|---|---|---|
klassen/{klasseId} | servergeneriert, 96 Bit, kein Geheimnis | trägt das Eigentum: lehrerUid. Felder abiturJahr + zug; der Anzeigename wird gerechnet, nicht gespeichert. |
klassen/{id}/uebungen/{exerciseId} | scenario.id ist die exerciseId | enthält den Lehrbuch-Auszug — fremdes Urheberrecht, EU-resident abgelegt. |
klassen/{id}/freigabe/{exerciseId} | Dokument-ID = exerciseId | je freigegebener Übung ein Dokument; listReleases liefert alle, istFreigabeAktiv filtert. |
children/{childId} | Dokument-ID = Zugangscode, 128 Bit | der Code wird bewusst nicht zusätzlich als Feld geschrieben — zwei Kopien desselben Secrets könnten auseinanderlaufen. |
children/{id}/sessions/{sessionId} | Sitzungs-UUID | drei Zustände: offen anlegen, je Turn hochzählen, abschließen. Nichts Abgeleitetes wird gespeichert. |
lehrwerke/{lehrwerkId} | 96 Bit, kein Geheimnis | gehört der Lehrkraft, nicht der Klasse — dasselbe Buch trägt mehrere Klassen. |
aufnahmen/{aufnahmeId} | Dokument-ID = Geheimnis aus dem QR-Code | oberste Ebene und flüchtig; native TTL-Policy auf ablaufAm. |
einstellungen/einwilligung + fassungen/{nr} | feste ID | kein 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.
| Dokument | Aussage | Realität im Code |
|---|---|---|
CLAUDE.mdDatenmodell-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.mdDatenmodell-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.mdDatenmodell-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.mdStruktur-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.mdFunctions-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_KEYals Secret); Grammatik-Auswertung und Buchfoto laufen über Vertex AI ineurope-west3per 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.
teacherProgressist das saubere Gegenbeispiel: es nimmt gar keineklasseIdentgegen, sondern liest überlistByLehrer. 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:
FirestoreLikestatt desFirestore-Typs,AuthTokenClientundGenaiModelsClientstatt des genai-SDK,MailTransportstatt nodemailer,AudioContextLikestatt 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
spokenWordsundMETRIC_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/startdesselben 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.