feat(backup): declare what a backup carries, and force a verdict on the rest

The mirror of the wipe plan, for the same reason and against a worse failure:
a list that was right when written and stops being right every time a model is
added. On the delete side that leaves data behind; here it means data does not
come back, and nothing says so until someone is restoring a backup because
something already went wrong.

The list is longer than the wipe list by sixteen models, and that gap is the
point. A wipe may lean on the database — deleting a medication cascades to its
schedules, dose changes and inventory events — but a restore has no cascade,
so every child has to be named here.

Every exclusion carries its reason in prose, and the guard enforces a length
floor on it. That floor is not pedantry: the first draft of this file said
'Same as WithingsConnection' four times, which is a pointer rather than a
reason, and a pointer silently inherits whatever the thing it points at
becomes. Writing the decision where it is made is the only way it stays
reviewable.

The guard found two things in its own author's work on the first run: a model
with no verdict at all, and a reason too thin to be one.

The restore side of the guard — that every carried model also has a restore
branch — lands with the payload work it will police.
This commit is contained in:
Marc-André Bombeck committed 2026-07-27 05:14:39 +02:00
1 parent 8e9da490a9
commit a2e9587781
2 files changed
+389

No files matched your search

@@ -0,0 +1,128 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
AUTH_MODELS_OUT_OF_SCOPE,
BACKED_UP_MODELS,
DERIVED_MODELS,
NOT_IN_BACKUP_MODELS,
backupVerdict,
} from "@/lib/export/backup-plan";
/**
* Every model in the schema has a written verdict about the backup.
*
* This is the first half of the backup guard: classification. The second half
* — that every `BACKED_UP` model actually has a payload reader AND a restore
* branch — lives beside it, because a model that is classified as carried and
* then never restored is the defect, not the fix.
*
* The failure this prevents: a model is added to `schema.prisma`, it is
* user-scoped from birth, and it is outside the backup by default. Nothing
* notices, and the gap is only discovered by someone restoring a backup and
* finding a part of their record missing — which is the worst possible moment
* and the worst possible messenger.
*/
const SCHEMA_PATH = resolve(__dirname, "../../prisma/schema.prisma");
/** Models declared in the schema, minus `User` itself (the account row). */
function schemaModels(): string[] {
const source = readFileSync(SCHEMA_PATH, "utf8");
return [...source.matchAll(/^model\s+(\w+)\s*\{/gm)]
.map((m) => m[1])
.filter((name) => name !== "User");
}
/**
* Models with no `userId` and no user relation are instance-scoped (queue
* tables, rate-limit buckets, invite codes the operator issues). They are not
* an account's data and the wipe plan already declares them as such.
*/
function isUserScoped(model: string): boolean {
const source = readFileSync(SCHEMA_PATH, "utf8");
const block = new RegExp(
`^model\\s+${model}\\s*\\{([\\s\\S]*?)^\\}`,
"m",
).exec(source);
if (!block) return false;
const body = block[1];
return /\buserId\b/.test(body) || /\buser\s+User\b/.test(body);
}
describe("every schema model has a backup verdict", () => {
const models = schemaModels();
it("reads a schema with models in it (no vacuous pass)", () => {
expect(models.length).toBeGreaterThan(50);
});
it("classifies every user-scoped model", () => {
const unclassified = models
.filter(isUserScoped)
.filter((m) => backupVerdict(m) === null);
if (unclassified.length > 0) {
throw new Error(
`${unclassified.length} user-scoped model(s) have no backup verdict:\n\n` +
unclassified.map((m) => ` ❌ ${m}`).join("\n") +
"\n\nAdd each to src/lib/export/backup-plan.ts: to BACKED_UP_MODELS if an " +
"account would lose something real without it, to DERIVED_MODELS with what " +
"rebuilds it, or to NOT_IN_BACKUP_MODELS with why it must not travel. " +
"A new model is user-scoped from birth and outside the backup by default, " +
"which is exactly the silence this test exists to break.",
);
}
});
it("carries no verdict for a model the schema no longer has", () => {
const known = new Set(models);
const stale = [
...BACKED_UP_MODELS,
...Object.keys(DERIVED_MODELS),
...Object.keys(NOT_IN_BACKUP_MODELS),
...AUTH_MODELS_OUT_OF_SCOPE,
].filter((m) => !known.has(m));
expect(
stale,
`verdicts for models that no longer exist: ${stale.join(", ")}`,
).toEqual([]);
});
it("gives each model exactly one verdict", () => {
const seen = new Map<string, string[]>();
const add = (model: string, list: string) => {
seen.set(model, [...(seen.get(model) ?? []), list]);
};
for (const m of BACKED_UP_MODELS) add(m, "BACKED_UP");
for (const m of Object.keys(DERIVED_MODELS)) add(m, "DERIVED");
for (const m of Object.keys(NOT_IN_BACKUP_MODELS)) add(m, "NOT_IN_BACKUP");
for (const m of AUTH_MODELS_OUT_OF_SCOPE) add(m, "AUTH");
const doubled = [...seen.entries()].filter(([, lists]) => lists.length > 1);
expect(
doubled.map(([m, lists]) => `${m}: ${lists.join(" + ")}`),
"a model classified twice means two readers can each believe the other handles it",
).toEqual([]);
});
it("gives every excluded model a reason, not an empty string", () => {
const empty = [
...Object.entries(DERIVED_MODELS),
...Object.entries(NOT_IN_BACKUP_MODELS),
]
// 20 characters let "Same as WithingsConnection." through, which is a
// pointer rather than a reason: if the entry it points at changes, this
// one silently inherits something else. 60 forces the decision to be
// written where it is made.
.filter(([, reason]) => reason.trim().length < 60)
.map(([model]) => model);
expect(
empty,
"a one-word reason is how a decision stops being reviewable",
).toEqual([]);
});
});
+261
View File
@@ -0,0 +1,261 @@
/**
* The one declaration of what a backup contains, and what it deliberately does
* not.
*
* The wipe plan next door ({@link ../data-wipe/wipe-plan}) answers the mirror
* question and got the same treatment for the same reason: a list that was
* right the day it was written, then quietly stopped being right every time a
* model was added. On the delete side that meant data survived a wipe. On this
* side it means data does not survive a restore, which is worse, because a
* wipe at least tells you it happened.
*
* ── Why this list is longer than the wipe list ──────────────────────────────
*
* The wipe may lean on the database: deleting a `Medication` cascades to its
* schedules, dose changes and inventory events, so the wipe plan never has to
* name them. **A restore has no cascade.** Every child has to be carried
* explicitly and re-inserted explicitly, so sixteen models the wipe plan never
* needed to mention are decisions here.
*
* ── The three verdicts ──────────────────────────────────────────────────────
*
* `BACKED_UP` — the account would lose something real if this were missing.
* The guard requires BOTH a payload reader AND a restore
* branch for every one of these, because carried-but-never-
* restored is the exact defect this file exists to prevent and
* it looks identical to working until someone needs it.
* `DERIVED` — recomputable from `BACKED_UP` rows. Leaving it out keeps the
* file smaller and cannot lose anything; the reason names what
* recomputes it.
* `NOT_IN_BACKUP` — deliberately excluded. Credentials that must not travel,
* provider handshake state that is meaningless on another
* host, ledgers whose value is that they were written here.
*
* Every entry carries its reason in prose. "Obvious" is not a reason: the
* whole class of defect here is someone deciding something was obvious and
* nobody being able to check afterwards.
*/
/** A model whose absence from a restore would lose something the account owns. */
export const BACKED_UP_MODELS = [
// ── Measurements ──────────────────────────────────────────────────────────
"Measurement",
// ── Medications, and everything hanging off one ───────────────────────────
// The parent alone restores a drug list with no schedule, no history and no
// stock — a shape the app renders without complaint, which is why this was
// easy to miss.
"Medication",
"MedicationSchedule",
"MedicationScheduleRevision",
"MedicationDoseChange",
"MedicationIntakeEvent",
"MedicationPauseEra",
"MedicationSideEffect",
"MedicationEfficacyTarget",
"MedicationInventoryItem",
"MedicationInventoryEvent",
// ── Mood ──────────────────────────────────────────────────────────────────
"MoodEntry",
"MoodEntryTagLink",
"MoodTag",
"MoodTagCategory",
"MoodTagHidden",
// ── Clinical record ───────────────────────────────────────────────────────
"MentalHealthAssessment",
"LabResult",
"Biomarker",
"Allergy",
"FamilyHistoryEntry",
"IllnessEpisode",
"IllnessDayLog",
"IllnessSymptomLink",
"EcgRecording",
"UserHealthProfile",
"MeasurementReminder",
// ── Cycle ─────────────────────────────────────────────────────────────────
"CycleProfile",
"MenstrualCycle",
"CycleDayLog",
"CycleSymptom",
"CycleSymptomLink",
// ── Custom metrics ────────────────────────────────────────────────────────
"CustomMetric",
"CustomMetricEntry",
// ── Activity and nutrition ────────────────────────────────────────────────
"Workout",
"WorkoutRoute",
"WorkoutSamples",
"NutrientIntakeDay",
"PersonalRecord",
"UserAchievement",
// ── Environment ───────────────────────────────────────────────────────────
"EnvironmentContext",
"EnvironmentTravelLocation",
// ── Documents ─────────────────────────────────────────────────────────────
"InboundDocument",
"DocumentConditionLink",
"ExtractedFact",
// ── Coach ─────────────────────────────────────────────────────────────────
"CoachConversation",
"CoachMessage",
"CoachConversationDocument",
"CoachFact",
"CoachPlan",
"CoachReminder",
// ── Preferences and consent ───────────────────────────────────────────────
"NotificationPreference",
"ReminderPhaseConfig",
"ConsentReceipt",
] as const;
/**
* Recomputable from backed-up rows. The reason names what rebuilds it, so a
* future reader can check the claim rather than trust it.
*/
export const DERIVED_MODELS: Readonly<Record<string, string>> = {
MeasurementRollup:
"A per-day, per-metric reconstruction of `Measurement`. The boot-time rollup backfill rebuilds it for an account that has none.",
MedicationComplianceRollup:
"Recomputed from `MedicationIntakeEvent` against the schedule by the compliance analytics tier.",
MoodEntryRollup:
"A per-day reconstruction of `MoodEntry`, rebuilt by the same rollup tier that rebuilds the measurement one.",
CyclePrediction:
"A forecast derived from `MenstrualCycle` history; regenerated on the next cycle read.",
StrainTrimpCache:
"A cache of a pure function over `Workout` and heart-rate samples.",
WorkoutInsight:
"Generated narrative over `Workout`; regenerates on demand. Carrying it would restore an interpretation of data rather than the data.",
WorkoutInsightGenerationClaim:
"A concurrency claim guarding the generator above. Meaningless outside the run that took it.",
InsightNarrative:
"Generated prose over measurements. Same reasoning as WorkoutInsight — the record restores, the essay about it does not.",
DocumentContentIndex:
"A search index over `InboundDocument` text; rebuilt by the indexer.",
DocumentThumbnail:
"Rendered from the stored document bytes on demand, and those bytes are carried, so the picture rebuilds itself on first view.",
ArrivalReaction:
"Derived from what changed since the last visit; recomputed per visit.",
DismissedPriorityItem:
"A per-item dismissal of a recomputed priority list. The list regenerates; a stale dismissal would suppress an item the new list means to raise.",
};
/**
* Deliberately excluded, with the reason. These are the entries most likely to
* be argued with later, so each says what it would cost to include.
*/
export const NOT_IN_BACKUP_MODELS: Readonly<Record<string, string>> = {
// Provider credentials and handshake state.
WithingsConnection:
"An OAuth grant bound to this deployment's client registration. Restored elsewhere it is a dead token that makes the integration look connected while it silently fails — the exact shape of defect this release is about.",
WithingsOAuthState:
"A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.",
WhoopConnection:
"An OAuth grant bound to this deployment's client registration; on another host it is a dead token that reads as connected.",
WhoopOAuthState:
"A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.",
WhoopConnectTicket:
"A single-use ticket that binds one connect attempt to one browser; spent or expired long before a restore.",
FitbitConnection:
"An OAuth grant bound to this deployment's client registration; on another host it is a dead token that reads as connected.",
FitbitOAuthState:
"A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.",
GoogleHealthConnection:
"An OAuth grant bound to this deployment's client registration; on another host it is a dead token that reads as connected.",
GoogleHealthOAuthState:
"A CSRF nonce for a handshake that is valid for minutes and is already over by the time any backup is read.",
McpOAuthConnection:
"A grant issued to a client that was registered against this instance.",
IntegrationStatus:
"A ledger of what this deployment's syncs did. Restoring it onto another host would assert a sync history that host never had.",
// Credentials and device identity.
ApiToken:
"Bearer credentials. A backup file is not a credential store, and a leaked file must not become a set of working tokens.",
Device:
"An APNs token identifying one install on one phone. Restored elsewhere it addresses a device that will never receive from this host.",
PushSubscription:
"A Web Push endpoint issued by one browser for one origin. Another host cannot send to it, and the browser that owns it re-subscribes on its own.",
NotificationChannel:
"Carries channel secrets (Telegram chat binding, ntfy topic). Same reasoning as ApiToken.",
UserKnownDevice:
"A device-recognition ledger. Restoring it would pre-trust devices on a host that has never seen them.",
StepUpElevation:
"Proof that a second factor was satisfied minutes ago. Carrying it would let a restored file re-grant an elevation nobody performed.",
// Ledgers whose meaning is local to the deployment.
PushAttempt:
"A 90-day delivery ledger for this host's sends. It documents what happened here.",
AuditLog:
"An append-only record of actions taken on this instance. Merging one instance's audit trail into another's would make the trail untrue.",
CoachUsage:
"Spend metering against this operator's own AI budget. Restoring it would charge one instance's consumption against another instance's cap.",
ProviderHealth:
"What this host observed when it called its providers. Another host has its own network, keys and rate limits, so the observation does not transfer.",
IdempotencyKey:
"Replay protection for requests this host already answered. Restoring it would suppress a legitimate new request.",
DataBackup:
"The backup catalogue itself. A backup that contains the list of backups is a recursion with no reader.",
ImportJob:
"A job record pointing at an uploaded file that the backup does not carry, so restoring it would resurrect a task with nothing to work on.",
MedicationIntakeImportJob:
"A job record pointing at an uploaded dose-history file that is not carried in the backup, so restoring the job would resurrect a task with nothing to work on.",
TelegramPromptContext:
"Records which reminder a Telegram reply refers to, keyed by a message id in one chat on one bot. Meaningless against any other chat, and the conversation it belongs to is not carried either.",
// Sharing and outbound links.
ClinicianShareLink:
"A live URL handed to a practice. Restoring it would resurrect a link the account may have revoked deliberately — and it points at a host that may not be this one.",
ClinicianShareLinkDocument:
"Names which documents a share link exposed. Without the link it grants nothing, and with it, it would re-expose files through a URL the account may have revoked.",
// Transient notification bookkeeping.
MoodReminderDispatch:
"A per-day record of whether this host already sent today's prompt. The next day writes its own, and a restored one would suppress a send.",
TelegramScheduledDeletion:
"A pending delete addressed at message ids in one chat on one bot. Those ids mean something else, or nothing, anywhere the messages do not exist.",
TelegramReminderMessage:
"Message ids in one chat on one bot, used to edit or clean up a sent reminder. They address messages that a restored instance never sent.",
RecommendationFeedback:
"Feedback on generated recommendations that are themselves DERIVED. Restoring the feedback without the item it judged leaves an orphan.",
};
/** Auth material: not user data, and excluded from the wipe for the same reason. */
export const AUTH_MODELS_OUT_OF_SCOPE: readonly string[] = [
"Session",
"RefreshToken",
"Passkey",
"WebauthnMfaCredential",
"MfaRecoveryCode",
"MfaChallenge",
"AuthChallenge",
"TrustedDevice",
"OidcNativeHandoff",
"InviteRedemption",
];
export type BackupVerdict = "BACKED_UP" | "DERIVED" | "NOT_IN_BACKUP" | "AUTH";
/**
* The verdict for a model, or `null` when the schema carries a model this file
* has never ruled on. The guard turns that `null` into a failing test, which
* is the whole point: a new model gets a decision, not a default.
*/
export function backupVerdict(model: string): BackupVerdict | null {
if ((BACKED_UP_MODELS as readonly string[]).includes(model)) {
return "BACKED_UP";
}
if (model in DERIVED_MODELS) return "DERIVED";
if (model in NOT_IN_BACKUP_MODELS) return "NOT_IN_BACKUP";
if (AUTH_MODELS_OUT_OF_SCOPE.includes(model)) return "AUTH";
return null;
}