fix(ingest): refuse an external id that cannot be a stable identity

An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.

Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.

Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.

A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 17:21:00 +02:00
1 parent ef9891fec5
commit 2ab66ab2e3
26 files changed
+1232 -64

No files matched your search

+33
View File
@@ -32,6 +32,12 @@ import {
cycleBulkSchema,
MAX_CYCLE_BULK_ENTRIES,
} from "@/lib/validations/cycle";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
@@ -121,9 +127,33 @@ async function postBulk(request: NextRequest): Promise<Response> {
let updated = 0;
let duplicates = 0;
let skipped = 0;
const unstableShapes: UnstableExternalIdShape[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
// The `(userId, source, externalId)` upsert key is only idempotent
// while the id is STABLE across client launches. An id that rotates
// per process (an object description carrying a memory address)
// never matches its own earlier row, so every re-post mints another
// day log. Refused per entry — one bad row must not stop the rest of
// the batch landing.
const unstable =
entry.externalId === undefined
? null
: classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
skipped += 1;
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
externalId: entry.externalId,
});
continue;
}
try {
const cycleId = owningCycleId(entry.date);
const r = await upsertCycleDayLog(
@@ -206,6 +236,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
updated,
duplicates,
skipped,
...(unstableShapes.length > 0
? unstableExternalIdMeta("cycle.day_log.bulk", unstableShapes)
: {}),
},
});
+13 -3
View File
@@ -27,9 +27,13 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
cycleDayLogInputSchema,
cycleDayLogCreateSchema,
cycleDayLogQuerySchema,
} from "@/lib/validations/cycle";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
@@ -82,11 +86,17 @@ async function postDayLog(request: NextRequest): Promise<Response> {
});
if (jsonError) return jsonError;
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
const parsed = cycleDayLogCreateSchema.safeParse(rawBody);
if (!parsed.success) {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.day-log.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
meta: {
issue_count: parsed.error.issues.length,
...(shape
? unstableExternalIdMeta("cycle.day_log.create", [shape])
: {}),
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.day-log.invalid",
+9 -1
View File
@@ -24,6 +24,10 @@ import {
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { withIdempotency } from "@/lib/idempotency";
import { cyclePeriodSchema } from "@/lib/validations/cycle";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import type { FlowLevel } from "@/lib/cycle/types";
import {
@@ -49,9 +53,13 @@ async function postPeriod(request: NextRequest): Promise<Response> {
const parsed = cyclePeriodSchema.safeParse(rawBody);
if (!parsed.success) {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.period.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
meta: {
issue_count: parsed.error.issues.length,
...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}),
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.period.invalid",
+42 -1
View File
@@ -23,6 +23,11 @@ import {
isPlausibleEntryInstant,
validateEntryInstant,
} from "@/lib/validations/entry-instant";
import {
classifyExternalId,
unstableExternalIdMeta,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
@@ -60,6 +65,10 @@ const measurementSchema = z
// export is idempotent rather than minting a duplicate set — mirrors the
// mood path. Absent → first-write-wins create with a NULL externalId
// (distinct in the unique key), which DOES duplicate on re-import.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/app/api/import/route.ts
externalId: z.string().min(1).max(120).optional(),
})
.refine(
@@ -92,6 +101,10 @@ const moodEntrySchema = z
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/app/api/import/route.ts
externalId: z.string().min(1).max(120).optional(),
})
.superRefine((entry, ctx) => {
@@ -170,6 +183,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const userId = user.id;
const data = parsed.data;
const stats = { measurements: 0, moodEntries: 0, skipped: 0 };
const unstableShapes: UnstableExternalIdShape[] = [];
let writeFailed = false;
// Import measurements
@@ -189,6 +203,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
}> = [];
if (data.measurements?.length) {
for (const m of data.measurements) {
// The `(userId, type, source, externalId)` upsert key is only
// idempotent while the id is STABLE; an id that rotates between
// exports re-imports as a fresh row every time. Skipped per entry,
// like every other rejected row in this importer.
const unstable =
m.externalId === undefined ? null : classifyExternalId(m.externalId);
if (unstable) {
unstableShapes.push(unstable);
stats.skipped++;
continue;
}
try {
// `measuredAt` is already a `Date` (validateEntryInstant transform).
const measuredAt = m.measuredAt;
@@ -299,6 +324,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
const fallbackOccurrences = new Map<string, number>();
const reservedFallbackInstants = new Set<number>();
for (const e of data.moodEntries) {
// Same stability floor as the measurement loop above — the mood
// upsert keys on `(userId, source, externalId)`.
const unstableMood =
e.externalId === undefined ? null : classifyExternalId(e.externalId);
if (unstableMood) {
unstableShapes.push(unstableMood);
stats.skipped++;
continue;
}
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
const occurrence =
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
@@ -455,7 +489,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
annotate({ meta: { import_stats: stats } });
annotate({
meta: {
import_stats: stats,
...(unstableShapes.length > 0
? unstableExternalIdMeta("import.json", unstableShapes)
: {}),
},
});
try {
await auditLog("import.upload", {
@@ -0,0 +1,239 @@
/**
* `POST /api/measurements/batch` — external-id stability floor.
*
* `(user_id, type, source, external_id)` is idempotent only while the id
* is stable across client launches. An id that rotates per process — an
* object description carrying a memory address — never matches its own
* earlier row, so every sync sweep mints a fresh measurement.
*
* The refusal is PER ENTRY, matching the range / timestamp guards on this
* route: a client draining a HealthKit queue must not lose 499 good rows
* because one carried a bad id.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: vi.fn(
async (_tx: unknown, input: Record<string, unknown>) => ({
status: "inserted",
row: { id: "row-1", ...input },
}),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const DAY_MS = 24 * 60 * 60 * 1000;
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** A plain step entry anchored a day back so it never date-bombs. */
function stepEntry(externalId: string) {
const at = new Date(Date.now() - DAY_MS).toISOString();
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 1200,
unit: "count",
startDate: at,
endDate: at,
externalId,
};
}
async function statusesFor(entries: unknown[]) {
const res = await POST(makeRequest({ entries }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
entries: Array<{ index: number; status: string; reason?: string }>;
};
};
return body.data.entries;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
{ id: "row-1" },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/measurements/batch — unstable external ids", () => {
it("skips an object-description id instead of minting a row", async () => {
const results = await statusesFor([
stepEntry("<HKHealthConceptIdentifier: 0x12568db80>"),
]);
expect(results[0]).toMatchObject({
index: 0,
status: "skipped",
reason: "unstable_external_id",
});
expect(reconcileExternalMeasurement).not.toHaveBeenCalled();
});
it("skips a bare memory-address id", async () => {
const results = await statusesFor([stepEntry("0x126b25160")]);
expect(results[0]).toMatchObject({
status: "skipped",
reason: "unstable_external_id",
});
});
it("skips ONLY the offending entries — the rest of the batch still lands", async () => {
const results = await statusesFor([
stepEntry("hk-uuid-1"),
stepEntry("<HKHealthConceptIdentifier: 0x12568db80>"),
stepEntry("stats:HKQuantityTypeIdentifierStepCount:2026-07-25"),
stepEntry("0xdeadbeef"),
]);
expect(results[0]!.status).not.toBe("skipped");
expect(results[1]).toMatchObject({ reason: "unstable_external_id" });
expect(results[2]!.status).not.toBe("skipped");
expect(results[3]).toMatchObject({ reason: "unstable_external_id" });
// Two good rows still reached the writer.
expect(reconcileExternalMeasurement).toHaveBeenCalledTimes(2);
});
it("never turns one bad row into a whole-batch 422", async () => {
const res = await POST(
makeRequest({
entries: [stepEntry("hk-uuid-1"), stepEntry("0xdeadbeef")],
}),
);
expect(res.status).toBe(200);
});
it("accepts the real id shapes this route receives", async () => {
const results = await statusesFor([
stepEntry("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30"),
stepEntry("stats:HKQuantityTypeIdentifierStepCount:2026-07-25"),
stepEntry("hk-uuid-41"),
]);
for (const r of results) expect(r.status).not.toBe("skipped");
});
it("annotates the refusal with the shape, never the id", async () => {
await statusesFor([
stepEntry("<HKHealthConceptIdentifier: 0x12568db80>"),
stepEntry("0xdeadbeef"),
]);
const calls = vi.mocked(annotate).mock.calls.map(([c]) => c);
const rejection = calls.find(
(c) => c?.meta && "external_id_rejected" in c.meta,
);
expect(rejection?.meta).toMatchObject({
external_id_rejected: 2,
external_id_shapes: "object_description,pointer_address",
external_id_surface: "measurement.batch",
});
expect(JSON.stringify(rejection)).not.toContain("0x12568db80");
});
it("emits no rejection annotation on a clean batch", async () => {
await statusesFor([stepEntry("hk-uuid-1")]);
const calls = vi.mocked(annotate).mock.calls.map(([c]) => c);
expect(calls.some((c) => c?.meta && "external_id_rejected" in c.meta)).toBe(
false,
);
});
});
+35
View File
@@ -62,6 +62,12 @@ import {
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -139,6 +145,9 @@ const batchEntrySchema = z.object({
// iOS build omits them and the bucket keeps the avg-only contract.
valueMin: z.number().finite().optional(),
valueMax: z.number().finite().optional(),
// Stability is enforced per entry in the loop below, not at the field:
// a schema-level refusal would fail the whole batch on one bad row.
// @external-id-checked-per-entry: src/app/api/measurements/batch/route.ts
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
@@ -268,9 +277,29 @@ async function postBatch(request: NextRequest): Promise<Response> {
};
const prepared: Prepared[] = [];
const results: EntryResult[] = new Array(entries.length);
const unstableShapes: UnstableExternalIdShape[] = [];
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
// The `(user_id, type, source, external_id)` dedup key above is only
// idempotent while the id is STABLE across client launches. An id
// that rotates per process (an object description carrying a memory
// address) never matches its own earlier row, so every sync sweep
// mints a fresh measurement. Refused per entry, like the range and
// timestamp guards below — one malformed row must not stop the other
// 499 from landing.
const unstable = classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
results[index] = {
index,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
};
continue;
}
const mapped = mapAppleHealthEntry({
hkIdentifier: entry.hkIdentifier,
value: entry.value,
@@ -396,6 +425,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
if (unstableShapes.length > 0) {
annotate({
meta: unstableExternalIdMeta("measurement.batch", unstableShapes),
});
}
let insertedCount = 0;
let updatedCount = 0;
let duplicateCount = 0;
@@ -622,6 +622,65 @@ describe("POST /api/medications — Apple Health mirror (v1.28)", () => {
expect(prisma.medication.create).not.toHaveBeenCalled();
});
/**
* The mirror's idempotency is only as good as the stability of the
* concept id. A client that serialised an opaque platform object with
* its default description sent `<HKHealthConceptIdentifier: 0x…>` — a
* memory address that changes on every launch — and a live instance
* grew 23 phantom medications in a day, one per sync sweep, none of
* which could ever collect a dose. The create refuses that shape now.
*/
it("422s on an object-description externalId instead of minting a phantom mirror", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(
postReq({
...MIRROR_BODY,
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
const issue = body.details.issues.find((i) => i.path === "externalId");
expect(issue).toBeDefined();
// The client developer has to be able to act on this without asking.
expect(issue!.message).toContain("stable across app restarts");
expect(issue!.message).toContain("object description");
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s on a bare memory-address externalId", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(
postReq({ ...MIRROR_BODY, externalId: "0xdeadbeef" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; message: string }> };
};
expect(
body.details.issues.some(
(i) => i.path === "externalId" && i.message.includes("memory address"),
),
).toBe(true);
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("still accepts the stable concept-id shapes the mirror really sends", async () => {
for (const externalId of [
"hk-concept-1",
"8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
"a3f5c9e1b7d2486fa0c3e5b7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6",
]) {
vi.mocked(prisma.medication.create).mockClear();
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(postReq({ ...MIRROR_BODY, externalId }));
expect(res.status).toBe(201);
expect(lastCreateData().externalId).toBe(externalId);
}
});
it("a plain create keeps today's behavior — no external columns forwarded", async () => {
const res = await POST(
postReq({
@@ -75,6 +75,12 @@ import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
@@ -128,6 +134,10 @@ const bulkEntrySchema = z
// v1.28 — the HealthKit `HKMedicationDoseEvent` UUID. Drives the
// idempotent re-sync: an entry whose `(userId, externalId)` already
// exists live reports `duplicate` (first-write-wins per Apple dose).
// Stability is enforced per entry in the loop below, not at the
// field: a schema-level refusal would fail the whole batch on one
// bad row.
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
externalId: z.string().trim().min(1).max(128).optional(),
})
.refine(
@@ -318,6 +328,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
let updated = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
// touched by the batch so one rollup recompute fires per pair after
// all inserts complete. Per-row recompute would balloon a 500-entry
@@ -345,6 +356,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// The `(userId, externalId)` dose-event dedup below is only
// idempotent while the id is STABLE across client launches. An id
// that rotates per process (an object description carrying a memory
// address) never matches its own earlier row, so every re-sync mints
// another intake event. Refused per entry — one bad row must not
// stop the rest of the batch landing.
const unstable =
entry.externalId === undefined
? null
: classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
});
continue;
}
// v1.28 — Apple dose-event replay: the batch pre-check found a live
// row already carrying this external UUID (or an earlier entry in
// THIS batch inserted it). First-write-wins per Apple dose — report
@@ -803,6 +835,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
updated,
duplicates,
skipped: skipped.length,
...(unstableShapes.length > 0
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
: {}),
},
});
+14
View File
@@ -10,6 +10,10 @@ import {
safeJson,
} from "@/lib/api-response";
import { createMedicationSchema } from "@/lib/validations/medication";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import {
getMedicationCategories,
setMedicationCategory,
@@ -98,6 +102,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = createMedicationSchema.safeParse(body);
if (!parsed.success) {
// Surface an unstable concept id as its own wide event: the refusal
// is the visible half of the phantom-mirror class, and a silent 422
// in the generic bucket is how it stayed invisible the first time.
const shape = unstableExternalIdShape(body);
if (shape) {
annotate({
action: { name: "medication.external_id.rejected" },
meta: unstableExternalIdMeta("medication.create", [shape]),
});
}
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
+11 -1
View File
@@ -36,6 +36,10 @@ import {
createAssessmentSchema,
listAssessmentsSchema,
} from "@/lib/validations/mental-health";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import {
INSTRUMENTS,
INSTRUMENT_MEASUREMENT_TYPE,
@@ -183,9 +187,15 @@ async function postAssessment(request: NextRequest): Promise<Response> {
const parsed = createAssessmentSchema.safeParse(body);
if (!parsed.success) {
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mental-health.create.validation-failed" },
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
meta: {
issue_count: sanitiseZodIssues(parsed.error.issues).length,
...(shape
? unstableExternalIdMeta("mental_health.assessment.create", [shape])
: {}),
},
});
return returnAllZodIssues(parsed.error, 422);
}
+35
View File
@@ -43,6 +43,12 @@ import {
moodLevelEnum,
moodSourceEnum,
} from "@/lib/validations/mood";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
@@ -95,6 +101,10 @@ const bulkEntrySchema = z.object({
* the measurements batch endpoint. NULL = no dedup hint; the
* existing `(userId, date, moodLoggedAt)` unique index still
* protects against straight-up duplicates.
*
* Stability is enforced per entry in the loop below, not at the field:
* a schema-level refusal would fail the whole batch on one bad row.
* @external-id-checked-per-entry: src/app/api/mood-entries/bulk/route.ts
*/
externalId: z.string().min(1).max(120).optional(),
});
@@ -187,6 +197,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// Perf — a single indexed existence read replaces the per-entry
// `findUnique` probe (up to 500 sequential round trips pre-fix). Every
@@ -241,6 +252,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
// The `(userId, source, externalId)` dedup key only holds while the
// id is STABLE across client launches; a per-process value (an object
// description carrying a memory address) mints a fresh entry on every
// re-post. Per-entry refusal, never a whole-batch 422.
const unstable =
entry.externalId === undefined
? null
: classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
externalId: entry.externalId,
});
continue;
}
const date = moodDateKey(entry.moodLoggedAt, tz);
const score = getScoreForMood(entry.mood);
@@ -415,6 +447,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
inserted,
duplicates,
skipped: skipped.length,
...(unstableShapes.length > 0
? unstableExternalIdMeta("mood.bulk", unstableShapes)
: {}),
},
});
+9 -1
View File
@@ -13,6 +13,10 @@ import {
listMoodEntriesSchema,
getScoreForMood,
} from "@/lib/validations/mood";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -160,9 +164,13 @@ async function postMoodEntry(request: NextRequest) {
// v1.4.43 W6 — iOS mood log hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}),
},
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
+43 -10
View File
@@ -70,6 +70,12 @@ import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
} from "@/lib/validations/workout";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows";
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
@@ -281,7 +287,26 @@ async function postBatch(request: NextRequest): Promise<Response> {
};
const results: EntryResult[] = new Array(workouts.length);
const prepared: Prepared[] = workouts.map((w, index) => {
const unstableShapes: UnstableExternalIdShape[] = [];
const prepared: Prepared[] = workouts.flatMap((w, index) => {
// The `(userId, source, externalId)` dedup key is only idempotent
// while the id is STABLE across client launches. An id that rotates
// per process (an object description carrying a memory address)
// never matches its own earlier row, so every re-sync mints another
// workout. Refused per entry — one bad row must not stop the rest of
// the batch landing.
const unstable =
w.externalId === undefined ? null : classifyExternalId(w.externalId);
if (unstable) {
unstableShapes.push(unstable);
results[index] = {
index,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
};
return [];
}
const durationSec = Math.max(
0,
Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000),
@@ -319,17 +344,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
? (w.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
};
return {
index,
row,
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
};
return [
{
index,
row,
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
},
];
});
if (unstableShapes.length > 0) {
annotate({
meta: unstableExternalIdMeta("workout.batch", unstableShapes),
});
}
// v1.4.42 W5 — write-time cross-source dedup. The v1.5 iOS app
// drains a HealthKit observer queue that frequently carries the
// SAME logical workout from two paired sensors (Apple Watch +