← All posts

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.

← Newer: The sky stops pretending Older: Projects, and a second pair of eyes →