# Sprint 6B – Metrikkatalog V2 und read-only Daten-API

**Stand:** 2026-07-14
**Status:** Sprint 6B ist mit Commit `9d611ea` auf `origin/main` veröffentlicht und produktiv ausgerollt. Sprint 6B.1 ist ein lokales Integrations-Gate ohne Deployment.
**Voraussetzung:** Sprint 6A ist mit Commit `469c86c` abgenommen und zusammen mit Sprint 6B veröffentlicht.

## Ziel und Grenze

Sprint 6B macht bereits vorhandene Gesundheitsdaten auffindbar und über eine strikt begrenzte read-only API abrufbar. Der Sprint baut noch keine neue Explorer-Oberfläche. Der bestehende statische JSON-Snapshot bleibt Druck- und Fallbackvertrag; große Apple-Reihen, Dokumenttexte und der Katalog V2 werden nicht in das globale HTML eingebettet.

Die API ist standardmäßig deaktiviert. Sie antwortet nur mit expliziter read-only Datenbank über `HEALTH_DASHBOARD_DB` und mindestens einer privaten Authentisierungskonfiguration: nicht-browserbasierter Bearer-Token über `HEALTH_DASHBOARD_API_TOKEN_FILE` und/oder Browser-Session-Secret über `HEALTH_DASHBOARD_BROWSER_SESSION_SECRET_FILE`. Der bestehende Dashboard-/Dokumentpfad kann seinen bisherigen DB-Default weiterverwenden; die neuen `/api/v1/`-Routen dürfen dies nicht. Sprint 6B.1 ändert weder V4 noch das normale V5-Dashboard und wird nicht deployt.

## Metrikkatalog V2

Jeder Eintrag in `dashboard_v5/metric_catalog_v2.py` enthält:

- stabile ID, Label, Aliasse und Suchbegriffe,
- Kategorie, Quelle und Source-Identifier,
- Parservertrag,
- Einheit, Werttyp und Präzision,
- Aggregation und erwartete Messfrequenz,
- Overlay-Gruppe,
- ausschließlich vergangenheitsbasierte Baseline-Regel,
- Reference-Policy,
- Drill-down-Ziel,
- Korrelationsfreigabe und erlaubte Lags,
- Datenschutzklasse `health_sensitive`.

Ein beobachteter Source-Identifier wird nicht automatisch freigegeben. Er benötigt eine explizite Zuordnung in `APPLE_SOURCE_SPECS` oder der kanonischen Labor-Allowlist sowie einen synthetischen Vertragstest.

## Programmatische Source-Inventur

`inventory_sources(connection)` inventarisiert read-only und ohne Messwerte:

- Apple-Identifier, Einheiten sowie aggregierte Beobachtungszahlen,
- ausschließlich originalverifizierte und kanonisch allowlistete Laborparameter,
- tatsächlich vorhandene Parameter der Tabelle `vitalzeichen`.

Der lokale Metadaten-Audit fand 64 unterschiedliche Apple-Identifier. Davon sind nur diese zwölf Source-Identifier freigegeben:

| Source-Identifier | Öffentliche Metriken | Einheit | Tagesaggregation | Parservertrag |
| --- | --- | --- | --- | --- |
| `sleep_analysis` | `apple.sleep` | h | Summe | Apple Health Analytics v2 |
| `resting_heart_rate` | `apple.resting_heart_rate` | bpm | Mittelwert | Apple Health Analytics v2 |
| `heart_rate_variability` | `apple.hrv` | ms | Mittelwert | Apple Health Analytics v2 |
| `step_count` | `apple.steps` | Schritte | Summe | Apple Health Analytics v2 |
| `walking_running_distance` | `apple.distance` | km | Summe | Apple Health Analytics v2 |
| `active_energy` | `apple.active_energy` | kcal | Summe | Apple Health Analytics v2 |
| `blood_oxygen_saturation` | `apple.oxygen_saturation` | % | Mittelwert | Apple Health Analytics v2 |
| `respiratory_rate` | `apple.respiratory_rate` | Atemzüge/min | Mittelwert | Apple Health Analytics v2 |
| `physical_effort` | `apple.physical_effort` | kcal/h/kg | Mittelwert | Apple Health Analytics v2 |
| `weight_body_mass` | `apple.weight` | kg | letzter Tageswert | Apple Health Analytics v2 |
| `body_mass_index` | `apple.bmi` | kg/m² | letzter Tageswert | Apple Health Analytics v2 |
| `blood_pressure` | Panel, systolisch, diastolisch | mmHg | letztes zusammengehöriges Tagespanel | Blood-pressure panel v1 |

52 weitere beobachtete Apple-Identifier bleiben `observed_not_released`; sie sind weder über `series` noch durch freie Parameterwahl abrufbar. Der lokale `vitalzeichen`-Bestand enthält derzeit keine freigabefähigen Parameter. Blutdruck stammt nachgewiesen aus Apple-Health-Korrelationsobjekten mit gemeinsamem systolischem und diastolischem Wert.

Sprint 6B.1 schreibt den maschinenlesbaren Ist-Report [`generated/apple-health-identifier-inventory-v1.json`](generated/apple-health-identifier-inventory-v1.json): exakt 64 Identifier mit `released`, Grund, beobachteten Einheiten, Beobachtungszahl und Priorität. Der Generator `dashboard_v5/apple_identifier_report.py` öffnet SQLite ausschließlich `mode=ro` und bricht ab, falls die erwartete Identifierzahl nicht exakt 64 ist. Unbekannte Identifier erhalten ausschließlich `P3_evidence_review`, keine automatische Freigabe.

Kanonische Laborparameter:

- CRP,
- D-Dimer,
- Fibrinogen,
- Faktor VIII,
- Thrombozyten,
- Leukozyten,
- Ferritin.

Ein Katalogeintrag kann `supported_no_data` melden. Das ist insbesondere für einen parserseitig unterstützten Parameter ohne aktuell verifizierte Beobachtung vorgesehen und darf nicht als Nullwert interpretiert werden.

## Endpunkte

| Endpunkt | Query-Vertrag | Ergebnis |
| --- | --- | --- |
| `GET /api/v1/metric-catalog?q=` | nur optionales, einzelnes `q` | Katalog V2 und Availability |
| `GET /api/v1/series?metric=&from=&to=&resolution=` | allowlistete Metrik; Von/Bis gemeinsam; `day` oder `week` | Punkte, Quelle, Einheit, Aggregation, Abdeckung, Zürich-Zeitzone |
| `GET /api/v1/day/{date}` | exaktes ISO-Datum, keine Queryparameter | nur beobachtete Tageswerte, Labor und Ereignisse |
| `GET /api/v1/events?from=&to=&types=` | Von/Bis verpflichtend; allowlistete Typen | getrennte Medikamentengaben, Symptomtage und Health Events/-Phasen |
| `GET /api/v1/labs` | keine Queryparameter | ausschließlich originalverifizierte kanonische Laborbeobachtungen |
| `GET /api/v1/search?q=` | exakt ein Suchbegriff, 2–80 Zeichen | gruppierte Treffer |

Alle API-Antworten tragen `Cache-Control: no-store`, `Pragma: no-cache`, `nosniff`, `DENY`, eine restriktive CSP und `Europe/Zurich` im Datenvertrag.

## Zeiträume und Aggregation

- Freie Zeiträume dürfen höchstens 3.660 Kalendertage umfassen.
- 7/30/90/180 werden deterministisch durch explizite Von-/Bis-Daten angefragt.
- „Alle“ wird durch Weglassen von Von/Bis angefragt und bleibt derselben maximalen Spanne und den Zeilenlimits unterworfen.
- Zukünftige Zeiträume und Tage werden abgewiesen; zukünftige oder zeitlich ungültige Source-Beobachtungen werden nicht veröffentlicht.
- Tagespunkte werden nie interpoliert; ein fehlender Tag ist kein Punkt.
- Numerische Null bleibt eine echte Beobachtung.
- Jede Serienantwort liefert `aggregation_rule`, `coverage.observation_count`, Quellen, Einheit und Zeitzone. Jede Wochenzeile enthält zusätzlich `observation_count`, `observed_days` und Qualität.
- Tägliche Metriken erzeugen Wochenpunkte ausschließlich aus sieben vorhandenen numerischen Tagen derselben ISO-Kalenderwoche (`complete_calendar_week_daily_metric`).
- Sporadische Metriken mit expliziter `intermittent`-Frequenz – darunter Blutdruckkomponenten, Gewicht und BMI – benötigen mindestens eine Beobachtung in der ISO-Woche und verwenden ihre katalogdefinierte Methode (aktuell letzter beobachteter Wert). Sie tragen `observed_measurements_within_iso_week`; fehlende Tage bleiben Lücken und werden nicht aufgefüllt.
- Metriken ohne definierte Wochenregel, etwa Labor-`observation`-Reihen, liefern bei `resolution=week` deterministisch keine Punkte und den Status `unsupported_for_metric`; sie werden nicht künstlich aggregiert.
- Apple-Rohzeilen, tägliche Rohzeilen, Serienpunkte, Laborbeobachtungen, Ereignisse und Suchgruppen besitzen getrennte harte Limits: maximal 3.660 Serienpunkte, 1.000 Ereignisse, 100 Laborbeobachtungen, 20 Treffer je Suchgruppe und 512.000 Byte je HTTP-Response. SQLite-Abfragen besitzen zusätzlich eine feste Laufzeitgrenze.
- Alle SQL-Statements sind feste serverseitige Templates; Browserparameter bestimmen weder Tabellen noch Spalten oder SQL-Fragmente.

## Blutdruck

`panel.blood_pressure` gruppiert:

- `apple.blood_pressure.systolic`,
- `apple.blood_pressure.diastolic`.

Beide Komponenten werden nur aus demselben allowlisteten Apple-Health-Korrelationsobjekt gelesen. Suche nach `Blutdruck`, `BD`, `RR`, `systolisch` oder `diastolisch` liefert Panel und Komponenten. Das Panel selbst ist keine numerische Einzelserie. Fehlen Beobachtungen, meldet der Katalog `supported_no_data` statt eines Nullwerts.

## Labor- und Dokumentvertrag

Laborwerte verlassen die Datenbank nur, wenn alle Bedingungen erfüllt sind:

- `validierungsstatus = validiert`,
- `verified_against_original = 1`,
- `reference_range_source = scanned_original`,
- kanonisches Parameter-/Einheitenpaar in der Allowlist,
- exakt numerischer Beobachtungswert,
- mindestens eine exakt numerische beobachtungsspezifische Referenzgrenze,
- keine mehrdeutige zweite Beobachtung desselben Parameters am selben Tag.

`reference.min` und `reference.max` stammen aus genau dieser Beobachtung und werden nur als exakte numerische Texte übernommen. Ein Dokument wird nur als sichere Metadatenstruktur `{id, date, category, institution}` verknüpft, wenn `canonical_document_id` auf ein Dokument mit `review_status = geprueft` zeigt. `id` ist dabei eine API-spezifische opaque Kennung (`api-document-*`) und ausdrücklich kein Link auf die separate Legacy-Route `/health-doc/{id}`. Dateiname, lokale Pfade, Drive-ID, Drive-URL, Volltext, Hash und Provenienzfreitext sind nicht Teil des API-Vertrags.

## Suche

Aliasse umfassen unter anderem:

- Blutdruck, BD, RR, systolisch, diastolisch,
- Herzfrequenz, Puls, Ruhepuls, HRV,
- Schlaf,
- CRP, D-Dimer, Faktor VIII,
- Aphten, Aphthen, Mundulzera,
- Medikamente und Medikation.

Antwortgruppen sind immer:

- `metrics`,
- `laboratory`,
- `symptoms_events`,
- `documents`,
- `days`.

Dokumentvolltext folgt erst in Sprint 6E. Sprint 6B durchsucht bei geprüften Dokumenten nur sichere Kategorie-, Institution- und Datumsmetadaten.

## Authentisierung

Nicht-browserbasierte Clients dürfen weiter ausschließlich den privaten Bearer-Token verwenden:

```http
Authorization: Bearer ***
```

Der Token steht nie in URL, HTML, JavaScript, Browserstorage, API-Antworten oder Serverlogs. Die konfigurierte Datei muss regulär und eigentümergeprüft sein, Modus `0600` besitzen, ohne Symlink über `O_NOFOLLOW | O_NONBLOCK` geöffnet werden und einen 32–128 Zeichen langen URL-sicheren Token enthalten.

Für Browser gibt es ausschließlich `POST /api/v1/browser-session`: Der einmalige, native HTTP-Basic-Bootstrap prüft ein **separates** privates `HEALTH_DASHBOARD_BROWSER_SESSION_SECRET_FILE`; der Bearer-Token wird dabei weder angefragt noch gesendet. Bei Erfolg speichert der Server nur serverseitig eine kurzlebige Session und liefert `Set-Cookie: health_api_session=…; Path=/; HttpOnly; SameSite=Strict; Max-Age≤3600`. Der Pfad `/` ist erforderlich, weil dieselbe kurzlebige, same-origin geschützte Browser-Session neben `/api/v1/*` auch die authentisierten V5-Queue-Routen `/health-actions/*` absichert. V5 ruft danach die API same-origin mit der HttpOnly-Cookie-Session auf; JavaScript kann Cookie oder Bearer nicht lesen. Optionales `Secure` wird nur über die explizite Serverkonfiguration aktiviert, damit private HTTP-Bindings nicht stillschweigend eine funktionslose Cookie-Session erhalten.

`connect-src 'self'` wird ausschließlich auf `/health-dashboard-v5` gesetzt. V4 und alle anderen HTML-/Asset-Routen behalten `connect-src 'none'`. Es gibt keine CORS-Header. Fremder `Origin` oder `Sec-Fetch-Site: cross-site` wird vor Datenzugriff mit `403` blockiert. Fehlende oder falsche Authentisierung ergibt `401`; Schreibmethoden auf regulären `/api/v1/`-Datenendpunkten ergeben `405 read_only_endpoint`.

## Synthetische API-Beispiele

Die folgenden JSON-Blöcke sind gekürzte, gezielt ausgewählte Feldauszüge aus tatsächlich ausgeführten Antworten der synthetischen Sprint-6B-Fixture; sie sind keine behaupteten vollständigen Response-Bodies.

```bash
API_BASE=http://127.0.0.1:8014
API_TOKEN='<synthetischer oder lokal sicher geladener Token>'
curl --fail --header "Authorization: Bearer ${API_TOKEN}" \
  "${API_BASE}/api/v1/metric-catalog?q=Blutdruck"
```

```json
{
  "version": 2,
  "timezone": "Europe/Zurich",
  "groups": {
    "metrics": [
      {"id": "panel.blood_pressure", "availability": {"status": "available"}},
      {"id": "apple.blood_pressure.systolic", "unit": "mmHg"},
      {"id": "apple.blood_pressure.diastolic", "unit": "mmHg"}
    ]
  }
}
```

```bash
curl --fail --header "Authorization: Bearer ${API_TOKEN}" \
  "${API_BASE}/api/v1/series?metric=apple.steps&from=2026-06-01&to=2026-06-14&resolution=day"
```

```json
{
  "metric": "apple.steps",
  "unit": "Schritte",
  "aggregation": "sum",
  "resolution": "day",
  "timezone": "Europe/Zurich",
  "coverage": {
    "from": "2026-06-01",
    "to": "2026-06-14",
    "expected_days": 14,
    "observed_days": 14,
    "missing_days": 0,
    "gaps": "omitted_not_interpolated"
  },
  "source": {
    "type": "apple_health",
    "identifier": "step_count",
    "parser": "apple_health_analytics_v2"
  },
  "points": [
    {"date": "2026-06-11", "value": 0, "quality": "direct", "source_class": "app"}
  ]
}
```

```bash
curl --fail --header "Authorization: Bearer ${API_TOKEN}" \
  "${API_BASE}/api/v1/day/2026-06-14"
```

```json
{"date":"2026-06-14","timezone":"Europe/Zurich","metrics":{"apple.sleep":{"value":6.6,"unit":"h","aggregation":"sum","quality":"direct"}}}
```

```bash
curl --fail --header "Authorization: Bearer ${API_TOKEN}" \
  "${API_BASE}/api/v1/events?from=2026-06-05&to=2026-06-15&types=medication_administered"
```

```json
{"timezone":"Europe/Zurich","events":[{"date":"2026-06-11","type":"medication_administered","label":"SYNTHETIC_EVENT_ON_MEASUREMENT_GAP"}]}
```

```bash
curl --fail --header "Authorization: Bearer ${API_TOKEN}" \
  "${API_BASE}/api/v1/labs"
```

```json
{"timezone":"Europe/Zurich","observations":[{"date":"2026-05-01","id":"lab-observation-1","metric_id":"lab.crp","parameter":"CRP","unit":"mg/L","value":12.5,"quality":"verified_original","reference":{"min":"0","max":"5","source":"scanned_original"},"document":{"id":"api-document-ff9af9bfaeb67c7240ccabfa","date":"2026-05-01","category":"Labor","institution":"Synthetisches Labor"}}]}
```

```bash
curl --fail --header "Authorization: Bearer ${API_TOKEN}" \
  "${API_BASE}/api/v1/search?q=CRP"
```

```json
{"query":"CRP","timezone":"Europe/Zurich","groups":{"metrics":[],"laboratory":[{"id":"lab.crp","label":"CRP","unit":"mg/L","availability":{"status":"available","observations":1}}],"symptoms_events":[],"documents":[],"days":[]}}
```

## Sicherheits- und Datenschutzgrenzen

- keine freie Metrik-, Tabellen-, Spalten-, Pfad- oder Sortierauswahl,
- kein SQL oder Dateipfad aus Browserparametern,
- keine lokalen Pfade, Dateinamen, Drive-IDs/-URLs, Roh-JSONs oder Hashes in Antworten,
- keine Notizen oder Dokumentvolltexte,
- nur read-only SQLite-Verbindungen mit `query_only=ON`, `trusted_schema=OFF` und Query-Deadline,
- Bearer-Token aus privater Datei, nie aus Queryparametern,
- feste Response- und Source-Limits,
- kein Remote-Fetch und keine Änderung des statischen Snapshotvertrags,
- keine Diagnose, Kausalitäts-, Dringlichkeits- oder Therapieaussage.

## Test-Slices

- Katalog/Source-Inventur: `tests/test_dashboard_v5_sprint6b_catalog.py`
- API, Labor, Events, Suche und HTTP-Grenzen: `tests/test_dashboard_v5_sprint6b_api.py`
- Sprint-6B.1 Browser-Session, CSP, Wochenregeln und 64-Identifier-Report: `tests/test_dashboard_v5_sprint6b1_integration.py` sowie `tests/browser/dashboard_v5_api_session.spec.js`
- Apple-Aggregation und Einheiten: `tests/test_apple_health_analytics_v2.py`
- bestehender Snapshot und mobile Erfassung bleiben Regression-Gates.

## Release-Evidenz

- Katalog-/Source-Slice: 6 Tests bestanden.
- Apple-/Serien-Slice: 4 Tests bestanden.
- Labor-/Event-/Such-Slice: 4 Tests bestanden.
- fokussierter Sprint-6B-Katalog-/API-Gate: 20 Tests bestanden.
- vollständige Python-Suite: 181 Tests bestanden.
- vollständige synthetische Playwright-Suite: 23 Tests bestanden.
- Ruff, `py_compile`, `git diff --check`, JSON-Beispielvalidierung und deterministische Ausführung aller sechs Endpunkte bestanden.
- unabhängige Abschlussurteile: Security/Privacy **APPROVE**, Medical/Data Contract **APPROVE**, Quality/Release Integrity **APPROVE**.
- Sprint 6B wurde nach der ursprünglichen Abnahme mit `9d611ea` gepusht und produktiv deployt. Sprint 6B.1 verändert weder V4 noch `deploy/` und führt ausdrücklich keinen weiteren Push oder Deploymentlauf aus.
- Sprint-6B.1-Abschluss: 184 Python-Tests und 25 synthetische Playwright-Tests bestanden. Das versionierte, maschinenlesbare Testprotokoll einschließlich des korrigierten CSP-Erwartungstests steht in [`test-results/sprint6b1-integration-gate-20260714.json`](test-results/sprint6b1-integration-gate-20260714.json).
