diff --git a/messages/de.json b/messages/de.json
index a2d2e6d9e..f7dd21931 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6922,7 +6922,14 @@
"restoreSkippedPersonalRecordReference": "Herkunft einer Bestleistung",
"restoreSkippedEcgReference": "Herkunft eines EKG-Streifens",
"restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation",
- "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume"
+ "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume",
+ "restoreMissingTitle": "Wiederherstellung abgelehnt — die Datei ist unvollständig",
+ "restoreMissingDescription": "Das Manifest der Datei sagt, sie enthalte den folgenden Abschnitt, und sie enthält ihn nicht. Es wurde nichts geändert: Eine Wiederherstellung ersetzt eine ganze Datenklasse, ein fehlender Abschnitt hätte sie also geleert.",
+ "restoreMissingRemedy": "Nimm eine Notfall-Sicherung oder exportiere das Konto erneut und lade die neue Datei hoch. Ein Abschnitt, den das Manifest ausdrücklich als weggelassen ausweist, ist etwas anderes und wird normal wiederhergestellt.",
+ "restoreMissingDocuments": "Dokumente",
+ "restoreMissingWorkouts": "Workouts",
+ "restoreMissingMentalHealth": "Psychische Fragebögen",
+ "restoreMissingConsent": "Einwilligungsnachweise"
},
"danger-zone": {
"title": "Gefahrenzone",
diff --git a/messages/en.json b/messages/en.json
index ba0a5ac87..db7c97817 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6922,7 +6922,14 @@
"restoreSkippedPersonalRecordReference": "Personal best provenance",
"restoreSkippedEcgReference": "ECG strip provenance",
"restoreSkippedMedicationTarget": "Medication target analyte",
- "restoreSkippedScheduleRevisionLink": "Schedule era succession"
+ "restoreSkippedScheduleRevisionLink": "Schedule era succession",
+ "restoreMissingTitle": "Restore refused — the file is incomplete",
+ "restoreMissingDescription": "The file's own manifest says it carries the section below, and it does not. Nothing was changed: a restore replaces a whole class of records, so putting back a section that is not in the file would have emptied it.",
+ "restoreMissingRemedy": "Use a disaster-recovery snapshot, or export the account again and upload the new file. A section the manifest declares as omitted is a different thing and restores normally.",
+ "restoreMissingDocuments": "Documents",
+ "restoreMissingWorkouts": "Workouts",
+ "restoreMissingMentalHealth": "Mental-health screeners",
+ "restoreMissingConsent": "Consent receipts"
},
"danger-zone": {
"title": "Danger Zone",
diff --git a/messages/es.json b/messages/es.json
index 632a9f9ed..3562ce5b7 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6922,7 +6922,14 @@
"restoreSkippedPersonalRecordReference": "Procedencia de un récord personal",
"restoreSkippedEcgReference": "Procedencia de un trazado de ECG",
"restoreSkippedMedicationTarget": "Analito objetivo de un medicamento",
- "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta"
+ "restoreSkippedScheduleRevisionLink": "Sucesión de periodos de pauta",
+ "restoreMissingTitle": "Restauración rechazada: el archivo está incompleto",
+ "restoreMissingDescription": "El propio manifiesto del archivo dice que incluye la sección siguiente, y no la incluye. No se cambió nada: una restauración sustituye toda una clase de registros, así que una sección ausente la habría vaciado.",
+ "restoreMissingRemedy": "Usa una copia de recuperación ante desastres, o vuelve a exportar la cuenta y sube el archivo nuevo. Una sección que el manifiesto declara omitida es otra cosa y se restaura con normalidad.",
+ "restoreMissingDocuments": "Documentos",
+ "restoreMissingWorkouts": "Entrenamientos",
+ "restoreMissingMentalHealth": "Cuestionarios de salud mental",
+ "restoreMissingConsent": "Comprobantes de consentimiento"
},
"danger-zone": {
"title": "Zona de peligro",
diff --git a/messages/fr.json b/messages/fr.json
index 5e03401f1..5faaecd7a 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6922,7 +6922,14 @@
"restoreSkippedPersonalRecordReference": "Provenance d'un record personnel",
"restoreSkippedEcgReference": "Provenance d'un tracé ECG",
"restoreSkippedMedicationTarget": "Analyte cible d'un médicament",
- "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma"
+ "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma",
+ "restoreMissingTitle": "Restauration refusée — le fichier est incomplet",
+ "restoreMissingDescription": "Le manifeste du fichier indique qu'il contient la section ci-dessous, et il ne la contient pas. Rien n'a été modifié : une restauration remplace toute une classe d'enregistrements, une section absente l'aurait donc vidée.",
+ "restoreMissingRemedy": "Utilise une sauvegarde de reprise après sinistre, ou réexporte le compte et téléverse le nouveau fichier. Une section que le manifeste déclare omise est autre chose et se restaure normalement.",
+ "restoreMissingDocuments": "Documents",
+ "restoreMissingWorkouts": "Séances",
+ "restoreMissingMentalHealth": "Questionnaires de santé mentale",
+ "restoreMissingConsent": "Preuves de consentement"
},
"danger-zone": {
"title": "Zone de danger",
diff --git a/messages/it.json b/messages/it.json
index eb02d4511..85ad8bcf1 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6922,7 +6922,14 @@
"restoreSkippedPersonalRecordReference": "Provenienza di un primato personale",
"restoreSkippedEcgReference": "Provenienza di un tracciato ECG",
"restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco",
- "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema"
+ "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema",
+ "restoreMissingTitle": "Ripristino rifiutato — il file è incompleto",
+ "restoreMissingDescription": "Il manifesto del file dichiara di contenere la sezione qui sotto, e non la contiene. Non è stato modificato nulla: un ripristino sostituisce un'intera classe di record, quindi una sezione assente l'avrebbe svuotata.",
+ "restoreMissingRemedy": "Usa uno snapshot di disaster recovery, oppure esporta di nuovo l'account e carica il file nuovo. Una sezione che il manifesto dichiara omessa è un'altra cosa e si ripristina normalmente.",
+ "restoreMissingDocuments": "Documenti",
+ "restoreMissingWorkouts": "Allenamenti",
+ "restoreMissingMentalHealth": "Questionari sulla salute mentale",
+ "restoreMissingConsent": "Ricevute di consenso"
},
"danger-zone": {
"title": "Zona pericolosa",
diff --git a/messages/pl.json b/messages/pl.json
index 18514d304..7e83719f0 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6922,7 +6922,14 @@
"restoreSkippedPersonalRecordReference": "Pochodzenie rekordu osobistego",
"restoreSkippedEcgReference": "Pochodzenie zapisu EKG",
"restoreSkippedMedicationTarget": "Analit docelowy leku",
- "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu"
+ "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu",
+ "restoreMissingTitle": "Przywracanie odrzucone — plik jest niekompletny",
+ "restoreMissingDescription": "Manifest pliku mówi, że zawiera on poniższą sekcję, a jej nie zawiera. Nic nie zostało zmienione: przywracanie zastępuje całą klasę rekordów, więc brakująca sekcja by ją opróżniła.",
+ "restoreMissingRemedy": "Użyj kopii do odtwarzania po awarii albo wyeksportuj konto ponownie i wgraj nowy plik. Sekcja, którą manifest jawnie deklaruje jako pominiętą, to co innego i przywraca się normalnie.",
+ "restoreMissingDocuments": "Dokumenty",
+ "restoreMissingWorkouts": "Treningi",
+ "restoreMissingMentalHealth": "Kwestionariusze zdrowia psychicznego",
+ "restoreMissingConsent": "Potwierdzenia zgody"
},
"danger-zone": {
"title": "Strefa zagrożenia",
diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts
index f15f3bc8a..ec71b6817 100644
--- a/src/app/api/admin/backups/[id]/restore/route.ts
+++ b/src/app/api/admin/backups/[id]/restore/route.ts
@@ -40,6 +40,7 @@ import {
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { restoreCycleData } from "@/lib/cycle/backup";
import {
+ findMissingBackupSections,
recordUnknownKeys,
summarizeRestoreSkips,
type RestoreSkipLog,
@@ -193,9 +194,14 @@ const handler = apiHandler(
return apiError("Failed to decrypt backup payload", 500);
}
+ // Parsed once and kept, because the schema's per-section `.default([])`
+ // erases the difference between a section that is absent and one that is
+ // empty — and that difference is what the completeness check below reads.
+ let raw: unknown;
let payload;
try {
- payload = parseBackupPayload(plaintext);
+ raw = JSON.parse(plaintext);
+ payload = parseBackupPayload(raw);
} catch (err) {
await auditLog("admin.backups.restore.failed", {
userId: admin.id,
@@ -289,6 +295,39 @@ const handler = apiHandler(
},
});
+ // A section the file's own manifest says it carries, and does not.
+ //
+ // The restore's first act is to delete the class it is about to rebuild, so
+ // an absent section is not a gap in the file — it is an erasure of that part
+ // of the account, reported as a success. #237: a portable file whose
+ // document payload was gone emptied the vault and said nothing. The whole
+ // file is refused instead, above the transaction, for the same reason the
+ // metadata-only check below sits there.
+ //
+ // A DECLARED omission is not this. The portable export leaves the screener
+ // administrations and the consent receipts out on purpose and says so in the
+ // manifest; `findMissingBackupSections` reads the manifest and not the
+ // emptiness of an array, so those files stay restorable. So does every file
+ // written before the manifest existed, which declares nothing at all.
+ const missingSections = findMissingBackupSections(raw);
+ if (missingSections.length > 0) {
+ await auditLog("admin.backups.restore.failed", {
+ userId: admin.id,
+ ipAddress: getClientIp(request),
+ details: {
+ backupId: id,
+ ownerId,
+ reason: "section_missing",
+ sections: missingSections,
+ },
+ });
+ return apiError(
+ `Backup is missing ${missingSections.join(", ")} — the file's own manifest says it carries ${missingSections.length === 1 ? "that section" : "those sections"}. Nothing was changed. Use a disaster-recovery snapshot, or export the account again.`,
+ 422,
+ { errorCode: "backup.section.missing", sections: missingSections },
+ );
+ }
+
// Portable exports intentionally omit document ciphertext. They remain
// valid upload/download artifacts, but cannot be used to manufacture an
// InboundDocument row. A DR restore must fail before any delete rather
diff --git a/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx
new file mode 100644
index 000000000..d818af957
--- /dev/null
+++ b/src/components/admin/__tests__/backups-restore-refusal-notice.test.tsx
@@ -0,0 +1,111 @@
+/**
+ * A refused backup has to tell the operator what to do next.
+ *
+ * The server refuses a file whose manifest claims a section the file does not
+ * carry, before it deletes anything. That refusal reaches a person through the
+ * `meta.errorCode` on the error envelope, and a bare "restore failed" toast
+ * would waste it: the operator on the other end is frequently holding the only
+ * copy of an account, and the two facts they can act on — which section is
+ * missing, and that nothing was changed — have to stay on screen.
+ *
+ * Pinned here:
+ * 1. the missing sections are named, not counted,
+ * 2. the notice says nothing was changed and what to do instead,
+ * 3. no sections means no notice — a panel that appears after every restore
+ * trains the operator to ignore it,
+ * 4. the copy resolves in more than one locale,
+ * 5. `missingSectionsOf` keys on the errorCode and not on the message text,
+ * so rewording the server's prose cannot silently remove the panel.
+ *
+ * Mutation check: render a count instead of the list and case 1 goes red; drop
+ * the remedy line and case 2 goes red; return the notice unconditionally and
+ * case 3 goes red; match on the message string in `missingSectionsOf` and case
+ * 5 goes red.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import {
+ RestoreRefusalNotice,
+ missingSectionsOf,
+} from "@/components/admin/backups-section";
+import { ApiError } from "@/lib/api/api-fetch";
+import type { MissingBackupSection } from "@/lib/export/restore-skips";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ usePathname: () => "/admin/backups",
+}));
+
+function render(
+ sections: MissingBackupSection[],
+ locale: "en" | "de" = "en",
+): string {
+ return renderToStaticMarkup(
+
+ {}} />
+ ,
+ );
+}
+
+describe("RestoreRefusalNotice", () => {
+ it("names every missing section", () => {
+ const html = render(["documents", "consent"]);
+
+ expect(html).toContain('data-slot="restore-refusal-notice"');
+ expect(html).toContain("Documents");
+ expect(html).toContain("Consent receipts");
+ });
+
+ it("says nothing was changed, and what to do instead", () => {
+ const html = render(["documents"]);
+
+ expect(html).toContain("Nothing was changed");
+ expect(html).toContain("disaster-recovery snapshot");
+ });
+
+ it("renders nothing when no section was missing", () => {
+ expect(render([])).toBe("");
+ });
+
+ it("speaks the operator's language", () => {
+ const german = render(["documents", "mentalHealth"], "de");
+
+ expect(german).toContain("Dokumente");
+ expect(german).toContain("Psychische Fragebögen");
+ expect(german).toContain("Es wurde nichts geändert");
+ });
+});
+
+describe("missingSectionsOf", () => {
+ it("reads the sections off the refusal envelope", () => {
+ const err = new ApiError("Backup is missing documents", 422, {
+ errorCode: "backup.section.missing",
+ sections: ["documents"],
+ });
+
+ expect(missingSectionsOf(err)).toEqual(["documents"]);
+ });
+
+ it("ignores an error whose message merely looks like one", () => {
+ // The message is prose the server may reword. Only the code counts.
+ const err = new ApiError("Backup is missing documents", 422, {
+ errorCode: "something_else",
+ sections: ["documents"],
+ });
+
+ expect(missingSectionsOf(err)).toEqual([]);
+ expect(missingSectionsOf(new Error("Backup is missing documents"))).toEqual(
+ [],
+ );
+ });
+
+ it("survives a refusal with no section list", () => {
+ const err = new ApiError("Refused", 422, {
+ errorCode: "backup.section.missing",
+ });
+
+ expect(missingSectionsOf(err)).toEqual([]);
+ });
+});
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index 598d6aab9..5e2243a91 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -49,11 +49,18 @@ import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
import type {
+ MissingBackupSection,
RestoreSkipSummary,
SkippedCatalogue,
} from "@/lib/export/restore-skips";
import { getApiErrorMessage } from "./_shared";
-import { apiFetch, apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
+import {
+ ApiError,
+ apiFetch,
+ apiFetchRaw,
+ apiGet,
+ apiPost,
+} from "@/lib/api/api-fetch";
/**
* Typed-confirmation dialog for restore. The destructive Restore button
@@ -374,6 +381,114 @@ function catalogueLabel(
return unlabelled;
}
+/**
+ * Which section a refused file claimed and did not carry, as a label.
+ *
+ * Spelled out with literal `t()` calls for the same reason as
+ * `catalogueLabel` above — an interpolated key is invisible to
+ * `i18n-call-site-coverage.test.ts`, and a renamed section would ship as raw
+ * dot notation. The `never` at the foot is what makes the compiler say the
+ * chain is exhaustive.
+ */
+function missingSectionLabel(
+ section: MissingBackupSection,
+ t: ReturnType["t"],
+) {
+ if (section === "documents") {
+ return t("admin.section.backups.restoreMissingDocuments");
+ }
+ if (section === "workouts") {
+ return t("admin.section.backups.restoreMissingWorkouts");
+ }
+ if (section === "mentalHealth") {
+ return t("admin.section.backups.restoreMissingMentalHealth");
+ }
+ if (section === "consent") {
+ return t("admin.section.backups.restoreMissingConsent");
+ }
+ const unlabelled: never = section;
+ return unlabelled;
+}
+
+/**
+ * Why a file was refused, and what to do about it.
+ *
+ * Held on screen rather than left to the toast, exactly like the skip report
+ * below it. The operator refused here is often holding the only copy of an
+ * account; "restore failed" for four seconds is not enough to act on, and the
+ * two facts that let them act — which section the file claimed and did not
+ * carry, and that nothing was changed — have to survive long enough to be read.
+ */
+/**
+ * The refused sections carried on an `ApiError`, or none.
+ *
+ * Keyed on `meta.errorCode` and not on the message text: the message is prose
+ * the server may reword, and matching on it would turn a copy edit into a
+ * silent loss of the panel.
+ */
+export function missingSectionsOf(err: unknown): MissingBackupSection[] {
+ if (!(err instanceof ApiError)) return [];
+ if (err.meta?.errorCode !== "backup.section.missing") return [];
+ const sections = err.meta?.sections;
+ if (!Array.isArray(sections)) return [];
+ return sections.filter(
+ (section): section is MissingBackupSection => typeof section === "string",
+ );
+}
+
+export function RestoreRefusalNotice({
+ sections,
+ onDismiss,
+}: {
+ sections: MissingBackupSection[];
+ onDismiss: () => void;
+}) {
+ const { t } = useTranslations();
+ if (sections.length === 0) return null;
+ return (
+
+ );
+}
+
/**
* What the last restore could not put back.
*
@@ -585,6 +700,13 @@ export function BackupsSection() {
// until they dismiss it.
const [skipped, setSkipped] = useState(null);
+ // Why the last restore was refused before it touched anything. Same reason
+ // the skip report is held rather than toasted: the operator has to be able to
+ // read it after the four seconds are up.
+ const [refusedSections, setRefusedSections] = useState<
+ MissingBackupSection[]
+ >([]);
+
// Restore: typed-confirmation dialog. The mutation is keyed by row id
// and used inline by `` below — keeping the
// mutation here lets the parent invalidate the list query on success.
@@ -604,6 +726,7 @@ export function BackupsSection() {
// A restore that dropped links is not a plain success and must not read
// as one. The count and the exact keys go on screen; the same report is
// in the audit row for anyone asking later.
+ setRefusedSections([]);
const report = data.skipped;
if (report && report.links > 0) {
setSkipped(report);
@@ -622,6 +745,16 @@ export function BackupsSection() {
queryClient.invalidateQueries();
},
onError: (err) => {
+ // The server refuses an incomplete file before it deletes anything, and
+ // names the sections in `meta`. That is the one restore failure the
+ // operator can act on, so it gets the panel rather than only a toast.
+ const sections = missingSectionsOf(err);
+ if (sections.length > 0) {
+ setRefusedSections(sections);
+ toast.error(t("admin.section.backups.restoreMissingTitle"));
+ return;
+ }
+ setRefusedSections([]);
toast.error(
err instanceof Error && err.message
? err.message
@@ -745,6 +878,11 @@ export function BackupsSection() {
+ setRefusedSections([])}
+ />
+
{skipped ? (
= {}) {
+ return {
+ schemaVersion: "2",
+ exportedAt: "2026-08-01T00:00:00.000Z",
+ userId: "user-1",
+ manifest: PORTABLE_MANIFEST,
+ documents: [],
+ workouts: [],
+ mentalHealthAssessments: [],
+ consentReceipts: [],
+ ...overrides,
+ };
+}
+
+describe("findMissingBackupSections", () => {
+ it("names the documents section a portable file claims and does not carry", () => {
+ const file = portable();
+ delete (file as Record).documents;
+
+ expect(findMissingBackupSections(file)).toEqual(["documents"]);
+ });
+
+ it("finds nothing in a complete portable file", () => {
+ expect(findMissingBackupSections(portable())).toEqual([]);
+ });
+
+ it("does not read an empty section as a missing one", () => {
+ // An account with no documents writes `[]`, and that file is fine. Refusing
+ // it would make the check worse than the silence it replaces.
+ expect(findMissingBackupSections(portable({ documents: [] }))).toEqual([]);
+ });
+
+ it("leaves a DECLARED omission alone", () => {
+ // The portable export omits both of these on purpose and says so. This is
+ // the boundary: the keys are gone, and the file is still restorable.
+ const file = portable();
+ delete (file as Record).mentalHealthAssessments;
+ delete (file as Record).consentReceipts;
+
+ expect(findMissingBackupSections(file)).toEqual([]);
+ });
+
+ it("names the same two sections when the manifest says they ARE carried", () => {
+ // Identical payload to the case above, one word different in the manifest.
+ // That word is the whole rule.
+ const file = portable({ manifest: DR_MANIFEST });
+ delete (file as Record).mentalHealthAssessments;
+ delete (file as Record).consentReceipts;
+
+ expect(findMissingBackupSections(file)).toEqual([
+ "mentalHealth",
+ "consent",
+ ]);
+ });
+
+ it("claims nothing about a file with no manifest", () => {
+ // Every backup written before the manifest existed. It declares nothing, so
+ // nothing is missing from it, and it stays restorable.
+ const legacy = portable({ manifest: null });
+ delete (legacy as Record).documents;
+ delete (legacy as Record).workouts;
+
+ expect(findMissingBackupSections(legacy)).toEqual([]);
+ });
+
+ it("reports every missing section rather than the first", () => {
+ const file = portable({ manifest: DR_MANIFEST });
+ delete (file as Record).documents;
+ delete (file as Record).workouts;
+
+ expect(findMissingBackupSections(file)).toEqual(["documents", "workouts"]);
+ });
+
+ it("survives a payload that is not an object at all", () => {
+ expect(findMissingBackupSections(null)).toEqual([]);
+ expect(findMissingBackupSections("nope")).toEqual([]);
+ expect(findMissingBackupSections([])).toEqual([]);
+ });
+});
diff --git a/src/lib/export/restore-skips.ts b/src/lib/export/restore-skips.ts
index ef8acb6c3..11ffe61d7 100644
--- a/src/lib/export/restore-skips.ts
+++ b/src/lib/export/restore-skips.ts
@@ -216,3 +216,91 @@ export function summarizeRestoreSkips(log: RestoreSkipLog): RestoreSkipSummary {
links: catalogueKeys.reduce((total, entry) => total + entry.links, 0),
};
}
+
+/**
+ * A section the file's own manifest says it carries, and does not.
+ *
+ * This is the other half of the honesty contract above, and it is deliberately
+ * the opposite answer. A skip drops one edge and names it, because the record
+ * it hangs off survives and giving the operator the rest is worth more than
+ * refusing them everything. A missing SECTION is not one edge — it is a whole
+ * class of the account, and the restore's first act is to delete the class it
+ * is about to rebuild. Restoring a file whose documents section is not there
+ * therefore does not lose a link. It empties the vault, tells the operator the
+ * restore succeeded, and leaves them to find out later.
+ *
+ * So this one refuses the file. Nothing is deleted, and the response names the
+ * section rather than saying "invalid backup", because an operator holding the
+ * only copy of an account needs to know whether the file is worthless or
+ * whether they picked the wrong one of two.
+ *
+ * ── The manifest is what tells a missing section from an omitted one ────────
+ *
+ * A payload that omits a section on purpose is not broken, and refusing it
+ * would break the two exports that do it. `buildSensitiveBackupSection` leaves
+ * the screener administrations and the consent receipts out of a PORTABLE file
+ * — the first because the encrypted per-item answers include the PHQ-9
+ * self-harm item, the second because a consent belongs to the operator it was
+ * given to — and says so in the manifest as `included: "omitted"`. That is a
+ * declared omission, and it stays restorable.
+ *
+ * The discriminator is therefore the manifest and nothing else. Not the emptiness
+ * of the array: an account with no documents honestly writes `[]`, and treating
+ * that as a loss would refuse a perfectly good file. Not the export's purpose
+ * either, which the payload does not state. Only "the file said it carries this,
+ * and the key is not there".
+ *
+ * A file with NO manifest at all declares nothing, so nothing is missing from
+ * it. Every backup written before the manifest existed is in that category and
+ * has to stay restorable.
+ */
+export type MissingBackupSection =
+ "documents" | "workouts" | "mentalHealth" | "consent";
+
+/**
+ * Which payload key each manifest entry speaks for.
+ *
+ * `included: "omitted"` is the one value that means "not carried". Every other
+ * value the writers produce — `metadata-only`, `encrypted-content`,
+ * `summary-only`, `full` — describes HOW MUCH of the section travels, not
+ * whether the section is there, and all of them come with the key present.
+ */
+const MANIFEST_SECTIONS: ReadonlyArray<{
+ manifestKey: MissingBackupSection;
+ payloadKey: string;
+}> = [
+ { manifestKey: "documents", payloadKey: "documents" },
+ { manifestKey: "workouts", payloadKey: "workouts" },
+ { manifestKey: "mentalHealth", payloadKey: "mentalHealthAssessments" },
+ { manifestKey: "consent", payloadKey: "consentReceipts" },
+];
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/**
+ * Sections the raw file claims and does not carry, in manifest order.
+ *
+ * Takes the RAW parsed JSON, not a `BackupPayload`. Every section key in
+ * `backupPayloadSchema` carries `.default([])`, so by the time the payload has
+ * been through the schema an absent section and an empty one are the same
+ * value — which is exactly the distinction this function exists to make. Run it
+ * on the object that came out of `JSON.parse`.
+ */
+export function findMissingBackupSections(
+ raw: unknown,
+): MissingBackupSection[] {
+ if (!isRecord(raw)) return [];
+ const manifest = raw.manifest;
+ if (!isRecord(manifest)) return [];
+
+ const missing: MissingBackupSection[] = [];
+ for (const { manifestKey, payloadKey } of MANIFEST_SECTIONS) {
+ const entry = manifest[manifestKey];
+ if (!isRecord(entry)) continue;
+ if (entry.included === "omitted") continue;
+ if (raw[payloadKey] === undefined) missing.push(manifestKey);
+ }
+ return missing;
+}
diff --git a/tests/integration/backup-missing-section-restore.test.ts b/tests/integration/backup-missing-section-restore.test.ts
new file mode 100644
index 000000000..89702af11
--- /dev/null
+++ b/tests/integration/backup-missing-section-restore.test.ts
@@ -0,0 +1,238 @@
+/**
+ * A file that claims a section it does not carry is refused whole (#237).
+ *
+ * The restore's first act is to delete the class of records it is about to
+ * rebuild. So a payload whose documents section is simply absent does not
+ * "restore everything else": it empties the vault, reports success, and leaves
+ * the operator to discover the hole later. The maintainer's decision is to
+ * refuse the file instead — above the transaction, like the metadata-only
+ * check beside it, so the account survives the refusal.
+ *
+ * The hard part is not the refusal. It is the boundary, and both halves are
+ * asserted here against the real route and real rows:
+ *
+ * MISSING — the manifest says the file carries documents, and the key is not
+ * there. Refused, named, nothing deleted.
+ *
+ * DECLARED OMITTED — the manifest says the file does NOT carry the
+ * mental-health screeners or the consent receipts, and the keys are not
+ * there. That is what every portable export writes, on purpose, and it has to
+ * restore. The two cases below are the same builder, the same account and the
+ * same deleted-key trick; the only difference is what the manifest said, which
+ * is exactly the claim being made.
+ *
+ * Mutation check: drop the `included === "omitted"` skip in
+ * `findMissingBackupSections` and the second case goes red. Move the refusal
+ * below the `prisma.$transaction(...)` call and the first case's surviving-rows
+ * assertions go red while its status assertion stays green — which is why the
+ * rows are asserted separately rather than trusted to follow from the status.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+process.env.ENCRYPTION_KEY =
+ "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+
+import { encrypt } from "@/lib/crypto";
+import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
+import { POST } from "@/app/api/admin/backups/[id]/restore/route";
+import { invalidateUserData } from "@/lib/cache/invalidate";
+
+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(),
+}));
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+ vi.mocked(invalidateUserData).mockClear();
+});
+
+function makeRequest(id: string) {
+ return new Request(`http://localhost/api/admin/backups/${id}/restore`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ confirm: "RESTORE" }),
+ });
+}
+
+/**
+ * An admin account with one measurement and nothing else, signed in.
+ *
+ * The measurement is the witness. It references nothing the refusal is about,
+ * so it can only be lost to the wipe — which is what tells a refused restore
+ * apart from a destroyed account.
+ */
+async function seedOwner(username: string) {
+ const prisma = getPrismaClient();
+ const owner = await prisma.user.create({
+ data: { username, email: `${username}@example.test`, role: "ADMIN" },
+ });
+ const session = await prisma.session.create({
+ data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ await prisma.measurement.create({
+ data: {
+ userId: owner.id,
+ type: "WEIGHT",
+ value: 79.2,
+ unit: "kg",
+ source: "MANUAL",
+ measuredAt: new Date("2026-07-11T06:15:00.000Z"),
+ },
+ });
+ return owner;
+}
+
+/**
+ * A real portable export of that account, with the named keys cut out of the
+ * JSON before it is sealed.
+ *
+ * Cutting them from the RAW object rather than from a parsed payload is the
+ * whole point: every section in `backupPayloadSchema` carries `.default([])`,
+ * so a payload that has been through the schema no longer knows the difference
+ * between a section that was absent and one that was empty.
+ */
+async function sealPortableWithout(
+ ownerId: string,
+ keys: string[],
+): Promise<{ backupId: string; manifest: Record }> {
+ const prisma = getPrismaClient();
+ // No `purpose`, so the builder takes the portable branch — the file a person
+ // gets from "export my data".
+ const { payload } = await buildFullBackupPayload(prisma, ownerId);
+ const raw = JSON.parse(JSON.stringify(payload)) as Record;
+ for (const key of keys) delete raw[key];
+
+ const backup = await prisma.dataBackup.create({
+ data: {
+ userId: ownerId,
+ type: "MISSING_SECTION_RESTORE",
+ data: encrypt(JSON.stringify(raw)),
+ },
+ });
+ return {
+ backupId: backup.id,
+ manifest: raw.manifest as Record,
+ };
+}
+
+async function restore(backupId: string) {
+ return await POST(
+ makeRequest(backupId) as unknown as Parameters[0],
+ { params: Promise.resolve({ id: backupId }) },
+ );
+}
+
+describe("a backup whose manifest claims a section the file does not carry", () => {
+ it("refuses the whole file, names the section, and changes nothing", async () => {
+ const prisma = getPrismaClient();
+ const owner = await seedOwner("missing-section-owner");
+ const { backupId, manifest } = await sealPortableWithout(owner.id, [
+ "documents",
+ ]);
+
+ // Prove the premise rather than assume it: the file still SAYS it carries
+ // documents. Without this the test would keep passing if the builder ever
+ // stopped writing the manifest entry.
+ expect(
+ (manifest.documents as { included?: string } | undefined)?.included,
+ ).toBe("metadata-only");
+
+ const response = await restore(backupId);
+
+ expect(response.status).toBe(422);
+ const body = (await response.json()) as {
+ error: string;
+ meta?: { errorCode?: string; sections?: string[] };
+ };
+ expect(
+ body.error,
+ "the message names the section, so the operator can act on it",
+ ).toContain("documents");
+ expect(
+ body.error,
+ "and says the account is intact, because that is the decision they need",
+ ).toContain("Nothing was changed");
+ expect(body.meta?.errorCode).toBe("backup.section.missing");
+ expect(body.meta?.sections).toEqual(["documents"]);
+
+ // The refusal is only worth anything above the wipe.
+ expect(
+ await prisma.measurement.count({ where: { userId: owner.id } }),
+ "a refused restore must not have run the wipe",
+ ).toBe(1);
+ // A second, independent witness: the invalidation only fires at the end of
+ // a restore that actually happened.
+ expect(vi.mocked(invalidateUserData)).not.toHaveBeenCalled();
+
+ const auditRow = await prisma.auditLog.findFirstOrThrow({
+ where: { action: "admin.backups.restore.failed" },
+ orderBy: { createdAt: "desc" },
+ });
+ const details = JSON.parse(auditRow.details ?? "{}") as {
+ reason?: string;
+ sections?: string[];
+ };
+ expect(details.reason).toBe("section_missing");
+ expect(details.sections).toEqual(["documents"]);
+ });
+
+ it("restores a file whose manifest DECLARES the same keys omitted", async () => {
+ const prisma = getPrismaClient();
+ const owner = await seedOwner("declared-omission-owner");
+ // The two sections a portable export leaves out on purpose, cut from the
+ // file exactly the way `documents` was cut above.
+ const { backupId, manifest } = await sealPortableWithout(owner.id, [
+ "mentalHealthAssessments",
+ "consentReceipts",
+ ]);
+
+ expect(
+ (manifest.mentalHealth as { included?: string } | undefined)?.included,
+ ).toBe("omitted");
+ expect(
+ (manifest.consent as { included?: string } | undefined)?.included,
+ ).toBe("omitted");
+
+ const response = await restore(backupId);
+
+ expect(
+ response.status,
+ "a declared omission is not a missing section",
+ ).toBe(200);
+ const body = (await response.json()) as { data: { restored: boolean } };
+ expect(body.data.restored).toBe(true);
+ // The restore genuinely ran: the measurement came back out of the file it
+ // was exported into, and the cache was evicted.
+ expect(
+ await prisma.measurement.count({ where: { userId: owner.id } }),
+ ).toBe(1);
+ expect(vi.mocked(invalidateUserData)).toHaveBeenCalledWith(owner.id);
+ });
+});