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",