From 5de488c35cfcd509e5a26ec935488b7d1b665c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 00:52:25 +0200 Subject: [PATCH] docs: state the sharing boundary that is actually enforced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two sentences promised that a manage grant reaches no settings at all. Both were true when they were written and stopped being true when the anamnesis destination opened, and both are in the worst places for a stale promise: one is the access description generated into the public contract and read by the native client, the other is the paragraph a delegate meets on a page sharing does not cover. Both now name the boundary the code draws — record content versus account configuration — and say which part of the record a manage grant reaches. What stays with the account owner is spelled out rather than implied: login, the second factor, provider connections, tokens, notification routing, module and threshold configuration, and who else has access. A guard checks the copy against the classification rather than against itself, so opening a second destination or closing this one moves the sentence with it. The contract is regenerated. --- docs/api/openapi.yaml | 14 ++- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/__tests__/sharing-boundary-copy.test.ts | 119 ++++++++++++++++++++ src/lib/validations/account-sharing.ts | 2 +- 9 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/sharing-boundary-copy.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 89fe122e2..86c2b2da4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12538,12 +12538,14 @@ paths: default: READ description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. - MANAGE can additionally change and remove entries, including ones the owner wrote, and read the - generated insights; it never reaches the identity surfaces (login, second factor, connections, - tokens, settings, or who else has access). Omitted means READ, so a client that predates the field - keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, - because that would widen what the delegate accepted without asking them again. The way up is a new - invitation the delegate accepts." + MANAGE can additionally change and remove entries, including ones the owner wrote, read the + generated insights, and record the record's own health background (allergies, family history). The + boundary is record content versus account configuration: MANAGE never reaches login, the second + factor, provider connections, API tokens, notification routing, module and threshold configuration, + or who else has access. Omitted means READ, so a client that predates the field keeps working. The + level is fixed when the invitation is written: no endpoint raises a live grant, because that would + widen what the delegate accepted without asking them again. The way up is a new invitation the + delegate accepts." type: string enum: - READ diff --git a/messages/de.json b/messages/de.json index 82318dc1f..b928bc2ac 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9522,7 +9522,7 @@ }, "unavailable": { "title": "Gehört nicht zum geteilten Zugriff", - "description": "Geteilt wird die Gesundheitsakte selbst. Einstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.", + "description": "Geteilt wird die Gesundheitsakte selbst, einschließlich der Vorgeschichte unter Anamnese. Kontoeinstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.", "leave": "Zurück zu meiner Akte" }, "state": { diff --git a/messages/en.json b/messages/en.json index af1f56262..208915163 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9522,7 +9522,7 @@ }, "unavailable": { "title": "Not part of shared access", - "description": "Sharing covers the health record itself. Settings, connected services, notifications and the AI features stay with the person who owns the account.", + "description": "Sharing covers the health record itself, including the health background under Anamnesis. Account settings, connected services, notifications and the AI features stay with the person who owns the account.", "leave": "Back to my record" }, "state": { diff --git a/messages/es.json b/messages/es.json index 49c232874..8cc2cb450 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9522,7 +9522,7 @@ }, "unavailable": { "title": "No forma parte del acceso compartido", - "description": "Se comparte el historial de salud en sí. Los ajustes, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.", + "description": "Se comparte el historial de salud en sí, incluidos los antecedentes que hay en Anamnesis. Los ajustes de la cuenta, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.", "leave": "Volver a mi historial" }, "state": { diff --git a/messages/fr.json b/messages/fr.json index 97cb05c19..6668aba8d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9522,7 +9522,7 @@ }, "unavailable": { "title": "Ne fait pas partie de l'accès partagé", - "description": "Le partage porte sur le dossier de santé lui-même. Les réglages, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.", + "description": "Le partage porte sur le dossier de santé lui-même, y compris les antécédents dans Anamnèse. Les réglages du compte, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.", "leave": "Revenir à mon dossier" }, "state": { diff --git a/messages/it.json b/messages/it.json index c5c97cfc3..e922a3689 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9522,7 +9522,7 @@ }, "unavailable": { "title": "Non fa parte dell'accesso condiviso", - "description": "La condivisione riguarda la cartella sanitaria in sé. Impostazioni, servizi collegati, notifiche e funzioni di IA restano alla persona proprietaria dell'account.", + "description": "La condivisione riguarda la cartella sanitaria in sé, compresa l'anamnesi. Le impostazioni dell'account, i servizi collegati, le notifiche e le funzioni di IA restano alla persona proprietaria dell'account.", "leave": "Torna alla mia cartella" }, "state": { diff --git a/messages/pl.json b/messages/pl.json index f7e6d23a1..1866ce06b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9522,7 +9522,7 @@ }, "unavailable": { "title": "Nie należy do udostępnionego dostępu", - "description": "Udostępniana jest sama karta zdrowia. Ustawienia, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.", + "description": "Udostępniana jest sama karta zdrowia, w tym wywiad zdrowotny w sekcji Anamneza. Ustawienia konta, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.", "leave": "Wróć do mojej karty" }, "state": { diff --git a/src/__tests__/sharing-boundary-copy.test.ts b/src/__tests__/sharing-boundary-copy.test.ts new file mode 100644 index 000000000..a96aaa9a0 --- /dev/null +++ b/src/__tests__/sharing-boundary-copy.test.ts @@ -0,0 +1,119 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { inviteGrantSchema } from "@/lib/validations/account-sharing"; +import { + SETTINGS_DESTINATION_INVENTORY, + isManageDelegateSettingsDestination, +} from "@/lib/record-settings/classification"; + +/** + * What the product SAYS a manage grant cannot reach agrees with what it cannot + * reach. + * + * Two sentences make that promise, and both were written when it was true: + * + * * the `access` field's description in the sharing request schema, which is + * generated into `docs/api/openapi.yaml` and read by the native client — + * it said MANAGE "never reaches … settings"; + * * `recordSharing.unavailable.description`, the paragraph a delegate meets + * on a page sharing does not cover — it said settings "stay with the + * person who owns the account". + * + * v1.37.0 opened one Settings destination to a manage grant, and both + * sentences became false in the most expensive place a sentence can be false: + * one is a consent screen, the other is a published contract. A promise that + * has quietly stopped holding is worse than no promise, because somebody has + * already decided based on it. + * + * So the copy is checked against the classification rather than against + * itself. If a second destination is ever opened, or the anamnesis one is + * closed again, the sentence that describes the boundary has to move with it. + * + * What this cannot check is whether the wording is GOOD. It checks that the + * absolute claim is gone, that the boundary is named the way the code draws it + * (record content versus account configuration), and that all six locales say + * it — which is the part that rots silently. + */ +const ROOT = process.cwd(); +const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const; + +/** The published description of the level, off the Zod schema itself. */ +function accessDescription(): string { + const shape = ( + inviteGrantSchema as unknown as { + _def: { shape: Record }; + } + )._def.shape; + return shape.access?.description ?? ""; +} + +function description(locale: string): string { + const bundle = JSON.parse( + readFileSync(join(ROOT, `messages/${locale}.json`), "utf8"), + ) as { + recordSharing: { unavailable: { description: string } }; + }; + return bundle.recordSharing.unavailable.description; +} + +describe("the published boundary matches the one the code draws", () => { + const openDestinations = Object.keys(SETTINGS_DESTINATION_INVENTORY).filter( + isManageDelegateSettingsDestination, + ); + + it("has something to be wrong about", () => { + // The pin every leg below rests on. With no manage-reachable destination + // the old absolute sentence would have been true and this whole file would + // be asserting a style preference. + expect(openDestinations).not.toHaveLength(0); + expect(openDestinations).toContain("anamnesis"); + expect(accessDescription().length).toBeGreaterThan(200); + }); + + it("does not claim a manage grant reaches no settings at all", () => { + const published = accessDescription(); + // The exact clause that went stale, and the shape of any replacement for + // it. `settings` may appear — it has to, to say which parts stay with the + // owner — but not as part of a blanket denial. + expect(published).not.toMatch(/never reaches[^.]*\bsettings\b/i); + expect(published).not.toMatch(/no access to settings/i); + }); + + it("names the boundary the classification actually draws", () => { + // Not a synonym check for its own sake: "record content versus account + // configuration" IS the rule `isManageDelegateSettingsDestination` + // implements, and a description that described some other boundary would + // be a second, disagreeing specification. + expect(accessDescription()).toMatch( + /record content versus account configuration/i, + ); + // And the destination that was opened is named, so a reader of the + // contract can tell which part of the record it means. + expect(accessDescription()).toMatch(/allergies, family history/i); + }); + + it("keeps the refusal paragraph honest in all six locales", () => { + // The delegate-facing half. It is shown on a page sharing does NOT cover, + // so a sentence that over-claims there is read at the exact moment + // somebody is working out where the edge is. + const missing: string[] = []; + for (const locale of LOCALES) { + const text = description(locale); + expect(text.length, locale).toBeGreaterThan(40); + // Every locale has to acknowledge the health background, because that + // is the part the old sentence implicitly denied. Matched on the stem + // rather than the English spelling, and short enough to survive the + // accent: the section is "Anamnèse" in French and "Anamneza" in Polish, + // so anything past "anamn" is already language-specific and a matcher + // tuned to one of them reports the others as broken. + if (!/anamn/i.test(text)) missing.push(locale); + // And it must still say the account itself stays with its owner — + // dropping that would fix one over-claim by removing the boundary. + expect(text, locale).toMatch(/konto|account|compte|cuenta|konta|koncie/i); + } + expect(missing).toEqual([]); + }); +}); diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index a6c6315c7..5e47ad567 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -58,7 +58,7 @@ export const inviteGrantSchema = z .enum(["READ", "WRITE", "MANAGE"]) .default("READ") .describe( - "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights; it never reaches the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", + "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. MANAGE can additionally change and remove entries, including ones the owner wrote, read the generated insights, and record the record's own health background (allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens, notification routing, module and threshold configuration, or who else has access. Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", ), /** * v1.37.0 — which sections of the record the invitation opens.