Back to writing

12 August 2026

The `{...spread}` that leaked identity fields

When you have to file something unfamiliar at an office — an expense you have never claimed, a form you have never seen — you don't start from a blank page. You find the last person who filed one, copy their version, and change the fields that are obviously yours. Your name, your date, your amount. It works almost every time.

It fails on the field you didn't look at. Their employee number, pre-printed in the footer, rides along on your copy, and payroll posts your claim against their record. Everything you changed was right. The one thing you didn't know to change was the thing that mattered.

We did exactly this in code.

What the spread actually copied

We needed a reversal for an existing ledger entry. The fastest way to build one is to take the entry you already have and change what's different:

const reversal = {
  ...entry,
  id: newId(),
  type: "reversal",
  amount: -entry.amount,
};

3 fields changed, everything else inherited. That "everything else" included externalId — the identifier the upstream system had assigned to the original entry, the one we use to match their callbacks back to our rows. The reversal now carried the entry's externalId as its own.

2 different rows, one externalId. We had copied the form and left the previous person's number in the footer.

The symptom showed up 3 hops away

Nothing looked wrong at first. The reversal saved, the amount was negative, the UI rendered both rows, the totals came out right. The bug stayed dormant because nothing had queried by externalId yet.

Then the upstream system sent a status callback — "this one settled" — keyed on externalId. Our handler ran findOne({ externalId }), got back whichever of the 2 rows the database returned first, and flipped it to settled. Half the time it settled the entry. Half the time it settled the reversal. Same input, different row, no error.

It took 3 days to find because the failing code was nowhere near the code that caused it. The spread was in the write path. The wrong row turned up in a callback handler 2 services away.

Build the new thing from a list, not from a sibling

The fix was to stop cloning. A reversal is a different entity from the entry it reverses, so it gets its fields named — the ones it should carry, and no more:

const reversal = {
  id: newId(),
  type: "reversal",
  amount: -entry.amount,
  tenantId: entry.tenantId,   // carried on purpose
  parentId: entry.id,         // the link we DO want
  createdAt: now(),
  // externalId intentionally absent — the reversal is not the entry
};

The link we actually wanted between the 2 rows was parentId — a reference that says "this reverses that." What we had instead was a shared externalId, which tells the outside world "these are the same thing." One is a relationship between rows. The other is a collision on identity.

The test, and the constraint that would have caught it anyway

The test is a single assertion: the new entity must not carry the source's external identity.

test("a reversal does not inherit the entry's externalId", () => {
  const entry = makeEntry({ externalId: "ext_123" });
  const reversal = buildReversal(entry);
  expect(reversal.externalId).toBeUndefined();
  expect(reversal.parentId).toBe(entry.id);
});

There was also a cheaper guard we didn't have: a partial unique index on externalId where it isn't null. With that in place, the second row is rejected at write time, the moment the spread tries to persist a duplicate. The test documents the intent; the constraint enforces it even when someone later writes a code path that forgets the test. We added both.

How we find the rest of them

A spread that builds a persisted entity is now something we stop and read. The grep is blunt, but it surfaces every candidate:

# every place we build an object by spreading another
rg "\{\s*\.\.\.\w+" --type ts -n

For each hit, one question: which identity-bearing fields ride along that shouldn't — externalId, parentId, any foreign key, anything a lookup or a callback keys on? We found 4 more spreads building entities from siblings. One had the same bug waiting: a cloned record carrying a source sourceRef that nothing had queried yet. It would have fired the first time someone did.

Anything that constructs one of these entities now goes through a builder that names its fields. The spread is gone from the write path.

The rule

When you build one entity by spreading another, you copy its identity, not only its data. Change the name badge on the front and the old one is still stitched inside the collar. New entities are built from an explicit set of fields — or a factory that owns that set — never by cloning a neighbor and crossing out the parts you happen to remember.