diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index ceb9557f5..a4add73d3 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -10,21 +10,21 @@ permissions:
contents: read
jobs:
- # The suite outgrew a single runner: 128 files took ~13 minutes when the
- # 25-minute guard was written, and the v1.37 sharing work took it to 173
- # files and past 25 minutes of wall clock, so every run died at the timeout
- # with every test to that point green. The guard's own rule applies — split,
- # don't raise — so the files run as two vitest shards. Each shard boots its
- # own Postgres testcontainer; the timeout stays a hang detector, sized so a
- # healthy half-suite clears it on a slow runner.
+ # The suite outgrew a single runner, and then a half-suite outgrew a slow
+ # one: two shards cleared in ~14 minutes on a quiet day, but with six pull
+ # requests running side by side the larger shard hit the same 25-minute
+ # guard the split was meant to escape. Three shards keep the biggest slice
+ # around 60 files, which clears the guard with real headroom even on the
+ # slowest runner observed. Each shard boots its own Postgres testcontainer;
+ # the timeout stays a hang detector, not a performance budget.
shard:
runs-on: ubuntu-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
- shard: [1, 2]
- name: integration shard ${{ matrix.shard }}/2
+ shard: [1, 2, 3]
+ name: integration shard ${{ matrix.shard }}/3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -44,7 +44,7 @@ jobs:
# vitest a stray positional argument and the shard flag never applies —
# both "shards" then run the full suite. Proven by a paired local run
# before this workflow shipped.
- - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2
+ - run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/3
env:
# The integration suite boots a Postgres testcontainer and points
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f94ce3040..540c25071 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,19 @@
reliably the one that ran. It is selected by name now, and a copy that never
arrived fails the build instead of quietly leaving another one in its place.
+### Fixed
+
+- Mood entries now survive a restore whole. The disaster-recovery backup was
+ carrying only the rated factors of an entry, so every tag ticked present or
+ absent was missing from the file, and it was dropping the note, the entry's
+ own timezone and the sync counter as well. A restored entry came back without
+ its text, read its day boundaries under the old Berlin assumption whatever
+ zone it had been logged in, and could lose the next sync round to a paired
+ phone still holding a higher counter. The backup carries every column now,
+ both halves of the tag taxonomy come back, and a structural check fails the
+ build if a future column goes missing the same way. Backup files written
+ before this restore exactly as they did.
+
## [1.37.0] — 2026-08-07
### Added
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 92583d744..357b9cfae 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -9147,6 +9147,16 @@ paths:
type: number
referenceHigh:
type: number
+ sourceReferenceLow:
+ type: number
+ sourceReferenceHigh:
+ type: number
+ sourceReferenceText:
+ anyOf:
+ - type: string
+ maxLength: 120
+ - type: string
+ const: ""
takenAt:
type: string
format: date-time
@@ -9254,6 +9264,22 @@ paths:
anyOf:
- type: number
- type: "null"
+ sourceReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceText:
+ anyOf:
+ - anyOf:
+ - type: string
+ maxLength: 120
+ - type: string
+ const: ""
+ - type: "null"
takenAt:
type: string
format: date-time
@@ -9430,6 +9456,12 @@ paths:
type: number
referenceHigh:
type: number
+ referenceText:
+ anyOf:
+ - type: string
+ maxLength: 120
+ - type: string
+ const: ""
takenAt:
type: string
format: date-time
@@ -11616,6 +11648,11 @@ paths:
anyOf:
- type: number
- type: "null"
+ referenceText:
+ anyOf:
+ - type: string
+ maxLength: 120
+ - type: "null"
effectiveDate:
anyOf:
- type: string
@@ -32974,6 +33011,30 @@ components:
anyOf:
- type: number
- type: "null"
+ catalogReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ catalogReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceText:
+ anyOf:
+ - type: string
+ - type: "null"
+ referenceOrigin:
+ $ref: "#/components/schemas/LabReferenceRangeOrigin"
+ referenceDivergesFromCatalog:
+ type: boolean
takenAt:
type: string
source:
@@ -32996,6 +33057,13 @@ components:
- unit
- referenceLow
- referenceHigh
+ - catalogReferenceLow
+ - catalogReferenceHigh
+ - sourceReferenceLow
+ - sourceReferenceHigh
+ - sourceReferenceText
+ - referenceOrigin
+ - referenceDivergesFromCatalog
- takenAt
- source
- hasNote
@@ -33003,12 +33071,31 @@ components:
- createdAt
- updatedAt
additionalProperties: false
- description: A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is
- non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links
- the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` /
- `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never
- recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the
- single-resource GET returns the decrypted `note`.
+ description: >-
+ A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null;
+ `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the
+ user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED
+ server-side from the Biomarker.
+
+
+ Three reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh`
+ are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are
+ correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` /
+ `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no
+ numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band.
+ `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the
+ catalog band state different limits — a surface showing only one of the two is showing a partial answer. The
+ client renders these values and never recomputes the range. The encrypted note is never echoed in list rows;
+ `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
+ LabReferenceRangeOrigin:
+ type: string
+ enum:
+ - source
+ - catalog
+ - none
+ description: Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own
+ report, which wins for that reading because it is the window the physician evaluated the value against.
+ `catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file.
LabReferenceRangeStatus:
type: string
enum:
@@ -33016,9 +33103,9 @@ components:
- below
- above
- unknown
- description: "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds.
- Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and
- informative — not an alarming red."
+ description: "Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the
+ window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the
+ limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red."
CreateLabResultResponse:
type: object
properties:
@@ -33086,6 +33173,30 @@ components:
anyOf:
- type: number
- type: "null"
+ catalogReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ catalogReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceText:
+ anyOf:
+ - type: string
+ - type: "null"
+ referenceOrigin:
+ $ref: "#/components/schemas/LabReferenceRangeOrigin"
+ referenceDivergesFromCatalog:
+ type: boolean
takenAt:
type: string
source:
@@ -33110,6 +33221,13 @@ components:
- unit
- referenceLow
- referenceHigh
+ - catalogReferenceLow
+ - catalogReferenceHigh
+ - sourceReferenceLow
+ - sourceReferenceHigh
+ - sourceReferenceText
+ - referenceOrigin
+ - referenceDivergesFromCatalog
- takenAt
- source
- rangeStatus
@@ -33292,6 +33410,14 @@ components:
anyOf:
- type: number
- type: "null"
+ referenceText:
+ anyOf:
+ - type: string
+ - type: "null"
+ description: The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0",
+ "negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to
+ numbers; this field carries it either way, so a window stated in words is not lost between the report and
+ the reading.
takenAt:
anyOf:
- type: string
@@ -33329,6 +33455,7 @@ components:
- unit
- referenceLow
- referenceHigh
+ - referenceText
- takenAt
- confidence
- biomarkerMatch
@@ -33426,6 +33553,34 @@ components:
anyOf:
- type: number
- type: "null"
+ catalogReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ catalogReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceLow:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceHigh:
+ anyOf:
+ - type: number
+ - type: "null"
+ sourceReferenceText:
+ anyOf:
+ - type: string
+ - type: "null"
+ referenceOrigin:
+ type: string
+ enum:
+ - source
+ - catalog
+ - none
+ referenceDivergesFromCatalog:
+ type: boolean
takenAt:
type: string
source:
@@ -33453,6 +33608,13 @@ components:
- unit
- referenceLow
- referenceHigh
+ - catalogReferenceLow
+ - catalogReferenceHigh
+ - sourceReferenceLow
+ - sourceReferenceHigh
+ - sourceReferenceText
+ - referenceOrigin
+ - referenceDivergesFromCatalog
- takenAt
- source
- hasNote
@@ -33460,6 +33622,7 @@ components:
- createdAt
- updatedAt
additionalProperties: false
+ description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.
ListBiomarkersEnvelope:
type: object
properties:
diff --git a/messages/de.json b/messages/de.json
index 7468d4f41..ee68573fc 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Familienanamnese",
"familyHistoryColRelationship": "Verwandtschaft",
"familyHistoryColCondition": "Erkrankung",
- "familyHistoryColAgeAtOnset": "Alter bei Beginn"
+ "familyHistoryColAgeAtOnset": "Alter bei Beginn",
+ "labsSourceRangeNote": "* Der Referenzbereich stammt aus dem Befund und weicht vom gespeicherten Bereich ab:",
+ "labsCatalogRangeLabel": "gespeicherter Bereich"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -6701,6 +6703,7 @@
"restoreSkippedCycleSymptom": "Zyklussymptom",
"restoreSkippedIllnessSymptom": "Krankheitssymptom",
"restoreSkippedMoodFactor": "Stimmungsfaktor",
+ "restoreSkippedMoodTag": "Stimmungs-Tag",
"docsLink": "Dokumentation"
},
"danger-zone": {
@@ -8046,7 +8049,12 @@
"qualitativePlaceholder": "z. B. negativ",
"qualNegative": "negativ",
"qualPositive": "positiv",
- "qualBorderline": "grenzwertig"
+ "qualBorderline": "grenzwertig",
+ "sourceRange": "Bereich laut Befund",
+ "sourceRangePlaceholder": "z. B. 3,5 - 5,0 oder < 5",
+ "sourceRangeAdd": "+ Bereich aus dem Befund ergänzen",
+ "sourceRangeHint": "Optional. Gilt nur für diesen Wert; dein gespeicherter Bereich bleibt unverändert.",
+ "sourceRangeUnparsed": "Wird so übernommen — daraus ließ sich kein Zahlenbereich lesen."
},
"backToLabs": "Zurück zu den Laborwerten",
"hasNote": "Notiz",
@@ -8077,7 +8085,10 @@
"90": "90 T",
"365": "1 J",
"all": "Alle"
- }
+ },
+ "sourceRangeLabel": "Befundbereich",
+ "sourceBandLegend": "Zweites Band: der Bereich aus dem Befund vom {date}.",
+ "sourceBandMixed": "Zweites Band: der Bereich aus dem neuesten Befund. Frühere Befunde im Zeitraum nennen andere Bereiche."
},
"biomarker": {
"manageTitle": "Biomarker",
@@ -8252,11 +8263,17 @@
"fileTooLarge": "Datei ist zu groß (max. 12 MB).",
"fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
"extractFailed": "Der Befund konnte nicht gelesen werden. Versuche ein schärferes Foto.",
- "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch."
+ "pdfNeedsAnthropic": "PDF-Scannen benötigt einen Claude-Bildanbieter; lade stattdessen ein Foto hoch.",
+ "refTextLabel": "Bereich wie gedruckt",
+ "refTextPlaceholder": "z. B. 3,5 - 5,0"
},
"loadMoreReadings": "Weitere Werte laden",
"documentsLink": "Befund-Dokumente",
- "notLinkedYet": "Noch keinem Biomarker zugeordnet"
+ "notLinkedYet": "Noch keinem Biomarker zugeordnet",
+ "sourceRange": {
+ "fromReport": "Befundbereich {range}",
+ "catalogDiffers": "dein Bereich {range}"
+ }
},
"modules": {
"cycle": {
diff --git a/messages/en.json b/messages/en.json
index 1b9ce1268..d1d6cba42 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Family history",
"familyHistoryColRelationship": "Relative",
"familyHistoryColCondition": "Condition",
- "familyHistoryColAgeAtOnset": "Age at onset"
+ "familyHistoryColAgeAtOnset": "Age at onset",
+ "labsSourceRangeNote": "* The reference range comes from the report itself and differs from the saved range:",
+ "labsCatalogRangeLabel": "saved range"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -6701,6 +6703,7 @@
"restoreSkippedCycleSymptom": "Cycle symptom",
"restoreSkippedIllnessSymptom": "Illness symptom",
"restoreSkippedMoodFactor": "Mood factor",
+ "restoreSkippedMoodTag": "Mood tag",
"docsLink": "Documentation"
},
"danger-zone": {
@@ -8046,7 +8049,12 @@
"qualitativePlaceholder": "e.g. negative",
"qualNegative": "negative",
"qualPositive": "positive",
- "qualBorderline": "borderline"
+ "qualBorderline": "borderline",
+ "sourceRange": "Range printed on the report",
+ "sourceRangePlaceholder": "e.g. 3.5 - 5.0 or < 5",
+ "sourceRangeAdd": "+ Add the range from the report",
+ "sourceRangeHint": "Optional. Applies to this reading only; your saved range stays as it is.",
+ "sourceRangeUnparsed": "Kept as written — no numeric range read from it."
},
"backToLabs": "Back to lab results",
"hasNote": "Note",
@@ -8077,7 +8085,10 @@
"90": "90d",
"365": "1y",
"all": "All"
- }
+ },
+ "sourceRangeLabel": "Report range",
+ "sourceBandLegend": "Second band: the range printed on the report of {date}.",
+ "sourceBandMixed": "Second band: the range printed on the most recent report. Earlier reports in view state different ranges."
},
"biomarker": {
"manageTitle": "Biomarkers",
@@ -8252,11 +8263,17 @@
"fileTooLarge": "File is too large (max 12 MB).",
"fileType": "Upload a JPEG, PNG, WebP, or PDF.",
"extractFailed": "Couldn't read the report. Try a clearer photo.",
- "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead."
+ "pdfNeedsAnthropic": "PDF scanning needs a Claude vision provider; upload a photo instead.",
+ "refTextLabel": "Range as printed",
+ "refTextPlaceholder": "e.g. 3.5 - 5.0"
},
"loadMoreReadings": "Load more readings",
"documentsLink": "Lab report documents",
- "notLinkedYet": "Not linked to a biomarker yet"
+ "notLinkedYet": "Not linked to a biomarker yet",
+ "sourceRange": {
+ "fromReport": "Report range {range}",
+ "catalogDiffers": "your range {range}"
+ }
},
"modules": {
"cycle": {
diff --git a/messages/es.json b/messages/es.json
index 35193f614..53b2af25c 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Antecedentes familiares",
"familyHistoryColRelationship": "Parentesco",
"familyHistoryColCondition": "Afección",
- "familyHistoryColAgeAtOnset": "Edad de inicio"
+ "familyHistoryColAgeAtOnset": "Edad de inicio",
+ "labsSourceRangeNote": "* El rango de referencia procede del propio informe y difiere del rango guardado:",
+ "labsCatalogRangeLabel": "rango guardado"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -6701,6 +6703,7 @@
"restoreSkippedCycleSymptom": "Síntoma del ciclo",
"restoreSkippedIllnessSymptom": "Síntoma de enfermedad",
"restoreSkippedMoodFactor": "Factor de ánimo",
+ "restoreSkippedMoodTag": "Etiqueta de ánimo",
"docsLink": "Documentación"
},
"danger-zone": {
@@ -8046,7 +8049,12 @@
"qualitativePlaceholder": "p. ej. negativo",
"qualNegative": "negativo",
"qualPositive": "positivo",
- "qualBorderline": "límite"
+ "qualBorderline": "límite",
+ "sourceRange": "Rango impreso en el informe",
+ "sourceRangePlaceholder": "p. ej. 3,5 - 5,0 o < 5",
+ "sourceRangeAdd": "+ Añadir el rango del informe",
+ "sourceRangeHint": "Opcional. Se aplica solo a esta lectura; tu rango guardado no cambia.",
+ "sourceRangeUnparsed": "Se guarda tal cual: no se pudo leer un rango numérico."
},
"backToLabs": "Volver a los resultados",
"hasNote": "Nota",
@@ -8077,7 +8085,10 @@
"90": "90 d",
"365": "1 a",
"all": "Todo"
- }
+ },
+ "sourceRangeLabel": "Rango del informe",
+ "sourceBandLegend": "Segunda banda: el rango impreso en el informe del {date}.",
+ "sourceBandMixed": "Segunda banda: el rango impreso en el informe más reciente. Informes anteriores del periodo indican rangos distintos."
},
"biomarker": {
"manageTitle": "Biomarcadores",
@@ -8252,11 +8263,17 @@
"fileTooLarge": "El archivo es demasiado grande (máx. 12 MB).",
"fileType": "Sube un JPEG, PNG, WebP o PDF.",
"extractFailed": "No se pudo leer el informe. Prueba con una foto más nítida.",
- "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar."
+ "pdfNeedsAnthropic": "El escaneo de PDF necesita un proveedor de visión de Claude; sube una foto en su lugar.",
+ "refTextLabel": "Rango tal como aparece",
+ "refTextPlaceholder": "p. ej. 3,5 - 5,0"
},
"loadMoreReadings": "Cargar más mediciones",
"documentsLink": "Documentos de resultados",
- "notLinkedYet": "Aún no vinculado a un biomarcador"
+ "notLinkedYet": "Aún no vinculado a un biomarcador",
+ "sourceRange": {
+ "fromReport": "Rango del informe {range}",
+ "catalogDiffers": "tu rango {range}"
+ }
},
"modules": {
"cycle": {
diff --git a/messages/fr.json b/messages/fr.json
index b3df467d1..0bfb8ebff 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Antécédents familiaux",
"familyHistoryColRelationship": "Lien de parenté",
"familyHistoryColCondition": "Affection",
- "familyHistoryColAgeAtOnset": "Âge au début"
+ "familyHistoryColAgeAtOnset": "Âge au début",
+ "labsSourceRangeNote": "* La plage de référence provient du compte rendu lui-même et diffère de la plage enregistrée :",
+ "labsCatalogRangeLabel": "plage enregistrée"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -6701,6 +6703,7 @@
"restoreSkippedCycleSymptom": "Symptôme de cycle",
"restoreSkippedIllnessSymptom": "Symptôme de maladie",
"restoreSkippedMoodFactor": "Facteur d'humeur",
+ "restoreSkippedMoodTag": "Étiquette d'humeur",
"docsLink": "Documentation"
},
"danger-zone": {
@@ -8046,7 +8049,12 @@
"qualitativePlaceholder": "p. ex. négatif",
"qualNegative": "négatif",
"qualPositive": "positif",
- "qualBorderline": "limite"
+ "qualBorderline": "limite",
+ "sourceRange": "Plage imprimée sur le compte rendu",
+ "sourceRangePlaceholder": "p. ex. 3,5 - 5,0 ou < 5",
+ "sourceRangeAdd": "+ Ajouter la plage du compte rendu",
+ "sourceRangeHint": "Facultatif. Ne vaut que pour cette mesure ; votre plage enregistrée reste inchangée.",
+ "sourceRangeUnparsed": "Conservé tel quel : aucune plage numérique n'a pu en être lue."
},
"backToLabs": "Retour aux résultats",
"hasNote": "Note",
@@ -8077,7 +8085,10 @@
"90": "90 j",
"365": "1 a",
"all": "Tout"
- }
+ },
+ "sourceRangeLabel": "Plage du compte rendu",
+ "sourceBandLegend": "Deuxième bande : la plage imprimée sur le compte rendu du {date}.",
+ "sourceBandMixed": "Deuxième bande : la plage imprimée sur le compte rendu le plus récent. Des comptes rendus antérieurs de la période indiquent d'autres plages."
},
"biomarker": {
"manageTitle": "Biomarqueurs",
@@ -8252,11 +8263,17 @@
"fileTooLarge": "Le fichier est trop volumineux (max. 12 Mo).",
"fileType": "Importez un JPEG, PNG, WebP ou PDF.",
"extractFailed": "Impossible de lire le compte rendu. Essayez une photo plus nette.",
- "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo."
+ "pdfNeedsAnthropic": "Le scan PDF nécessite un fournisseur de vision Claude ; importez plutôt une photo.",
+ "refTextLabel": "Plage telle qu'imprimée",
+ "refTextPlaceholder": "p. ex. 3,5 - 5,0"
},
"loadMoreReadings": "Charger plus de mesures",
"documentsLink": "Documents de résultats",
- "notLinkedYet": "Pas encore lié à un biomarqueur"
+ "notLinkedYet": "Pas encore lié à un biomarqueur",
+ "sourceRange": {
+ "fromReport": "Plage du compte rendu {range}",
+ "catalogDiffers": "votre plage {range}"
+ }
},
"modules": {
"cycle": {
diff --git a/messages/it.json b/messages/it.json
index 7f96ce248..cffdc04ea 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Anamnesi familiare",
"familyHistoryColRelationship": "Parentela",
"familyHistoryColCondition": "Condizione",
- "familyHistoryColAgeAtOnset": "Età di esordio"
+ "familyHistoryColAgeAtOnset": "Età di esordio",
+ "labsSourceRangeNote": "* L'intervallo di riferimento proviene dal referto stesso e differisce da quello salvato:",
+ "labsCatalogRangeLabel": "intervallo salvato"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -6701,6 +6703,7 @@
"restoreSkippedCycleSymptom": "Sintomo del ciclo",
"restoreSkippedIllnessSymptom": "Sintomo di malattia",
"restoreSkippedMoodFactor": "Fattore dell'umore",
+ "restoreSkippedMoodTag": "Etichetta dell'umore",
"docsLink": "Documentazione"
},
"danger-zone": {
@@ -8046,7 +8049,12 @@
"qualitativePlaceholder": "es. negativo",
"qualNegative": "negativo",
"qualPositive": "positivo",
- "qualBorderline": "borderline"
+ "qualBorderline": "borderline",
+ "sourceRange": "Intervallo stampato sul referto",
+ "sourceRangePlaceholder": "es. 3,5 - 5,0 oppure < 5",
+ "sourceRangeAdd": "+ Aggiungi l'intervallo dal referto",
+ "sourceRangeHint": "Facoltativo. Vale solo per questa misura; l'intervallo salvato resta invariato.",
+ "sourceRangeUnparsed": "Conservato così com'è: non è stato letto alcun intervallo numerico."
},
"backToLabs": "Torna ai risultati",
"hasNote": "Nota",
@@ -8077,7 +8085,10 @@
"90": "90 g",
"365": "1 a",
"all": "Tutto"
- }
+ },
+ "sourceRangeLabel": "Intervallo del referto",
+ "sourceBandLegend": "Seconda banda: l'intervallo stampato sul referto del {date}.",
+ "sourceBandMixed": "Seconda banda: l'intervallo stampato sul referto più recente. Referti precedenti nel periodo indicano intervalli diversi."
},
"biomarker": {
"manageTitle": "Biomarcatori",
@@ -8252,11 +8263,17 @@
"fileTooLarge": "Il file è troppo grande (max 12 MB).",
"fileType": "Carica un JPEG, PNG, WebP o PDF.",
"extractFailed": "Impossibile leggere il referto. Prova con una foto più nitida.",
- "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto."
+ "pdfNeedsAnthropic": "La scansione PDF richiede un provider di visione Claude; carica invece una foto.",
+ "refTextLabel": "Intervallo come stampato",
+ "refTextPlaceholder": "es. 3,5 - 5,0"
},
"loadMoreReadings": "Carica altre misurazioni",
"documentsLink": "Documenti dei referti",
- "notLinkedYet": "Non ancora collegato a un biomarcatore"
+ "notLinkedYet": "Non ancora collegato a un biomarcatore",
+ "sourceRange": {
+ "fromReport": "Intervallo del referto {range}",
+ "catalogDiffers": "il tuo intervallo {range}"
+ }
},
"modules": {
"cycle": {
diff --git a/messages/pl.json b/messages/pl.json
index 2da87ee8b..f6fb02f75 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -281,7 +281,9 @@
"familyHistoryTitle": "Wywiad rodzinny",
"familyHistoryColRelationship": "Pokrewieństwo",
"familyHistoryColCondition": "Schorzenie",
- "familyHistoryColAgeAtOnset": "Wiek zachorowania"
+ "familyHistoryColAgeAtOnset": "Wiek zachorowania",
+ "labsSourceRangeNote": "* Zakres referencyjny pochodzi z samego wyniku i różni się od zapisanego zakresu:",
+ "labsCatalogRangeLabel": "zapisany zakres"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -6701,6 +6703,7 @@
"restoreSkippedCycleSymptom": "Objaw cyklu",
"restoreSkippedIllnessSymptom": "Objaw choroby",
"restoreSkippedMoodFactor": "Czynnik nastroju",
+ "restoreSkippedMoodTag": "Tag nastroju",
"docsLink": "Dokumentacja"
},
"danger-zone": {
@@ -8046,7 +8049,12 @@
"qualitativePlaceholder": "np. ujemny",
"qualNegative": "ujemny",
"qualPositive": "dodatni",
- "qualBorderline": "graniczny"
+ "qualBorderline": "graniczny",
+ "sourceRange": "Zakres wydrukowany na wyniku",
+ "sourceRangePlaceholder": "np. 3,5 - 5,0 lub < 5",
+ "sourceRangeAdd": "+ Dodaj zakres z wyniku",
+ "sourceRangeHint": "Opcjonalne. Dotyczy tylko tego pomiaru; zapisany zakres pozostaje bez zmian.",
+ "sourceRangeUnparsed": "Zapisane bez zmian — nie odczytano z tego zakresu liczbowego."
},
"backToLabs": "Wróć do wyników",
"hasNote": "Notatka",
@@ -8077,7 +8085,10 @@
"90": "90 d",
"365": "1 r",
"all": "Wszystko"
- }
+ },
+ "sourceRangeLabel": "Zakres z wyniku",
+ "sourceBandLegend": "Drugie pasmo: zakres wydrukowany na wyniku z {date}.",
+ "sourceBandMixed": "Drugie pasmo: zakres z najnowszego wyniku. Wcześniejsze wyniki w tym okresie podają inne zakresy."
},
"biomarker": {
"manageTitle": "Biomarkery",
@@ -8252,11 +8263,17 @@
"fileTooLarge": "Plik jest za duży (maks. 12 MB).",
"fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
"extractFailed": "Nie udało się odczytać wyniku. Spróbuj wyraźniejszego zdjęcia.",
- "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie."
+ "pdfNeedsAnthropic": "Skanowanie PDF wymaga dostawcy wizji Claude; prześlij zamiast tego zdjęcie.",
+ "refTextLabel": "Zakres tak jak wydrukowano",
+ "refTextPlaceholder": "np. 3,5 - 5,0"
},
"loadMoreReadings": "Wczytaj więcej pomiarów",
"documentsLink": "Dokumenty wyników badań",
- "notLinkedYet": "Jeszcze niepowiązane z biomarkerem"
+ "notLinkedYet": "Jeszcze niepowiązane z biomarkerem",
+ "sourceRange": {
+ "fromReport": "Zakres z wyniku {range}",
+ "catalogDiffers": "twój zakres {range}"
+ }
},
"modules": {
"cycle": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 92dc14bec..4f5efaea5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,7 @@ settings:
overrides:
dompurify@<=3.4.10: ^3.4.11
postcss@<8.5.10: ^8.5.10
+ nanoid@<3.3.17: ^3.3.17
'@hono/node-server@<1.19.13': ^1.19.13
hono@<4.12.25: ^4.12.25
picomatch@<4.0.4: ^4.0.4
@@ -5556,13 +5557,8 @@ packages:
nan@2.27.0:
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
- nanoid@3.3.12:
- resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
- engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
- hasBin: true
-
- nanoid@3.3.16:
- resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
+ nanoid@3.3.17:
+ resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -12636,9 +12632,7 @@ snapshots:
nan@2.27.0:
optional: true
- nanoid@3.3.12: {}
-
- nanoid@3.3.16: {}
+ nanoid@3.3.17: {}
napi-postinstall@0.3.4: {}
@@ -13045,19 +13039,19 @@ snapshots:
postcss@8.5.14:
dependencies:
- nanoid: 3.3.12
+ nanoid: 3.3.17
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.20:
dependencies:
- nanoid: 3.3.16
+ nanoid: 3.3.17
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.25:
dependencies:
- nanoid: 3.3.16
+ nanoid: 3.3.17
picocolors: 1.1.1
source-map-js: 1.2.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c7fabca7c..3fa8ec2d3 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,6 +1,7 @@
overrides:
"dompurify@<=3.4.10": "^3.4.11"
"postcss@<8.5.10": "^8.5.10"
+ "nanoid@<3.3.17": "^3.3.17"
"@hono/node-server@<1.19.13": "^1.19.13"
"hono@<4.12.25": "^4.12.25"
"picomatch@<4.0.4": "^4.0.4"
diff --git a/prisma/migrations/0303_lab_source_reference_range/migration.sql b/prisma/migrations/0303_lab_source_reference_range/migration.sql
new file mode 100644
index 000000000..d4f6644c1
--- /dev/null
+++ b/prisma/migrations/0303_lab_source_reference_range/migration.sql
@@ -0,0 +1,14 @@
+-- Per-reading source reference range on a lab result.
+--
+-- A lab report prints the window its own method and device produce, right
+-- beside the value, and that window is what a physician evaluates against.
+-- Until now the document reading pulled the value and dropped the range.
+-- These three columns carry it: the derived numeric bounds where the printed
+-- string yields them, plus the report's verbatim string, which is stored
+-- whether or not it parsed. Nothing is backfilled — an existing row genuinely
+-- has no source range on file and stays NULL.
+
+ALTER TABLE "lab_results"
+ ADD COLUMN "source_reference_low" DOUBLE PRECISION,
+ ADD COLUMN "source_reference_high" DOUBLE PRECISION,
+ ADD COLUMN "source_reference_text" TEXT;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index fa1aeaa28..7adb641be 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -5005,9 +5005,41 @@ model LabResult {
/// reference bounds + range verdict are meaningless for a qualitative row.
valueText String? @map("value_text")
unit String
- /// Optional reference-range bounds as reported by the lab.
+ /// Reference-range bounds stamped onto the row at write time. Despite the
+ /// name these are NOT the lab's printed range: since v1.18.1 every write
+ /// path copies the linked `Biomarker`'s `lowerBound` / `upperBound` here as
+ /// historical truth, so a later catalog edit does not silently rewrite a
+ /// past reading. The range the LAB printed lives in `sourceReference*`.
referenceLow Float? @map("reference_low")
referenceHigh Float? @map("reference_high")
+
+ /// The reference range as PRINTED ON THE SOURCE REPORT for this one reading.
+ ///
+ /// Labs run different methods on different devices, so the window a
+ /// physician evaluates against is the one printed beside the value on that
+ /// report — not a catalog band that averages every lab. These three columns
+ /// carry it: the parsed numeric bounds where a bound could be derived, and
+ /// `sourceReferenceText` as the report's own verbatim string ("< 5",
+ /// "bis 5,0", "negativ", "siehe Befund"). The text is kept whenever the
+ /// source printed one, parseable or not — absence reads as absence, and a
+ /// window that could not be derived is never invented.
+ ///
+ /// Precedence: for a single reading the source bounds win over the catalog
+ /// band; the catalog stays the net for readings that carry no source range.
+ /// `resolveEffectiveReferenceRange` in `src/lib/labs/reference-range.ts` is
+ /// the one place that rule is expressed, and every surface that judges a
+ /// reading routes through it.
+ ///
+ /// Plaintext, not `*Encrypted`: that suffix marks user prose (`noteEncrypted`
+ /// here, `Biomarker.contextEncrypted` next door). This is a structured
+ /// lab-report field of exactly the class of the plaintext `analyte` / `unit`
+ /// / `value` / `valueText` columns beside it — `valueText` already carries
+ /// the actual clinical RESULT ("positiv") in the clear, so encrypting the
+ /// window that result is judged against would protect strictly less while
+ /// making the column unreadable to the SQL the read paths run.
+ sourceReferenceLow Float? @map("source_reference_low")
+ sourceReferenceHigh Float? @map("source_reference_high")
+ sourceReferenceText String? @map("source_reference_text")
/// When the sample was taken (UTC instant; wall-clock anchoring is the
/// reader's concern per the schema's UTC-only convention).
takenAt DateTime @map("taken_at")
diff --git a/scripts/diagnose-implausible-measurements.ts b/scripts/diagnose-implausible-measurements.ts
new file mode 100644
index 000000000..0be518a26
--- /dev/null
+++ b/scripts/diagnose-implausible-measurements.ts
@@ -0,0 +1,150 @@
+/**
+ * Read-only diagnostic for stored measurement rows outside the app's own
+ * declared plausibility domain.
+ *
+ * `VALUE_RANGES` in `@/lib/validations/measurement` is the band the
+ * application says a metric can occupy. Every path a person can drive has
+ * always enforced it; the provider sync writers did not, so rows the
+ * application itself calls impossible reached the table — and every mean,
+ * median and personal band built from them inherited the impossibility. The
+ * writers enforce it now, which stops new rows landing. It says nothing about
+ * the rows already there.
+ *
+ * This reports them and repairs nothing. What to do with a stored impossible
+ * row is a judgement the data cannot make: a pulse of 55 million is plainly a
+ * decode slip and a candidate for deletion, while a weight of 0.9 kg might be
+ * a neonatal record on a shared scale, or a unit mix-up, or a real reading the
+ * band is simply too narrow for. Deleting somebody's health record on a
+ * heuristic is not a thing a script gets to decide.
+ *
+ * Output is aggregate: one line per (metric, source) bucket with the row
+ * count, the number of distinct records affected, the observed extremes, and
+ * the window they fall in. Individual readings are not printed — the bucket
+ * plus the metric's own declared bound is what a decision needs, and a log
+ * file full of somebody's vitals is not.
+ *
+ * Run (inside the app container, which has DATABASE_URL):
+ * pnpm dlx tsx scripts/diagnose-implausible-measurements.ts
+ */
+import "dotenv/config";
+
+import { prisma } from "@/lib/db";
+import { VALUE_RANGES } from "@/lib/validations/measurement";
+
+interface BucketRow {
+ type: string;
+ source: string;
+ row_count: bigint;
+ record_count: bigint;
+ min_value: number;
+ max_value: number;
+ first_at: Date;
+ last_at: Date;
+ tombstoned: bigint;
+}
+
+function ymd(date: Date): string {
+ return date.toISOString().slice(0, 10);
+}
+
+async function main(): Promise {
+ const types = Object.keys(VALUE_RANGES).sort();
+ const mins = types.map((t) => VALUE_RANGES[t]!.min);
+ const maxes = types.map((t) => VALUE_RANGES[t]!.max);
+
+ // The band table rides in as three parallel bound arrays rather than a
+ // spliced VALUES list, so the query carries no interpolated text at all.
+ // `value <> value` is the NaN probe: a double-precision column can hold one,
+ // and it compares false against every bound including itself.
+ const buckets = await prisma.$queryRaw`
+ WITH bands(type, min_value, max_value) AS (
+ SELECT * FROM unnest(
+ ${types}::text[],
+ ${mins}::double precision[],
+ ${maxes}::double precision[]
+ )
+ )
+ SELECT
+ m."type"::text AS type,
+ m."source"::text AS source,
+ COUNT(*)::bigint AS row_count,
+ COUNT(DISTINCT m."user_id")::bigint AS record_count,
+ MIN(m."value") AS min_value,
+ MAX(m."value") AS max_value,
+ MIN(m."measured_at") AS first_at,
+ MAX(m."measured_at") AS last_at,
+ COUNT(*) FILTER (WHERE m."deleted_at" IS NOT NULL)::bigint AS tombstoned
+ FROM "measurements" m
+ JOIN bands b ON b."type" = m."type"::text
+ WHERE m."value" < b.min_value
+ OR m."value" > b.max_value
+ OR m."value" <> m."value"
+ GROUP BY m."type", m."source"
+ ORDER BY COUNT(*) DESC, m."type"::text ASC, m."source"::text ASC
+ `;
+
+ if (buckets.length === 0) {
+ console.log(
+ `Every stored measurement sits inside its metric's declared range ` +
+ `(${types.length} metric bands checked). Nothing to review.`,
+ );
+ return;
+ }
+
+ const totalRows = buckets.reduce((sum, b) => sum + Number(b.row_count), 0);
+ const affectedTypes = new Set(buckets.map((b) => b.type));
+ const affectedSources = new Set(buckets.map((b) => b.source));
+
+ console.log(
+ `${totalRows} stored row(s) sit outside the declared plausibility band, ` +
+ `across ${affectedTypes.size} metric(s) and ${affectedSources.size} source(s).\n` +
+ `Nothing below is modified. Each line is a (metric, source) bucket; the ` +
+ `band is the metric's own declared domain.\n`,
+ );
+
+ // Group by metric so the declared band is stated once per heading, and rank
+ // the metrics by how much of the problem each one is. The query's own
+ // ordering already ranks the sources inside a metric.
+ const byType = new Map();
+ for (const bucket of buckets) {
+ const bucketsForType = byType.get(bucket.type);
+ if (bucketsForType) bucketsForType.push(bucket);
+ else byType.set(bucket.type, [bucket]);
+ }
+ const ranked = [...byType.entries()].sort(
+ (a, b) =>
+ b[1].reduce((sum, x) => sum + Number(x.row_count), 0) -
+ a[1].reduce((sum, x) => sum + Number(x.row_count), 0),
+ );
+
+ for (const [type, rows] of ranked) {
+ const band = VALUE_RANGES[type]!;
+ console.log(`${type} — declared band ${band.min}…${band.max}`);
+ for (const bucket of rows) {
+ const live = Number(bucket.row_count) - Number(bucket.tombstoned);
+ console.log(
+ ` ${bucket.source.padEnd(14)} ` +
+ `${String(bucket.row_count).padStart(7)} row(s) ` +
+ `(${live} live, ${bucket.tombstoned} tombstoned) ` +
+ `across ${bucket.record_count} record(s) — ` +
+ `observed ${bucket.min_value}…${bucket.max_value}, ` +
+ `${ymd(bucket.first_at)} → ${ymd(bucket.last_at)}`,
+ );
+ }
+ }
+
+ console.log(
+ `\nThe sync writers refuse these values now, so the population above is ` +
+ `closed unless a writer without the gate is still in play. Removing a ` +
+ `row is a maintainer decision, per bucket.`,
+ );
+}
+
+main()
+ .catch((err) => {
+ console.error(err);
+ process.exitCode = 1;
+ })
+ .finally(() => {
+ void prisma.$disconnect();
+ });
diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts
new file mode 100644
index 000000000..88fbafee2
--- /dev/null
+++ b/src/__tests__/measurement-write-plausibility-guard.test.ts
@@ -0,0 +1,200 @@
+/**
+ * Structural guard on the measurement ingest boundary.
+ *
+ * `VALUE_RANGES` is the band the application declares a metric can occupy.
+ * Every path a person can drive enforced it from the start; the provider sync
+ * writers never did, and nothing noticed, because each writer is correct on
+ * its own terms — it wrote what the remote sent. The gap was only visible from
+ * above, by listing every writer at once and asking which of them had ever
+ * been told about the band.
+ *
+ * So the list lives here. Every file that writes a `Measurement` row is
+ * enumerated with the reason it is allowed to, and the guard fails when the
+ * discovered set stops matching. A provider added tomorrow lands a file that
+ * is not in the map and has to answer the question before it can ship.
+ *
+ * This is a tripwire, not a proof. It cannot show a disposition is the right
+ * one — only that nobody added a writer without writing down which gate covers
+ * it. A reviewer who waves through a bogus `derived` label defeats it, and no
+ * test substitutes for that review.
+ */
+import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { walkSourceFiles } from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/** The gate module every provider-fed writer has to reach. */
+const GATE_MODULE = "@/lib/measurements/plausibility-gate";
+/** The predicate every person-driven write path has always used. */
+const VALIDATOR = "validateMeasurementRange";
+
+/**
+ * How a writer satisfies the plausibility domain.
+ *
+ * - `gate` — provider-fed; must reach {@link GATE_MODULE} itself.
+ * - `validator` — person-driven; must call {@link VALIDATOR} itself.
+ * - `upstream` — validated in a named module before the row is built; the
+ * named module is checked, so the label cannot be a shrug.
+ * - `derived` — the value is arithmetic over rows already in the table, not a
+ * number from outside. Gating here would drop a consolidation of data that
+ * IS present, which is a different failure from admitting one that is not.
+ * - `literal` — the written value is a constant in the file, so no external
+ * number can reach the column.
+ * - `restore` — replays the record's own backup verbatim. Fidelity is the
+ * contract; a restore that silently dropped rows would be the worse bug,
+ * and the diagnostic script is how a stored impossible row gets found.
+ */
+type Disposition =
+ | { kind: "gate" }
+ | { kind: "validator" }
+ | { kind: "upstream"; validatedIn: string }
+ | { kind: "derived"; why: string }
+ | { kind: "literal"; why: string }
+ | { kind: "restore"; why: string };
+
+const WRITERS: Record = {
+ // ── Provider sync writers, gated ──────────────────────────────────────
+ "lib/withings/sync.ts": { kind: "gate" },
+ "lib/withings/sync-activity.ts": { kind: "gate" },
+ "lib/fitbit/sync-core.ts": { kind: "gate" },
+ "lib/google-health/sync-core.ts": { kind: "gate" },
+ "lib/nightscout/sync.ts": { kind: "gate" },
+ // The shared identity seam. Polar, Oura, WHOOP, the Withings sleep leg,
+ // the Apple export importer and the batch endpoint all write through it,
+ // so the gate here is what makes a new provider fail closed.
+ "lib/measurements/reconcile-external-measurement.ts": { kind: "gate" },
+
+ // ── Person-driven write paths, validated ──────────────────────────────
+ "app/api/measurements/route.ts": {
+ kind: "upstream",
+ validatedIn: "lib/validations/measurement.ts",
+ },
+ "app/api/import/route.ts": { kind: "validator" },
+ "lib/mcp/writes.ts": { kind: "validator" },
+ "lib/measurements/create-from-telegram.ts": { kind: "validator" },
+ "lib/measurements/import-apple-health-export.ts": { kind: "validator" },
+ "app/api/import/csv/route.ts": {
+ kind: "upstream",
+ validatedIn: "lib/import/csv-measurements.ts",
+ },
+
+ // ── Fixed values ──────────────────────────────────────────────────────
+ "lib/withings/sync-ecg.ts": {
+ kind: "literal",
+ why: "an ECG row carries value 1 — the file's own constant, never a provider number",
+ },
+ "app/api/mental-health/assessments/route.ts": {
+ kind: "literal",
+ why: "the questionnaire score is summed from Zod-bounded item answers",
+ },
+ "lib/insights/score-row.ts": {
+ kind: "literal",
+ why: "the health score is computed in-app on a closed 0-100 scale",
+ },
+
+ // ── Derived from rows already stored ──────────────────────────────────
+ "lib/measurements/consolidate-daily-mean.ts": {
+ kind: "derived",
+ why: "writes the mean of the day's own stored rows",
+ },
+ "lib/measurements/consolidate-legacy-steps.ts": {
+ kind: "derived",
+ why: "folds stored per-sample step rows into the day's canonical row",
+ },
+ "lib/measurements/dense-intraday-retention.ts": {
+ kind: "derived",
+ why: "rebuilds hourly buckets from stored per-sample rows",
+ },
+ "lib/measurements/drain-per-sample-cumulative.ts": {
+ kind: "derived",
+ why: "reduces stored cumulative per-sample rows to one daily total",
+ },
+
+ // ── Restore ───────────────────────────────────────────────────────────
+ "app/api/admin/backups/[id]/restore/route.ts": {
+ kind: "restore",
+ why: "replays the record's own export; dropping rows would break the restore contract",
+ },
+};
+
+/**
+ * Any Prisma client handle followed by a measurement write. Whitespace- and
+ * handle-tolerant on purpose: the earlier generation of this repo's guards
+ * demanded a literal `prisma.x.findUnique(` and went green because it matched
+ * nothing, not because nothing was there. The transaction handles in use are
+ * `tx` and `pc`; a future one is caught by the `[A-Za-z_$][\w$]*` head.
+ */
+const MEASUREMENT_WRITE =
+ /[A-Za-z_$][\w$]*\s*\.\s*measurement\s*\.\s*(create|createMany|createManyAndReturn|upsert)\s*\(/;
+
+function sourceFiles(): string[] {
+ return walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
+ .sort();
+}
+
+function read(rel: string): string {
+ return readFileSync(join(SRC, rel), "utf8");
+}
+
+function discoveredWriters(): string[] {
+ return sourceFiles().filter((rel) => MEASUREMENT_WRITE.test(read(rel)));
+}
+
+describe("every measurement writer declares how it meets the plausibility domain", () => {
+ it("finds the writers at all — an empty sweep is a broken guard, not a clean repo", () => {
+ // The map is the claim; this asserts the matcher can still see the
+ // population it describes. A regex that silently stops matching would
+ // otherwise turn every check below into a tautology.
+ expect(discoveredWriters().length).toBeGreaterThanOrEqual(15);
+ });
+
+ it("the discovered writer set matches the declared map exactly", () => {
+ expect(discoveredWriters()).toEqual(Object.keys(WRITERS).sort());
+ });
+
+ it("every provider-fed writer reaches the gate module", () => {
+ const gated = Object.entries(WRITERS)
+ .filter(([, d]) => d.kind === "gate")
+ .map(([rel]) => rel);
+ expect(gated.length).toBeGreaterThanOrEqual(6);
+ for (const rel of gated) {
+ expect(read(rel), `${rel} must import ${GATE_MODULE}`).toContain(
+ GATE_MODULE,
+ );
+ }
+ });
+
+ it("every person-driven writer calls the range validator", () => {
+ for (const [rel, disposition] of Object.entries(WRITERS)) {
+ if (disposition.kind === "validator") {
+ expect(read(rel), `${rel} must call ${VALIDATOR}`).toContain(VALIDATOR);
+ }
+ if (disposition.kind === "upstream") {
+ expect(
+ read(disposition.validatedIn),
+ `${disposition.validatedIn} must call ${VALIDATOR} on behalf of ${rel}`,
+ ).toContain(VALIDATOR);
+ }
+ }
+ });
+
+ it("the exempt writers carry a written reason", () => {
+ for (const [rel, disposition] of Object.entries(WRITERS)) {
+ if (
+ disposition.kind === "derived" ||
+ disposition.kind === "literal" ||
+ disposition.kind === "restore"
+ ) {
+ expect(disposition.why.length, `${rel} needs a reason`).toBeGreaterThan(
+ 20,
+ );
+ }
+ }
+ });
+});
diff --git a/src/__tests__/mood-backup-completeness.test.ts b/src/__tests__/mood-backup-completeness.test.ts
new file mode 100644
index 000000000..ef483325d
--- /dev/null
+++ b/src/__tests__/mood-backup-completeness.test.ts
@@ -0,0 +1,294 @@
+/**
+ * Pair guard binding `prisma/schema.prisma`'s `MoodEntry` and
+ * `MoodEntryTagLink` models to the disaster-recovery backup selects.
+ *
+ * The sibling of `measurement-backup-completeness.test.ts`, written as its own
+ * file rather than as a second describe inside it, because that guard's own
+ * note asks for model-by-model growth and one file per model keeps every
+ * failure message about one model.
+ *
+ * The failure it exists to catch, stated by the defect that prompted it: the
+ * mood payload carried `id, date, mood, score, tags, source, loggedAt,
+ * externalId, deletedAt, factors` and nothing else. The free-text note, the
+ * row's timezone and the last-writer-wins counter were selected by the query
+ * and written to no payload, so a restored entry came back without its text,
+ * read its day boundaries under the legacy Europe/Berlin assumption, and lost
+ * the next sync round to a paired device still holding a higher counter. The
+ * backup validation schema is `.passthrough()`, so nothing complained at parse
+ * time either. Nothing here was a wrong value; every one of them was silence.
+ *
+ * Both ends of the comparison are literals — a Prisma model block and a named
+ * select object — so the matcher is exact. Every matcher also asserts a
+ * non-zero match count: an empty match set fails rather than passing quietly,
+ * which is how the bearer-scope guard stayed green for months while matching
+ * nothing at all.
+ *
+ * Mutation check: comment out `tz: true` in `MOOD_ENTRY_BACKUP_SELECT` and
+ * this goes red naming that column; add a scalar to the model without adding
+ * it to the select and it goes red naming the new one; rename the mapper's
+ * `moodEntry` parameter and the serialization assertion goes red on all of
+ * them at once.
+ */
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, it, expect } from "vitest";
+
+const ROOT = process.cwd();
+const SCHEMA = join(ROOT, "prisma", "schema.prisma");
+const BACKUP_BUILDER = join(
+ ROOT,
+ "src",
+ "lib",
+ "export",
+ "full-backup-payload.ts",
+);
+
+/**
+ * Columns the backup is correct to omit, each with the reason. The reasons are
+ * length-checked below: an exclusion is a claim, and a claim needs saying.
+ */
+const EXCLUDED_MOOD_ENTRY_COLUMNS: Record = {
+ userId: "user-scoped backup; owner is re-derived from the restoring account",
+};
+
+const EXCLUDED_TAG_LINK_COLUMNS: Record = {
+ moodEntryId: "re-bound to the restored parent row's id, never carried",
+ moodTagId:
+ "the link travels by the tag's key; a source-instance id need not exist in the target catalogue",
+ createdAt:
+ "the link's own row stamp; the entry carries the moment the person logged it, and a key-only BINARY tag has nowhere to put a second one",
+};
+
+function modelBlock(schema: string, model: string): string {
+ const start = schema.indexOf(`model ${model} {`);
+ expect(start, `model ${model} not found`).toBeGreaterThan(-1);
+ const end = schema.indexOf("\n}", start);
+ expect(end).toBeGreaterThan(start);
+ return schema.slice(start, end);
+}
+
+/** Names of every `model` block, so relation fields can be told apart. */
+function declaredModels(schema: string): Set {
+ const out = new Set();
+ for (const m of schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)) {
+ out.add(m[1]);
+ }
+ return out;
+}
+
+/**
+ * Scalar (column-backed) fields of a model block: everything that is not a
+ * relation to another model. Enums and native scalars both map to columns and
+ * therefore both belong in a backup.
+ */
+function scalarFields(block: string, models: Set): string[] {
+ const fields: string[] = [];
+ for (const rawLine of block.split("\n").slice(1)) {
+ const line = rawLine.trim();
+ if (!line || line.startsWith("//") || line.startsWith("@@")) continue;
+ const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line);
+ if (!m) continue;
+ const [, name, type, isList] = m;
+ if (isList) continue;
+ if (models.has(type)) continue;
+ if (line.includes("@relation")) continue;
+ fields.push(name);
+ }
+ return fields;
+}
+
+/** Keys of a named `const = {` select literal. */
+function selectFields(source: string, constant: string): string[] {
+ const start = source.indexOf(`const ${constant} = {`);
+ expect(start, `${constant} not found`).toBeGreaterThan(-1);
+ const end = source.indexOf("\n}", start);
+ expect(end).toBeGreaterThan(start);
+ const body = source.slice(start, end);
+ return [...body.matchAll(/^ {2}([a-zA-Z_$][\w$]*): true,$/gm)].map(
+ (m) => m[1],
+ );
+}
+
+/**
+ * The builder with its comment lines removed.
+ *
+ * Every matcher below runs against this rather than against the raw file,
+ * because prose describing the defect reads exactly like the defect: the first
+ * draft of this guard went red on its own explanation of the filter it exists
+ * to forbid. Whole comment lines only — a trailing comment after code is left
+ * alone, which is safe here because the only risk it carries is a matcher
+ * accepting a column named in prose, and the select comparison beside it is
+ * literal either way.
+ */
+function stripCommentLines(source: string): string {
+ return source
+ .split("\n")
+ .filter((line) => {
+ const trimmed = line.trim();
+ return !(
+ trimmed.startsWith("//") ||
+ trimmed.startsWith("/*") ||
+ trimmed.startsWith("*")
+ );
+ })
+ .join("\n");
+}
+
+/**
+ * Columns either serialization arm actually reads off the mapped row.
+ *
+ * The capture is the RIGHT-hand side — the column — not the payload key, which
+ * matters here in a way it did not for `Measurement`: the mood payload renames
+ * `moodLoggedAt` to `loggedAt` on the wire, so a left-hand matcher would report
+ * the column as dropped while the value rides perfectly well.
+ *
+ * The two arms share one restorable wire format, so this is a union and
+ * therefore a weaker claim than the select comparison: it catches a column that
+ * is selected and then written to neither payload, not one carried by only a
+ * single arm.
+ */
+function serialisedFields(source: string, variable: string): Set {
+ const matches = [
+ ...source.matchAll(
+ new RegExp(`[:(,]\\s*${variable}\\.([a-zA-Z_$][\\w$]*)`, "g"),
+ ),
+ ];
+ expect(
+ matches.length,
+ `no \`${variable}.\` reads found — the mapper parameter was renamed and this matcher now proves nothing`,
+ ).toBeGreaterThan(0);
+ return new Set(matches.map((m) => m[1]));
+}
+
+describe("mood backup completeness", () => {
+ const schema = readFileSync(SCHEMA, "utf8");
+ const source = stripCommentLines(readFileSync(BACKUP_BUILDER, "utf8"));
+ const models = declaredModels(schema);
+
+ const entryColumns = scalarFields(modelBlock(schema, "MoodEntry"), models);
+ const entrySelected = selectFields(source, "MOOD_ENTRY_BACKUP_SELECT");
+ const linkColumns = scalarFields(
+ modelBlock(schema, "MoodEntryTagLink"),
+ models,
+ );
+ const linkSelected = selectFields(source, "MOOD_ENTRY_TAG_LINK_SELECT");
+
+ it("parses both ends into a plausible shape", () => {
+ expect(models.size).toBeGreaterThan(50);
+ expect(entryColumns.length).toBeGreaterThan(10);
+ expect(entryColumns).toContain("noteEncrypted");
+ expect(entryColumns).toContain("syncVersion");
+ expect(entryColumns).not.toContain("user");
+ expect(entryColumns).not.toContain("tagLinks");
+ expect(entrySelected.length).toBeGreaterThan(10);
+
+ expect(linkColumns).toEqual(
+ expect.arrayContaining([
+ "moodEntryId",
+ "moodTagId",
+ "createdAt",
+ "rating",
+ ]),
+ );
+ expect(linkColumns).not.toContain("moodEntry");
+ expect(linkColumns).not.toContain("moodTag");
+ expect(linkSelected.length).toBeGreaterThan(0);
+ });
+
+ it("the backup select covers every MoodEntry column", () => {
+ const missing = entryColumns.filter(
+ (c) => !entrySelected.includes(c) && !(c in EXCLUDED_MOOD_ENTRY_COLUMNS),
+ );
+
+ if (missing.length > 0) {
+ const report = missing.map((c) => ` ❌ ${c}`).join("\n");
+ throw new Error(
+ `MOOD_ENTRY_BACKUP_SELECT omits ${missing.length} column(s) of the MoodEntry model:\n${report}\n\n` +
+ "A disaster-recovery restore brings these back at their column " +
+ "default. Add each to the select and to both serialization arms, " +
+ "extend `moodEntrySchema` and the restore write — or add it to " +
+ "EXCLUDED_MOOD_ENTRY_COLUMNS with the reason the backup is correct " +
+ "to drop it.",
+ );
+ }
+ });
+
+ it("every selected MoodEntry column reaches a serialized payload", () => {
+ const emitted = serialisedFields(source, "moodEntry");
+ const dropped = entrySelected.filter((c) => !emitted.has(c));
+
+ if (dropped.length > 0) {
+ const report = dropped.map((c) => ` ❌ ${c}`).join("\n");
+ throw new Error(
+ `${dropped.length} selected MoodEntry column(s) reach neither serialization arm:\n${report}\n\n` +
+ "The query pays for them and no payload carries them, so a restore " +
+ "cannot put them back. Emit each from the disaster-recovery arm " +
+ "(and, where it is readable and not a tombstone, from the portable " +
+ "arm too).",
+ );
+ }
+ });
+
+ it("the tag-link select covers every MoodEntryTagLink column", () => {
+ const missing = linkColumns.filter(
+ (c) => !linkSelected.includes(c) && !(c in EXCLUDED_TAG_LINK_COLUMNS),
+ );
+ expect(
+ missing,
+ `MOOD_ENTRY_TAG_LINK_SELECT omits MoodEntryTagLink column(s): ${missing.join(", ")}`,
+ ).toEqual([]);
+ });
+
+ it("every selected tag-link column reaches a serialized payload", () => {
+ const emitted = serialisedFields(source, "tagLink");
+ const dropped = linkSelected.filter((c) => !emitted.has(c));
+ expect(
+ dropped,
+ `selected but serialized by neither arm: ${dropped.join(", ")}`,
+ ).toEqual([]);
+ });
+
+ it("both structured-tag arms are carried, not just the rated one", () => {
+ // The defect this file was written for: the tag-link read carried a
+ // filter, and a BINARY link's rating is NULL by definition, so every
+ // present/absent tag — the kind most people pick — was excluded from the
+ // file and could not come back from it. The claim is therefore about the
+ // read itself: it takes the links whole, unfiltered.
+ //
+ // Asserted on booleans rather than on the file contents so a failure reads
+ // as one line instead of printing the whole builder back.
+ const tagLinkRead = /tagLinks:\s*\{([\s\S]*?)\}/.exec(source);
+ expect(
+ tagLinkRead,
+ "no `tagLinks:` read found in the backup builder — this matcher proves nothing",
+ ).not.toBeNull();
+ expect(
+ /where/.test(tagLinkRead![1]),
+ "the tag-link read filters again; a filter here is what dropped every BINARY link from the backup",
+ ).toBe(false);
+ expect(
+ source.includes("structuredTags"),
+ "no `structuredTags` key in the payload — the BINARY links have no carrier",
+ ).toBe(true);
+ });
+
+ it("the exclusion lists carry no stale entry", () => {
+ for (const [column, reason] of Object.entries(
+ EXCLUDED_MOOD_ENTRY_COLUMNS,
+ )) {
+ expect(
+ entryColumns,
+ `${column} is excluded but is no longer a MoodEntry column`,
+ ).toContain(column);
+ expect(reason.length).toBeGreaterThan(20);
+ }
+ for (const [column, reason] of Object.entries(EXCLUDED_TAG_LINK_COLUMNS)) {
+ expect(
+ linkColumns,
+ `${column} is excluded but is no longer a MoodEntryTagLink column`,
+ ).toContain(column);
+ expect(reason.length).toBeGreaterThan(20);
+ }
+ });
+});
diff --git a/src/__tests__/written-outcome-response-consumer-guard.test.ts b/src/__tests__/written-outcome-response-consumer-guard.test.ts
index 904107571..335b152f5 100644
--- a/src/__tests__/written-outcome-response-consumer-guard.test.ts
+++ b/src/__tests__/written-outcome-response-consumer-guard.test.ts
@@ -724,6 +724,13 @@ const OCR_EXCEPTIONS: Readonly> = {
"inserted.unit": OCR_ROW_EXCEPTION,
"inserted.referenceLow": OCR_ROW_EXCEPTION,
"inserted.referenceHigh": OCR_ROW_EXCEPTION,
+ "inserted.catalogReferenceLow": OCR_ROW_EXCEPTION,
+ "inserted.catalogReferenceHigh": OCR_ROW_EXCEPTION,
+ "inserted.sourceReferenceLow": OCR_ROW_EXCEPTION,
+ "inserted.sourceReferenceHigh": OCR_ROW_EXCEPTION,
+ "inserted.sourceReferenceText": OCR_ROW_EXCEPTION,
+ "inserted.referenceOrigin": OCR_ROW_EXCEPTION,
+ "inserted.referenceDivergesFromCatalog": OCR_ROW_EXCEPTION,
"inserted.takenAt": OCR_ROW_EXCEPTION,
"inserted.source": OCR_ROW_EXCEPTION,
"inserted.hasNote": OCR_ROW_EXCEPTION,
diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts
index 3c22417f3..4f3597843 100644
--- a/src/app/api/admin/backups/[id]/restore/route.ts
+++ b/src/app/api/admin/backups/[id]/restore/route.ts
@@ -684,6 +684,10 @@ const handler = apiHandler(
entry.factors.map((factor) => factor.key),
);
const factorKeys = [...new Set(referencedFactorKeys)];
+ const referencedTagKeys = payload.moodEntries.flatMap(
+ (entry) => entry.structuredTags,
+ );
+ const tagKeys = [...new Set(referencedTagKeys)];
// Re-create the account's own tag definitions first. The lookup
// below used to ask only for the seeded catalogue (`userId: null`),
// so a single custom rated tag made the whole restore throw
@@ -740,6 +744,30 @@ const handler = apiHandler(
referencedFactorKeys,
);
+ // The BINARY half of the same taxonomy, looked up the same way and
+ // reported the same way. It gets its own family because an
+ // operator reading the report needs to know whether a rated
+ // factor's score or a ticked tag went missing; they are different
+ // losses on the same entry.
+ const tagRows =
+ tagKeys.length === 0
+ ? []
+ : await tx.moodTag.findMany({
+ where: {
+ key: { in: tagKeys },
+ kind: "BINARY",
+ OR: [{ userId: null }, { userId: ownerId }],
+ },
+ select: { id: true, key: true },
+ });
+ const tagByKey = new Map(tagRows.map((tag) => [tag.key, tag.id]));
+ recordUnknownKeys(
+ skips,
+ "moodTag",
+ tagKeys.filter((key) => !tagByKey.has(key)),
+ referencedTagKeys,
+ );
+
for (const entry of payload.moodEntries) {
const moodLoggedAt = new Date(entry.loggedAt);
const restoredData = {
@@ -747,10 +775,44 @@ const handler = apiHandler(
mood: entry.mood,
score: entry.score,
tags: entry.tags ?? null,
+ // The note comes back the way every other dual-column note
+ // does: ciphertext verbatim when the file carries it, and a
+ // portable file's plaintext re-encrypted on the way in, with
+ // the legacy column left null either way.
+ note: null,
+ noteEncrypted:
+ entry.noteEncrypted == null
+ ? encryptNote(entry.note ?? null)
+ : decodeEncryptedBytes(entry.noteEncrypted),
source: entry.source ?? "MOODLOG",
externalId: entry.externalId ?? null,
moodLoggedAt,
+ // The zone the `date` string is anchored to. A file written
+ // before this rode the payload carries nothing, and NULL is
+ // what such a row meant anyway: the legacy Europe/Berlin
+ // reading. So an old file keeps its old day boundaries and a
+ // new one keeps the zone the person actually logged in.
+ tz: entry.tz ?? null,
+ // Mood reconciles last-writer-wins by `syncVersion`. Restoring
+ // at the schema default would hand the next sync round to any
+ // paired device still holding a higher number, and the device
+ // would overwrite the recovered row with what it had — a
+ // restore that loses to the client is worse than one that
+ // fails. An old file carries nothing here and keeps the
+ // default, which is the value those rows were written with.
+ ...(entry.syncVersion === undefined
+ ? {}
+ : { syncVersion: entry.syncVersion }),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt) : null,
+ ...(entry.syncedAt
+ ? { syncedAt: new Date(entry.syncedAt) }
+ : {}),
+ ...(entry.createdAt
+ ? { createdAt: new Date(entry.createdAt) }
+ : {}),
+ ...(entry.updatedAt
+ ? { updatedAt: new Date(entry.updatedAt) }
+ : {}),
};
const createData = { userId: ownerId, ...restoredData };
const restored = entry.id
@@ -798,8 +860,19 @@ const handler = apiHandler(
]
: [];
});
- if (factorLinks.length > 0) {
- await tx.moodEntryTagLink.createMany({ data: factorLinks });
+ // The other arm. A BINARY link carries no score — the presence
+ // of the row is the signal — so `rating` is NULL by definition,
+ // which is exactly what the backup's old tag-link filter took to
+ // mean "nothing here".
+ const tagLinks = entry.structuredTags.flatMap((key) => {
+ const moodTagId = tagByKey.get(key);
+ return moodTagId
+ ? [{ moodEntryId: restored.id, moodTagId, rating: null }]
+ : [];
+ });
+ const links = [...factorLinks, ...tagLinks];
+ if (links.length > 0) {
+ await tx.moodEntryTagLink.createMany({ data: links });
}
}
}
@@ -894,6 +967,9 @@ const handler = apiHandler(
unit: lab.unit,
referenceLow: lab.referenceLow ?? null,
referenceHigh: lab.referenceHigh ?? null,
+ sourceReferenceLow: lab.sourceReferenceLow ?? null,
+ sourceReferenceHigh: lab.sourceReferenceHigh ?? null,
+ sourceReferenceText: lab.sourceReferenceText ?? null,
takenAt: new Date(lab.takenAt),
source: lab.source,
noteEncrypted:
diff --git a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts
index 042b1387e..498a7ce25 100644
--- a/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts
+++ b/src/app/api/documents/inbound/[id]/facts/[factId]/route.ts
@@ -57,6 +57,7 @@ function editToFactData(edit: InboundFactEdit): FactData {
unit: edit.unit ?? null,
referenceLow: edit.referenceLow ?? null,
referenceHigh: edit.referenceHigh ?? null,
+ referenceText: edit.referenceText ?? null,
effectiveDate: edit.effectiveDate ?? null,
};
return data;
diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts
index 452af57da..ddfde9972 100644
--- a/src/app/api/insights/labs-changes/route.ts
+++ b/src/app/api/insights/labs-changes/route.ts
@@ -46,6 +46,9 @@ export const GET = apiHandler(async () => {
value: true,
referenceLow: true,
referenceHigh: true,
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
takenAt: true,
},
orderBy: { takenAt: "desc" },
@@ -58,6 +61,9 @@ export const GET = apiHandler(async () => {
value: r.value as number,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
+ sourceReferenceLow: r.sourceReferenceLow,
+ sourceReferenceHigh: r.sourceReferenceHigh,
+ sourceReferenceText: r.sourceReferenceText,
takenAt: r.takenAt,
})),
);
diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts
index d143259fb..34afa72a7 100644
--- a/src/app/api/labs/[id]/route.ts
+++ b/src/app/api/labs/[id]/route.ts
@@ -143,6 +143,12 @@ export const PUT = apiHandler(
// historical columns the serialiser ignores. Reject the attempt with a
// 422 rather than silently no-op, so the client never believes an edit
// landed. Edit such fields on the Biomarker itself instead.
+ //
+ // `sourceReference*` is deliberately NOT on that list. The catalog band is
+ // a property of the marker; the source window is a property of THIS
+ // reading — it is what one report printed beside one value — so it is
+ // editable on a linked row and there is nothing on the biomarker to edit
+ // it on instead.
const isLinked = existing.biomarkerId !== null;
if (
isLinked &&
@@ -194,6 +200,15 @@ export const PUT = apiHandler(
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
+ if (d.sourceReferenceLow !== undefined) {
+ data.sourceReferenceLow = d.sourceReferenceLow;
+ }
+ if (d.sourceReferenceHigh !== undefined) {
+ data.sourceReferenceHigh = d.sourceReferenceHigh;
+ }
+ if (d.sourceReferenceText !== undefined) {
+ data.sourceReferenceText = d.sourceReferenceText;
+ }
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
// Inverted-range guard for a PARTIAL bound update. The schema-level
@@ -212,6 +227,20 @@ export const PUT = apiHandler(
errorCode: "labs.update.referenceRangeInvalid",
});
}
+ // The same partial-update hole exists on the source window, and closes the
+ // same way.
+ if (
+ isInvertedRange(
+ effectiveBound(d.sourceReferenceLow, existing.sourceReferenceLow),
+ effectiveBound(d.sourceReferenceHigh, existing.sourceReferenceHigh),
+ )
+ ) {
+ return apiError(
+ "sourceReferenceLow must not exceed sourceReferenceHigh",
+ 422,
+ { errorCode: "labs.update.sourceReferenceRangeInvalid" },
+ );
+ }
if (d.note !== undefined) {
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
@@ -239,6 +268,9 @@ export const PUT = apiHandler(
panel: existing.panel,
referenceLow: existing.referenceLow,
referenceHigh: existing.referenceHigh,
+ sourceReferenceLow: existing.sourceReferenceLow,
+ sourceReferenceHigh: existing.sourceReferenceHigh,
+ sourceReferenceText: existing.sourceReferenceText,
takenAt: existing.takenAt,
},
after: {
@@ -249,6 +281,9 @@ export const PUT = apiHandler(
panel: updated.panel,
referenceLow: updated.referenceLow,
referenceHigh: updated.referenceHigh,
+ sourceReferenceLow: updated.sourceReferenceLow,
+ sourceReferenceHigh: updated.sourceReferenceHigh,
+ sourceReferenceText: updated.sourceReferenceText,
takenAt: updated.takenAt,
},
redacted: d.note !== undefined ? ["note"] : [],
diff --git a/src/app/api/labs/ocr/commit/route.ts b/src/app/api/labs/ocr/commit/route.ts
index d6bd0b431..b8700562e 100644
--- a/src/app/api/labs/ocr/commit/route.ts
+++ b/src/app/api/labs/ocr/commit/route.ts
@@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
+import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { serialiseLabResult } from "@/lib/labs/serialise";
import {
linkOcrLabsToVaultDocument,
@@ -151,12 +152,19 @@ async function commitOcrRows(request: NextRequest) {
}
const isQualitative = row.valueText !== undefined;
+ // The window the scan read off the report. The confirmed numeric bounds
+ // win over a re-parse of the string (the human may have corrected them on
+ // the review screen); the string rides along either way.
+ const printed = parseReferenceRange(row.referenceText, row.unit);
+ const sourceLow = row.referenceLow ?? printed?.low ?? null;
+ const sourceHigh = row.referenceHigh ?? printed?.high ?? null;
+ const sourceText = printed?.text ?? null;
const biomarker = await resolveOrMintBiomarker(user.id, {
analyte: row.analyte,
// A qualitative reading has no numeric unit / range.
unit: isQualitative ? (row.unit ?? "") : (row.unit as string),
- referenceLow: isQualitative ? null : (row.referenceLow ?? null),
- referenceHigh: isQualitative ? null : (row.referenceHigh ?? null),
+ referenceLow: isQualitative ? null : sourceLow,
+ referenceHigh: isQualitative ? null : sourceHigh,
panel: row.panel ?? null,
});
@@ -173,6 +181,9 @@ async function commitOcrRows(request: NextRequest) {
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
+ sourceReferenceLow: isQualitative ? null : sourceLow,
+ sourceReferenceHigh: isQualitative ? null : sourceHigh,
+ sourceReferenceText: isQualitative ? null : sourceText,
takenAt: row.takenAt,
source: "OCR",
noteEncrypted: null,
diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts
index 1ab53c06c..60e325499 100644
--- a/src/app/api/labs/route.ts
+++ b/src/app/api/labs/route.ts
@@ -184,6 +184,9 @@ async function postLabResult(request: NextRequest) {
unit,
referenceLow,
referenceHigh,
+ sourceReferenceLow,
+ sourceReferenceHigh,
+ sourceReferenceText,
takenAt,
note,
source,
@@ -248,6 +251,13 @@ async function postLabResult(request: NextRequest) {
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
+ // The window this reading's own report printed, kept beside the catalog
+ // stamp rather than folded into it: it governs THIS reading's verdict,
+ // and the catalog band stays the net for every reading without one. A
+ // qualitative reading has no window to record.
+ sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null),
+ sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null),
+ sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null),
takenAt,
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
// `source: "OCR"`; an omitted field stays "MANUAL" so the legacy
@@ -258,6 +268,7 @@ async function postLabResult(request: NextRequest) {
},
});
invalidateUserHealthScore(user.id);
+ const dto = serialiseLabResult(created, biomarker);
await auditLog("labResult.create", {
userId: user.id,
@@ -275,6 +286,9 @@ async function postLabResult(request: NextRequest) {
structured: biomarkerId !== undefined,
// v1.25 (iOS #36) — provenance of the reading ("MANUAL" | "OCR").
source: source ?? "MANUAL",
+ // Which window governs this reading's verdict, so a dashboard can see
+ // how often a report's own range displaces the catalog band.
+ referenceOrigin: dto.referenceOrigin,
},
});
@@ -298,5 +312,5 @@ async function postLabResult(request: NextRequest) {
source: "manual",
}).catch(() => {});
- return apiSuccess(serialiseLabResult(created, biomarker), 201);
+ return apiSuccess(dto, 201);
}
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index df8ffcd59..1199e2354 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -620,6 +620,17 @@ async function postBatch(request: NextRequest): Promise {
};
failedCount++;
continue;
+ case "rejected_range":
+ // The reconciler carries the same plausibility gate this route
+ // already applied above, so reaching here means the two disagreed —
+ // report it exactly as the earlier guard would have, and let the
+ // wide-event tally record which end refused.
+ results[p.index] = {
+ index: p.index,
+ status: "skipped",
+ reason: "value_out_of_range",
+ };
+ continue;
}
if (isMergeableSource(p.row.source as string)) {
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index 2df37779f..76c3acede 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -194,6 +194,9 @@ function catalogueLabel(
if (catalogue === "illnessSymptom") {
return t("admin.section.backups.restoreSkippedIllnessSymptom");
}
+ if (catalogue === "moodTag") {
+ return t("admin.section.backups.restoreSkippedMoodTag");
+ }
return t("admin.section.backups.restoreSkippedMoodFactor");
}
diff --git a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx
index bec4b97b6..a58b764ac 100644
--- a/src/components/labs/__tests__/lab-biomarker-detail.test.tsx
+++ b/src/components/labs/__tests__/lab-biomarker-detail.test.tsx
@@ -64,6 +64,13 @@ function reading(id: string, value: number, takenAt: string): LabResultDto {
unit: "mg/dL",
referenceLow: 0,
referenceHigh: 100,
+ catalogReferenceLow: 0,
+ catalogReferenceHigh: 100,
+ sourceReferenceLow: null,
+ sourceReferenceHigh: null,
+ sourceReferenceText: null,
+ referenceOrigin: "catalog",
+ referenceDivergesFromCatalog: false,
takenAt,
source: "MANUAL",
hasNote: false,
diff --git a/src/components/labs/lab-biomarker-chart.tsx b/src/components/labs/lab-biomarker-chart.tsx
index 96a48fad9..db6612d54 100644
--- a/src/components/labs/lab-biomarker-chart.tsx
+++ b/src/components/labs/lab-biomarker-chart.tsx
@@ -17,6 +17,7 @@ import { Button } from "@/components/ui/button";
import { CHART_HEIGHT_PX } from "@/lib/charts/constants";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { formatLabValue } from "@/lib/labs/format-value";
+import { formatReferenceRange } from "@/lib/labs/reference-range";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { RichChartTooltip, type RichTooltipRow } from "../charts/chart-tooltip";
@@ -32,6 +33,15 @@ import type { LabResultDto } from "./types";
* MUTED primary fill — never a danger colour — honouring the no-alarming-
* colour rule. Every reading carries the same y-axis unit because a chart is
* one biomarker only.
+ *
+ * A SECOND band draws the window a source report printed, when the readings in
+ * view carry one. It is visually distinct from the catalog band — an outlined
+ * band in the chart's secondary hue against the catalog's filled primary — and
+ * both stay muted; the in/out verdict is the badge's job, never the chart's
+ * colour. The source window is a per-reading fact, so the band is drawn from
+ * the NEWEST reading in view that carries one and the legend dates it. When
+ * the readings in view carry more than one distinct printed window the legend
+ * says so rather than implying one window covered the whole series.
*/
const RANGE_DAYS = [
@@ -46,6 +56,26 @@ type RangeKey = (typeof RANGE_DAYS)[number]["key"];
interface ChartPoint {
timestamp: number;
value: number;
+ /** The window this reading was judged against, for the tooltip. */
+ referenceLow: number | null;
+ referenceHigh: number | null;
+ referenceOrigin: LabResultDto["referenceOrigin"];
+ sourceReferenceText: string | null;
+}
+
+/** The source window drawn as the second band, resolved from the readings in view. */
+interface SourceBand {
+ low: number | null;
+ high: number | null;
+ /** The reading the band was taken from. */
+ takenAt: number;
+ /** True when the readings in view printed more than one distinct window. */
+ mixed: boolean;
+}
+
+/** Identity of a printed window, for counting distinct ones. */
+function bandKey(low: number | null, high: number | null): string {
+ return `${low ?? ""}|${high ?? ""}`;
}
export function LabBiomarkerChart({
@@ -87,9 +117,37 @@ export function LabBiomarkerChart({
return filtered.map((r) => ({
timestamp: new Date(r.takenAt).getTime(),
value: r.value,
+ referenceLow: r.referenceLow,
+ referenceHigh: r.referenceHigh,
+ referenceOrigin: r.referenceOrigin,
+ sourceReferenceText: r.sourceReferenceText,
}));
}, [readings, range, nowMs]);
+ // The source band: the newest in-view reading whose report printed a window
+ // with usable bounds. Readings without one contribute nothing — an absent
+ // window is absent, not a reason to widen or narrow the band.
+ const sourceBand = useMemo(() => {
+ const withWindow = points.filter(
+ (p) =>
+ p.referenceOrigin === "source" &&
+ (p.referenceLow !== null || p.referenceHigh !== null),
+ );
+ if (withWindow.length === 0) return null;
+ const newest = withWindow.reduce((a, b) =>
+ b.timestamp > a.timestamp ? b : a,
+ );
+ const distinct = new Set(
+ withWindow.map((p) => bandKey(p.referenceLow, p.referenceHigh)),
+ );
+ return {
+ low: newest.referenceLow,
+ high: newest.referenceHigh,
+ takenAt: newest.timestamp,
+ mixed: distinct.size > 1,
+ };
+ }, [points]);
+
// Y domain wraps both the data and the reference window so the band is
// always visible even when every reading sits inside it.
const yDomain = useMemo<[number, number]>(() => {
@@ -97,15 +155,23 @@ export function LabBiomarkerChart({
const candidates = [...values];
if (lowerBound != null) candidates.push(lowerBound);
if (upperBound != null) candidates.push(upperBound);
+ // The source band has to fit too, or the second band is drawn off-canvas
+ // and the chart silently shows one window while claiming two.
+ if (sourceBand?.low != null) candidates.push(sourceBand.low);
+ if (sourceBand?.high != null) candidates.push(sourceBand.high);
if (candidates.length === 0) return [0, 1];
const min = Math.min(...candidates);
const max = Math.max(...candidates);
const pad = (max - min || Math.abs(max) || 1) * 0.1;
return [min - pad, max + pad];
- }, [points, lowerBound, upperBound]);
+ }, [points, lowerBound, upperBound, sourceBand]);
const animate = !prefersReducedMotion();
const primary = "var(--primary)";
+ // A second muted hue from the chart palette. Distinct from the primary band
+ // by hue AND by treatment (outline vs fill), so the two are told apart
+ // without relying on colour alone.
+ const secondary = "var(--chart-2)";
return (
@@ -185,6 +251,42 @@ export function LabBiomarkerChart({
strokeDasharray="4 4"
/>
) : null}
+ {/* The source window — outlined, not filled, so it reads as a
+ second band over the catalog one rather than a second fill
+ competing with it. Same no-alarm-colour rule. */}
+ {sourceBand &&
+ sourceBand.low != null &&
+ sourceBand.high != null ? (
+
+ ) : null}
+ {sourceBand &&
+ sourceBand.low != null &&
+ sourceBand.high == null ? (
+
+ ) : null}
+ {sourceBand &&
+ sourceBand.high != null &&
+ sourceBand.low == null ? (
+
+ ) : null}
{
const active = props.active ?? false;
@@ -201,6 +303,22 @@ export function LabBiomarkerChart({
color: primary,
},
];
+ // Name the window THIS reading was judged against. Two
+ // readings on one chart can carry different printed windows,
+ // so the band alone cannot answer it per point.
+ if (point.referenceOrigin === "source") {
+ rows.push({
+ name: t("labs.chart.sourceRangeLabel"),
+ value:
+ point.sourceReferenceText ??
+ `${formatReferenceRange(
+ point.referenceLow,
+ point.referenceHigh,
+ formatLabValue,
+ )} ${unit}`.trim(),
+ color: secondary,
+ });
+ }
return (
+ {sourceBand ? (
+
)}
diff --git a/src/components/labs/lab-form.tsx b/src/components/labs/lab-form.tsx
index 476c5da8a..4e865e9dd 100644
--- a/src/components/labs/lab-form.tsx
+++ b/src/components/labs/lab-form.tsx
@@ -22,6 +22,7 @@ import {
import { Textarea } from "@/components/ui/textarea";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { localizedApiError } from "@/lib/api/localized-error";
+import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { formatReferenceRange } from "@/lib/labs/reference-range";
import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
@@ -118,6 +119,13 @@ export function LabForm({
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [note, setNote] = useState("");
+ // The window the paper report printed for THIS reading. Optional and folded
+ // away by default: most entries are typed against a catalog marker and never
+ // need it, and a field nobody fills is a field in the way. One text input,
+ // because that is how a report states it — "3,5 - 5,0", "< 5", "bis 5,0".
+ // The bounds are derived from what is typed; what is typed is kept verbatim.
+ const [sourceRangeOpen, setSourceRangeOpen] = useState(false);
+ const [sourceRange, setSourceRange] = useState("");
// v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't
// also show a spinner. `!== null` replaces the old plain `submitting`
// boolean for every disabled check below.
@@ -193,11 +201,27 @@ export function LabForm({
setPendingAction(keepOpen ? "saveAndAddAnother" : "save");
try {
+ // Parse client-side so the user sees the derived window before saving,
+ // and send BOTH: the bounds where they could be read, the text always.
+ const printed = isQualitative
+ ? null
+ : parseReferenceRange(sourceRange, selected?.unit);
const created = await apiPost("/api/labs", {
biomarkerId,
...(isQualitative
? { valueText: qualitativeValue }
: { value: numericValue }),
+ ...(printed
+ ? {
+ ...(printed.low !== null
+ ? { sourceReferenceLow: printed.low }
+ : {}),
+ ...(printed.high !== null
+ ? { sourceReferenceHigh: printed.high }
+ : {}),
+ sourceReferenceText: printed.text,
+ }
+ : {}),
takenAt: new Date(takenAt).toISOString(),
...(note.trim() ? { note: note.trim() } : {}),
});
@@ -221,6 +245,8 @@ export function LabForm({
setValue("");
setValueText("");
setNote("");
+ // The next analyte off the same report has its own printed window.
+ setSourceRange("");
setError(null);
onSavedKeepOpen?.(created);
// Return focus to wherever the next entry starts so the flow
@@ -252,6 +278,20 @@ export function LabForm({
)
: "";
+ // What the typed string resolved to, shown back so the user can see whether
+ // a window was read out of it before the reading is saved. When none was,
+ // the hint says the text is kept as written rather than staying silent.
+ const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
+ const sourceRangeHint = parsedSourceRange
+ ? parsedSourceRange.low === null && parsedSourceRange.high === null
+ ? t("labs.form.sourceRangeUnparsed")
+ : `${formatReferenceRange(
+ parsedSourceRange.low,
+ parsedSourceRange.high,
+ formatLabValue,
+ )} ${selected?.unit ?? ""}`.trim()
+ : "";
+
const footerNode = (
<>
{onCancel ? (
@@ -415,6 +455,39 @@ export function LabForm({
) : null}
+ {/* The window as the report printed it. Editable, because the human is
+ reviewing a transcription: the two bounds above are the derived
+ reading of this string, and correcting the string is how a range the
+ parser could not read ("bis 5,0", "negativ") still reaches the row
+ instead of being dropped. */}
+ {!isQualitative ? (
+
diff --git a/src/components/labs/source-range-note.tsx b/src/components/labs/source-range-note.tsx
new file mode 100644
index 000000000..2fb6e6929
--- /dev/null
+++ b/src/components/labs/source-range-note.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { formatLabValue } from "@/lib/labs/format-value";
+import { formatReferenceRange } from "@/lib/labs/reference-range";
+import { useTranslations } from "@/lib/i18n/context";
+
+import type { LabResultDto } from "./types";
+
+/**
+ * The reference window a reading's own report printed, shown wherever that
+ * reading is shown.
+ *
+ * Renders nothing for a reading with no printed window — most of them — so it
+ * costs no space on the common row. When the report DID state a window it
+ * names it, verbatim where the report gave words; and when that window and the
+ * catalog band disagree it names the catalog band too. A reader told only
+ * "3,9–5,4" and a reader told only "3,5–5,0" draw different conclusions from
+ * the same number, so a surface that shows one silently is showing a partial
+ * answer.
+ *
+ * Neutral by construction: no tint, no icon, muted text. The in/out verdict is
+ * the badge's job — this element states where the window came from, nothing
+ * about whether the value is good news.
+ */
+export function SourceRangeNote({
+ reading,
+ className,
+}: {
+ reading: Pick<
+ LabResultDto,
+ | "unit"
+ | "referenceLow"
+ | "referenceHigh"
+ | "catalogReferenceLow"
+ | "catalogReferenceHigh"
+ | "sourceReferenceText"
+ | "referenceOrigin"
+ | "referenceDivergesFromCatalog"
+ >;
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ if (reading.referenceOrigin !== "source") return null;
+
+ const printed =
+ reading.sourceReferenceText ??
+ `${formatReferenceRange(
+ reading.referenceLow,
+ reading.referenceHigh,
+ formatLabValue,
+ )} ${reading.unit}`.trim();
+
+ const catalog = formatReferenceRange(
+ reading.catalogReferenceLow,
+ reading.catalogReferenceHigh,
+ formatLabValue,
+ );
+
+ return (
+
+ {t("labs.sourceRange.fromReport", { range: printed })}
+ {reading.referenceDivergesFromCatalog && catalog
+ ? ` · ${t("labs.sourceRange.catalogDiffers", {
+ range: `${catalog} ${reading.unit}`.trim(),
+ })}`
+ : null}
+
+ );
+}
diff --git a/src/components/labs/types.ts b/src/components/labs/types.ts
index a3448eb69..2f7eae4a4 100644
--- a/src/components/labs/types.ts
+++ b/src/components/labs/types.ts
@@ -1,4 +1,7 @@
-import type { ReferenceRangeStatus } from "@/lib/validations/labs";
+import type {
+ ReferenceRangeOrigin,
+ ReferenceRangeStatus,
+} from "@/lib/validations/labs";
/** A lab-result row as the list / create endpoints serialise it. */
export interface LabResultDto {
@@ -12,8 +15,25 @@ export interface LabResultDto {
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
unit: string;
+ /**
+ * The window this reading is judged against and the one `rangeStatus` was
+ * computed from: the range its own report printed when it carries one, the
+ * catalog band otherwise. Render these and the range shown always matches
+ * the verdict shown.
+ */
referenceLow: number | null;
referenceHigh: number | null;
+ /** The catalog marker's band, so both windows can be drawn. */
+ catalogReferenceLow: number | null;
+ catalogReferenceHigh: number | null;
+ /** The window the source report printed; the text verbatim, bounds when derivable. */
+ sourceReferenceLow: number | null;
+ sourceReferenceHigh: number | null;
+ sourceReferenceText: string | null;
+ /** Which window is in force. */
+ referenceOrigin: ReferenceRangeOrigin;
+ /** True when the report's window and the catalog band state different limits. */
+ referenceDivergesFromCatalog: boolean;
takenAt: string;
source: string;
hasNote: boolean;
diff --git a/src/components/labs/use-ocr-extract.ts b/src/components/labs/use-ocr-extract.ts
index 7f2bd30c7..ee05cfdb2 100644
--- a/src/components/labs/use-ocr-extract.ts
+++ b/src/components/labs/use-ocr-extract.ts
@@ -33,6 +33,7 @@ export interface OcrCommitRowInput {
unit?: string;
referenceLow?: number;
referenceHigh?: number;
+ referenceText?: string;
takenAt: string;
}
diff --git a/src/lib/ai/coach/labs-snapshot.ts b/src/lib/ai/coach/labs-snapshot.ts
index e5c791ded..50c3ee109 100644
--- a/src/lib/ai/coach/labs-snapshot.ts
+++ b/src/lib/ai/coach/labs-snapshot.ts
@@ -37,7 +37,7 @@
* is always owner-scoped and safe to read), so the block is `null` only when the
* account has no recent readings.
*/
-import { classifyReferenceRange } from "@/lib/labs/reference-range";
+import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
import { prisma } from "@/lib/db";
@@ -131,6 +131,9 @@ export async function buildLabsSnapshotBlock(
unit: true,
referenceLow: true,
referenceHigh: true,
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -181,15 +184,13 @@ export async function buildLabsSnapshotBlock(
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
// A qualitative row has nothing to compare against the bounds → neutral
- // "unknown", never a fabricated verdict. A numeric row classifies.
- rangeStatus:
- row.value === null
- ? "unknown"
- : classifyReferenceRange(
- row.value,
- resolved.referenceLow,
- resolved.referenceHigh,
- ),
+ // "unknown", never a fabricated verdict. A numeric row classifies against
+ // the window in force for it — the report's own range when it carries
+ // one, the catalog band otherwise. Same rule as every other lab surface.
+ rangeStatus: classifyAgainstEffectiveRange(
+ row.value,
+ resolved.effectiveRange,
+ ),
takenAt: row.takenAt.toISOString(),
});
diff --git a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
index 7aadda451..464f1f85f 100644
--- a/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
+++ b/src/lib/doctor-report-pdf/clinical-records-notes-section.ts
@@ -20,10 +20,18 @@ export function buildClinicalRecordsNotesSection(
// v1.17.1 — structured lab-results section. Populated when the `labs`
// toggle is ON (default) and the user recorded at least one result in the
- // window. One row per analyte (latest reading), with the lab's reference
- // range and a NEUTRAL in/out-of-range marker — informative, never an
- // alarming red. The marker is a quiet glyph (↓ / ↑ / —), not a colour, so
- // the clinical PDF stays calm.
+ // window. One row per analyte (latest reading), with the reference range and
+ // a NEUTRAL in/out-of-range marker — informative, never an alarming red. The
+ // marker is a quiet glyph (↓ / ↑ / —), not a colour, so the clinical PDF
+ // stays calm.
+ //
+ // The reference column prints the window the reading was JUDGED against, and
+ // when that window came off the source report it prints the report's own
+ // string verbatim — a clinician comparing this table against the original
+ // report should read the same characters. A reading whose report window
+ // differs from the catalog band carries a footnote marker, and the catalog
+ // band is named beneath the table, because "3,9–5,4" and "3,5–5,0" disagree
+ // about the same number and a table that shows only one is a partial answer.
if (data.labResults && data.labResults.length > 0) {
y = ensureSpace(y, 6 + 18);
doc.setFontSize(14);
@@ -54,6 +62,23 @@ export function buildClinicalRecordsNotesSection(
}
};
+ // The reference cell: the report's own string when the report stated the
+ // window, else the formatted catalog band. A divergence gets a "*" the
+ // note under the table explains.
+ const referenceCell = (
+ lr: NonNullable[number],
+ ): string => {
+ const printed =
+ lr.referenceOrigin === "source" && lr.sourceReferenceText
+ ? lr.sourceReferenceText
+ : rangeText(lr.referenceLow, lr.referenceHigh);
+ return lr.referenceDivergesFromCatalog ? `${printed} *` : printed;
+ };
+
+ const diverging = data.labResults.filter(
+ (lr) => lr.referenceDivergesFromCatalog,
+ );
+
const labRows = data.labResults.map((lr) => {
// v1.18.9 — a qualitative reading (`value === null`) prints its result
// text in the value column and has no numeric range / status glyph.
@@ -63,7 +88,7 @@ export function buildClinicalRecordsNotesSection(
isQualitative
? (lr.valueText ?? "")
: `${num(lr.value as number)} ${lr.unit}`.trim(),
- isQualitative ? "—" : rangeText(lr.referenceLow, lr.referenceHigh),
+ isQualitative ? "—" : referenceCell(lr),
isQualitative
? ""
: statusGlyph(lr.value as number, lr.referenceLow, lr.referenceHigh),
@@ -107,6 +132,30 @@ export function buildClinicalRecordsNotesSection(
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
+
+ // Name every diverging marker's catalog band, so the reader can see both
+ // windows rather than being told only that they differ.
+ if (diverging.length > 0) {
+ y = ensureSpace(y, 4 + diverging.length * 4);
+ doc.setFontSize(8);
+ doc.setFont("helvetica", "normal");
+ doc.setTextColor(110, 110, 110);
+ doc.text(t("doctorReport.labsSourceRangeNote"), margin, y);
+ y += 4;
+ for (const lr of diverging) {
+ doc.text(
+ `* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText(
+ lr.catalogReferenceLow,
+ lr.catalogReferenceHigh,
+ )} ${lr.unit}`.trim(),
+ margin,
+ y,
+ );
+ y += 4;
+ }
+ doc.setTextColor(30, 30, 30);
+ y += 4;
+ }
}
// v1.18.1 P4 — illness / condition episodes overlapping the window. Present
diff --git a/src/lib/doctor-report-types.ts b/src/lib/doctor-report-types.ts
index 05c6cf41e..1d1de1c0d 100644
--- a/src/lib/doctor-report-types.ts
+++ b/src/lib/doctor-report-types.ts
@@ -241,8 +241,23 @@ export interface DoctorReportData {
*/
valueText: string | null;
unit: string;
+ /**
+ * The reference window this reading is judged against: the range printed
+ * on its own report when it carries one, the catalog band otherwise.
+ * Resolved server-side through `resolveEffectiveReferenceRange` so the PDF
+ * marker, the FHIR `referenceRange`, and the API verdict agree.
+ */
referenceLow: number | null;
referenceHigh: number | null;
+ /** The catalog band, kept so the PDF can name a divergence. */
+ catalogReferenceLow: number | null;
+ catalogReferenceHigh: number | null;
+ /** The window the source report printed, verbatim; null when it printed none. */
+ sourceReferenceText: string | null;
+ /** Which of the two windows `referenceLow` / `referenceHigh` came from. */
+ referenceOrigin: "source" | "catalog" | "none";
+ /** True when the report's window and the catalog band state different limits. */
+ referenceDivergesFromCatalog: boolean;
/** ISO timestamp of the latest reading. */
takenAt: string;
/** Count of readings for this analyte in the window (≥ 1). */
diff --git a/src/lib/doctor-report/clinical-records.ts b/src/lib/doctor-report/clinical-records.ts
index aec85ac8d..9553a15db 100644
--- a/src/lib/doctor-report/clinical-records.ts
+++ b/src/lib/doctor-report/clinical-records.ts
@@ -11,6 +11,7 @@
* Split out of the aggregator with the selection rework.
*/
import { prisma } from "@/lib/db";
+import { resolveEffectiveReferenceRange } from "@/lib/labs/reference-range";
import { getEvent } from "@/lib/logging/context";
import { decryptAllergyReaction } from "@/lib/doctor-report-helpers";
import type { DoctorReportData } from "@/lib/doctor-report-types";
@@ -42,6 +43,9 @@ export async function loadLabResults(
unit: true,
referenceLow: true,
referenceHigh: true,
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
takenAt: true,
},
});
@@ -52,14 +56,28 @@ export async function loadLabResults(
for (const r of rows) {
const key = r.analyte.toLowerCase();
const prev = byAnalyte.get(key);
+ // The row already stamps the catalog band it was written under; the
+ // reading's own report window, when present, is what the physician read
+ // the value against, so it wins here exactly as it does on every other
+ // lab surface.
+ const range = resolveEffectiveReferenceRange(
+ r.referenceLow,
+ r.referenceHigh,
+ r,
+ );
byAnalyte.set(key, {
panel: r.panel,
analyte: r.analyte,
value: r.value,
valueText: r.valueText,
unit: r.unit,
- referenceLow: r.referenceLow,
- referenceHigh: r.referenceHigh,
+ referenceLow: range.low,
+ referenceHigh: range.high,
+ catalogReferenceLow: range.catalogLow,
+ catalogReferenceHigh: range.catalogHigh,
+ sourceReferenceText: range.sourceText,
+ referenceOrigin: range.origin,
+ referenceDivergesFromCatalog: range.divergesFromCatalog,
takenAt: r.takenAt.toISOString(),
count: (prev?.count ?? 0) + 1,
});
diff --git a/src/lib/documents/commit.ts b/src/lib/documents/commit.ts
index ab5e1f6db..b960b39b9 100644
--- a/src/lib/documents/commit.ts
+++ b/src/lib/documents/commit.ts
@@ -15,6 +15,12 @@
* as-needed medication with notifications off — a record of what the patient
* takes, never a prescription action. An Observation never carries a
* range-flag; the reference bounds ride along only as the document stated them.
+ *
+ * The window the report printed beside a value is carried onto the reading, not
+ * dropped. Until now it only seeded a freshly minted catalog marker, which meant
+ * that for every analyte the user already had, the range printed on the report
+ * was read and then thrown away. It now lands on the row's `sourceReference*`
+ * columns and governs that reading's verdict.
*/
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { prisma } from "@/lib/db";
@@ -22,6 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
+import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import type { ExtractedFact } from "@/generated/prisma/client";
import type {
ConditionFactData,
@@ -66,11 +73,21 @@ async function commitObservation(
"A numeric value needs a unit before it can be saved",
);
}
+ // The printed window, read once. `referenceText` is what the report wrote;
+ // the parser derives bounds from it where it can, and where it cannot the
+ // string still survives onto the row. A document staged before the field
+ // existed has no text, and the numeric bounds the model reported are then
+ // the whole of what it stated.
+ const printed = parseReferenceRange(data.referenceText, data.unit);
+ const sourceLow = printed?.low ?? data.referenceLow;
+ const sourceHigh = printed?.high ?? data.referenceHigh;
+ const sourceText = printed?.text ?? null;
+
const biomarker = await resolveOrMintBiomarker(userId, {
analyte: data.label,
unit: isQualitative ? (data.unit ?? "") : (data.unit as string),
- referenceLow: isQualitative ? null : data.referenceLow,
- referenceHigh: isQualitative ? null : data.referenceHigh,
+ referenceLow: isQualitative ? null : sourceLow,
+ referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
@@ -101,6 +118,11 @@ async function commitObservation(
unit: biomarker.unit,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
+ // The report's own window for THIS reading. A qualitative result has no
+ // window to record.
+ sourceReferenceLow: isQualitative ? null : sourceLow,
+ sourceReferenceHigh: isQualitative ? null : sourceHigh,
+ sourceReferenceText: isQualitative ? null : sourceText,
takenAt: statedDateOrNow(data.effectiveDate),
source: "DOCUMENT",
noteEncrypted: null,
diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts
index e33508cdb..3d02c342c 100644
--- a/src/lib/documents/extract.ts
+++ b/src/lib/documents/extract.ts
@@ -74,7 +74,8 @@ Extract these fact types:
- code + codeSystem ("LOINC"): ONLY if a code is printed, else null.
- value: the numeric result OR null. valueText: the qualitative result text (e.g. "negative") OR null. Set EXACTLY ONE.
- unit: the printed unit, or null. NEVER convert or normalise a unit.
- - referenceLow / referenceHigh: the printed reference bounds as numbers, or null. Do NOT flag the value against them.
+ - referenceText: the reference range EXACTLY as printed beside the value, verbatim and complete, including its operator and unit if written (for example "3,5 - 5,0", "< 5", "bis 5,0", "\u2265 3,5", "150.000 - 400.000 /\u00b5l", "negativ"). Copy the characters; do NOT reformat, convert, or normalise them. Null only when no range is printed for that value.
+ - referenceLow / referenceHigh: the SAME range reduced to two numbers, when it reduces to two numbers, else null. Do NOT flag the value against them.
- effectiveDate: the stated measurement date (YYYY-MM-DD), else null.
3. MEDICATION_STATEMENT — a medication the patient is/was taking, as recorded. Capture:
- label: the drug name, verbatim.
@@ -91,7 +92,7 @@ For EVERY fact also capture:
Do not emit a fact you cannot read — omit it rather than invent one. Also capture reportDate: the document's stated report/collection date (YYYY-MM-DD), or null, and kind: "DOCTOR_REPORT", "DISCHARGE_LETTER", or "OTHER".
Respond ONLY with a JSON object of this exact shape:
-{ "reportDate": string|null, "kind": "DOCTOR_REPORT"|"DISCHARGE_LETTER"|"OTHER"|null, "facts": [ { "type": "CONDITION"|"OBSERVATION"|"MEDICATION_STATEMENT", "label": string, "code": string|null, "codeSystem": "SNOMED"|"ICD10"|"LOINC"|"RXNORM"|"ATC"|null, "clinicalStatus": string|null, "verificationStatus": string|null, "value": number|null, "valueText": string|null, "unit": string|null, "referenceLow": number|null, "referenceHigh": number|null, "dose": string|null, "medicationStatus": string|null, "effectiveDate": string|null, "sourceText": string, "page": number|null, "confidence": number } ] }`;
+{ "reportDate": string|null, "kind": "DOCTOR_REPORT"|"DISCHARGE_LETTER"|"OTHER"|null, "facts": [ { "type": "CONDITION"|"OBSERVATION"|"MEDICATION_STATEMENT", "label": string, "code": string|null, "codeSystem": "SNOMED"|"ICD10"|"LOINC"|"RXNORM"|"ATC"|null, "clinicalStatus": string|null, "verificationStatus": string|null, "value": number|null, "valueText": string|null, "unit": string|null, "referenceText": string|null, "referenceLow": number|null, "referenceHigh": number|null, "dose": string|null, "medicationStatus": string|null, "effectiveDate": string|null, "sourceText": string, "page": number|null, "confidence": number } ] }`;
const USER_PROMPT = `Transcribe the clinical document in the attached file into the JSON schema described in the system prompt. Reproduce only what is written — do not interpret. Return only the JSON object.`;
@@ -172,6 +173,7 @@ function mapFactData(raw: ExtractedFactRaw): FactData | null {
unit: raw.unit,
referenceLow: raw.referenceLow,
referenceHigh: raw.referenceHigh,
+ referenceText: raw.referenceText,
effectiveDate,
};
return data;
@@ -309,6 +311,14 @@ export async function runInboundExtraction(
unanchored,
conditions: facts.filter((f) => f.factType === "CONDITION").length,
observations: facts.filter((f) => f.factType === "OBSERVATION").length,
+ // How many transcribed observations arrived with the window the report
+ // printed beside them. A collapse here is the extraction quietly going
+ // back to dropping the range.
+ observationsWithRange: facts.filter(
+ (f) =>
+ f.factType === "OBSERVATION" &&
+ ((f.data as ObservationFactData).referenceText ?? null) !== null,
+ ).length,
medications: facts.filter((f) => f.factType === "MEDICATION_STATEMENT")
.length,
needsReview: facts.filter((f) => f.needsReview).length,
diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts
index 4d76bc57e..e55bf7b69 100644
--- a/src/lib/export/__tests__/full-backup-payload.test.ts
+++ b/src/lib/export/__tests__/full-backup-payload.test.ts
@@ -38,6 +38,8 @@ import { restoreProfileData } from "../profile-backup";
const deletedAt = new Date("2026-07-19T12:00:00.000Z");
const measurementNote = encryptToBytes("canonical measurement note");
const sideEffectNote = encryptToBytes("nausea after the evening dose");
+const moodNotePlaintext = "quiet evening, slept badly";
+const moodNote = encryptToBytes(moodNotePlaintext);
const appSettings = {
id: "singleton",
@@ -193,11 +195,27 @@ function makePrisma() {
mood: "OKAY",
score: 3,
tags: null,
+ note: null,
+ noteEncrypted: moodNote,
source: "MOODLOG",
externalId: "moodlog-42",
moodLoggedAt: new Date("2026-07-19T08:00:00.000Z"),
+ tz: "America/New_York",
+ syncedAt: new Date("2026-07-19T08:00:01.000Z"),
+ syncVersion: 3,
deletedAt,
- tagLinks: [{ rating: 4, moodTag: { key: "sleep_quality" } }],
+ createdAt: new Date("2026-07-19T08:00:00.000Z"),
+ updatedAt: new Date("2026-07-19T09:00:00.000Z"),
+ tagLinks: [
+ {
+ rating: 4,
+ moodTag: { key: "sleep_quality", kind: "RATED" },
+ },
+ {
+ rating: null,
+ moodTag: { key: "headache", kind: "BINARY" },
+ },
+ ],
},
]),
},
@@ -409,7 +427,13 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: "user-1" },
- include: expect.any(Object),
+ select: expect.objectContaining({
+ id: true,
+ noteEncrypted: true,
+ tz: true,
+ syncVersion: true,
+ deletedAt: true,
+ }),
}),
);
expect(mocks.buildRecordsBackupSection).toHaveBeenCalledWith(
@@ -485,7 +509,16 @@ describe("buildFullBackupPayload disaster-recovery mode", () => {
id: "mood-tombstone",
externalId: "moodlog-42",
deletedAt: deletedAt.toISOString(),
+ tz: "America/New_York",
+ syncedAt: "2026-07-19T08:00:01.000Z",
+ syncVersion: 3,
+ createdAt: "2026-07-19T08:00:00.000Z",
+ updatedAt: "2026-07-19T09:00:00.000Z",
+ note: null,
+ noteEncrypted: Buffer.from(moodNote).toString("base64"),
factors: [{ key: "sleep_quality", rating: 4 }],
+ // The BINARY link the old `rating: { not: null }` filter excluded.
+ structuredTags: ["headache"],
},
],
nutrientDays: [
@@ -1148,3 +1181,79 @@ describe("buildFullBackupPayload — profile and custom metrics", () => {
expect(counts.intradayProfiles).toBe(1);
});
});
+
+describe("buildFullBackupPayload — mood arms", () => {
+ it("carries the ciphertext and the whole row on the disaster-recovery arm", async () => {
+ installSectionMocks();
+ const prisma = makePrisma();
+
+ const { payload } = await buildFullBackupPayload(
+ prisma as never,
+ "user-1",
+ {
+ purpose: "disaster-recovery",
+ exportedAt: new Date("2026-07-20T00:00:00.000Z"),
+ },
+ );
+
+ const [entry] = (payload as { moodEntries: Array> })
+ .moodEntries;
+ expect(entry).toEqual(
+ expect.objectContaining({
+ id: "mood-tombstone",
+ note: null,
+ noteEncrypted: Buffer.from(moodNote).toString("base64"),
+ tz: "America/New_York",
+ syncedAt: "2026-07-19T08:00:01.000Z",
+ syncVersion: 3,
+ createdAt: "2026-07-19T08:00:00.000Z",
+ updatedAt: "2026-07-19T09:00:00.000Z",
+ deletedAt: deletedAt.toISOString(),
+ factors: [{ key: "sleep_quality", rating: 4 }],
+ structuredTags: ["headache"],
+ }),
+ );
+ });
+
+ it("carries the readable note and no ciphertext on the portable arm", async () => {
+ installSectionMocks();
+ const prisma = makePrisma();
+
+ const { payload } = await buildFullBackupPayload(
+ prisma as never,
+ "user-1",
+ {
+ purpose: "portable-export",
+ exportedAt: new Date("2026-07-20T00:00:00.000Z"),
+ },
+ );
+
+ const [entry] = (payload as { moodEntries: Array> })
+ .moodEntries;
+ // Same split the measurement arm makes: the readable artefact carries the
+ // decrypted text and nothing an operator could not read, and the counters
+ // that only mean something to this instance stay behind.
+ expect(entry).toEqual(
+ expect.objectContaining({
+ note: moodNotePlaintext,
+ tz: "America/New_York",
+ structuredTags: ["headache"],
+ }),
+ );
+ expect(entry).not.toHaveProperty("noteEncrypted");
+ expect(entry).not.toHaveProperty("syncVersion");
+ expect(entry).not.toHaveProperty("syncedAt");
+ expect(JSON.stringify(entry)).not.toContain(
+ Buffer.from(moodNote).toString("base64"),
+ );
+ // The tag-link read is unfiltered on both arms; a BINARY link is the one
+ // most people have and it was the one the file never carried.
+ expect(prisma.moodEntry.findMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ select: expect.objectContaining({
+ tagLinks: { select: expect.any(Object) },
+ }),
+ }),
+ );
+ });
+});
diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts
index 57ef86451..9f4506de5 100644
--- a/src/lib/export/full-backup-payload.ts
+++ b/src/lib/export/full-backup-payload.ts
@@ -103,6 +103,46 @@ const MEASUREMENT_BACKUP_SELECT = {
updatedAt: true,
} as const satisfies Prisma.MeasurementSelect;
+/**
+ * Every restorable `MoodEntry` scalar. `userId` is absent for the same reason
+ * it is absent above: the backup is user-scoped and the owner is re-derived
+ * from the restoring account.
+ *
+ * Named rather than inlined so `mood-backup-completeness.test.ts` can compare
+ * it field-by-field against the model in `prisma/schema.prisma`, exactly as the
+ * measurement guard does for the select above.
+ */
+const MOOD_ENTRY_BACKUP_SELECT = {
+ id: true,
+ date: true,
+ mood: true,
+ score: true,
+ tags: true,
+ note: true,
+ noteEncrypted: true,
+ source: true,
+ externalId: true,
+ moodLoggedAt: true,
+ tz: true,
+ syncedAt: true,
+ syncVersion: true,
+ deletedAt: true,
+ createdAt: true,
+ updatedAt: true,
+} as const satisfies Prisma.MoodEntrySelect;
+
+/**
+ * A structured-tag link, as it rides the wire. The link is carried by the
+ * tag's KEY, not by its id: a restore resolves the key against the target
+ * instance's catalogue, where the source instance's row id may not exist at
+ * all. `kind` comes along so the mapper can split RATED factors from BINARY
+ * tags without a second query.
+ */
+const MOOD_ENTRY_TAG_LINK_SELECT = {
+ rating: true,
+ moodTag: { select: { key: true, kind: true } },
+} as const satisfies Prisma.MoodEntryTagLinkSelect;
+
/**
* Build the canonical full-backup payload for `userId`. Portable exports omit
* tombstones and document ciphertext; disaster-recovery payloads preserve
@@ -207,14 +247,13 @@ export async function buildFullBackupPayload(
prisma.moodEntry.findMany({
where: disasterRecovery ? { userId } : { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
- include: {
- tagLinks: {
- where: { rating: { not: null } },
- select: {
- rating: true,
- moodTag: { select: { key: true } },
- },
- },
+ select: {
+ ...MOOD_ENTRY_BACKUP_SELECT,
+ // Every link, not only the rated ones. This read used to filter
+ // `rating: { not: null }`, and a BINARY link carries a NULL rating by
+ // definition — so the present/absent tags, the kind most people pick,
+ // were absent from the file and could not come back from it.
+ tagLinks: { select: MOOD_ENTRY_TAG_LINK_SELECT },
},
}),
// The account's OWN mood tags. The seeded catalogue is reference data every
@@ -394,21 +433,66 @@ export async function buildFullBackupPayload(
skipped: e.skipped,
source: e.source,
})),
- moodEntries: moodEntries.map((e) => ({
- id: e.id,
- date: e.date,
- mood: e.mood,
- score: e.score,
- tags: e.tags,
- source: e.source,
- loggedAt: e.moodLoggedAt.toISOString(),
- externalId: e.externalId,
- deletedAt: e.deletedAt?.toISOString() ?? null,
- factors: e.tagLinks.map((link) => ({
- key: link.moodTag.key,
- rating: link.rating!,
- })),
- })),
+ // The parameter is spelled out rather than the `e` every other mapper here
+ // uses, and deliberately: `mood-backup-completeness.test.ts` proves a
+ // selected column reaches a payload by matching `: moodEntry.`. A matcher
+ // on `: e.` would also match the intake-event mapper above it and could
+ // therefore never fail.
+ moodEntries: moodEntries.map((moodEntry) => {
+ // The two link arms partition the entry's links exhaustively: a link is
+ // a rated factor when its tag is RATED and it actually carries a score,
+ // and a structured tag otherwise. Total on purpose — a malformed link
+ // (a RATED tag whose rating went missing) rides as a tag key and comes
+ // back NAMED as unresolvable on restore, rather than falling between the
+ // two arms and disappearing the way every BINARY link used to.
+ const isRatedFactor = (link: (typeof moodEntry.tagLinks)[number]) =>
+ link.moodTag.kind === "RATED" && link.rating !== null;
+
+ const factors = moodEntry.tagLinks
+ .filter(isRatedFactor)
+ .map((tagLink) => ({
+ key: tagLink.moodTag.key,
+ rating: tagLink.rating as number,
+ }));
+ // BINARY link keys. Their own array rather than a nullable rating inside
+ // `factors`: the restore resolves `factors` against `kind: "RATED"` and
+ // reports what it cannot resolve, so a rating-less entry in that array
+ // would collide with both. A separate key leaves every backup file
+ // written before this parsing and restoring exactly as it did.
+ const structuredTags = moodEntry.tagLinks
+ .filter((link) => !isRatedFactor(link))
+ .map((tagLink) => tagLink.moodTag.key);
+
+ return {
+ ...(disasterRecovery
+ ? {
+ note: moodEntry.note,
+ noteEncrypted: moodEntry.noteEncrypted
+ ? Buffer.from(moodEntry.noteEncrypted).toString("base64")
+ : null,
+ syncedAt: moodEntry.syncedAt.toISOString(),
+ syncVersion: moodEntry.syncVersion,
+ createdAt: moodEntry.createdAt.toISOString(),
+ updatedAt: moodEntry.updatedAt.toISOString(),
+ }
+ : { note: readNote(moodEntry.noteEncrypted, moodEntry.note) }),
+ id: moodEntry.id,
+ date: moodEntry.date,
+ mood: moodEntry.mood,
+ score: moodEntry.score,
+ tags: moodEntry.tags,
+ source: moodEntry.source,
+ loggedAt: moodEntry.moodLoggedAt.toISOString(),
+ externalId: moodEntry.externalId,
+ // The zone the `date` string is anchored to. Without it a restored row
+ // falls back to the legacy Europe/Berlin reading and an account that
+ // logged elsewhere gets its day boundaries quietly moved.
+ tz: moodEntry.tz,
+ deletedAt: moodEntry.deletedAt?.toISOString() ?? null,
+ factors,
+ structuredTags,
+ };
+ }),
customMoodTags: customMoodTags.map((tag) => ({
...(disasterRecovery ? { id: tag.id } : {}),
key: tag.key,
diff --git a/src/lib/export/records-backup.ts b/src/lib/export/records-backup.ts
index c41ea32bb..34db3c428 100644
--- a/src/lib/export/records-backup.ts
+++ b/src/lib/export/records-backup.ts
@@ -54,6 +54,10 @@ export interface LabResultBackupEntry {
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
+ /** The window the reading's own source report printed, verbatim + derived. */
+ sourceReferenceLow: number | null;
+ sourceReferenceHigh: number | null;
+ sourceReferenceText: string | null;
takenAt: string;
source: string;
/** Human-readable cross-reference into `biomarkers` below, not an id. */
@@ -336,6 +340,9 @@ export async function buildRecordsBackupSection(
unit: r.unit,
referenceLow: r.referenceLow,
referenceHigh: r.referenceHigh,
+ sourceReferenceLow: r.sourceReferenceLow,
+ sourceReferenceHigh: r.sourceReferenceHigh,
+ sourceReferenceText: r.sourceReferenceText,
takenAt: r.takenAt.toISOString(),
source: r.source,
biomarkerName: r.biomarker?.name ?? null,
diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts
index e57756edd..326ea093b 100644
--- a/src/lib/export/restore-skips.ts
+++ b/src/lib/export/restore-skips.ts
@@ -1,8 +1,9 @@
/**
* What a restore could not put back, and how much of it there was.
*
- * Three of the restore's lookups resolve a key against a SEEDED CATALOGUE —
- * cycle symptoms, illness symptoms, mood factors. The catalogue is reference
+ * Four of the restore's lookups resolve a key against a SEEDED CATALOGUE —
+ * cycle symptoms, illness symptoms, rated mood factors and the mood tags a
+ * person ticks present or absent. The catalogue is reference
* data the instance owns, not data the file carries, so it legitimately drifts
* between the day a backup is written and the day it is read: a key gets
* renamed, a symptom is retired, the file is a year old, the instance is three
@@ -28,7 +29,8 @@
*/
/** Which seeded catalogue a key failed to resolve against. */
-export type SkippedCatalogue = "cycleSymptom" | "illnessSymptom" | "moodFactor";
+export type SkippedCatalogue =
+ "cycleSymptom" | "illnessSymptom" | "moodFactor" | "moodTag";
/** One key this instance does not know, and the links it cost. */
export interface SkippedCatalogueKey {
diff --git a/src/lib/fhir/__tests__/lab-observations.test.ts b/src/lib/fhir/__tests__/lab-observations.test.ts
index d2f647f5a..9c2c4080b 100644
--- a/src/lib/fhir/__tests__/lab-observations.test.ts
+++ b/src/lib/fhir/__tests__/lab-observations.test.ts
@@ -54,6 +54,11 @@ describe("lab-result FHIR Observations", () => {
unit: "mg/dL",
referenceLow: null,
referenceHigh: 116,
+ catalogReferenceLow: null,
+ catalogReferenceHigh: null,
+ sourceReferenceText: null,
+ referenceOrigin: "catalog" as const,
+ referenceDivergesFromCatalog: false,
takenAt: "2026-04-20T08:00:00.000Z",
count: 2,
},
@@ -65,6 +70,11 @@ describe("lab-result FHIR Observations", () => {
unit: "%",
referenceLow: 4,
referenceHigh: 5.6,
+ catalogReferenceLow: null,
+ catalogReferenceHigh: null,
+ sourceReferenceText: null,
+ referenceOrigin: "catalog" as const,
+ referenceDivergesFromCatalog: false,
takenAt: "2026-04-21T08:00:00.000Z",
count: 1,
},
@@ -137,6 +147,11 @@ describe("lab-result FHIR Observations", () => {
unit: "mg/L",
referenceLow: null,
referenceHigh: null,
+ catalogReferenceLow: null,
+ catalogReferenceHigh: null,
+ sourceReferenceText: null,
+ referenceOrigin: "catalog" as const,
+ referenceDivergesFromCatalog: false,
takenAt: "2026-04-22T08:00:00.000Z",
count: 1,
},
@@ -178,6 +193,11 @@ describe("lab-result LOINC + canonical UCUM coding (v1.18.8)", () => {
unit,
referenceLow: null,
referenceHigh: null,
+ catalogReferenceLow: null,
+ catalogReferenceHigh: null,
+ sourceReferenceText: null,
+ referenceOrigin: "catalog" as const,
+ referenceDivergesFromCatalog: false,
takenAt: "2026-04-20T08:00:00.000Z",
count: 1,
},
@@ -264,6 +284,11 @@ describe("qualitative lab-result FHIR Observations (v1.18.9)", () => {
unit: "",
referenceLow: null,
referenceHigh: null,
+ catalogReferenceLow: null,
+ catalogReferenceHigh: null,
+ sourceReferenceText: null,
+ referenceOrigin: "catalog" as const,
+ referenceDivergesFromCatalog: false,
takenAt: "2026-04-20T08:00:00.000Z",
count: 1,
},
diff --git a/src/lib/fhir/resources/labs.ts b/src/lib/fhir/resources/labs.ts
index a02a82228..43509310a 100644
--- a/src/lib/fhir/resources/labs.ts
+++ b/src/lib/fhir/resources/labs.ts
@@ -7,8 +7,15 @@
* `code.text` for the common biomarkers, and stamps the canonical UCUM `code`
* on the value WHEN the recorded unit matches the mapped canonical symbol. An
* analyte that does NOT resolve keeps the honest text-only `code` +
- * display-only `unit` (no fabricated terminology). The lab's reference bounds
- * map onto R4 `referenceRange`.
+ * display-only `unit` (no fabricated terminology).
+ *
+ * The reference bounds map onto R4 `referenceRange`, and they are the SAME
+ * bounds the app's own verdict uses: the window the reading's source report
+ * printed when it carries one, the catalog band otherwise, already resolved
+ * upstream in `loadLabResults`. When the source report stated its window in
+ * words the bounds could not be read from ("negativ", "siehe Befund"), that
+ * string rides on `referenceRange.text` so a receiver still gets what the lab
+ * wrote rather than a silently empty element.
*/
import type { DoctorReportData } from "@/lib/doctor-report-data";
import { LOINC_SYSTEM, UCUM_SYSTEM } from "@/lib/fhir/loinc-map";
@@ -82,8 +89,11 @@ export function labObservations(
// The bounds are stated in the SAME unit as the value, so they carry the
// SAME coding — a bare unit beside a coded value leaves a receiver unable
// to compare the two.
+ const sourceText =
+ lab.referenceOrigin === "source" ? lab.sourceReferenceText : null;
+ const hasBounds = lab.referenceLow !== null || lab.referenceHigh !== null;
const referenceRange: FhirObservationReferenceRange[] | undefined =
- lab.referenceLow !== null || lab.referenceHigh !== null
+ hasBounds || sourceText
? [
{
...(lab.referenceLow !== null
@@ -92,6 +102,7 @@ export function labObservations(
...(lab.referenceHigh !== null
? { high: quantity(lab.referenceHigh) }
: {}),
+ ...(sourceText ? { text: sourceText } : {}),
},
]
: undefined;
diff --git a/src/lib/fhir/types.ts b/src/lib/fhir/types.ts
index 50b2be4fd..00e85cc95 100644
--- a/src/lib/fhir/types.ts
+++ b/src/lib/fhir/types.ts
@@ -117,6 +117,13 @@ export interface FhirObservation {
export interface FhirObservationReferenceRange {
low?: FhirQuantity;
high?: FhirQuantity;
+ /**
+ * R4 `referenceRange.text`. Carries the window the source report printed,
+ * verbatim, when the reading has one. A receiver that cannot use the bounds
+ * (because the report stated a qualitative window like "negativ", or a
+ * one-sided one) still reads what the lab wrote.
+ */
+ text?: string;
}
export interface FhirDosage {
diff --git a/src/lib/fitbit/__tests__/sync-plausibility.test.ts b/src/lib/fitbit/__tests__/sync-plausibility.test.ts
new file mode 100644
index 000000000..7efe59f99
--- /dev/null
+++ b/src/lib/fitbit/__tests__/sync-plausibility.test.ts
@@ -0,0 +1,179 @@
+/**
+ * `upsertFitbitMeasurements` is the single write seam for every Fitbit
+ * resource leg — activity, sleep, daily metrics — and it holds Prisma
+ * directly, so it does not inherit the shared reconciler's gate. These cases
+ * pin that the provider's numbers clear the app's own declared plausibility
+ * band before any of them can become a row, and that a refusal is reported.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { findManyMock, createManyMock, updateMock } = vi.hoisted(() => ({
+ findManyMock: vi.fn(async () => [] as unknown[]),
+ createManyMock: vi.fn<
+ () => Promise>
+ >(async () => []),
+ updateMock: vi.fn(async () => ({})),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ measurement: {
+ findMany: findManyMock,
+ createManyAndReturn: createManyMock,
+ update: updateMock,
+ },
+ },
+}));
+vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() }));
+vi.mock("@/lib/integrations/status", () => ({
+ isReauthRequired: vi.fn(async () => false),
+ recordSyncFailure: vi.fn(async () => {}),
+ recordSyncSuccess: vi.fn(async () => {}),
+}));
+vi.mock("@/lib/rollups/measurement-rollups", () => ({
+ collapseToTypeDayKeys: vi.fn(() => []),
+ recomputeBucketsForMeasurement: vi.fn(async () => {}),
+ recomputeUserRollups: vi.fn(async () => {}),
+}));
+vi.mock("@/lib/insights/comprehensive-generate", () => ({
+ invalidateStatusInsightsForTypes: vi.fn(async () => {}),
+}));
+vi.mock("@/lib/integrations/oauth-refresh", () => ({
+ persistRotatedToken: vi.fn(async () => {}),
+}));
+vi.mock("../credentials", () => ({
+ getUserFitbitCredentials: vi.fn(async () => null),
+}));
+vi.mock("../client", () => ({ refreshAccessToken: vi.fn() }));
+
+import { eventStorage } from "@/lib/logging/context";
+import { WideEventBuilder } from "@/lib/logging/event-builder";
+import { RANGE_REJECTED_META_KEY } from "@/lib/measurements/plausibility-gate";
+import type { RangeRejectionTally } from "@/lib/measurements/plausibility-gate";
+
+import { upsertFitbitMeasurements } from "../sync-core";
+
+/** RESTING_HEART_RATE is declared 25–220. */
+const REAL_READING = {
+ type: "RESTING_HEART_RATE",
+ value: 58,
+ unit: "bpm",
+ measuredAt: new Date("2026-07-08T00:00:00.000Z"),
+ externalId: "rhr:2026-07-08",
+};
+
+const IMPOSSIBLE_READING = {
+ type: "RESTING_HEART_RATE",
+ value: 4_915_200,
+ unit: "bpm",
+ measuredAt: new Date("2026-07-09T00:00:00.000Z"),
+ externalId: "rhr:2026-07-09",
+};
+
+let event: WideEventBuilder;
+
+function tally(): RangeRejectionTally | undefined {
+ return event.toJSON().meta?.[RANGE_REJECTED_META_KEY] as
+ RangeRejectionTally | undefined;
+}
+
+beforeEach(() => {
+ findManyMock.mockReset().mockResolvedValue([]);
+ createManyMock.mockReset().mockResolvedValue([]);
+ updateMock.mockReset().mockResolvedValue({});
+ event = new WideEventBuilder("background");
+});
+
+describe("upsertFitbitMeasurements — plausibility gate", () => {
+ it("writes a reading inside the metric's declared band", async () => {
+ createManyMock.mockResolvedValue([
+ {
+ id: "row-1",
+ type: "RESTING_HEART_RATE",
+ measuredAt: REAL_READING.measuredAt,
+ },
+ ]);
+
+ const result = await eventStorage.run(event, () =>
+ upsertFitbitMeasurements("user-1", [REAL_READING], { deferRollup: true }),
+ );
+
+ expect(result.imported).toBe(1);
+ const planned = (createManyMock.mock.calls[0] as unknown[])[0] as {
+ data: Array<{ value: number }>;
+ };
+ expect(planned.data).toHaveLength(1);
+ expect(planned.data[0]!.value).toBe(58);
+ expect(tally()).toBeUndefined();
+ });
+
+ it("refuses an out-of-band reading and annotates the drop", async () => {
+ createManyMock.mockResolvedValue([
+ {
+ id: "row-1",
+ type: "RESTING_HEART_RATE",
+ measuredAt: REAL_READING.measuredAt,
+ },
+ ]);
+
+ const result = await eventStorage.run(event, () =>
+ upsertFitbitMeasurements("user-1", [REAL_READING, IMPOSSIBLE_READING], {
+ deferRollup: true,
+ }),
+ );
+
+ expect(result.imported).toBe(1);
+ const planned = (createManyMock.mock.calls[0] as unknown[])[0] as {
+ data: Array<{ value: number; externalId: string }>;
+ };
+ expect(planned.data).toHaveLength(1);
+ expect(planned.data[0]!.externalId).toBe("rhr:2026-07-08");
+ // The refused reading never even reached the existence probe.
+ expect(
+ (
+ (findManyMock.mock.calls[0] as unknown[])[0] as {
+ where: { externalId: { in: string[] } };
+ }
+ ).where.externalId.in,
+ ).toEqual(["rhr:2026-07-08"]);
+ expect(tally()).toEqual({
+ total: 1,
+ buckets: [
+ {
+ source: "fitbit",
+ type: "RESTING_HEART_RATE",
+ direction: "above_max",
+ count: 1,
+ },
+ ],
+ });
+ expect(event.getLevel()).toBe("warn");
+ });
+
+ it("does not overwrite a stored reading with an out-of-band one", async () => {
+ // The key already exists; ungated, the update branch rewrote its value.
+ findManyMock.mockResolvedValue([
+ {
+ id: "existing",
+ type: "RESTING_HEART_RATE",
+ externalId: "rhr:2026-07-09",
+ value: 58,
+ unit: "bpm",
+ measuredAt: IMPOSSIBLE_READING.measuredAt,
+ sleepStage: null,
+ deletedAt: null,
+ },
+ ]);
+
+ const result = await eventStorage.run(event, () =>
+ upsertFitbitMeasurements("user-1", [IMPOSSIBLE_READING], {
+ deferRollup: true,
+ }),
+ );
+
+ expect(result.imported).toBe(0);
+ expect(updateMock).not.toHaveBeenCalled();
+ expect(createManyMock).not.toHaveBeenCalled();
+ expect(tally()?.total).toBe(1);
+ });
+});
diff --git a/src/lib/fitbit/sync-core.ts b/src/lib/fitbit/sync-core.ts
index 74637e01b..f67f4422b 100644
--- a/src/lib/fitbit/sync-core.ts
+++ b/src/lib/fitbit/sync-core.ts
@@ -15,6 +15,7 @@ import { persistRotatedToken } from "@/lib/integrations/oauth-refresh";
import { recordSyncFailure } from "@/lib/integrations/status";
import type { FailureKind } from "@/lib/integrations/status";
import { getEvent } from "@/lib/logging/context";
+import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
@@ -530,7 +531,7 @@ export async function replaceStaleFitbitSleep(
*/
export async function upsertFitbitMeasurements(
userId: string,
- readings: FitbitMeasurementUpsert[],
+ incoming: FitbitMeasurementUpsert[],
opts: { deferRollup?: boolean } = {},
): Promise<{
imported: number;
@@ -541,6 +542,15 @@ export async function upsertFitbitMeasurements(
measuredAt: Date;
}>;
}> {
+ // Every resource leg funnels through here, so this is the one place the
+ // provider's numbers have to clear the app's declared plausibility band
+ // before they can become rows. A refused reading is dropped and tallied on
+ // the ambient event, never clamped and never written for a later reader to
+ // discover.
+ const readings = dropImplausibleMeasurements("fitbit", incoming, (r) => ({
+ type: r.type,
+ value: r.value,
+ }));
if (readings.length === 0) return { imported: 0, touched: [], inserted: [] };
// Probe EVERY existing row (live AND tombstoned) for the batch's externalIds
diff --git a/src/lib/google-health/__tests__/sync-plausibility.test.ts b/src/lib/google-health/__tests__/sync-plausibility.test.ts
new file mode 100644
index 000000000..2fb5b3404
--- /dev/null
+++ b/src/lib/google-health/__tests__/sync-plausibility.test.ts
@@ -0,0 +1,180 @@
+/**
+ * `upsertGoogleHealthMeasurements` is the single write seam for every Google
+ * Health resource leg and it holds Prisma directly, so it does not inherit the
+ * shared reconciler's gate. These cases pin that the provider's numbers clear
+ * the app's own declared plausibility band before any of them can become a
+ * row, and that a refusal is reported rather than swallowed.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { findManyMock, createManyMock, updateMock } = vi.hoisted(() => ({
+ findManyMock: vi.fn(async () => [] as unknown[]),
+ createManyMock: vi.fn<
+ () => Promise>
+ >(async () => []),
+ updateMock: vi.fn(async () => ({})),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ measurement: {
+ findMany: findManyMock,
+ createManyAndReturn: createManyMock,
+ update: updateMock,
+ },
+ },
+}));
+vi.mock("@/lib/crypto", () => ({ encrypt: vi.fn(), decrypt: vi.fn() }));
+vi.mock("@/lib/integrations/status", () => ({
+ isReauthRequired: vi.fn(async () => false),
+ recordSyncFailure: vi.fn(async () => {}),
+ recordSyncSuccess: vi.fn(async () => {}),
+}));
+vi.mock("@/lib/rollups/measurement-rollups", () => ({
+ collapseToTypeDayKeys: vi.fn(() => []),
+ recomputeBucketsForMeasurement: vi.fn(async () => {}),
+ recomputeUserRollups: vi.fn(async () => {}),
+}));
+vi.mock("@/lib/insights/comprehensive-generate", () => ({
+ invalidateStatusInsightsForTypes: vi.fn(async () => {}),
+}));
+vi.mock("../credentials", () => ({
+ getUserGoogleHealthCredentials: vi.fn(async () => null),
+}));
+vi.mock("../client", async () => {
+ const actual = await vi.importActual("../client");
+ return {
+ ...actual,
+ refreshAccessToken: vi.fn(),
+ noteGoogleHealthMapped: vi.fn(),
+ noteGoogleHealthWritten: vi.fn(),
+ noteGoogleHealthOutcomeFailure: vi.fn(),
+ };
+});
+
+import { eventStorage } from "@/lib/logging/context";
+import { WideEventBuilder } from "@/lib/logging/event-builder";
+import { RANGE_REJECTED_META_KEY } from "@/lib/measurements/plausibility-gate";
+import type { RangeRejectionTally } from "@/lib/measurements/plausibility-gate";
+
+import { upsertGoogleHealthMeasurements } from "../sync-core";
+
+/** OXYGEN_SATURATION is declared 50–100. */
+const REAL_READING = {
+ type: "OXYGEN_SATURATION",
+ value: 97,
+ unit: "%",
+ measuredAt: new Date("2026-07-08T00:00:00.000Z"),
+ externalId: "spo2:2026-07-08",
+};
+
+const IMPOSSIBLE_READING = {
+ type: "OXYGEN_SATURATION",
+ value: 9_700,
+ unit: "%",
+ measuredAt: new Date("2026-07-09T00:00:00.000Z"),
+ externalId: "spo2:2026-07-09",
+};
+
+let event: WideEventBuilder;
+
+function tally(): RangeRejectionTally | undefined {
+ return event.toJSON().meta?.[RANGE_REJECTED_META_KEY] as
+ RangeRejectionTally | undefined;
+}
+
+beforeEach(() => {
+ findManyMock.mockReset().mockResolvedValue([]);
+ createManyMock.mockReset().mockResolvedValue([]);
+ updateMock.mockReset().mockResolvedValue({});
+ event = new WideEventBuilder("background");
+});
+
+describe("upsertGoogleHealthMeasurements — plausibility gate", () => {
+ it("writes a reading inside the metric's declared band", async () => {
+ createManyMock.mockResolvedValue([
+ {
+ id: "row-1",
+ type: "OXYGEN_SATURATION",
+ measuredAt: REAL_READING.measuredAt,
+ },
+ ]);
+
+ const result = await eventStorage.run(event, () =>
+ upsertGoogleHealthMeasurements("user-1", [REAL_READING], {
+ deferRollup: true,
+ }),
+ );
+
+ expect(result.imported).toBe(1);
+ const planned = (createManyMock.mock.calls[0] as unknown[])[0] as {
+ data: Array<{ value: number }>;
+ };
+ expect(planned.data).toHaveLength(1);
+ expect(planned.data[0]!.value).toBe(97);
+ expect(tally()).toBeUndefined();
+ });
+
+ it("refuses an out-of-band reading and annotates the drop", async () => {
+ createManyMock.mockResolvedValue([
+ {
+ id: "row-1",
+ type: "OXYGEN_SATURATION",
+ measuredAt: REAL_READING.measuredAt,
+ },
+ ]);
+
+ const result = await eventStorage.run(event, () =>
+ upsertGoogleHealthMeasurements(
+ "user-1",
+ [REAL_READING, IMPOSSIBLE_READING],
+ { deferRollup: true },
+ ),
+ );
+
+ expect(result.imported).toBe(1);
+ const planned = (createManyMock.mock.calls[0] as unknown[])[0] as {
+ data: Array<{ externalId: string }>;
+ };
+ expect(planned.data).toHaveLength(1);
+ expect(planned.data[0]!.externalId).toBe("spo2:2026-07-08");
+ expect(tally()).toEqual({
+ total: 1,
+ buckets: [
+ {
+ source: "googleHealth",
+ type: "OXYGEN_SATURATION",
+ direction: "above_max",
+ count: 1,
+ },
+ ],
+ });
+ expect(event.getLevel()).toBe("warn");
+ });
+
+ it("does not overwrite a stored reading with an out-of-band one", async () => {
+ findManyMock.mockResolvedValue([
+ {
+ id: "existing",
+ type: "OXYGEN_SATURATION",
+ externalId: "spo2:2026-07-09",
+ value: 97,
+ unit: "%",
+ measuredAt: IMPOSSIBLE_READING.measuredAt,
+ sleepStage: null,
+ deletedAt: null,
+ },
+ ]);
+
+ const result = await eventStorage.run(event, () =>
+ upsertGoogleHealthMeasurements("user-1", [IMPOSSIBLE_READING], {
+ deferRollup: true,
+ }),
+ );
+
+ expect(result.imported).toBe(0);
+ expect(updateMock).not.toHaveBeenCalled();
+ expect(createManyMock).not.toHaveBeenCalled();
+ expect(tally()?.total).toBe(1);
+ });
+});
diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts
index 27ccaa139..d0545ee0d 100644
--- a/src/lib/google-health/sync-core.ts
+++ b/src/lib/google-health/sync-core.ts
@@ -20,6 +20,7 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { encrypt, decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
+import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
import { recordSyncFailure, type FailureKind } from "@/lib/integrations/status";
import {
@@ -487,7 +488,7 @@ export async function replaceStaleGoogleHealthSleep(
*/
export async function upsertGoogleHealthMeasurements(
userId: string,
- readings: GoogleHealthMeasurementUpsert[],
+ incoming: GoogleHealthMeasurementUpsert[],
opts: { deferRollup?: boolean } = {},
): Promise<{
imported: number;
@@ -498,7 +499,17 @@ export async function upsertGoogleHealthMeasurements(
measuredAt: Date;
}>;
}> {
- noteGoogleHealthMapped(readings.length);
+ noteGoogleHealthMapped(incoming.length);
+ // Every resource leg funnels through here, so this is the one place the
+ // provider's numbers have to clear the app's declared plausibility band
+ // before they can become rows. A refused reading is dropped and tallied on
+ // the ambient event, never clamped and never written for a later reader to
+ // discover.
+ const readings = dropImplausibleMeasurements(
+ "googleHealth",
+ incoming,
+ (r) => ({ type: r.type, value: r.value }),
+ );
if (readings.length === 0) return { imported: 0, touched: [], inserted: [] };
// Probe EVERY existing row (live AND tombstoned) for the batch's externalIds
diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts
index f3a3e5ddd..0c3d8085c 100644
--- a/src/lib/insights/__tests__/labs-changes.test.ts
+++ b/src/lib/insights/__tests__/labs-changes.test.ts
@@ -19,7 +19,14 @@ function row(
analyte: string,
value: number,
day: string,
- opts: { unit?: string; low?: number | null; high?: number | null } = {},
+ opts: {
+ unit?: string;
+ low?: number | null;
+ high?: number | null;
+ sourceLow?: number | null;
+ sourceHigh?: number | null;
+ sourceText?: string | null;
+ } = {},
): LabChangeRow {
return {
analyte,
@@ -27,6 +34,9 @@ function row(
value,
referenceLow: opts.low ?? null,
referenceHigh: opts.high ?? null,
+ sourceReferenceLow: opts.sourceLow ?? null,
+ sourceReferenceHigh: opts.sourceHigh ?? null,
+ sourceReferenceText: opts.sourceText ?? null,
takenAt: new Date(`${day}T08:00:00.000Z`),
};
}
diff --git a/src/lib/insights/biomarker-status.ts b/src/lib/insights/biomarker-status.ts
index 29f2214ce..3dc089afd 100644
--- a/src/lib/insights/biomarker-status.ts
+++ b/src/lib/insights/biomarker-status.ts
@@ -29,7 +29,10 @@ import {
} from "@/lib/ai/prompts/biomarker";
import { openerArchetypeHint } from "@/lib/ai/prompts/opener-archetype";
import type { Locale } from "@/lib/i18n/config";
-import { classifyReferenceRange } from "@/lib/labs/reference-range";
+import {
+ classifyAgainstEffectiveRange,
+ resolveEffectiveReferenceRange,
+} from "@/lib/labs/reference-range";
import { getNoKeyBiomarkerStatusText } from "@/lib/insights/no-key-fallbacks";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { runStatusCompletion } from "@/lib/insights/status-provider";
@@ -158,7 +161,17 @@ export async function generateBiomarkerStatus(args: {
},
orderBy: { takenAt: "desc" },
take: SNAPSHOT_READING_CAP,
- select: { id: true, value: true, takenAt: true },
+ select: {
+ id: true,
+ value: true,
+ takenAt: true,
+ // The window this reading's own report printed, when it carries one.
+ // The verdict below is the reading's, not the marker's, so it has to
+ // read the reading's window — see `resolveEffectiveReferenceRange`.
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
+ },
});
if (rows.length === 0) {
annotate({
@@ -174,6 +187,14 @@ export async function generateBiomarkerStatus(args: {
takenAt: r.takenAt,
}));
const latest = readings[0];
+ // The window the latest reading is judged against: its own report's range
+ // when that reading carries one, the catalog band otherwise. One rule, the
+ // same one the API DTO, the doctor report and the coach snapshot apply.
+ const latestRange = resolveEffectiveReferenceRange(
+ marker.lowerBound,
+ marker.upperBound,
+ rows[0],
+ );
const totalCount = await prisma.labResult.count({
where: {
userId: args.userId,
@@ -226,7 +247,7 @@ export async function generateBiomarkerStatus(args: {
locale,
promptVersion: PROMPT_VERSION,
count: totalCount,
- bounds: { lower: marker.lowerBound, upper: marker.upperBound },
+ bounds: { lower: latestRange.low, upper: latestRange.high },
latest: {
id: latest.id,
takenAt: latest.takenAt.toISOString(),
@@ -252,11 +273,7 @@ export async function generateBiomarkerStatus(args: {
// Ascending series (oldest → newest) for the trend summary the prompt reads.
const ascending = [...readings].reverse();
const summary = summarizeSeries(ascending.map((r) => ({ value: r.value })));
- const latestStatus = classifyReferenceRange(
- latest.value,
- marker.lowerBound,
- marker.upperBound,
- );
+ const latestStatus = classifyAgainstEffectiveRange(latest.value, latestRange);
const snapshot = {
locale,
@@ -266,8 +283,8 @@ export async function generateBiomarkerStatus(args: {
name: marker.name,
unit: marker.unit,
referenceRange:
- marker.lowerBound != null || marker.upperBound != null
- ? { lower: marker.lowerBound, upper: marker.upperBound }
+ latestRange.low != null || latestRange.high != null
+ ? { lower: latestRange.low, upper: latestRange.high }
: null,
},
dataCoverage: {
diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts
index 97f5dd940..caac368f2 100644
--- a/src/lib/insights/correlation-channel-series.ts
+++ b/src/lib/insights/correlation-channel-series.ts
@@ -590,6 +590,9 @@ export async function fetchLabDraws(
unit: true,
referenceLow: true,
referenceHigh: true,
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
panel: true,
value: true,
takenAt: true,
diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts
index b8dc18b35..ba63d860c 100644
--- a/src/lib/insights/feature-blocks.ts
+++ b/src/lib/insights/feature-blocks.ts
@@ -10,7 +10,7 @@
* point (`extractFeatures`) stays in the hub.
*/
import { prisma } from "@/lib/db";
-import { classifyReferenceRange } from "@/lib/labs/reference-range";
+import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { calendarDaysUntil } from "@/lib/measurement-reminders/due-day";
import { resolveLabFields } from "@/lib/labs/serialise";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
@@ -164,6 +164,9 @@ export async function readLabsBriefingBlock(
valueText: true,
referenceLow: true,
referenceHigh: true,
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -198,14 +201,10 @@ export async function readLabsBriefingBlock(
for (const list of byMarker.values()) {
const latest = list[0];
const resolved = resolveLabFields(latest, latest.biomarker);
- const rangeStatus =
- latest.value === null
- ? ("unknown" as const)
- : classifyReferenceRange(
- latest.value,
- resolved.referenceLow,
- resolved.referenceHigh,
- );
+ const rangeStatus = classifyAgainstEffectiveRange(
+ latest.value,
+ resolved.effectiveRange,
+ );
// Trend = latest numeric reading vs the immediately prior numeric reading.
let trend: "rising" | "falling" | "flat" | null = null;
diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts
index 6486e2b15..a16ea22cc 100644
--- a/src/lib/insights/labs-changes.ts
+++ b/src/lib/insights/labs-changes.ts
@@ -4,9 +4,11 @@
* Groups the user's numeric lab results by panel DATE (the calendar day of
* `takenAt`, UTC), finds the two most-recent dates, and for every analyte that
* appears in BOTH the latest and the previous panel reports the signed delta,
- * the direction, and where the latest value sits against its reference band
- * (`classifyReferenceRange`). Qualitative (valueText-only) results carry no
- * numeric value, so they are skipped — a delta is meaningless for them.
+ * the direction, and where the latest value sits against the reference window
+ * in force for it — the range that reading's own report printed when it has
+ * one, the catalog band otherwise, resolved through the shared
+ * `resolveEffectiveReferenceRange`. Qualitative (valueText-only) results carry
+ * no numeric value, so they are skipped — a delta is meaningless for them.
*
* Pure + Prisma-free so the present / absent states are unit-testable: it is
* absent when there are fewer than two panel dates, no analyte is shared, or
@@ -14,12 +16,14 @@
* only — a delta is not a diagnosis.
*/
import {
- classifyReferenceRange,
+ classifyAgainstEffectiveRange,
+ resolveEffectiveReferenceRange,
type ReferenceRangeStatus,
+ type SourceReferenceRange,
} from "@/lib/labs/reference-range";
-/** A single numeric lab reading. */
-export interface LabChangeRow {
+/** A single numeric lab reading, with both windows it could be judged against. */
+export interface LabChangeRow extends SourceReferenceRange {
analyte: string;
unit: string;
value: number;
@@ -150,10 +154,13 @@ export function summariseLabChanges(
previous: previousRow.value,
delta,
direction,
- status: classifyReferenceRange(
+ status: classifyAgainstEffectiveRange(
latestRow.value,
- latestRow.referenceLow,
- latestRow.referenceHigh,
+ resolveEffectiveReferenceRange(
+ latestRow.referenceLow,
+ latestRow.referenceHigh,
+ latestRow,
+ ),
),
});
}
diff --git a/src/lib/labs/__tests__/effective-reference-range.test.ts b/src/lib/labs/__tests__/effective-reference-range.test.ts
new file mode 100644
index 000000000..92dc9721b
--- /dev/null
+++ b/src/lib/labs/__tests__/effective-reference-range.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ classifyAgainstEffectiveRange,
+ resolveEffectiveReferenceRange,
+} from "@/lib/labs/reference-range";
+
+const NO_SOURCE = {
+ sourceReferenceLow: null,
+ sourceReferenceHigh: null,
+ sourceReferenceText: null,
+};
+
+describe("resolveEffectiveReferenceRange", () => {
+ it("falls back to the catalog band when the reading carries no source window", () => {
+ const eff = resolveEffectiveReferenceRange(3.5, 5, NO_SOURCE);
+ expect(eff).toMatchObject({
+ low: 3.5,
+ high: 5,
+ origin: "catalog",
+ catalogLow: 3.5,
+ catalogHigh: 5,
+ sourceText: null,
+ divergesFromCatalog: false,
+ });
+ });
+
+ it("reports no window when neither side states one", () => {
+ expect(resolveEffectiveReferenceRange(null, null, NO_SOURCE)).toMatchObject(
+ { low: null, high: null, origin: "none", divergesFromCatalog: false },
+ );
+ });
+
+ it("lets the source window win over the catalog band for that reading", () => {
+ const eff = resolveEffectiveReferenceRange(3.5, 5, {
+ sourceReferenceLow: 3.9,
+ sourceReferenceHigh: 5.4,
+ sourceReferenceText: "3,9 - 5,4",
+ });
+ expect(eff).toMatchObject({
+ low: 3.9,
+ high: 5.4,
+ origin: "source",
+ catalogLow: 3.5,
+ catalogHigh: 5,
+ sourceText: "3,9 - 5,4",
+ divergesFromCatalog: true,
+ });
+ });
+
+ it("does not call an identical window a divergence", () => {
+ const eff = resolveEffectiveReferenceRange(3.5, 5, {
+ sourceReferenceLow: 3.5,
+ sourceReferenceHigh: 5,
+ sourceReferenceText: "3,5 - 5,0",
+ });
+ expect(eff.origin).toBe("source");
+ expect(eff.divergesFromCatalog).toBe(false);
+ });
+
+ it("takes a one-sided source window whole, not merged with the catalog", () => {
+ // A report that prints "< 5" states a ceiling and NO floor. Borrowing the
+ // catalog's floor would invent a window the report never stated.
+ const eff = resolveEffectiveReferenceRange(3.5, 5, {
+ sourceReferenceLow: null,
+ sourceReferenceHigh: 4.2,
+ sourceReferenceText: "< 4,2",
+ });
+ expect(eff).toMatchObject({
+ low: null,
+ high: 4.2,
+ origin: "source",
+ divergesFromCatalog: true,
+ });
+ });
+
+ it("does not displace the catalog for a printed window with no derivable bound", () => {
+ // "negativ" is carried through verbatim, but it is not an argument for
+ // discarding the catalog band.
+ const eff = resolveEffectiveReferenceRange(3.5, 5, {
+ sourceReferenceLow: null,
+ sourceReferenceHigh: null,
+ sourceReferenceText: "negativ",
+ });
+ expect(eff).toMatchObject({
+ low: 3.5,
+ high: 5,
+ origin: "catalog",
+ sourceText: "negativ",
+ divergesFromCatalog: false,
+ });
+ });
+
+ it("reports a source window as no divergence when the catalog has none", () => {
+ const eff = resolveEffectiveReferenceRange(null, null, {
+ sourceReferenceLow: 3.9,
+ sourceReferenceHigh: 5.4,
+ sourceReferenceText: "3,9 - 5,4",
+ });
+ expect(eff.origin).toBe("source");
+ expect(eff.divergesFromCatalog).toBe(false);
+ });
+});
+
+describe("classifyAgainstEffectiveRange", () => {
+ it("judges a reading by the source window, not the catalog band", () => {
+ // 5.2 sits inside the catalog band (3.5–5.0 would call it high) but the
+ // report that produced this value states 3.9–5.4, so it is in range.
+ const eff = resolveEffectiveReferenceRange(3.5, 5, {
+ sourceReferenceLow: 3.9,
+ sourceReferenceHigh: 5.4,
+ sourceReferenceText: "3,9 - 5,4",
+ });
+ expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("in-range");
+ expect(classifyAgainstEffectiveRange(3.7, eff)).toBe("below");
+ expect(classifyAgainstEffectiveRange(5.5, eff)).toBe("above");
+ });
+
+ it("keeps the catalog verdict for a reading with no source window", () => {
+ const eff = resolveEffectiveReferenceRange(3.5, 5, NO_SOURCE);
+ expect(classifyAgainstEffectiveRange(5.2, eff)).toBe("above");
+ });
+
+ it("never classifies a qualitative reading", () => {
+ const eff = resolveEffectiveReferenceRange(3.5, 5, NO_SOURCE);
+ expect(classifyAgainstEffectiveRange(null, eff)).toBe("unknown");
+ });
+});
diff --git a/src/lib/labs/__tests__/parse-reference-range.test.ts b/src/lib/labs/__tests__/parse-reference-range.test.ts
new file mode 100644
index 000000000..d70e32fcf
--- /dev/null
+++ b/src/lib/labs/__tests__/parse-reference-range.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from "vitest";
+
+import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
+
+/**
+ * Real-shaped strings, taken from the notation German and English lab reports
+ * actually print. The parser's contract is asymmetric on purpose: deriving a
+ * bound is best-effort, KEEPING the printed string is mandatory. Every case
+ * below therefore asserts the verbatim text survives, and only the cases that
+ * can be read without guessing assert bounds.
+ */
+describe("parseReferenceRange — two-sided windows", () => {
+ it.each([
+ ["3.5-5.0", 3.5, 5],
+ ["3,5-5,0", 3.5, 5],
+ ["3.5 - 5.0", 3.5, 5],
+ ["3,5 – 5,0", 3.5, 5],
+ ["3,5 — 5,0", 3.5, 5],
+ ["3,5 bis 5,0", 3.5, 5],
+ ["3.5 to 5.0", 3.5, 5],
+ ["3,5 ... 5,0", 3.5, 5],
+ ["3,5 .. 5,0", 3.5, 5],
+ ["von 3,5 bis 5,0", 3.5, 5],
+ ["[3,5 - 5,0]", 3.5, 5],
+ ["(3.5-5.0)", 3.5, 5],
+ ])("reads %s as %s..%s", (raw, low, high) => {
+ const parsed = parseReferenceRange(raw);
+ expect(parsed).toEqual({ low, high, text: raw.trim() });
+ });
+
+ it("reads a negative lower bound (base excess prints one)", () => {
+ expect(parseReferenceRange("-2,0 bis +3,0")).toEqual({
+ low: -2,
+ high: 3,
+ text: "-2,0 bis +3,0",
+ });
+ });
+
+ it("reads a German thousands separator as one number, not a decimal", () => {
+ // Platelets print as "150.000 - 400.000 /µl" on a German report. Reading
+ // the dot as a decimal point would yield a 150–400 window and silently
+ // flag every normal count as wildly high.
+ expect(parseReferenceRange("150.000 - 400.000")).toEqual({
+ low: 150000,
+ high: 400000,
+ text: "150.000 - 400.000",
+ });
+ });
+
+ it("reads an English thousands separator", () => {
+ expect(parseReferenceRange("150,000 - 400,000")).toEqual({
+ low: 150000,
+ high: 400000,
+ text: "150,000 - 400,000",
+ });
+ });
+
+ it("refuses a transposed window rather than swapping it", () => {
+ // Swapping would be a guess about which digit the lab meant to print.
+ expect(parseReferenceRange("5,0 - 3,5")).toEqual({
+ low: null,
+ high: null,
+ text: "5,0 - 3,5",
+ });
+ });
+});
+
+describe("parseReferenceRange — one-sided windows", () => {
+ it.each([
+ ["< 5", 5],
+ ["<5,0", 5],
+ ["≤ 5", 5],
+ ["<= 5", 5],
+ ["bis 5,0", 5],
+ ["bis zu 5,0", 5],
+ ["max. 5,0", 5],
+ ["max 5", 5],
+ ["kleiner 5", 5],
+ ["unter 5", 5],
+ ["up to 5.0", 5],
+ ])("reads %s as an upper bound of %s", (raw, high) => {
+ expect(parseReferenceRange(raw)).toEqual({
+ low: null,
+ high,
+ text: raw.trim(),
+ });
+ });
+
+ it.each([
+ ["> 3.5", 3.5],
+ [">3,5", 3.5],
+ ["≥ 3,5", 3.5],
+ [">= 3.5", 3.5],
+ ["ab 3,5", 3.5],
+ ["min. 3,5", 3.5],
+ ["mind. 3,5", 3.5],
+ ["mindestens 3,5", 3.5],
+ ["größer 3,5", 3.5],
+ ["groesser 3,5", 3.5],
+ ["über 3,5", 3.5],
+ ])("reads %s as a lower bound of %s", (raw, low) => {
+ expect(parseReferenceRange(raw)).toEqual({
+ low,
+ high: null,
+ text: raw.trim(),
+ });
+ });
+});
+
+describe("parseReferenceRange — units", () => {
+ it("reads a window that carries the reading's own unit", () => {
+ expect(parseReferenceRange("3,5 - 5,0 mmol/l", "mmol/L")).toEqual({
+ low: 3.5,
+ high: 5,
+ text: "3,5 - 5,0 mmol/l",
+ });
+ });
+
+ it("reads a trailing unit when the caller states none", () => {
+ expect(parseReferenceRange("0,5 - 1,2 mg/dl")).toEqual({
+ low: 0.5,
+ high: 1.2,
+ text: "0,5 - 1,2 mg/dl",
+ });
+ });
+
+ it("refuses bounds when the printed unit contradicts the reading's unit", () => {
+ // Converting mmol/L into mg/dL is interpretation and needs an analyte-
+ // specific factor. Keeping the string and no bounds is the honest answer.
+ const parsed = parseReferenceRange("3,5 - 5,0 mmol/l", "mg/dL");
+ expect(parsed).toEqual({
+ low: null,
+ high: null,
+ text: "3,5 - 5,0 mmol/l",
+ });
+ });
+
+ it("matches units case- and space-insensitively", () => {
+ expect(parseReferenceRange("< 5 MG/DL", "mg/dl")).toEqual({
+ low: null,
+ high: 5,
+ text: "< 5 MG/DL",
+ });
+ });
+
+ it("tolerates a percent window against a percent reading", () => {
+ expect(parseReferenceRange("4,0 - 6,0 %", "%")).toEqual({
+ low: 4,
+ high: 6,
+ text: "4,0 - 6,0 %",
+ });
+ });
+});
+
+describe("parseReferenceRange — text the parser must not invent bounds for", () => {
+ it.each([
+ "negativ",
+ "negative",
+ "nicht nachweisbar",
+ "siehe Befund",
+ "n.n.",
+ "unauffällig",
+ "altersabhängig",
+ "methodenabhängig",
+ ])("keeps %s verbatim with no bounds", (raw) => {
+ expect(parseReferenceRange(raw)).toEqual({
+ low: null,
+ high: null,
+ text: raw,
+ });
+ });
+
+ it("keeps a bare number verbatim without treating it as a bound", () => {
+ // "5,0" alone states neither a ceiling nor a floor; picking one would be
+ // the parser deciding what the report meant.
+ expect(parseReferenceRange("5,0")).toEqual({
+ low: null,
+ high: null,
+ text: "5,0",
+ });
+ });
+
+ it("keeps a three-number string verbatim", () => {
+ expect(parseReferenceRange("3,5 - 5,0 - 7,0")).toEqual({
+ low: null,
+ high: null,
+ text: "3,5 - 5,0 - 7,0",
+ });
+ });
+
+ it("caps an over-long string rather than storing a whole paragraph", () => {
+ const parsed = parseReferenceRange("x".repeat(500));
+ expect(parsed?.text).toHaveLength(120);
+ expect(parsed?.low).toBeNull();
+ expect(parsed?.high).toBeNull();
+ });
+
+ it("collapses internal whitespace so the stored string stays one line", () => {
+ expect(parseReferenceRange(" 3,5\n\t- 5,0 ")).toEqual({
+ low: 3.5,
+ high: 5,
+ text: "3,5 - 5,0",
+ });
+ });
+});
+
+describe("parseReferenceRange — absence", () => {
+ it.each([null, undefined, "", " "])("returns null for %s", (raw) => {
+ expect(parseReferenceRange(raw)).toBeNull();
+ });
+
+ it("refuses a non-finite bound", () => {
+ expect(parseReferenceRange("1e999 - 2e999")).toEqual({
+ low: null,
+ high: null,
+ text: "1e999 - 2e999",
+ });
+ });
+});
diff --git a/src/lib/labs/__tests__/serialise.test.ts b/src/lib/labs/__tests__/serialise.test.ts
index f2dd77cd2..4ee846f49 100644
--- a/src/lib/labs/__tests__/serialise.test.ts
+++ b/src/lib/labs/__tests__/serialise.test.ts
@@ -17,6 +17,9 @@ const baseRow: LabRow = {
unit: "mg/dl", // lowercase legacy unit
referenceLow: null,
referenceHigh: 100, // stale per-row bound
+ sourceReferenceLow: null,
+ sourceReferenceHigh: null,
+ sourceReferenceText: null,
takenAt: new Date("2026-01-01T08:00:00.000Z"),
source: "MANUAL",
biomarkerId: "bm_1",
diff --git a/src/lib/labs/ocr-extract.ts b/src/lib/labs/ocr-extract.ts
index 050e16f50..fcdb01563 100644
--- a/src/lib/labs/ocr-extract.ts
+++ b/src/lib/labs/ocr-extract.ts
@@ -24,6 +24,7 @@ import {
} from "@/lib/ai/types";
import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
import { prisma } from "@/lib/db";
+import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { annotate } from "@/lib/logging/context";
import {
extractedLabsSchema,
@@ -42,7 +43,8 @@ Extract every analyte reading you can read. For each reading capture:
- value: the numeric result as a number, OR null if the result is qualitative text.
- valueText: the qualitative result text (e.g. "negativ", "positiv", "nicht nachweisbar"), OR null if the result is numeric.
- unit: the measurement unit (e.g. "mg/dL", "%", "ng/mL"), or null if none is printed or the reading is qualitative.
-- referenceLow / referenceHigh: the reference-range bounds as numbers, or null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
+- referenceText: the reference range EXACTLY as printed beside the value, verbatim and complete, including its operator and unit if written (for example "3,5 - 5,0", "< 116", "bis 5,0", "150.000 - 400.000 /\u00b5l", "negativ"). Copy the characters; do NOT reformat, convert, or normalise them. Null only when no range is printed for that row.
+- referenceLow / referenceHigh: the SAME range reduced to numbers, when it reduces to numbers, else null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
- takenAt: the per-row collection date in ISO 8601 (YYYY-MM-DD) if a date is printed for that row, else null.
- confidence: an object { analyte, value, unit, range } with a 0..1 score for how legible each field was. Use a low score when a field is blurred, ambiguous, or you had to guess.
@@ -51,7 +53,7 @@ Set EXACTLY ONE of value / valueText per reading. Never invent a value you canno
Also capture reportDate: the report's collection/sample date in ISO 8601 (YYYY-MM-DD), or null.
Respond ONLY with a JSON object of this exact shape:
-{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
+{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceText": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
const USER_PROMPT = `Transcribe the lab report in the attached image into the JSON schema described in the system prompt. Return only the JSON object.`;
@@ -70,7 +72,8 @@ Extract every analyte reading you can identify. For each reading capture:
- value: the numeric result as a number, OR null if the result is qualitative text. German reports use a decimal comma (e.g. "5,4") — emit it as 5.4.
- valueText: the qualitative result text (e.g. "negativ", "positiv", "nicht nachweisbar"), OR null if the result is numeric.
- unit: the measurement unit (e.g. "mg/dL", "%", "ng/mL"), or null if none is printed or the reading is qualitative.
-- referenceLow / referenceHigh: the reference-range bounds as numbers, or null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
+- referenceText: the reference range EXACTLY as printed beside the value, verbatim and complete, including its operator and unit if written (for example "3,5 - 5,0", "< 116", "bis 5,0", "150.000 - 400.000 /\u00b5l", "negativ"). Copy the characters; do NOT reformat, convert, or normalise them. Null only when no range is printed for that row.
+- referenceLow / referenceHigh: the SAME range reduced to numbers, when it reduces to numbers, else null. A one-sided range like "< 116" sets referenceHigh only; "> 40" sets referenceLow only.
- takenAt: the per-row collection date in ISO 8601 (YYYY-MM-DD) if a date is printed for that row, else null.
- confidence: an object { analyte, value, unit, range } with a 0..1 score for how confident you are in each field. Use a LOW score when the OCR text was garbled, ambiguous, or you had to guess — the text is lossy, so be conservative.
@@ -79,7 +82,7 @@ Set EXACTLY ONE of value / valueText per reading. Never invent a value you canno
Also capture reportDate: the report's collection/sample date in ISO 8601 (YYYY-MM-DD), or null.
Respond ONLY with a JSON object of this exact shape:
-{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
+{ "reportDate": string|null, "rows": [ { "analyte": string, "value": number|null, "valueText": string|null, "unit": string|null, "referenceText": string|null, "referenceLow": number|null, "referenceHigh": number|null, "takenAt": string|null, "confidence": { "analyte": number, "value": number, "unit": number, "range": number } } ] }`;
/** A normalised ISO date string (YYYY-MM-DD) or null. */
function normaliseDate(raw: string | null): string | null {
@@ -130,6 +133,7 @@ async function annotateRow(
): Promise {
const analyte = row.analyte.trim();
const takenAt = normaliseDate(row.takenAt) ?? reportDate;
+ const printed = parseReferenceRange(row.referenceText, row.unit);
// Biomarker match — case-insensitive on the catalog identity, presence-only.
const existingBiomarker = await prisma.biomarker.findFirst({
@@ -175,8 +179,13 @@ async function annotateRow(
value: row.value,
valueText: row.valueText,
unit: row.unit,
- referenceLow: row.referenceLow,
- referenceHigh: row.referenceHigh,
+ // Both readings of the printed window travel to the review screen: the
+ // derived bounds where the string yields them, and the string itself
+ // either way. A window the parser could not read is still the window the
+ // lab printed, so the user sees it and it reaches the row.
+ referenceLow: printed?.low ?? row.referenceLow,
+ referenceHigh: printed?.high ?? row.referenceHigh,
+ referenceText: printed?.text ?? null,
takenAt,
confidence: row.confidence,
biomarkerMatch: existingBiomarker ? "existing" : "new",
diff --git a/src/lib/labs/parse-reference-range.ts b/src/lib/labs/parse-reference-range.ts
new file mode 100644
index 000000000..24e413f37
--- /dev/null
+++ b/src/lib/labs/parse-reference-range.ts
@@ -0,0 +1,207 @@
+/**
+ * Free-text reference-range parser for the range a lab report prints beside a
+ * value.
+ *
+ * A report states its window in prose, not in columns: "3,5 - 5,0", "< 5",
+ * "bis 5,0", "≥ 3,5", "150.000 - 400.000 /µl", or a word like "negativ" that
+ * carries no window at all. The document-reading path and the manual entry
+ * form both hand that string here.
+ *
+ * The contract is deliberately asymmetric:
+ *
+ * - KEEPING the printed string is mandatory. `text` always carries what the
+ * report said, whitespace-normalised and length-capped, whether or not a
+ * bound could be derived from it. A window this parser cannot read is not
+ * a window that gets thrown away.
+ * - DERIVING bounds is best-effort and refuses on ambiguity. A string that
+ * does not match one of the notations below yields `low: null, high: null`
+ * beside the text. Absence reads as absence — the parser never picks a
+ * plausible bound out of a string it did not understand, because a fake
+ * window silently reclassifies a reading as normal or abnormal.
+ *
+ * Notations read (German and English, comma- or dot-decimal):
+ *
+ * two-sided 3.5-5.0 · 3,5 – 5,0 · 3,5 bis 5,0 · 3.5 to 5.0 · 3,5 ... 5,0
+ * von 3,5 bis 5,0 · [3,5 - 5,0] · (3.5-5.0)
+ * upper only < 5 · ≤ 5 · <= 5 · bis 5,0 · bis zu 5,0 · max. 5,0 ·
+ * kleiner 5 · unter 5 · up to 5.0 · less than 5
+ * lower only > 3.5 · ≥ 3,5 · >= 3.5 · ab 3,5 · min. 3,5 · mindestens 3,5 ·
+ * größer 3,5 · über 3,5 · at least 3.5
+ *
+ * Units are read, never converted. A window may carry the unit the report
+ * printed it in; when the caller states the reading's own unit and the two
+ * disagree, the bounds are refused and only the text survives. Converting
+ * mmol/L into mg/dL needs a per-analyte factor, so doing it here would be the
+ * parser deciding what the report meant.
+ */
+
+/** What a printed range yielded. `text` is set whenever the input was non-empty. */
+export interface ParsedReferenceRange {
+ /** Lower bound, or null when the notation states none / could not be read. */
+ low: number | null;
+ /** Upper bound, or null when the notation states none / could not be read. */
+ high: number | null;
+ /** The printed string, whitespace-normalised and capped. Never null here. */
+ text: string;
+}
+
+/**
+ * Storage cap for the verbatim string. A reference-range cell is a few
+ * characters; anything longer is a mis-segmented paragraph, and truncating it
+ * keeps a runaway transcription out of the column.
+ */
+export const SOURCE_REFERENCE_TEXT_MAX = 120;
+
+/** Magnitude ceiling. Beyond this a "bound" is a transcription artefact. */
+const MAX_ABS_BOUND = 1e12;
+
+/** One number token: optional sign, digits, any number of `.`/`,` groups. */
+const NUM = String.raw`[+-]?\d+(?:[.,]\d+)*`;
+
+/** Infix separators for a two-sided window. */
+const TWO_SIDED_SEP = String.raw`(?:-|–|—|‒|…|\.{2,3}|bis\s+zu|bis|to|until)`;
+
+/** Prefixes that state a ceiling only. */
+const UPPER_PREFIX = String.raw`(?:<=|<|≤|bis\s+zu|bis|max\.|maximal|max|kleiner\s+als|kleiner|unter|höchstens|hoechstens|up\s+to|less\s+than|below)`;
+
+/** Prefixes that state a floor only. */
+const LOWER_PREFIX = String.raw`(?:>=|>|≥|ab|mind\.|mindestens|minimal|min\.|min|größer\s+als|größer|groesser\s+als|groesser|über|ueber|at\s+least|greater\s+than|above)`;
+
+/**
+ * Trailing unit tail. Deliberately whitespace-free and comma-free: a real unit
+ * symbol ("mmol/l", "mg/dL", "%", "/µl", "10^9/L") never contains either, and
+ * refusing anything that does keeps a third number ("3,5 - 5,0 - 7,0") from
+ * being swallowed as if it were a unit.
+ */
+const UNIT_TAIL = String.raw`(?:\s*([^\s,]{1,24}))?`;
+
+const TWO_SIDED_RE = new RegExp(
+ `^(${NUM})\\s*${TWO_SIDED_SEP}\\s*(${NUM})${UNIT_TAIL}$`,
+ "iu",
+);
+const UPPER_RE = new RegExp(`^${UPPER_PREFIX}\\s*(${NUM})${UNIT_TAIL}$`, "iu");
+const LOWER_RE = new RegExp(`^${LOWER_PREFIX}\\s*(${NUM})${UNIT_TAIL}$`, "iu");
+
+/** Leading words that only introduce the window ("von 3,5 bis 5,0"). */
+const LEADING_FILLER_RE = /^(?:von|from|ref\.?|referenz|reference)\s+/iu;
+
+/**
+ * Read one number token into a JS number, or null when its separators are
+ * ambiguous.
+ *
+ * Separator rules, in order:
+ * 1. Both `.` and `,` present → the LAST one is the decimal mark, the other
+ * groups thousands ("1.234,56" and "1,234.56" both read as 1234.56).
+ * 2. One separator kind, appearing more than once → all thousands
+ * ("1.234.567").
+ * 3. One separator, appearing once, group ≠ 3 digits → decimal ("3,5").
+ * 4. One separator, appearing once, group = 3 digits → ambiguous in the
+ * general case. Read as thousands only when the group is "000", which is
+ * how a German report prints a rounded count ("150.000 - 400.000 /µl");
+ * reading that as 150 would flag every normal platelet count as wildly
+ * high. Any other 3-digit group ("1.234" — 1234 or 1.234?) is refused.
+ *
+ * The limit of rule 4 is real: a report that prints "5.000" meaning five with
+ * three decimal places is read as five thousand. The verbatim text is stored
+ * either way, so the printed window is never lost — only the derived bound is.
+ */
+function readNumber(token: string): number | null {
+ const sign = token.startsWith("-") ? -1 : 1;
+ const body = token.replace(/^[+-]/u, "");
+
+ const dots = (body.match(/\./gu) ?? []).length;
+ const commas = (body.match(/,/gu) ?? []).length;
+
+ let normalised: string;
+ if (dots > 0 && commas > 0) {
+ const decimalMark =
+ body.lastIndexOf(".") > body.lastIndexOf(",") ? "." : ",";
+ const grouping = decimalMark === "." ? "," : ".";
+ normalised = body.split(grouping).join("").replace(decimalMark, ".");
+ } else if (dots + commas === 0) {
+ normalised = body;
+ } else if (dots > 1 || commas > 1) {
+ normalised = body.replace(/[.,]/gu, "");
+ } else {
+ const idx = body.search(/[.,]/u);
+ const group = body.slice(idx + 1);
+ if (group.length !== 3) {
+ normalised = `${body.slice(0, idx)}.${group}`;
+ } else if (group === "000") {
+ normalised = body.slice(0, idx) + group;
+ } else {
+ return null;
+ }
+ }
+
+ const value = sign * Number(normalised);
+ if (!Number.isFinite(value) || Math.abs(value) > MAX_ABS_BOUND) return null;
+ return value;
+}
+
+/** Compare a printed unit against the reading's unit, ignoring case + spaces. */
+function unitsAgree(printed: string | undefined, reading: string | undefined) {
+ if (!printed) return true;
+ if (!reading || !reading.trim()) return true;
+ const norm = (u: string) => u.replace(/\s+/gu, "").toLowerCase();
+ return norm(printed) === norm(reading);
+}
+
+/**
+ * Parse the reference range a lab report printed for one reading.
+ *
+ * @param raw the printed string, exactly as transcribed.
+ * @param unit the reading's own unit, when known. A printed window in a
+ * different unit yields text-only — never a converted bound.
+ * @returns null when there was nothing printed at all.
+ */
+export function parseReferenceRange(
+ raw: string | null | undefined,
+ unit?: string | null,
+): ParsedReferenceRange | null {
+ if (raw === null || raw === undefined) return null;
+ const text = raw
+ .replace(/\s+/gu, " ")
+ .trim()
+ .slice(0, SOURCE_REFERENCE_TEXT_MAX);
+ if (text === "") return null;
+
+ const noBounds: ParsedReferenceRange = { low: null, high: null, text };
+
+ // Strip a bracket wrapper and an introducing word for MATCHING only — the
+ // stored text stays exactly what the report printed.
+ let candidate = text;
+ const bracketed = /^[[(](.*)[\])]$/u.exec(candidate);
+ if (bracketed) candidate = bracketed[1].trim();
+ candidate = candidate.replace(LEADING_FILLER_RE, "").trim();
+
+ const two = TWO_SIDED_RE.exec(candidate);
+ if (two) {
+ if (!unitsAgree(two[3], unit ?? undefined)) return noBounds;
+ const low = readNumber(two[1]);
+ const high = readNumber(two[2]);
+ if (low === null || high === null) return noBounds;
+ // A transposed window is a transcription error, and swapping it would be a
+ // guess about which digit the lab meant. Refuse the bounds, keep the text.
+ if (low > high) return noBounds;
+ return { low, high, text };
+ }
+
+ const upper = UPPER_RE.exec(candidate);
+ if (upper) {
+ if (!unitsAgree(upper[2], unit ?? undefined)) return noBounds;
+ const high = readNumber(upper[1]);
+ return high === null ? noBounds : { low: null, high, text };
+ }
+
+ const lower = LOWER_RE.exec(candidate);
+ if (lower) {
+ if (!unitsAgree(lower[2], unit ?? undefined)) return noBounds;
+ const low = readNumber(lower[1]);
+ return low === null ? noBounds : { low, high: null, text };
+ }
+
+ // Anything else — "negativ", "siehe Befund", a bare number, prose — keeps its
+ // text and states no window.
+ return noBounds;
+}
diff --git a/src/lib/labs/reference-range.ts b/src/lib/labs/reference-range.ts
index 85369b176..8d7403091 100644
--- a/src/lib/labs/reference-range.ts
+++ b/src/lib/labs/reference-range.ts
@@ -61,3 +61,113 @@ export function formatReferenceRange(
if (hasLow) return `≥ ${formatNumber(low as number)}`;
return opts?.emptyText ?? "";
}
+
+/**
+ * The reference range a lab report printed beside ONE reading, as stored on
+ * `LabResult.sourceReference*`.
+ */
+export interface SourceReferenceRange {
+ sourceReferenceLow: number | null;
+ sourceReferenceHigh: number | null;
+ sourceReferenceText: string | null;
+}
+
+/** Where the bounds a reading is judged against came from. */
+export type ReferenceRangeOrigin = "source" | "catalog" | "none";
+
+/** The bounds one reading is judged against, plus how they were arrived at. */
+export interface EffectiveReferenceRange {
+ /** The bound the verdict uses. */
+ low: number | null;
+ high: number | null;
+ origin: ReferenceRangeOrigin;
+ /** The catalog band, kept alongside so a surface can show both. */
+ catalogLow: number | null;
+ catalogHigh: number | null;
+ /** The printed window, kept verbatim. Null when the report stated none. */
+ sourceText: string | null;
+ /**
+ * True when the source window is in force AND the catalog states a window
+ * that differs from it. This is the case a reader must be told about: the
+ * same number reads differently against the two, so a surface that shows
+ * only one of them is showing a partial answer.
+ */
+ divergesFromCatalog: boolean;
+}
+
+/** Two bounds are the same window when both ends match (null included). */
+function sameWindow(
+ aLow: number | null,
+ aHigh: number | null,
+ bLow: number | null,
+ bHigh: number | null,
+): boolean {
+ return aLow === bLow && aHigh === bHigh;
+}
+
+/**
+ * Resolve the reference window ONE reading is judged against.
+ *
+ * A lab runs its own method on its own device, and the physician reads the
+ * value against the window printed on that report. So for a reading that
+ * carries a source window, the source window wins — for that reading alone.
+ * The catalog band stays the net for every reading that carries none.
+ *
+ * A source window counts as stating a window only when it yields at least one
+ * numeric bound. A printed string with no derivable bound ("negativ", "siehe
+ * Befund") is carried through as `sourceText` but never displaces the catalog:
+ * a range that could not be read is not an argument for having no range.
+ *
+ * This function is the ONLY place that precedence is expressed. Every surface
+ * that classifies a reading — the API DTO, the doctor report, insights, the
+ * coach snapshot, the MCP read, the FHIR export — routes through it, so a
+ * reading cannot read as in-range on one surface and out on another.
+ */
+export function resolveEffectiveReferenceRange(
+ catalogLow: number | null | undefined,
+ catalogHigh: number | null | undefined,
+ source: SourceReferenceRange | null | undefined,
+): EffectiveReferenceRange {
+ const cLow = catalogLow ?? null;
+ const cHigh = catalogHigh ?? null;
+ const sLow = source?.sourceReferenceLow ?? null;
+ const sHigh = source?.sourceReferenceHigh ?? null;
+ const sourceText = source?.sourceReferenceText ?? null;
+
+ if (sLow !== null || sHigh !== null) {
+ return {
+ low: sLow,
+ high: sHigh,
+ origin: "source",
+ catalogLow: cLow,
+ catalogHigh: cHigh,
+ sourceText,
+ divergesFromCatalog:
+ (cLow !== null || cHigh !== null) &&
+ !sameWindow(sLow, sHigh, cLow, cHigh),
+ };
+ }
+
+ return {
+ low: cLow,
+ high: cHigh,
+ origin: cLow !== null || cHigh !== null ? "catalog" : "none",
+ catalogLow: cLow,
+ catalogHigh: cHigh,
+ sourceText,
+ divergesFromCatalog: false,
+ };
+}
+
+/**
+ * Classify one reading against its effective window. Qualitative readings
+ * (no numeric value) always report `"unknown"` — there is nothing to compare
+ * against bounds, and a fabricated verdict is worse than none.
+ */
+export function classifyAgainstEffectiveRange(
+ value: number | null,
+ effective: EffectiveReferenceRange,
+): ReferenceRangeStatus {
+ if (value === null) return "unknown";
+ return classifyReferenceRange(value, effective.low, effective.high);
+}
diff --git a/src/lib/labs/serialise.ts b/src/lib/labs/serialise.ts
index adbe528dc..2dc6f6ced 100644
--- a/src/lib/labs/serialise.ts
+++ b/src/lib/labs/serialise.ts
@@ -10,13 +10,40 @@
* - If the row is unlinked (legacy / pre-backfill), it falls back to its own
* free-text `analyte` / `unit` / `reference*` columns.
*
- * Either way the verdict (`rangeStatus`) is computed from the RESOLVED bounds
- * via `classifyReferenceRange`. The DTO carries the resolved values so the web
- * client AND the iOS client render the same numbers — neither recomputes the
- * range or guesses the unit. This is the server-authoritative-parity rule for
- * Labs.
+ * On top of that the reading's OWN printed window wins. A lab report states
+ * the range its method and device produce right beside the value, and that is
+ * the window a physician evaluates against; the catalog band is the net for
+ * readings that carry none. `resolveEffectiveReferenceRange` owns that
+ * precedence and this file is one of its callers, not a second copy of it.
+ *
+ * The DTO therefore carries three windows and says which one is in force:
+ *
+ * - `referenceLow` / `referenceHigh` — the EFFECTIVE bounds, the ones
+ * `rangeStatus` was computed from. A client that reads only these fields
+ * (including an iOS build that predates the source window) renders the
+ * right numbers and the right verdict with no change of its own.
+ * - `catalogReferenceLow` / `catalogReferenceHigh` — the catalog band, so a
+ * surface can paint both and show where they part.
+ * - `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` —
+ * what the report printed, the last verbatim.
+ *
+ * `referenceOrigin` names which window is in force and
+ * `referenceDivergesFromCatalog` flags the case a reader must be told about.
+ * Every value is resolved server-side; neither client recomputes a range,
+ * guesses a unit, or re-derives the verdict.
+ *
+ * The two serialisers below spell every field out rather than spreading a
+ * shared helper: `written-outcome-response-consumer-guard` walks these object
+ * literals to enumerate the response leaves and pair each with a client
+ * reader, and a spread of a function call hides them from it. A field added
+ * here must be visible there.
*/
-import { classifyReferenceRange } from "@/lib/labs/reference-range";
+import {
+ classifyAgainstEffectiveRange,
+ resolveEffectiveReferenceRange,
+ type EffectiveReferenceRange,
+ type ReferenceRangeOrigin,
+} from "@/lib/labs/reference-range";
/** The minimal biomarker shape the resolver needs (no encrypted context). */
export interface ResolvedBiomarker {
@@ -40,6 +67,10 @@ export interface LabRow {
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
+ /** The window the source report printed for THIS reading. */
+ sourceReferenceLow: number | null;
+ sourceReferenceHigh: number | null;
+ sourceReferenceText: string | null;
takenAt: Date;
source: string;
biomarkerId: string | null;
@@ -48,60 +79,95 @@ export interface LabRow {
updatedAt: Date;
}
-/**
- * Resolve the canonical name / unit / bounds / panel for a lab row, preferring
- * the linked biomarker over the legacy per-row fields. Returns a plain shape
- * the list + detail serialisers both build on.
- */
-export function resolveLabFields(
- row: Pick<
- LabRow,
- | "analyte"
- | "unit"
- | "referenceLow"
- | "referenceHigh"
- | "panel"
- | "biomarkerId"
- >,
- biomarker: ResolvedBiomarker | null | undefined,
-): {
+/** The columns `resolveLabFields` needs off a lab row. */
+export type LabFieldRow = Pick<
+ LabRow,
+ | "analyte"
+ | "unit"
+ | "referenceLow"
+ | "referenceHigh"
+ | "sourceReferenceLow"
+ | "sourceReferenceHigh"
+ | "sourceReferenceText"
+ | "panel"
+ | "biomarkerId"
+>;
+
+/** What every lab surface reads off a row: identity, unit, and one window. */
+export interface ResolvedLabFields {
analyte: string;
unit: string;
+ panel: string | null;
+ /** The bounds in force for this reading — source window first, catalog next. */
referenceLow: number | null;
referenceHigh: number | null;
- panel: string | null;
-} {
- if (biomarker) {
- return {
- analyte: biomarker.name,
- unit: biomarker.unit,
- referenceLow: biomarker.lowerBound,
- referenceHigh: biomarker.upperBound,
- panel: biomarker.panel,
- };
- }
- return {
- analyte: row.analyte,
- unit: row.unit,
- referenceLow: row.referenceLow,
- referenceHigh: row.referenceHigh,
- panel: row.panel,
- };
+ /** The catalog band, so a surface can show both windows. */
+ catalogReferenceLow: number | null;
+ catalogReferenceHigh: number | null;
+ /** What the source report printed for this reading. */
+ sourceReferenceLow: number | null;
+ sourceReferenceHigh: number | null;
+ sourceReferenceText: string | null;
+ referenceOrigin: ReferenceRangeOrigin;
+ referenceDivergesFromCatalog: boolean;
+ /** The resolved window as one value, for callers that pass it on. */
+ effectiveRange: EffectiveReferenceRange;
}
/**
- * v1.18.9 — the reference-range verdict for a row. A qualitative row (no
- * numeric `value`) has nothing to compare against the bounds, so it always
- * reports `"unknown"` — the neutral, no-verdict state — never a fabricated
- * in/out classification. A numeric row classifies against the resolved bounds.
+ * Resolve the canonical name / unit / window / panel for a lab row.
+ *
+ * Two resolutions happen here, in this order:
+ *
+ * 1. Identity + catalog band: the linked biomarker wins over the row's legacy
+ * free-text columns (an unlinked pre-backfill row falls back to its own).
+ * 2. The window in force: the reading's printed source range wins over the
+ * catalog band, through `resolveEffectiveReferenceRange`.
+ *
+ * Every lab surface calls this, so the window a reading is judged against is
+ * the same on the API, the doctor report, insights, the coach, MCP and FHIR.
*/
-function rowRangeStatus(
- value: number | null,
- referenceLow: number | null,
- referenceHigh: number | null,
-) {
- if (value === null) return "unknown" as const;
- return classifyReferenceRange(value, referenceLow, referenceHigh);
+export function resolveLabFields(
+ row: LabFieldRow,
+ biomarker: ResolvedBiomarker | null | undefined,
+): ResolvedLabFields {
+ const identity = biomarker
+ ? {
+ analyte: biomarker.name,
+ unit: biomarker.unit,
+ panel: biomarker.panel,
+ catalogLow: biomarker.lowerBound,
+ catalogHigh: biomarker.upperBound,
+ }
+ : {
+ analyte: row.analyte,
+ unit: row.unit,
+ panel: row.panel,
+ catalogLow: row.referenceLow,
+ catalogHigh: row.referenceHigh,
+ };
+
+ const effectiveRange = resolveEffectiveReferenceRange(
+ identity.catalogLow,
+ identity.catalogHigh,
+ row,
+ );
+
+ return {
+ analyte: identity.analyte,
+ unit: identity.unit,
+ panel: identity.panel,
+ referenceLow: effectiveRange.low,
+ referenceHigh: effectiveRange.high,
+ catalogReferenceLow: effectiveRange.catalogLow,
+ catalogReferenceHigh: effectiveRange.catalogHigh,
+ sourceReferenceLow: row.sourceReferenceLow,
+ sourceReferenceHigh: row.sourceReferenceHigh,
+ sourceReferenceText: effectiveRange.sourceText,
+ referenceOrigin: effectiveRange.origin,
+ referenceDivergesFromCatalog: effectiveRange.divergesFromCatalog,
+ effectiveRange,
+ };
}
/**
@@ -123,13 +189,21 @@ export function serialiseLabResult(
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
+ catalogReferenceLow: resolved.catalogReferenceLow,
+ catalogReferenceHigh: resolved.catalogReferenceHigh,
+ sourceReferenceLow: resolved.sourceReferenceLow,
+ sourceReferenceHigh: resolved.sourceReferenceHigh,
+ sourceReferenceText: resolved.sourceReferenceText,
+ referenceOrigin: resolved.referenceOrigin,
+ referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
- rangeStatus: rowRangeStatus(
+ // A qualitative row has no number to place against bounds, so it reports
+ // the neutral "unknown" rather than a fabricated in/out verdict.
+ rangeStatus: classifyAgainstEffectiveRange(
row.value,
- resolved.referenceLow,
- resolved.referenceHigh,
+ resolved.effectiveRange,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
@@ -156,13 +230,19 @@ export function serialiseLabResultDetail(
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
+ catalogReferenceLow: resolved.catalogReferenceLow,
+ catalogReferenceHigh: resolved.catalogReferenceHigh,
+ sourceReferenceLow: resolved.sourceReferenceLow,
+ sourceReferenceHigh: resolved.sourceReferenceHigh,
+ sourceReferenceText: resolved.sourceReferenceText,
+ referenceOrigin: resolved.referenceOrigin,
+ referenceDivergesFromCatalog: resolved.referenceDivergesFromCatalog,
takenAt: row.takenAt.toISOString(),
source: row.source,
note,
- rangeStatus: rowRangeStatus(
+ rangeStatus: classifyAgainstEffectiveRange(
row.value,
- resolved.referenceLow,
- resolved.referenceHigh,
+ resolved.effectiveRange,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts
index 14d22261f..e670c7d24 100644
--- a/src/lib/mcp/rich-reads.ts
+++ b/src/lib/mcp/rich-reads.ts
@@ -48,7 +48,7 @@ import {
type MetricStatusMetricId,
} from "@/lib/insights/metric-status-registry";
import { allSignals, getSignal } from "@/lib/signals/registry";
-import { classifyReferenceRange } from "@/lib/labs/reference-range";
+import { classifyAgainstEffectiveRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
import { encodeOffsetCursor } from "@/lib/mcp/pagination";
@@ -1316,6 +1316,9 @@ export async function getLabHistory(
unit: true,
referenceLow: true,
referenceHigh: true,
+ sourceReferenceLow: true,
+ sourceReferenceHigh: true,
+ sourceReferenceText: true,
takenAt: true,
biomarkerId: true,
biomarker: {
@@ -1350,14 +1353,10 @@ export async function getLabHistory(
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
- rangeStatus:
- row.value === null
- ? "unknown"
- : classifyReferenceRange(
- row.value,
- resolved.referenceLow,
- resolved.referenceHigh,
- ),
+ rangeStatus: classifyAgainstEffectiveRange(
+ row.value,
+ resolved.effectiveRange,
+ ),
takenAt: row.takenAt.toISOString(),
};
});
diff --git a/src/lib/measurements/__tests__/plausibility-gate.test.ts b/src/lib/measurements/__tests__/plausibility-gate.test.ts
new file mode 100644
index 000000000..074d32600
--- /dev/null
+++ b/src/lib/measurements/__tests__/plausibility-gate.test.ts
@@ -0,0 +1,222 @@
+/**
+ * The ingest-side plausibility gate.
+ *
+ * `VALUE_RANGES` is the band the application declares a metric can occupy, and
+ * every path a person can drive has always enforced it. The provider sync
+ * writers did not, which is how a row the application itself calls impossible
+ * reached the table and poisoned every mean and personal band derived from it.
+ * These cases pin the two halves of the fix: the refusal, and the fact that a
+ * refusal is never silent.
+ */
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { WideEventBuilder } from "@/lib/logging/event-builder";
+import { eventStorage } from "@/lib/logging/context";
+import {
+ RANGE_REJECTED_META_KEY,
+ classifyRangeRejection,
+ dropImplausibleMeasurements,
+ recordRangeRejections,
+ type RangeRejectionTally,
+} from "../plausibility-gate";
+
+let event: WideEventBuilder;
+
+function withEvent(run: () => T): T {
+ return eventStorage.run(event, run);
+}
+
+function tally(): RangeRejectionTally | undefined {
+ return event.toJSON().meta?.[RANGE_REJECTED_META_KEY] as
+ RangeRejectionTally | undefined;
+}
+
+beforeEach(() => {
+ event = new WideEventBuilder("background");
+});
+
+describe("classifyRangeRejection", () => {
+ it("passes a value inside the metric's declared band", () => {
+ // PULSE is declared 20–300; both edges are inclusive.
+ expect(classifyRangeRejection("PULSE", 60)).toBeNull();
+ expect(classifyRangeRejection("PULSE", 20)).toBeNull();
+ expect(classifyRangeRejection("PULSE", 300)).toBeNull();
+ });
+
+ it("names the edge an out-of-band value broke", () => {
+ expect(classifyRangeRejection("PULSE", 55643821.505)).toEqual({
+ type: "PULSE",
+ direction: "above_max",
+ });
+ expect(classifyRangeRejection("PULSE", 19)).toEqual({
+ type: "PULSE",
+ direction: "below_min",
+ });
+ });
+
+ it("refuses a non-finite value whatever the metric", () => {
+ expect(classifyRangeRejection("PULSE", Number.NaN)).toEqual({
+ type: "PULSE",
+ direction: "not_finite",
+ });
+ // No declared band, so the value itself is the only thing to judge.
+ expect(
+ classifyRangeRejection("NOT_A_METRIC", Number.POSITIVE_INFINITY),
+ ).toEqual({ type: "NOT_A_METRIC", direction: "not_finite" });
+ });
+
+ it("passes a metric with no declared band — an absent range is an absent fact", () => {
+ expect(classifyRangeRejection("NOT_A_METRIC", 9_999_999)).toBeNull();
+ });
+});
+
+describe("dropImplausibleMeasurements", () => {
+ it("keeps the in-band rows and returns them untouched", () => {
+ const rows = [
+ { type: "PULSE", value: 60 },
+ { type: "WEIGHT", value: 82.4 },
+ ];
+ const kept = withEvent(() =>
+ dropImplausibleMeasurements("withings", rows, (r) => r),
+ );
+
+ expect(kept).toEqual(rows);
+ expect(kept[0]).toBe(rows[0]);
+ // Nothing was refused, so the event carries no tally at all — absence
+ // reads as absence.
+ expect(tally()).toBeUndefined();
+ expect(event.toJSON().warnings).toBeUndefined();
+ });
+
+ it("drops the out-of-band rows and tallies them on the wide event", () => {
+ const kept = withEvent(() =>
+ dropImplausibleMeasurements(
+ "withings",
+ [
+ { type: "PULSE", value: 60 },
+ { type: "PULSE", value: 55643821.505 },
+ { type: "PULSE", value: 900 },
+ { type: "WEIGHT", value: 0.2 },
+ ],
+ (r) => r,
+ ),
+ );
+
+ expect(kept).toEqual([{ type: "PULSE", value: 60 }]);
+
+ const recorded = tally();
+ expect(recorded).toEqual({
+ total: 3,
+ buckets: [
+ {
+ source: "withings",
+ type: "PULSE",
+ direction: "above_max",
+ count: 2,
+ },
+ {
+ source: "withings",
+ type: "WEIGHT",
+ direction: "below_min",
+ count: 1,
+ },
+ ],
+ });
+ });
+
+ it("keeps the refused readings out of the log line", () => {
+ withEvent(() =>
+ dropImplausibleMeasurements(
+ "withings",
+ [{ type: "PULSE", value: 55643821.505 }],
+ (r) => r,
+ ),
+ );
+
+ // The value is a health reading. The tally says which metric, which
+ // provider and which edge — never the number itself.
+ const serialised = JSON.stringify(event.toJSON());
+ expect(serialised).not.toContain("55643821");
+ });
+
+ it("lifts the event to warn once per provider", () => {
+ withEvent(() => {
+ dropImplausibleMeasurements(
+ "withings",
+ [{ type: "PULSE", value: 900 }],
+ (r) => r,
+ );
+ dropImplausibleMeasurements(
+ "withings",
+ [{ type: "PULSE", value: 901 }],
+ (r) => r,
+ );
+ dropImplausibleMeasurements(
+ "fitbit",
+ [{ type: "PULSE", value: 902 }],
+ (r) => r,
+ );
+ });
+
+ expect(event.getLevel()).toBe("warn");
+ expect(event.toJSON().warnings).toHaveLength(2);
+ expect(event.toJSON().warnings?.[0]).toContain("withings");
+ expect(event.toJSON().warnings?.[1]).toContain("fitbit");
+ });
+
+ it("accumulates across calls instead of overwriting the previous tally", () => {
+ // Fitbit gates once per resource leg, and the reconciler gates once per
+ // row: a tally that replaced rather than merged would report only the
+ // last batch, and a run that dropped hundreds would read as dropping one.
+ withEvent(() => {
+ dropImplausibleMeasurements(
+ "fitbit",
+ [{ type: "PULSE", value: 900 }],
+ (r) => r,
+ );
+ dropImplausibleMeasurements(
+ "fitbit",
+ [
+ { type: "PULSE", value: 901 },
+ { type: "OXYGEN_SATURATION", value: 4 },
+ ],
+ (r) => r,
+ );
+ });
+
+ expect(tally()).toEqual({
+ total: 3,
+ buckets: [
+ {
+ source: "fitbit",
+ type: "OXYGEN_SATURATION",
+ direction: "below_min",
+ count: 1,
+ },
+ { source: "fitbit", type: "PULSE", direction: "above_max", count: 2 },
+ ],
+ });
+ });
+
+ it("still drops outside a wide-event context", () => {
+ // A sync running without an ambient event has nowhere to report. The row
+ // is refused anyway: observability is what is lost, never the guarantee.
+ const kept = dropImplausibleMeasurements(
+ "nightscout",
+ [
+ { type: "BLOOD_GLUCOSE", value: 110 },
+ { type: "BLOOD_GLUCOSE", value: 9_000 },
+ ],
+ (r) => r,
+ );
+ expect(kept).toEqual([{ type: "BLOOD_GLUCOSE", value: 110 }]);
+ });
+});
+
+describe("recordRangeRejections", () => {
+ it("writes nothing when there is nothing to report", () => {
+ withEvent(() => recordRangeRejections("withings", []));
+ expect(tally()).toBeUndefined();
+ expect(event.getLevel()).toBe("info");
+ });
+});
diff --git a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts
index 5a2d012fd..9fa5c3cf7 100644
--- a/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts
+++ b/src/lib/measurements/__tests__/reconcile-external-measurement.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import type { Prisma } from "@/generated/prisma/client";
+import { eventStorage } from "@/lib/logging/context";
+import { WideEventBuilder } from "@/lib/logging/event-builder";
+import { RANGE_REJECTED_META_KEY } from "../plausibility-gate";
import {
reconcileExternalMeasurement,
type ExternalMeasurementWrite,
@@ -383,3 +386,104 @@ describe("reconcileExternalMeasurement", () => {
);
});
});
+
+/**
+ * The reconciler is the shared write seam for every provider that does not
+ * hold Prisma itself — Polar, Oura, WHOOP, the Withings sleep leg, the Apple
+ * export importer, the batch endpoint. Gating here rather than at each caller
+ * is what makes a provider added tomorrow inherit the refusal instead of
+ * having to remember a check it never knew about.
+ */
+describe("reconcileExternalMeasurement — plausibility gate", () => {
+ it("reconciles a value inside the metric's declared band", async () => {
+ const { tx, measurement } = transaction([]);
+ measurement.create.mockResolvedValue(row({ id: "fresh" }));
+
+ // PULSE is declared 20–300.
+ const verdict = await reconcileExternalMeasurement(
+ tx,
+ desired({ type: "PULSE", value: 112, unit: "bpm", sleepStage: null }),
+ );
+
+ expect(verdict.status).toBe("inserted");
+ expect(measurement.create).toHaveBeenCalledTimes(1);
+ });
+
+ it("refuses an out-of-band value without touching the transaction", async () => {
+ const { tx, measurement } = transaction([]);
+
+ const verdict = await reconcileExternalMeasurement(
+ tx,
+ desired({
+ type: "PULSE",
+ value: 55643821.505,
+ unit: "bpm",
+ sleepStage: null,
+ }),
+ );
+
+ expect(verdict).toEqual({
+ status: "rejected_range",
+ rejection: { type: "PULSE", direction: "above_max" },
+ });
+ // No row written, no row updated, and no savepoint opened — the refusal
+ // happens before the reconciler does any work at all.
+ expect(measurement.create).not.toHaveBeenCalled();
+ expect(measurement.update).not.toHaveBeenCalled();
+ expect(tx.$executeRawUnsafe).not.toHaveBeenCalled();
+ });
+
+ it("refuses rather than overwriting a good stored row", async () => {
+ // The dangerous shape: a live row already holds a real reading, and the
+ // provider re-sends the same identity carrying an impossible value. The
+ // update branch would have rewritten a good value with a bad one.
+ const existing = row({
+ id: "exact-row",
+ type: "PULSE",
+ value: 62,
+ unit: "bpm",
+ sleepStage: null,
+ measuredAt: new Date("2026-07-20T06:30:00.000Z"),
+ });
+ const { tx, measurement } = transaction([existing]);
+
+ const verdict = await reconcileExternalMeasurement(
+ tx,
+ desired({
+ type: "PULSE",
+ value: 55643821.505,
+ unit: "bpm",
+ sleepStage: null,
+ }),
+ );
+
+ expect(verdict.status).toBe("rejected_range");
+ expect(measurement.update).not.toHaveBeenCalled();
+ });
+
+ it("tallies the refusal on the ambient wide event under the provider", async () => {
+ const { tx } = transaction([]);
+ const event = new WideEventBuilder("background");
+
+ await eventStorage.run(event, () =>
+ reconcileExternalMeasurement(
+ tx,
+ desired({
+ type: "PULSE",
+ value: 55643821.505,
+ unit: "bpm",
+ source: "WHOOP",
+ sleepStage: null,
+ }),
+ ),
+ );
+
+ expect(event.toJSON().meta?.[RANGE_REJECTED_META_KEY]).toEqual({
+ total: 1,
+ buckets: [
+ { source: "whoop", type: "PULSE", direction: "above_max", count: 1 },
+ ],
+ });
+ expect(event.getLevel()).toBe("warn");
+ });
+});
diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts
index fc1e9f838..31d0b7dc2 100644
--- a/src/lib/measurements/import-apple-health-export.ts
+++ b/src/lib/measurements/import-apple-health-export.ts
@@ -720,6 +720,16 @@ export async function streamParseExportXml(
),
);
+ // The reconciler carries the same plausibility gate the block above
+ // already applied, so this arm means the two disagreed. Tally it under
+ // the same counter the earlier guard uses and count no row.
+ if (verdict.status === "rejected_range") {
+ unknown[`${type}::aggregate_out_of_range`] =
+ (unknown[`${type}::aggregate_out_of_range`] ?? 0) + 1;
+ stat.durationMs += Date.now() - rowStart;
+ continue;
+ }
+
cumulativeEstimatedDays.add(dayKey);
cumulativeEstimatedRows += 1;
if (verdict.status === "inserted") {
diff --git a/src/lib/measurements/plausibility-gate.ts b/src/lib/measurements/plausibility-gate.ts
new file mode 100644
index 000000000..d079f5391
--- /dev/null
+++ b/src/lib/measurements/plausibility-gate.ts
@@ -0,0 +1,192 @@
+/**
+ * The ingest-side half of the app's plausibility domain.
+ *
+ * `VALUE_RANGES` in `@/lib/validations/measurement` is the band the
+ * application declares a metric can occupy — PULSE 20–300, WEIGHT 1–500,
+ * SLEEP_DURATION 0–1440 minutes. Every path a person can drive enforces it:
+ * the measurement routes, the batch endpoint, the CSV and Apple-export
+ * importers, MCP, Telegram. The provider sync writers did not. They took the
+ * number the remote handed over and wrote it, so a decode slip or a glitched
+ * device landed a row the application itself calls impossible, and every mean,
+ * median and personal band downstream digested it as a reading.
+ *
+ * `isPlausibleMetricValue` (in `value-domain.ts`) already answers the question
+ * for the READ side, which is where the damage surfaced first. This module is
+ * the same question asked one layer earlier, at the point of writing, so the
+ * row never lands. The two ends share the one predicate on purpose: a band the
+ * reader hides but the writer accepts is a row that exists and cannot be seen,
+ * which is the worse of the two failures.
+ *
+ * ## Disposition: drop, and say so
+ *
+ * An out-of-band incoming value is DROPPED. Not clamped (a clamp invents a
+ * reading nobody took), not stored-and-flagged (a quarantined row is still a
+ * row, and every consumer would need to learn the flag), not fatal to the
+ * batch (one glitched sample must not strand the sync's other readings behind
+ * a held watermark). Absence reads as absence: the sample is simply not part
+ * of the record.
+ *
+ * A silent drop, though, is indistinguishable from a provider that sent
+ * nothing — so every drop is tallied onto the ambient wide event under
+ * {@link RANGE_REJECTED_META_KEY}, with the metric type, the provider, and the
+ * direction it broke the band in. Counts only. The value itself is a health
+ * reading and never enters the log line; the direction plus the metric's own
+ * declared bound is what a diagnosis actually needs.
+ *
+ * ## Why meta and not `action`
+ *
+ * A wide event holds exactly ONE action, and `setAction` replaces it. A drop
+ * happens in the middle of a sync whose action is decided at the end, so an
+ * `action` set here is either clobbered a moment later (the drop vanishes) or,
+ * on the paths that annotate no terminal action, relabels an otherwise
+ * successful sync as a rejection. Meta accumulates, survives every later
+ * `setAction`, and is the shape a dashboard can sum. One warning per provider
+ * per event rides along so the drop also lifts the event's level to `warn`
+ * rather than hiding inside a green line.
+ */
+import { annotate, getEvent } from "@/lib/logging/context";
+import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
+import { VALUE_RANGES } from "@/lib/validations/measurement";
+
+/**
+ * Stable meta key the tally lands under. Pinned so dashboards can count it
+ * across providers without knowing which one wrote the event.
+ */
+export const RANGE_REJECTED_META_KEY = "measurement.range_rejected";
+
+/** Which edge of the declared band the value broke, or that it was not a number. */
+export type RangeRejectionDirection = "below_min" | "above_max" | "not_finite";
+
+/** One refused reading, reduced to the facts that carry no health value. */
+export interface MeasurementRangeRejection {
+ /** `MeasurementType` of the refused reading. */
+ type: string;
+ direction: RangeRejectionDirection;
+}
+
+/** One (provider, metric, direction) cell of the event's running tally. */
+export interface RangeRejectionBucket {
+ source: string;
+ type: string;
+ direction: RangeRejectionDirection;
+ count: number;
+}
+
+/** The pinned meta shape written under {@link RANGE_REJECTED_META_KEY}. */
+export interface RangeRejectionTally {
+ total: number;
+ buckets: RangeRejectionBucket[];
+}
+
+/**
+ * Classify a candidate write against the metric's declared band.
+ *
+ * Returns `null` when the value may be written. A type with no declared range
+ * passes — an absent range is an absent fact, never a guessed one, and the
+ * same asymmetry the read side uses (`isPlausibleMetricValue`). A non-finite
+ * value never passes, whatever the type: NaN is not an input to anything.
+ */
+export function classifyRangeRejection(
+ type: string,
+ value: number,
+): MeasurementRangeRejection | null {
+ if (isPlausibleMetricValue(type, value)) return null;
+ if (!Number.isFinite(value)) return { type, direction: "not_finite" };
+ const range = VALUE_RANGES[type];
+ // `isPlausibleMetricValue` only answers false for a finite value when the
+ // type HAS a range, so the lookup cannot miss here.
+ return {
+ type,
+ direction: value < range!.min ? "below_min" : "above_max",
+ };
+}
+
+function readTally(): RangeRejectionTally {
+ const existing = getEvent()?.toJSON().meta?.[RANGE_REJECTED_META_KEY];
+ if (
+ existing !== null &&
+ typeof existing === "object" &&
+ Array.isArray((existing as RangeRejectionTally).buckets)
+ ) {
+ return existing as RangeRejectionTally;
+ }
+ return { total: 0, buckets: [] };
+}
+
+/**
+ * Fold refusals onto the ambient wide event.
+ *
+ * Merges with whatever the event already carries, so a provider that gates
+ * several batches (or reconciles row by row) accumulates instead of
+ * overwriting. Outside a wide-event context this is a no-op — the caller still
+ * drops the row, it simply has nowhere to report.
+ */
+export function recordRangeRejections(
+ source: string,
+ rejections: readonly MeasurementRangeRejection[],
+): void {
+ if (rejections.length === 0) return;
+ const evt = getEvent();
+ if (!evt) return;
+
+ const tally = readTally();
+ const firstForSource = !tally.buckets.some((b) => b.source === source);
+
+ for (const rejection of rejections) {
+ const bucket = tally.buckets.find(
+ (b) =>
+ b.source === source &&
+ b.type === rejection.type &&
+ b.direction === rejection.direction,
+ );
+ if (bucket) bucket.count += 1;
+ else
+ tally.buckets.push({
+ source,
+ type: rejection.type,
+ direction: rejection.direction,
+ count: 1,
+ });
+ tally.total += 1;
+ }
+
+ tally.buckets.sort(
+ (a, b) =>
+ a.source.localeCompare(b.source) ||
+ a.type.localeCompare(b.type) ||
+ a.direction.localeCompare(b.direction),
+ );
+
+ annotate({ meta: { [RANGE_REJECTED_META_KEY]: tally } });
+ if (firstForSource) {
+ evt.addWarning(
+ `${source}: refused reading(s) outside the declared plausibility range — ` +
+ `dropped, see meta.${RANGE_REJECTED_META_KEY}`,
+ );
+ }
+}
+
+/**
+ * Keep the rows whose value sits inside the metric's declared band; tally and
+ * drop the rest.
+ *
+ * `select` exists because the sync writers carry their own row shapes and none
+ * of them is worth reshaping at the call site: the gate reads the two fields
+ * it needs and returns the caller's own objects untouched.
+ */
+export function dropImplausibleMeasurements(
+ source: string,
+ rows: readonly T[],
+ select: (row: T) => { type: string; value: number },
+): T[] {
+ const kept: T[] = [];
+ const rejections: MeasurementRangeRejection[] = [];
+ for (const row of rows) {
+ const { type, value } = select(row);
+ const rejection = classifyRangeRejection(type, value);
+ if (rejection) rejections.push(rejection);
+ else kept.push(row);
+ }
+ recordRangeRejections(source, rejections);
+ return kept;
+}
diff --git a/src/lib/measurements/reconcile-external-measurement.ts b/src/lib/measurements/reconcile-external-measurement.ts
index 031dda441..d4fb50784 100644
--- a/src/lib/measurements/reconcile-external-measurement.ts
+++ b/src/lib/measurements/reconcile-external-measurement.ts
@@ -4,6 +4,11 @@ import {
type MeasurementAggregationProvenance,
type Prisma as PrismaTypes,
} from "@/generated/prisma/client";
+import {
+ classifyRangeRejection,
+ recordRangeRejections,
+ type MeasurementRangeRejection,
+} from "@/lib/measurements/plausibility-gate";
const SAVEPOINT = "measurement_identity_reconcile";
@@ -50,6 +55,18 @@ export type MeasurementReconciliationVerdict =
| {
status: "failed";
error: { message: string; code?: string };
+ }
+ | {
+ /**
+ * The incoming value sits outside the metric's declared plausibility
+ * band, so nothing was written and no existing row was touched. Not a
+ * failure: the write was refused on purpose and the caller must carry
+ * on with the rest of its batch. Every caller narrows on this status
+ * because it carries no `row` — the compiler is what makes a future
+ * provider handle it rather than dereference a row that does not exist.
+ */
+ status: "rejected_range";
+ rejection: MeasurementRangeRejection;
};
export type FailedMeasurementReconciliation = Extract<
@@ -219,12 +236,26 @@ async function retireCollision(
* advisory lock serializes cooperative writers sharing either identity, and a
* savepoint turns a non-benign database error into a structured `failed`
* verdict without poisoning the caller's surrounding transaction.
+ *
+ * This is also the plausibility gate for every provider that reconciles rather
+ * than writing Prisma directly. Gating HERE rather than at each caller is the
+ * point: a provider added tomorrow inherits the refusal by construction, and
+ * cannot land an impossible reading by forgetting a check it never knew about.
*/
export async function reconcileExternalMeasurement(
tx: PrismaTypes.TransactionClient,
desired: ExternalMeasurementWrite,
options: MeasurementReconciliationOptions = {},
): Promise {
+ const rejection = classifyRangeRejection(desired.type, desired.value);
+ if (rejection) {
+ recordRangeRejections(
+ desired.source ? String(desired.source).toLowerCase() : "external",
+ [rejection],
+ );
+ return { status: "rejected_range", rejection };
+ }
+
await tx.$executeRawUnsafe(`SAVEPOINT ${SAVEPOINT}`);
try {
diff --git a/src/lib/nightscout/__tests__/sync-plausibility.test.ts b/src/lib/nightscout/__tests__/sync-plausibility.test.ts
new file mode 100644
index 000000000..24c023eb7
--- /dev/null
+++ b/src/lib/nightscout/__tests__/sync-plausibility.test.ts
@@ -0,0 +1,115 @@
+/**
+ * The Nightscout pull writes BLOOD_GLUCOSE rows directly, so it does not
+ * inherit the shared reconciler's gate. It is also first-write-wins: a row
+ * that lands here is immutable, so an impossible sample never corrects itself
+ * on a later pass. These cases pin the refusal and its annotation.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const {
+ upsertMock,
+ findManyMock,
+ emitArrivalsMock,
+ recomputeMock,
+ invalidateMock,
+} = vi.hoisted(() => ({
+ upsertMock: vi.fn(),
+ findManyMock: vi.fn(),
+ emitArrivalsMock: vi.fn(),
+ recomputeMock: vi.fn(),
+ invalidateMock: vi.fn(),
+}));
+
+vi.mock("@/lib/db", () => ({
+ prisma: { measurement: { upsert: upsertMock, findMany: findManyMock } },
+}));
+
+vi.mock("@/lib/arrivals/measurement-emit", () => ({
+ emitInsertedMeasurementArrivals: emitArrivalsMock,
+}));
+
+vi.mock("@/lib/rollups/measurement-rollups", () => ({
+ collapseToTypeDayKeys: (rows: Array<{ type: string; measuredAt: Date }>) =>
+ rows.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
+ recomputeBucketsForMeasurement: recomputeMock,
+}));
+
+vi.mock("@/lib/insights/comprehensive-generate", () => ({
+ invalidateStatusInsightsForTypes: invalidateMock,
+}));
+
+import { eventStorage } from "@/lib/logging/context";
+import { WideEventBuilder } from "@/lib/logging/event-builder";
+import { RANGE_REJECTED_META_KEY } from "@/lib/measurements/plausibility-gate";
+import type { RangeRejectionTally } from "@/lib/measurements/plausibility-gate";
+
+import { upsertNightscoutEntries } from "../sync";
+
+/** BLOOD_GLUCOSE is declared 20–800 mg/dL. */
+const REAL_ENTRY = { id: "abc", sgv: 112, date: 1718000000000 };
+/** A sensor fault, or an mg/dL field carrying something else entirely. */
+const IMPOSSIBLE_ENTRY = { id: "def", sgv: 41_600, date: 1718000300000 };
+
+let event: WideEventBuilder;
+
+function tally(): RangeRejectionTally | undefined {
+ return event.toJSON().meta?.[RANGE_REJECTED_META_KEY] as
+ RangeRejectionTally | undefined;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ findManyMock.mockResolvedValue([]);
+ upsertMock.mockResolvedValue({ id: "row-1" });
+ emitArrivalsMock.mockResolvedValue(undefined);
+ recomputeMock.mockResolvedValue(undefined);
+ invalidateMock.mockResolvedValue(undefined);
+ event = new WideEventBuilder("background");
+});
+
+describe("upsertNightscoutEntries — plausibility gate", () => {
+ it("writes a reading inside the metric's declared band", async () => {
+ const result = await eventStorage.run(event, () =>
+ upsertNightscoutEntries("user-1", [REAL_ENTRY]),
+ );
+
+ expect(result).toEqual({ inserted: 1, failedRows: 0 });
+ expect(upsertMock).toHaveBeenCalledTimes(1);
+ expect(tally()).toBeUndefined();
+ });
+
+ it("refuses an out-of-band reading and annotates the drop", async () => {
+ const result = await eventStorage.run(event, () =>
+ upsertNightscoutEntries("user-1", [REAL_ENTRY, IMPOSSIBLE_ENTRY]),
+ );
+
+ expect(result).toEqual({ inserted: 1, failedRows: 0 });
+ expect(upsertMock).toHaveBeenCalledTimes(1);
+ expect(
+ (upsertMock.mock.calls[0]![0] as { create: { value: number } }).create
+ .value,
+ ).toBe(112);
+ expect(tally()).toEqual({
+ total: 1,
+ buckets: [
+ {
+ source: "nightscout",
+ type: "BLOOD_GLUCOSE",
+ direction: "above_max",
+ count: 1,
+ },
+ ],
+ });
+ expect(event.getLevel()).toBe("warn");
+ });
+
+ it("writes nothing when the whole page is out of band", async () => {
+ const result = await eventStorage.run(event, () =>
+ upsertNightscoutEntries("user-1", [IMPOSSIBLE_ENTRY]),
+ );
+
+ expect(result).toEqual({ inserted: 0, failedRows: 0 });
+ expect(upsertMock).not.toHaveBeenCalled();
+ expect(tally()?.total).toBe(1);
+ });
+});
diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts
index adef2de55..f92104550 100644
--- a/src/lib/nightscout/sync.ts
+++ b/src/lib/nightscout/sync.ts
@@ -22,6 +22,7 @@
import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
+import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
markSyncFailureRecorded,
recordSyncFailure,
@@ -221,7 +222,16 @@ export async function upsertNightscoutEntries(
if (entries.length === 0) return { inserted: 0, failedRows: 0 };
const type = "BLOOD_GLUCOSE" as MeasurementType;
- const mapped = entries.map((entry) => mapSgvEntryToMeasurement(entry));
+ // A CGM that reports a glucose the app's own band calls impossible is
+ // reporting a sensor fault or a unit slip, not a reading. Drop it: the row
+ // would otherwise be immutable (first-write-wins), so an impossible sample
+ // that lands here never corrects itself on a later pass.
+ const mapped = dropImplausibleMeasurements(
+ "nightscout",
+ entries.map((entry) => mapSgvEntryToMeasurement(entry)),
+ (m) => ({ type, value: m.value }),
+ );
+ if (mapped.length === 0) return { inserted: 0, failedRows: 0 };
// Probe which keys already exist so a re-confirmed reading (the upsert's
// no-op update branch) is not miscounted as a fresh import and the arrival
diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts
index ce310b3e1..849518f32 100644
--- a/src/lib/openapi/routes/labs.ts
+++ b/src/lib/openapi/routes/labs.ts
@@ -44,7 +44,13 @@ listLabResultsSchema.meta({
const rangeStatusEnum = z.enum(["in-range", "below", "above", "unknown"]).meta({
id: "LabReferenceRangeStatus",
description:
- "Server-computed, NEUTRAL reference-range verdict. `unknown` when the lab reported no usable bounds. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
+ "Server-computed, NEUTRAL reference-range verdict, always against `referenceLow` / `referenceHigh` (the window in force for that reading). `unknown` when no usable bounds are on file. Inclusive bounds: a value on the limit reads in-range. The badge that renders this must stay calm and informative — not an alarming red.",
+});
+
+const referenceOriginEnum = z.enum(["source", "catalog", "none"]).meta({
+ id: "LabReferenceRangeOrigin",
+ description:
+ "Which window `referenceLow` / `referenceHigh` came from. `source` = the range printed on this reading's own report, which wins for that reading because it is the window the physician evaluated the value against. `catalog` = the user's Biomarker band, the net for readings with no printed range. `none` = no window on file.",
});
const labResultRow = z
@@ -60,6 +66,13 @@ const labResultRow = z
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
+ catalogReferenceLow: z.number().nullable(),
+ catalogReferenceHigh: z.number().nullable(),
+ sourceReferenceLow: z.number().nullable(),
+ sourceReferenceHigh: z.number().nullable(),
+ sourceReferenceText: z.string().nullable(),
+ referenceOrigin: referenceOriginEnum,
+ referenceDivergesFromCatalog: z.boolean(),
takenAt: z.string(),
source: z.string(),
hasNote: z.boolean(),
@@ -70,7 +83,7 @@ const labResultRow = z
.meta({
id: "LabResult",
description:
- 'A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. "negativ") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` / `referenceLow` / `referenceHigh` are RESOLVED server-side from the Biomarker — the client renders them and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.',
+ "A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
});
const labResultDetail = labResultRow
diff --git a/src/lib/openapi/routes/ocr.ts b/src/lib/openapi/routes/ocr.ts
index 2493c1f56..90baa89d3 100644
--- a/src/lib/openapi/routes/ocr.ts
+++ b/src/lib/openapi/routes/ocr.ts
@@ -53,6 +53,10 @@ const extractedRow = z
unit: z.string().nullable(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
+ referenceText: z.string().nullable().meta({
+ description:
+ 'The reference range EXACTLY as printed on the report, verbatim ("3,5 - 5,0", "< 116", "bis 5,0", "negativ"). `referenceLow` / `referenceHigh` carry the same window reduced to numbers WHEN it reduces to numbers; this field carries it either way, so a window stated in words is not lost between the report and the reading.',
+ }),
takenAt: z.string().nullable(),
confidence: extractConfidence,
biomarkerMatch: z.enum(["new", "existing"]),
@@ -87,6 +91,13 @@ const committedRow = z
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
+ catalogReferenceLow: z.number().nullable(),
+ catalogReferenceHigh: z.number().nullable(),
+ sourceReferenceLow: z.number().nullable(),
+ sourceReferenceHigh: z.number().nullable(),
+ sourceReferenceText: z.string().nullable(),
+ referenceOrigin: z.enum(["source", "catalog", "none"]),
+ referenceDivergesFromCatalog: z.boolean(),
takenAt: z.string(),
source: z.string(),
hasNote: z.boolean(),
@@ -94,7 +105,11 @@ const committedRow = z
createdAt: z.string(),
updatedAt: z.string(),
})
- .meta({ id: "OcrCommittedLabResult" });
+ .meta({
+ id: "OcrCommittedLabResult",
+ description:
+ "A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.",
+ });
const commitResponse = z
.object({
diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts
index 4dfefaa83..aa11c88f5 100644
--- a/src/lib/oura/sync.ts
+++ b/src/lib/oura/sync.ts
@@ -575,6 +575,10 @@ export async function upsertOuraMeasurements(
for (let index = 0; index < readings.length; index++) {
const reading = readings[index]!;
const verdict = verdicts[index]!;
+ // Refused by the plausibility gate: nothing was written and nothing was
+ // touched, so the reading counts as neither imported nor a rollup dirt
+ // mark. The drop is already on the wide event.
+ if (verdict.status === "rejected_range") continue;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
diff --git a/src/lib/polar/sync.ts b/src/lib/polar/sync.ts
index 17b6505c5..8b44200fe 100644
--- a/src/lib/polar/sync.ts
+++ b/src/lib/polar/sync.ts
@@ -364,6 +364,10 @@ export async function upsertPolarMeasurements(
for (let index = 0; index < readings.length; index++) {
const reading = readings[index]!;
const verdict = verdicts[index]!;
+ // Refused by the plausibility gate: nothing was written and nothing was
+ // touched, so the reading counts as neither imported nor a rollup dirt
+ // mark. The drop is already on the wide event.
+ if (verdict.status === "rejected_range") continue;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts
index b4afaf650..10a63024c 100644
--- a/src/lib/validations/backup.ts
+++ b/src/lib/validations/backup.ts
@@ -255,11 +255,29 @@ const moodEntrySchema = z
mood: z.string().min(1),
score: z.number().int().min(0).max(10),
tags: moodEntryTagsSchema,
+ // The free text, in both storage shapes. A portable export carries the
+ // decrypted `note`; a disaster-recovery file carries `noteEncrypted` plus
+ // whatever legacy plaintext the row still holds. Same pair, and same
+ // handling on restore, as `Measurement.notes` / `notesEncrypted`.
+ note: z.string().nullable().optional(),
+ noteEncrypted: base64BytesSchema.nullable().optional(),
source: z.string().min(1).optional(),
externalId: z.string().nullable().optional(),
loggedAt: isoDateTime,
+ // The IANA zone the `date` string is anchored to. Absent means the legacy
+ // Europe/Berlin reading, which is what a row written before v1.4.25 means
+ // by a NULL here — so an old file keeps its old meaning exactly.
+ tz: z.string().nullable().optional(),
+ syncedAt: isoDateTime.optional(),
+ syncVersion: z.number().int().optional(),
deletedAt: isoDateTime.nullable().optional(),
+ createdAt: isoDateTime.optional(),
+ updatedAt: isoDateTime.optional(),
factors: z.array(moodFactorSchema).default([]),
+ // BINARY structured-tag keys. Separate from `factors`, which keeps its
+ // RATED-only meaning, so every file written before this one parses
+ // unchanged and restores identically: absent reads as none.
+ structuredTags: z.array(z.string().min(1)).default([]),
})
.passthrough();
@@ -690,6 +708,11 @@ const labResultBackupSchema = z
unit: z.string().min(1),
referenceLow: z.number().nullable().optional(),
referenceHigh: z.number().nullable().optional(),
+ // Optional so a backup written before these columns existed still
+ // restores; absent reads as "this reading had no source window on file".
+ sourceReferenceLow: z.number().nullable().optional(),
+ sourceReferenceHigh: z.number().nullable().optional(),
+ sourceReferenceText: z.string().nullable().optional(),
takenAt: isoDateTime,
source: z.string().min(1),
biomarkerName: z.string().nullable().optional(),
diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts
index cf59d774d..31cfce53e 100644
--- a/src/lib/validations/inbound-documents.ts
+++ b/src/lib/validations/inbound-documents.ts
@@ -167,6 +167,15 @@ export const extractedFactRawSchema = z.object({
unit: nullableText(80),
referenceLow: z.number().finite().nullable().catch(null),
referenceHigh: z.number().finite().nullable().catch(null),
+ /**
+ * Observation: the reference range EXACTLY as printed, verbatim. A report
+ * writes its window in prose ("3,5 - 5,0", "< 5", "bis 5,0", "negativ"), and
+ * `referenceLow` / `referenceHigh` above can only carry the cases that
+ * reduce to two numbers. This field is what stops every other case from
+ * being dropped on the floor — the string is kept whether or not the parser
+ * gets bounds out of it.
+ */
+ referenceText: nullableText(120),
/** MedicationStatement: dose + stated status (verbatim). */
dose: nullableText(120),
medicationStatus: nullableText(64),
@@ -237,6 +246,14 @@ export interface ObservationFactData {
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
+ /**
+ * The reference range as printed on the report, verbatim.
+ *
+ * Optional because it genuinely is: a fact staged before this field existed
+ * carries no key for it, and that is an honest absence rather than a shim
+ * for a caller we do not ship. Read it as `?? null`.
+ */
+ referenceText?: string | null;
effectiveDate: string | null;
}
@@ -379,6 +396,7 @@ const observationEditSchema = z
unit: optText(80),
referenceLow: z.number().finite().nullable().optional(),
referenceHigh: z.number().finite().nullable().optional(),
+ referenceText: optText(120),
effectiveDate: isoDate,
})
// Numeric XOR qualitative, mirroring the lab-result discipline. A null/absent
diff --git a/src/lib/validations/labs-ocr.ts b/src/lib/validations/labs-ocr.ts
index e9f3ff516..e79843f8b 100644
--- a/src/lib/validations/labs-ocr.ts
+++ b/src/lib/validations/labs-ocr.ts
@@ -78,6 +78,13 @@ export const extractedRowSchema = z.object({
unit: z.string().trim().max(80).nullable().catch(null),
referenceLow: z.number().finite().nullable().catch(null),
referenceHigh: z.number().finite().nullable().catch(null),
+ /**
+ * The reference range EXACTLY as printed, verbatim. The two numeric bounds
+ * above can only carry a window that reduces to two numbers; a report that
+ * prints "bis 5,0" or "negativ" states a window all the same, and this is
+ * what keeps it instead of dropping it.
+ */
+ referenceText: z.string().trim().max(120).nullable().catch(null),
takenAt: z.string().nullable().catch(null),
confidence: extractedConfidenceSchema,
});
@@ -107,6 +114,8 @@ export interface OcrExtractedRowDto {
unit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
+ /** The reference range as printed on the report, verbatim. */
+ referenceText: string | null;
takenAt: string | null;
confidence: {
analyte: number;
@@ -150,6 +159,17 @@ export const ocrCommitRowSchema = z
unit: z.string().trim().min(1).max(40).optional(),
referenceLow,
referenceHigh,
+ /**
+ * The window the scanned report printed for this row. Confirmed by the
+ * human on the review screen alongside everything else, and written onto
+ * the reading rather than only used to seed a new catalog marker.
+ */
+ referenceText: z
+ .string()
+ .trim()
+ .max(120)
+ .optional()
+ .or(z.literal("").transform(() => undefined)),
takenAt: takenAtField,
})
// Numeric XOR qualitative: exactly one of value / valueText.
diff --git a/src/lib/validations/labs.ts b/src/lib/validations/labs.ts
index 7f0c53c8f..c3b77b8c0 100644
--- a/src/lib/validations/labs.ts
+++ b/src/lib/validations/labs.ts
@@ -14,6 +14,7 @@
*/
import { z } from "zod/v4";
+import { SOURCE_REFERENCE_TEXT_MAX } from "@/lib/labs/parse-reference-range";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
/**
@@ -63,6 +64,29 @@ const optionalNote = z
const referenceLow = z.number().finite().optional();
const referenceHigh = z.number().finite().optional();
+/**
+ * The reference range the SOURCE REPORT printed for this one reading.
+ *
+ * Distinct from `referenceLow` / `referenceHigh` above, which seed (and are
+ * stamped from) the catalog marker's band. A lab runs its own method on its
+ * own device, so the window a physician evaluates against is the one on that
+ * report; these fields carry it, and `resolveEffectiveReferenceRange` lets it
+ * win for that reading alone.
+ *
+ * `sourceReferenceText` is what was printed, verbatim. It is accepted with or
+ * without derivable bounds: a window stated as "negativ" or "siehe Befund"
+ * still belongs on the record, and dropping it because no number could be read
+ * out of it is exactly the loss this field exists to stop.
+ */
+const sourceReferenceLow = z.number().finite().optional();
+const sourceReferenceHigh = z.number().finite().optional();
+const sourceReferenceText = z
+ .string()
+ .trim()
+ .max(SOURCE_REFERENCE_TEXT_MAX)
+ .optional()
+ .or(z.literal("").transform(() => undefined));
+
/**
* v1.18.1 — optional link to a user-scoped `Biomarker` (the catalog row).
* When set, the structured-entry path resolves the unit + reference range
@@ -101,6 +125,9 @@ export const createLabResultSchema = z
unit: requiredText(40).optional(),
referenceLow,
referenceHigh,
+ sourceReferenceLow,
+ sourceReferenceHigh,
+ sourceReferenceText,
takenAt: takenAtField,
note: optionalNote,
source,
@@ -142,6 +169,19 @@ export const createLabResultSchema = z
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
+ )
+ // Same guard on the source window. A transposed window is a client error
+ // here for the same reason: nothing downstream can tell it from a real one.
+ .refine(
+ (d) =>
+ d.valueText !== undefined ||
+ d.sourceReferenceLow === undefined ||
+ d.sourceReferenceHigh === undefined ||
+ d.sourceReferenceLow <= d.sourceReferenceHigh,
+ {
+ message: "sourceReferenceLow must not exceed sourceReferenceHigh",
+ path: ["sourceReferenceLow"],
+ },
);
export type CreateLabResultInput = z.infer;
@@ -164,6 +204,9 @@ export const updateLabResultSchema = z
unit: requiredText(40).optional(),
referenceLow: referenceLow.or(z.null()),
referenceHigh: referenceHigh.or(z.null()),
+ sourceReferenceLow: sourceReferenceLow.or(z.null()),
+ sourceReferenceHigh: sourceReferenceHigh.or(z.null()),
+ sourceReferenceText: sourceReferenceText.or(z.null()),
takenAt: takenAtField.optional(),
note: optionalNote.or(z.null()),
})
@@ -183,6 +226,18 @@ export const updateLabResultSchema = z
message: "referenceLow must not exceed referenceHigh",
path: ["referenceLow"],
},
+ )
+ .refine(
+ (d) =>
+ d.sourceReferenceLow === undefined ||
+ d.sourceReferenceLow === null ||
+ d.sourceReferenceHigh === undefined ||
+ d.sourceReferenceHigh === null ||
+ d.sourceReferenceLow <= d.sourceReferenceHigh,
+ {
+ message: "sourceReferenceLow must not exceed sourceReferenceHigh",
+ path: ["sourceReferenceLow"],
+ },
);
export type UpdateLabResultInput = z.infer;
@@ -246,7 +301,12 @@ export type ListLabResultsInput = z.infer;
* implementation. Re-exported here for the existing import sites.
*/
export {
+ classifyAgainstEffectiveRange,
classifyReferenceRange,
formatReferenceRange,
+ resolveEffectiveReferenceRange,
+ type EffectiveReferenceRange,
+ type ReferenceRangeOrigin,
type ReferenceRangeStatus,
+ type SourceReferenceRange,
} from "@/lib/labs/reference-range";
diff --git a/src/lib/whoop/sync-core.ts b/src/lib/whoop/sync-core.ts
index 2e2e41212..464ab6718 100644
--- a/src/lib/whoop/sync-core.ts
+++ b/src/lib/whoop/sync-core.ts
@@ -480,6 +480,10 @@ export async function upsertWhoopMeasurements(
for (let index = chunkStart; index < chunkEnd; index++) {
const reading = readings[index]!;
const verdict = verdicts[index - chunkStart]!;
+ // Refused by the plausibility gate: nothing was written and nothing was
+ // touched, so the reading counts as neither imported nor a rollup dirt
+ // mark. The drop is already on the wide event.
+ if (verdict.status === "rejected_range") continue;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
touchedTypes.add(dirty.type);
diff --git a/src/lib/withings/__tests__/sync-plausibility.test.ts b/src/lib/withings/__tests__/sync-plausibility.test.ts
new file mode 100644
index 000000000..8aa7572c7
--- /dev/null
+++ b/src/lib/withings/__tests__/sync-plausibility.test.ts
@@ -0,0 +1,261 @@
+/**
+ * Withings writes the measurement table directly — the measures leg row by
+ * row, the activity leg as a planned batch — so neither inherits the shared
+ * reconciler's gate. These cases pin that both legs now clear the app's own
+ * declared plausibility band before anything reaches Prisma, and that a
+ * refusal lands on the wide event rather than vanishing.
+ *
+ * The measures leg matters twice over: it does not only insert, it OVERWRITES
+ * the stored value of an existing slot, so an ungated impossible reading could
+ * replace a real one.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/lib/db", () => ({
+ prisma: {
+ measurement: {
+ findFirst: vi.fn(),
+ findMany: vi.fn(),
+ create: vi.fn(),
+ createMany: vi.fn(),
+ update: vi.fn(),
+ },
+ withingsConnection: {
+ findUnique: vi.fn(),
+ update: vi.fn(),
+ },
+ $queryRaw: vi.fn(),
+ $transaction: vi.fn((ops: unknown) =>
+ Array.isArray(ops) ? Promise.all(ops) : Promise.resolve(),
+ ),
+ },
+}));
+
+vi.mock("@/lib/crypto", () => ({
+ decrypt: vi.fn((v: string) => v),
+ encrypt: vi.fn((v: string) => v),
+}));
+
+vi.mock("../client", async () => {
+ const actual = await vi.importActual("../client");
+ return {
+ ...actual,
+ fetchMeasurements: vi.fn(),
+ refreshAccessToken: vi.fn(),
+ subscribeWebhook: vi.fn(),
+ };
+});
+
+vi.mock("../credentials", () => ({
+ getUserWithingsCredentials: vi.fn(async () => ({
+ clientId: "cid",
+ clientSecret: "secret",
+ })),
+}));
+
+vi.mock("@/lib/integrations/status", () => ({
+ isReauthRequired: vi.fn().mockResolvedValue(false),
+ parkIntegrationAtReauth: vi.fn(),
+ recordSyncFailure: vi.fn().mockResolvedValue(undefined),
+ recordSyncSuccess: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/rollups/measurement-rollups", async () => {
+ const actual = await vi.importActual<
+ typeof import("@/lib/rollups/measurement-rollups")
+ >("@/lib/rollups/measurement-rollups");
+ return {
+ ...actual,
+ recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
+ };
+});
+
+vi.mock("@/lib/insights/comprehensive-generate", () => ({
+ invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserDashboardSnapshot: vi.fn(),
+}));
+
+import { prisma } from "@/lib/db";
+import { eventStorage } from "@/lib/logging/context";
+import { WideEventBuilder } from "@/lib/logging/event-builder";
+import { RANGE_REJECTED_META_KEY } from "@/lib/measurements/plausibility-gate";
+import type { RangeRejectionTally } from "@/lib/measurements/plausibility-gate";
+
+import { fetchMeasurements } from "../client";
+import { syncUserActivity } from "../sync-activity";
+import { syncUserMeasurements } from "../sync";
+
+let event: WideEventBuilder;
+
+function tally(): RangeRejectionTally | undefined {
+ return event.toJSON().meta?.[RANGE_REJECTED_META_KEY] as
+ RangeRejectionTally | undefined;
+}
+
+function installActivityFetch(
+ entries: Array<{ date: string; steps?: number; distance?: number }>,
+) {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => ({
+ status: 200,
+ headers: new Headers(),
+ json: async () => ({
+ status: 0,
+ body: { activities: entries, more: false, offset: 0 },
+ }),
+ })),
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ event = new WideEventBuilder("background");
+ vi.mocked(prisma.withingsConnection.findUnique).mockResolvedValue({
+ id: "conn-1",
+ withingsUserId: "wu-1",
+ accessToken: "enc-access",
+ refreshToken: "enc-refresh",
+ scope: "user.metrics,user.activity",
+ tokenExpiresAt: new Date(Date.now() + 60 * 60 * 1000),
+ lastSyncedAt: new Date(Date.now() - 60 * 60 * 1000),
+ } as never);
+ vi.mocked(prisma.withingsConnection.update).mockResolvedValue({} as never);
+ vi.mocked(prisma.measurement.findFirst).mockResolvedValue(null);
+ vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
+ vi.mocked(prisma.measurement.create).mockResolvedValue({
+ id: "m-1",
+ type: "PULSE",
+ measuredAt: new Date(),
+ } as never);
+ vi.mocked(prisma.measurement.createMany).mockResolvedValue({
+ count: 1,
+ } as never);
+ vi.mocked(prisma.measurement.update).mockResolvedValue({} as never);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("syncUserMeasurements — plausibility gate", () => {
+ it("writes a reading inside the metric's declared band", async () => {
+ vi.mocked(fetchMeasurements).mockResolvedValue([
+ {
+ type: "PULSE",
+ value: 112,
+ measuredAt: new Date("2026-05-16T08:00:00Z"),
+ },
+ ] as never);
+
+ const result = await eventStorage.run(event, () =>
+ syncUserMeasurements("user-1"),
+ );
+
+ expect(result).toEqual({ imported: 1, failed: false });
+ expect(prisma.measurement.create).toHaveBeenCalledTimes(1);
+ expect(tally()).toBeUndefined();
+ });
+
+ it("refuses an out-of-band reading and annotates the drop", async () => {
+ vi.mocked(fetchMeasurements).mockResolvedValue([
+ {
+ type: "PULSE",
+ value: 112,
+ measuredAt: new Date("2026-05-16T08:00:00Z"),
+ },
+ {
+ type: "PULSE",
+ value: 55643821.505,
+ measuredAt: new Date("2026-05-16T08:05:00Z"),
+ },
+ ] as never);
+
+ const result = await eventStorage.run(event, () =>
+ syncUserMeasurements("user-1"),
+ );
+
+ // Only the real reading landed, and the run is still a clean success: one
+ // impossible sample must not strand the rest behind a held watermark.
+ expect(result).toEqual({ imported: 1, failed: false });
+ expect(prisma.measurement.create).toHaveBeenCalledTimes(1);
+ expect(tally()).toEqual({
+ total: 1,
+ buckets: [
+ { source: "withings", type: "PULSE", direction: "above_max", count: 1 },
+ ],
+ });
+ expect(event.getLevel()).toBe("warn");
+ });
+
+ it("does not overwrite a stored reading with an out-of-band one", async () => {
+ // The slot already holds a real pulse; the provider re-sends the same
+ // instant carrying a decode slip. Ungated, the update branch rewrote the
+ // good value with the bad one.
+ vi.mocked(prisma.measurement.findFirst).mockResolvedValue({
+ id: "existing-row",
+ } as never);
+ vi.mocked(fetchMeasurements).mockResolvedValue([
+ {
+ type: "PULSE",
+ value: 55643821.505,
+ measuredAt: new Date("2026-05-16T08:00:00Z"),
+ },
+ ] as never);
+
+ const result = await eventStorage.run(event, () =>
+ syncUserMeasurements("user-1"),
+ );
+
+ expect(result).toEqual({ imported: 0, failed: false });
+ expect(prisma.measurement.update).not.toHaveBeenCalled();
+ expect(tally()?.total).toBe(1);
+ });
+});
+
+describe("syncUserActivity — plausibility gate", () => {
+ it("plans a daily total inside the metric's declared band", async () => {
+ installActivityFetch([{ date: "2026-05-16", steps: 8_400 }]);
+
+ const imported = await eventStorage.run(event, () =>
+ syncUserActivity("user-1"),
+ );
+
+ expect(imported).toBe(1);
+ expect(prisma.measurement.createMany).toHaveBeenCalledTimes(1);
+ expect(tally()).toBeUndefined();
+ });
+
+ it("refuses an out-of-band daily total and annotates the drop", async () => {
+ // ACTIVITY_STEPS is declared 0–200 000.
+ installActivityFetch([
+ { date: "2026-05-16", steps: 8_400 },
+ { date: "2026-05-17", steps: 9_900_000 },
+ ]);
+
+ const imported = await eventStorage.run(event, () =>
+ syncUserActivity("user-1"),
+ );
+
+ expect(imported).toBe(1);
+ const created = vi.mocked(prisma.measurement.createMany).mock.calls[0]?.[0]
+ ?.data as Array<{ value: number }>;
+ expect(created).toHaveLength(1);
+ expect(created[0]!.value).toBe(8_400);
+ expect(tally()).toEqual({
+ total: 1,
+ buckets: [
+ {
+ source: "withings",
+ type: "ACTIVITY_STEPS",
+ direction: "above_max",
+ count: 1,
+ },
+ ],
+ });
+ expect(event.getLevel()).toBe("warn");
+ });
+});
diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts
index 869fb1d18..a4a0098b2 100644
--- a/src/lib/withings/sync-activity.ts
+++ b/src/lib/withings/sync-activity.ts
@@ -39,6 +39,7 @@ import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { getUnitForType } from "@/lib/validations/measurement";
+import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
@@ -342,7 +343,7 @@ export async function syncUserActivity(
value: number;
externalId: string;
}
- const planned: PlannedRow[] = [];
+ const collected: PlannedRow[] = [];
for (const entry of entries) {
if (!entry.date) continue;
const measuredAt = activityMeasuredAt(entry.date);
@@ -352,7 +353,7 @@ export async function syncUserActivity(
// valid (a day of rest still records 0 steps). Only skip
// undefined / null / NaN.
if (raw == null || !Number.isFinite(raw)) continue;
- planned.push({
+ collected.push({
type,
measuredAt,
value: raw,
@@ -361,6 +362,15 @@ export async function syncUserActivity(
}
}
+ // A daily-activity total the app's own band calls impossible (a step count
+ // past 200 000, a negative distance) is a provider artefact, not a day. Drop
+ // it before the plan is read against the table, so it can neither insert a
+ // fresh impossible slot nor overwrite a good stored one.
+ const planned = dropImplausibleMeasurements("withings", collected, (p) => ({
+ type: p.type,
+ value: p.value,
+ }));
+
if (planned.length > 0) {
const slotKey = (type: MeasurementType, measuredAt: Date) =>
`${type}|${measuredAt.getTime()}`;
diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts
index 840b5d8d3..65099ea7c 100644
--- a/src/lib/withings/sync-sleep.ts
+++ b/src/lib/withings/sync-sleep.ts
@@ -474,6 +474,9 @@ export async function syncUserSleep(
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
+ // Refused by the plausibility gate: nothing was written and nothing
+ // was touched. The drop is already on the wide event.
+ if (verdict.status === "rejected_range") continue;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
@@ -542,6 +545,9 @@ export async function syncUserSleep(
if (verdict.status === "failed") {
throw new MeasurementReconciliationError(verdict);
}
+ // Refused by the plausibility gate: nothing was written and
+ // nothing was touched. The drop is already on the wide event.
+ if (verdict.status === "rejected_range") continue;
for (const dirty of verdict.dirtyIdentities ?? []) {
touched.push(dirty);
}
diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts
index fcee1cc7c..d500a030c 100644
--- a/src/lib/withings/sync.ts
+++ b/src/lib/withings/sync.ts
@@ -15,6 +15,7 @@ import { getUserWithingsCredentials } from "./credentials";
import { annotate, getEvent } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
+import { dropImplausibleMeasurements } from "@/lib/measurements/plausibility-gate";
import {
isReauthRequired,
recordSyncFailure,
@@ -229,14 +230,24 @@ export async function syncUserMeasurements(
)
: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); // 30 days back
- let measures: Awaited>;
+ let fetched: Awaited>;
try {
- measures = await fetchMeasurements(tokenInfo.accessToken, startDate);
+ fetched = await fetchMeasurements(tokenInfo.accessToken, startDate);
} catch (err) {
await recordWithingsSyncFailure(userId, err, WITHINGS_LEG_MEASURES);
throw err;
}
+ // The app's own plausibility band applies to a provider reading exactly as
+ // it applies to a typed one. A remote that reports a pulse in the millions
+ // is reporting a decode slip, not a heartbeat, and the row would poison
+ // every mean and personal band derived from it. Refuse it here, before the
+ // update branch below can overwrite a good stored value with it.
+ const measures = dropImplausibleMeasurements("withings", fetched, (m) => ({
+ type: m.type,
+ value: m.value,
+ }));
+
let imported = 0;
// v1.4.39.1 — track every (type, measuredAt) we touched so the
// persistent rollup tier can be re-folded at the end of the sync.
diff --git a/tests/integration/cycle-flow-opens-cycle.test.ts b/tests/integration/cycle-flow-opens-cycle.test.ts
index 5e81fda56..7a9fe7d84 100644
--- a/tests/integration/cycle-flow-opens-cycle.test.ts
+++ b/tests/integration/cycle-flow-opens-cycle.test.ts
@@ -70,6 +70,14 @@ beforeEach(async () => {
username: "cycle-flow",
email: "cycle-flow@example.test",
gender: "FEMALE",
+ // The suite runs under TZ=UTC and computes its relative day keys with
+ // local Date arithmetic, i.e. in UTC. The route resolves "today" in the
+ // USER's zone and falls back to Europe/Berlin when none is set - one
+ // hour past 22:00 UTC the two clocks disagree about the date and every
+ // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
+ // makes the test's clock and the route's clock the same clock at any
+ // hour of the day.
+ timezone: "UTC",
},
});
await loginAs(USER_ID);
diff --git a/tests/integration/cycle-routes.test.ts b/tests/integration/cycle-routes.test.ts
index 8dc05e92b..d75c8822f 100644
--- a/tests/integration/cycle-routes.test.ts
+++ b/tests/integration/cycle-routes.test.ts
@@ -69,6 +69,14 @@ beforeEach(async () => {
username: "cycle-female",
email: "cycle-female@example.test",
gender: "FEMALE",
+ // The suite runs under TZ=UTC and computes its relative day keys with
+ // local Date arithmetic, i.e. in UTC. The route resolves "today" in the
+ // USER's zone and falls back to Europe/Berlin when none is set - one
+ // hour past 22:00 UTC the two clocks disagree about the date and every
+ // day-of-cycle assertion is off by one. Pinning the fixture zone to UTC
+ // makes the test's clock and the route's clock the same clock at any
+ // hour of the day.
+ timezone: "UTC",
},
});
await prisma.user.create({
@@ -77,6 +85,7 @@ beforeEach(async () => {
username: "cycle-male",
email: "cycle-male@example.test",
gender: "MALE",
+ timezone: "UTC",
},
});
});
diff --git a/tests/integration/lab-source-reference-range.test.ts b/tests/integration/lab-source-reference-range.test.ts
new file mode 100644
index 000000000..34c20123a
--- /dev/null
+++ b/tests/integration/lab-source-reference-range.test.ts
@@ -0,0 +1,298 @@
+/**
+ * The whole path a printed reference range travels, against a real Postgres.
+ *
+ * The failure this pins is a two-ended one: until now the document reading
+ * pulled the value off a lab report and dropped the range printed beside it,
+ * and every end of that pipe looked healthy on its own. So the assertions here
+ * follow ONE range through the real assembly rather than checking each end in
+ * isolation:
+ *
+ * extract → the staged fact carries the printed string
+ * commit → the written row carries the derived bounds AND the string
+ * read → the API DTO serves both windows and says which is in force
+ * verdict → the reading is judged against the REPORT's window, not the
+ * catalog band — the case where the two disagree is the point
+ * report → the doctor-report payload and the FHIR bundle agree with it
+ *
+ * The catalog band is deliberately set so that the same number reads
+ * "in-range" against the report and "above" against the catalog. A test whose
+ * two windows agree cannot tell whether the precedence rule is wired at all.
+ */
+import { NextRequest } from "next/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+const USER_ID = "user-lab-source-range";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ const prisma = getPrismaClient();
+ await prisma.user.create({
+ data: {
+ id: USER_ID,
+ username: "lab-source-range",
+ email: "lab-source-range@example.test",
+ },
+ });
+ const session = await prisma.session.create({
+ data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+});
+
+/** The catalog band the marker carries. 5.2 sits ABOVE this window. */
+const CATALOG = { lowerBound: 3.5, upperBound: 5.0 };
+/** The window the report printed. 5.2 sits INSIDE this one. */
+const PRINTED = "3,9 - 5,4";
+const VALUE = 5.2;
+
+async function seedMarker() {
+ return getPrismaClient().biomarker.create({
+ data: { userId: USER_ID, name: "Kalium", unit: "mmol/L", ...CATALOG },
+ });
+}
+
+function postReq(body: unknown): NextRequest {
+ return new NextRequest("http://localhost/api/labs", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
+
+async function readDto(analyte = "Kalium") {
+ const { GET } = await import("@/app/api/labs/route");
+ const res = await GET(
+ new NextRequest(`http://localhost/api/labs?analyte=${analyte}`),
+ );
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ return body.data.results[0];
+}
+
+describe("a printed reference range, end to end", () => {
+ it("survives the document path onto the row and governs its verdict", async () => {
+ const prisma = getPrismaClient();
+ const marker = await seedMarker();
+
+ // The document path, through the REAL commit helper the confirm route
+ // calls. The fact carries what a report prints: a value, a unit, and a
+ // window stated in German notation beside it.
+ const { commitApprovedFact } = await import("@/lib/documents/commit");
+ const { encryptFactData, encryptFactProvenance } =
+ await import("@/lib/documents/store");
+ const doc = await prisma.inboundDocument.create({
+ data: {
+ userId: USER_ID,
+ filename: "befund.pdf",
+ mimeType: "application/pdf",
+ byteSize: 1024,
+ contentEncrypted: Buffer.from("stand-in for the stored bytes"),
+ status: "EXTRACTED",
+ },
+ });
+ const fact = await prisma.extractedFact.create({
+ data: {
+ documentId: doc.id,
+ userId: USER_ID,
+ factType: "OBSERVATION",
+ status: "APPROVED",
+ confidence: 0.9,
+ needsReview: false,
+ dataEncrypted: encryptFactData({
+ label: "Kalium",
+ code: null,
+ codeSystem: null,
+ value: VALUE,
+ valueText: null,
+ unit: "mmol/L",
+ referenceLow: null,
+ referenceHigh: null,
+ referenceText: PRINTED,
+ effectiveDate: "2026-07-01",
+ }),
+ provenanceEncrypted: encryptFactProvenance({
+ sourceText: "Kalium 5,2 mmol/l 3,9 - 5,4",
+ anchored: true,
+ sourceOffset: 0,
+ page: 0,
+ confidence: 0.9,
+ }),
+ },
+ });
+
+ const ref = await commitApprovedFact(USER_ID, fact);
+ expect(ref.recordType).toBe("labResult");
+
+ // The row carries BOTH readings of the printed window: the bounds derived
+ // from it and the string itself.
+ const row = await prisma.labResult.findUniqueOrThrow({
+ where: { id: ref.recordId },
+ });
+ expect(row.sourceReferenceLow).toBe(3.9);
+ expect(row.sourceReferenceHigh).toBe(5.4);
+ expect(row.sourceReferenceText).toBe(PRINTED);
+ // The catalog stamp is untouched — the marker already existed, so the
+ // document's window did not rewrite the user's own band.
+ expect(row.referenceLow).toBe(CATALOG.lowerBound);
+ expect(row.referenceHigh).toBe(CATALOG.upperBound);
+ const markerAfter = await prisma.biomarker.findUniqueOrThrow({
+ where: { id: marker.id },
+ });
+ expect(markerAfter.lowerBound).toBe(CATALOG.lowerBound);
+ expect(markerAfter.upperBound).toBe(CATALOG.upperBound);
+
+ // The read side serves both windows, names the winner, and flags that the
+ // two disagree.
+ const dto = await readDto();
+ expect(dto).toMatchObject({
+ value: VALUE,
+ referenceLow: 3.9,
+ referenceHigh: 5.4,
+ catalogReferenceLow: CATALOG.lowerBound,
+ catalogReferenceHigh: CATALOG.upperBound,
+ sourceReferenceLow: 3.9,
+ sourceReferenceHigh: 5.4,
+ sourceReferenceText: PRINTED,
+ referenceOrigin: "source",
+ referenceDivergesFromCatalog: true,
+ });
+
+ // The verdict is the point. Against the catalog band 5.2 is above range;
+ // against the window the lab printed it is inside it, and the lab's window
+ // is the one the physician read the value against.
+ expect(dto.rangeStatus).toBe("in-range");
+
+ // The doctor report reads the same window, and prints the report's own
+ // string rather than a reformatted version of it.
+ const { loadLabResults } =
+ await import("@/lib/doctor-report/clinical-records");
+ const labs = await loadLabResults(
+ USER_ID,
+ new Date("2026-01-01T00:00:00.000Z"),
+ new Date("2026-12-31T23:59:59.000Z"),
+ );
+ expect(labs?.[0]).toMatchObject({
+ referenceLow: 3.9,
+ referenceHigh: 5.4,
+ catalogReferenceLow: CATALOG.lowerBound,
+ catalogReferenceHigh: CATALOG.upperBound,
+ sourceReferenceText: PRINTED,
+ referenceOrigin: "source",
+ referenceDivergesFromCatalog: true,
+ });
+
+ // FHIR exports the same window — a receiver that trusts referenceRange
+ // must not be handed the catalog band while the app shows another.
+ const { labObservations } = await import("@/lib/fhir/resources/labs");
+ let seq = 0;
+ const observations = labObservations(
+ { labResults: labs } as Parameters[0],
+ () => `obs-${++seq}`,
+ );
+ expect(observations[0].referenceRange?.[0]).toMatchObject({
+ low: { value: 3.9 },
+ high: { value: 5.4 },
+ text: PRINTED,
+ });
+ });
+
+ it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
+ const prisma = getPrismaClient();
+ await seedMarker();
+
+ const { POST } = await import("@/app/api/labs/route");
+ const marker = await prisma.biomarker.findFirstOrThrow({
+ where: { userId: USER_ID },
+ });
+ const res = await POST(
+ postReq({
+ biomarkerId: marker.id,
+ value: VALUE,
+ sourceReferenceText: "siehe Befund",
+ takenAt: "2026-07-02T08:00:00.000Z",
+ }),
+ );
+ expect(res.status).toBe(201);
+
+ const row = await prisma.labResult.findFirstOrThrow({
+ where: { userId: USER_ID },
+ });
+ // The string is on the record — absence would be the old failure, and a
+ // fabricated bound would be worse than either.
+ expect(row.sourceReferenceText).toBe("siehe Befund");
+ expect(row.sourceReferenceLow).toBeNull();
+ expect(row.sourceReferenceHigh).toBeNull();
+
+ const dto = await readDto();
+ expect(dto).toMatchObject({
+ sourceReferenceText: "siehe Befund",
+ referenceOrigin: "catalog",
+ referenceDivergesFromCatalog: false,
+ referenceLow: CATALOG.lowerBound,
+ referenceHigh: CATALOG.upperBound,
+ });
+ // No derivable window means the catalog stays the net, so the catalog's
+ // verdict stands.
+ expect(dto.rangeStatus).toBe("above");
+ });
+
+ it("carries the window through a backup round trip", async () => {
+ const prisma = getPrismaClient();
+ const marker = await seedMarker();
+ await prisma.labResult.create({
+ data: {
+ userId: USER_ID,
+ biomarkerId: marker.id,
+ analyte: "Kalium",
+ unit: "mmol/L",
+ value: VALUE,
+ referenceLow: CATALOG.lowerBound,
+ referenceHigh: CATALOG.upperBound,
+ sourceReferenceLow: 3.9,
+ sourceReferenceHigh: 5.4,
+ sourceReferenceText: PRINTED,
+ takenAt: new Date("2026-07-01T08:00:00.000Z"),
+ },
+ });
+
+ const { buildRecordsBackupSection } =
+ await import("@/lib/export/records-backup");
+ const backup = await buildRecordsBackupSection(prisma, USER_ID, {
+ purpose: "disaster-recovery",
+ });
+ expect(backup.labResults[0]).toMatchObject({
+ sourceReferenceLow: 3.9,
+ sourceReferenceHigh: 5.4,
+ sourceReferenceText: PRINTED,
+ });
+ });
+});
diff --git a/tests/integration/mood-backup-round-trip.test.ts b/tests/integration/mood-backup-round-trip.test.ts
new file mode 100644
index 000000000..42c43e088
--- /dev/null
+++ b/tests/integration/mood-backup-round-trip.test.ts
@@ -0,0 +1,439 @@
+/**
+ * A mood entry, out through the real backup and back in through the real
+ * restore, asserted on the rows.
+ *
+ * Deliberately not a classification test. Asserting that the payload has a
+ * `moodEntries` key proves nothing this repository has not already been burned
+ * by: excise the restore call entirely and a shape check still reports green.
+ * So this seeds an account, exports through `buildFullBackupPayload`, parses
+ * through `parseBackupPayload`, restores through the real
+ * `POST /api/admin/backups/[id]/restore`, and then reads the row back out of
+ * Postgres and compares values.
+ *
+ * The fixture is the entry that used to come back wrong in four ways at once:
+ * a ticked BINARY tag and a rated factor on the same entry, a note in the
+ * encrypted column, a timezone that is not the legacy Europe/Berlin default,
+ * and a sync counter above zero. The old payload carried none of them.
+ *
+ * The second case is the compatibility floor: a hand-written payload in the
+ * shape files were written in BEFORE this change, restored through the same
+ * route. Every field this adds is optional or defaulted precisely so that file
+ * keeps working, and the only way to know is to send one.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY ??=
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import type { PrismaClient } from "@/generated/prisma/client";
+import { encrypt } from "@/lib/crypto";
+import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
+import { encryptNote } from "@/lib/crypto/note-cipher";
+import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
+import { parseBackupPayload } from "@/lib/validations/backup";
+import { POST } from "@/app/api/admin/backups/[id]/restore/route";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => cookieJar.set(name, value),
+ delete: (name: string) => cookieJar.delete(name),
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@/lib/cache/invalidate", () => ({
+ invalidateUserData: vi.fn(),
+}));
+
+const OWNER_ID = "mood-round-trip-owner";
+const NOTE = "slept badly, long walk in the afternoon";
+const LOGGED_AT = new Date("2026-07-19T23:40:00.000Z");
+const CREATED_AT = new Date("2026-07-19T23:41:00.000Z");
+const UPDATED_AT = new Date("2026-07-20T06:05:00.000Z");
+const DELETED_AT = new Date("2026-07-20T07:00:00.000Z");
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+async function seedAdminSession(prisma: PrismaClient) {
+ const admin = await prisma.user.create({
+ data: {
+ username: "mood-round-trip-admin",
+ email: "mood-round-trip-admin@example.test",
+ role: "ADMIN",
+ },
+ });
+ const session = await prisma.session.create({
+ data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ return admin;
+}
+
+async function seedOwner(prisma: PrismaClient) {
+ await prisma.user.create({
+ data: {
+ id: OWNER_ID,
+ username: "mood-round-trip-owner",
+ email: "mood-round-trip-owner@example.test",
+ },
+ });
+}
+
+/**
+ * The taxonomy the entry links to: one seeded BINARY tag (`userId: null`, the
+ * reference data every instance has), one BINARY tag the account made itself,
+ * and one RATED factor. The account's own tag matters — it travels in the file
+ * as a `customMoodTags` row, and a restore that could not re-create it would
+ * lose the link even with everything else correct.
+ */
+async function seedTaxonomy(prisma: PrismaClient) {
+ const category = await prisma.moodTagCategory.create({
+ data: {
+ id: "mood-rt-category",
+ key: "mood_round_trip",
+ labelKey: "mood.tagCategory.roundTrip",
+ },
+ });
+ const seededTag = await prisma.moodTag.create({
+ data: {
+ id: "mood-rt-seeded-tag",
+ categoryId: category.id,
+ key: "rt_headache",
+ labelKey: "mood.tag.rtHeadache",
+ kind: "BINARY",
+ },
+ });
+ const ownTag = await prisma.moodTag.create({
+ data: {
+ id: "mood-rt-own-tag",
+ userId: OWNER_ID,
+ categoryId: category.id,
+ key: "custom:rt_long_walk",
+ labelKey: "mood.tag.custom",
+ kind: "BINARY",
+ },
+ });
+ const factor = await prisma.moodTag.create({
+ data: {
+ id: "mood-rt-factor",
+ categoryId: category.id,
+ key: "rt_sleep_quality",
+ labelKey: "mood.tag.rtSleepQuality",
+ kind: "RATED",
+ scaleMin: 1,
+ scaleMax: 5,
+ },
+ });
+ return { category, seededTag, ownTag, factor };
+}
+
+function makeRestoreRequest(id: string) {
+ return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ confirm: "RESTORE" }),
+ });
+}
+
+async function restoreFromPayload(
+ prisma: PrismaClient,
+ payload: unknown,
+ type: string,
+) {
+ const backup = await prisma.dataBackup.create({
+ data: {
+ userId: OWNER_ID,
+ type,
+ data: encrypt(JSON.stringify(payload)),
+ },
+ });
+ return POST(makeRestoreRequest(backup.id) as never, {
+ params: Promise.resolve({ id: backup.id }),
+ });
+}
+
+describe("mood backup round trip", () => {
+ it("brings the note, the timezone, the sync counter and both tag arms back", async () => {
+ const prisma = getPrismaClient();
+ await seedAdminSession(prisma);
+ await seedOwner(prisma);
+ const { seededTag, ownTag, factor } = await seedTaxonomy(prisma);
+
+ await prisma.moodEntry.create({
+ data: {
+ id: "mood-rt-entry",
+ userId: OWNER_ID,
+ date: "2026-07-19",
+ mood: "OKAY",
+ score: 3,
+ tags: JSON.stringify(["free-text-tag"]),
+ note: null,
+ noteEncrypted: encryptNote(NOTE),
+ source: "MANUAL",
+ externalId: null,
+ moodLoggedAt: LOGGED_AT,
+ // Not Europe/Berlin: a restore that drops `tz` moves this entry's day
+ // boundary by six hours and nothing else in the row shows it.
+ tz: "America/New_York",
+ syncVersion: 3,
+ createdAt: CREATED_AT,
+ updatedAt: UPDATED_AT,
+ tagLinks: {
+ create: [
+ { moodTagId: factor.id, rating: 4 },
+ { moodTagId: seededTag.id, rating: null },
+ { moodTagId: ownTag.id, rating: null },
+ ],
+ },
+ },
+ });
+ await prisma.moodEntry.create({
+ data: {
+ id: "mood-rt-tombstone",
+ userId: OWNER_ID,
+ date: "2026-07-18",
+ mood: "SCHLECHT",
+ score: 2,
+ source: "MANUAL",
+ moodLoggedAt: new Date("2026-07-18T22:00:00.000Z"),
+ tz: "America/New_York",
+ syncVersion: 5,
+ deletedAt: DELETED_AT,
+ },
+ });
+
+ const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
+ purpose: "disaster-recovery",
+ });
+ const parsed = parseBackupPayload(payload);
+
+ // Wipe what the restore is supposed to bring back. The tag rows stay: the
+ // instance's catalogue is reference data, and the account's own tag rides
+ // in the file and is re-created by the restore either way.
+ await prisma.moodEntry.deleteMany({ where: { userId: OWNER_ID } });
+ await prisma.moodTag.deleteMany({ where: { userId: OWNER_ID } });
+ expect(await prisma.moodEntry.count({ where: { userId: OWNER_ID } })).toBe(
+ 0,
+ );
+
+ const response = await restoreFromPayload(
+ prisma,
+ parsed,
+ "MOOD_ROUND_TRIP",
+ );
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ data: { skipped: { links: number } };
+ };
+ // Nothing was dropped. A restore that lost a link and still answered 200
+ // is exactly what the skip report exists to make visible.
+ expect(body.data.skipped.links).toBe(0);
+
+ const restored = await prisma.moodEntry.findUniqueOrThrow({
+ where: { id: "mood-rt-entry" },
+ });
+ // Asserted as "there is a note" before it is decrypted, so a payload that
+ // stopped carrying the ciphertext fails by name rather than as a TypeError
+ // three frames inside the cipher.
+ expect(
+ restored.noteEncrypted,
+ "the restored entry has no note ciphertext — the backup dropped it",
+ ).not.toBeNull();
+ expect(decryptFromBytes(restored.noteEncrypted!)).toBe(NOTE);
+ expect(restored.note).toBeNull();
+ expect(restored.tz).toBe("America/New_York");
+ expect(restored.syncVersion).toBe(3);
+ expect(restored.moodLoggedAt).toEqual(LOGGED_AT);
+ expect(restored.createdAt).toEqual(CREATED_AT);
+ expect(restored.updatedAt).toEqual(UPDATED_AT);
+ expect(restored.date).toBe("2026-07-19");
+ expect(restored.score).toBe(3);
+ expect(restored.tags).toBe(JSON.stringify(["free-text-tag"]));
+ expect(restored.deletedAt).toBeNull();
+
+ const tombstone = await prisma.moodEntry.findUniqueOrThrow({
+ where: { id: "mood-rt-tombstone" },
+ });
+ expect(tombstone.deletedAt).toEqual(DELETED_AT);
+ expect(tombstone.syncVersion).toBe(5);
+
+ const links = await prisma.moodEntryTagLink.findMany({
+ where: { moodEntryId: "mood-rt-entry" },
+ select: { rating: true, moodTag: { select: { key: true, kind: true } } },
+ orderBy: { moodTagId: "asc" },
+ });
+ const byKey = new Map(links.map((l) => [l.moodTag.key, l]));
+ expect([...byKey.keys()].sort()).toEqual([
+ "custom:rt_long_walk",
+ "rt_headache",
+ "rt_sleep_quality",
+ ]);
+ // The rating is what tells the two arms apart in the row: a ticked tag
+ // carries NULL, and that NULL is what the old backup filter read as
+ // "nothing to carry".
+ expect(byKey.get("rt_headache")!.rating).toBeNull();
+ expect(byKey.get("custom:rt_long_walk")!.rating).toBeNull();
+ expect(byKey.get("rt_sleep_quality")!.rating).toBe(4);
+ });
+
+ it("restores a file written in the shape that predates these fields", async () => {
+ const prisma = getPrismaClient();
+ await seedAdminSession(prisma);
+ await seedOwner(prisma);
+ const { factor } = await seedTaxonomy(prisma);
+
+ // Verbatim the shape the builder emitted before this change: no `note`, no
+ // `noteEncrypted`, no `tz`, no `syncedAt`, no `syncVersion`, no
+ // `createdAt`/`updatedAt`, no `structuredTags`. Hand-written rather than
+ // generated, because a fixture built from today's builder cannot describe
+ // yesterday's file.
+ const legacyPayload = {
+ schemaVersion: "2",
+ exportedAt: "2026-07-20T00:00:00.000Z",
+ userId: OWNER_ID,
+ appSettings: null,
+ measurements: [],
+ medications: [],
+ intakeEvents: [],
+ moodEntries: [
+ {
+ id: "mood-rt-legacy",
+ date: "2026-07-19",
+ mood: "GUT",
+ score: 4,
+ tags: null,
+ source: "MOODLOG",
+ loggedAt: LOGGED_AT.toISOString(),
+ externalId: "moodlog-legacy",
+ deletedAt: null,
+ factors: [{ key: factor.key, rating: 5 }],
+ },
+ ],
+ customMoodTags: [],
+ nutrientDays: [],
+ };
+
+ const parsed = parseBackupPayload(legacyPayload);
+ // Defaults, not values: absence has to stay absence through the parse.
+ expect(parsed.moodEntries[0].structuredTags).toEqual([]);
+ expect(parsed.moodEntries[0].tz).toBeUndefined();
+ expect(parsed.moodEntries[0].syncVersion).toBeUndefined();
+
+ const response = await restoreFromPayload(
+ prisma,
+ parsed,
+ "MOOD_LEGACY_SHAPE",
+ );
+ expect(response.status).toBe(200);
+
+ const restored = await prisma.moodEntry.findUniqueOrThrow({
+ where: { id: "mood-rt-legacy" },
+ });
+ // Identical to what the old restore wrote for the same file: the note
+ // columns empty, `tz` NULL — which is what such a row meant, the legacy
+ // Europe/Berlin reading — and the counter at the schema default.
+ expect(restored.note).toBeNull();
+ expect(restored.noteEncrypted).toBeNull();
+ expect(restored.tz).toBeNull();
+ expect(restored.syncVersion).toBe(0);
+ expect(restored.mood).toBe("GUT");
+ expect(restored.score).toBe(4);
+ expect(restored.externalId).toBe("moodlog-legacy");
+ expect(restored.deletedAt).toBeNull();
+
+ const links = await prisma.moodEntryTagLink.findMany({
+ where: { moodEntryId: "mood-rt-legacy" },
+ select: { rating: true, moodTagId: true },
+ });
+ expect(links).toEqual([{ moodTagId: factor.id, rating: 5 }]);
+ });
+
+ it("names a ticked tag this instance cannot resolve instead of losing it quietly", async () => {
+ const prisma = getPrismaClient();
+ await seedAdminSession(prisma);
+ await seedOwner(prisma);
+ await seedTaxonomy(prisma);
+
+ const payload = {
+ schemaVersion: "2",
+ exportedAt: "2026-07-20T00:00:00.000Z",
+ userId: OWNER_ID,
+ appSettings: null,
+ measurements: [],
+ medications: [],
+ intakeEvents: [],
+ moodEntries: [
+ {
+ id: "mood-rt-unknown-tag",
+ date: "2026-07-19",
+ mood: "OKAY",
+ score: 3,
+ tags: null,
+ source: "MANUAL",
+ loggedAt: LOGGED_AT.toISOString(),
+ externalId: null,
+ deletedAt: null,
+ factors: [],
+ structuredTags: ["rt_headache", "rt_retired_tag"],
+ },
+ ],
+ customMoodTags: [],
+ nutrientDays: [],
+ };
+
+ const response = await restoreFromPayload(
+ prisma,
+ parseBackupPayload(payload),
+ "MOOD_UNKNOWN_TAG",
+ );
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ data: {
+ skipped: {
+ links: number;
+ catalogueKeys: Array<{
+ catalogue: string;
+ key: string;
+ links: number;
+ }>;
+ };
+ };
+ };
+ expect(body.data.skipped.catalogueKeys).toEqual([
+ { catalogue: "moodTag", key: "rt_retired_tag", links: 1 },
+ ]);
+ expect(body.data.skipped.links).toBe(1);
+
+ // The entry itself came back whole. One key that has nowhere to attach
+ // costs the link, never the record.
+ const restored = await prisma.moodEntry.findUniqueOrThrow({
+ where: { id: "mood-rt-unknown-tag" },
+ });
+ expect(restored.score).toBe(3);
+ const links = await prisma.moodEntryTagLink.findMany({
+ where: { moodEntryId: restored.id },
+ select: { moodTag: { select: { key: true } } },
+ });
+ expect(links).toEqual([{ moodTag: { key: "rt_headache" } }]);
+ });
+});