The bug that taught us to trust the wire, not the type
A quiet Postgres client default turned ratings, run distances, and receipt totals into strings — and made them disappear. Here's the decision that fixed it for good.
This one took a while to see, because nothing about it looked like a bug. Ratings were vanishing. Goal targets read as missing. Workout strain and distance sometimes just weren't there. Receipt amounts and trip budgets, gone. None of it crashed, none of it logged an error — the numbers just quietly weren't there, as if nobody had ever entered them.
The actual cause was three Postgres types crossing the wire as something other than what
the SQL implied. numeric comes back as a string ("20",
"4.5000000000000000"). bigint comes back as a string too. And
date arrives as a full midnight-UTC timestamp — which is its own separate
trap, because it renders as the previous day for anyone west of UTC. None of this
is a bug in Postgres or in the driver. It's the client library's documented default
behavior. It was never a decision anyone made — but it was absolutely load-bearing,
because the iOS client's decoders were doing as? Double on a string like
"4.5000000000000000", and that cast doesn't throw. It just returns
nil. A number that fails to parse doesn't look like a parsing failure. It
looks like missing data.
The fix, and the fix I didn't take
There were two ways to close this. One: register type parsers centrally on the server, so
numeric/bigint come back as actual numbers and date
comes back as a plain yyyy-MM-dd string. I investigated it seriously — it
turns out it wouldn't even be the breaking change it looks like, since nothing server-side
does string operations on those columns and the shipped iOS decoders would either start
working or keep working either way.
But I didn't take it, not yet. The decision I actually made: the clients coerce,
in one shared place — Wire.swift, compiled into both the phone and the Mac
build, tested against payloads captured from the real connection pool. Every field accepts
both shapes it might arrive as. It's a smaller, more contained change than touching the
server's wire format, and it means the fix ships without needing a coordinated,
alone-in-its-own-deploy backend change first. If the server-side fix happens later, it
happens deliberately, pinned by tests, shipped by itself — never bundled into a release
where a regression could have more than one possible cause.
There was also one genuine server bug hiding under the type confusion: soma.running
was summing numeric-as-string values, so weekly mileage across multiple runs came back
null instead of a number. That one wasn't a representation problem — it was
just wrong, and it's fixed regardless of which side ends up owning coercion long-term.
Why this is in the decision log at all
Because the actual lesson isn't "watch out for numeric columns." It's that a
client should never trust a type it hasn't verified, especially across a boundary as quiet
as a database driver's defaults. The fix that holds isn't remembering this one fact — it's
one place, tested against real data, that every client goes through. That's the same shape
of fix that shows up again a few days later, for a completely different kind of drift.