The operator texted me on a Wednesday morning. The monthly calendar in the ops app is still taking over a minute to load in production. Can you look?
I had already written this fix. I remembered writing it. I remembered the algorithmic bug and I remembered the code review comments. I remembered clicking Merge. The whole reason the operator was reporting the symptom again was the part I got wrong.
Merged is not deployed
The first thing I did — before opening the code — was check what production was actually running. Freebo’s API deploys off a long-lived production branch. Merges land on main. A separate promotion pushes main onto production when I’ve decided a batch is ready to ship.
The last promotion was b4d16da, at 15:24 CDT on the 24th.
The PR that added the fast month-view path was merged to main at 18:06 CDT on the 24th.
The fix landed on main two hours and forty-two minutes after the last thing I told production to swallow. Production had no idea ?mode=bookings was a thing. iOS did.
That gap — “merged, not deployed” — was the whole bug. But the reason production didn’t have the code even though I’d promoted a batch that day was the more interesting part.
The turbo filter that ate the deploy
Freebo is a Turborepo monorepo. Railway builds the API service only when files change under the packages the API depends on, which is expressed as a filter on the deploy step:
turbo run build --filter=@freebo/api...
The ... means “and everything it depends on.” Fine on paper. In practice, when I promote a batch that only touched apps/web and some content docs, Railway looks at the diff against its last successful build, sees zero changed files under @freebo/api or its dependency tree, and skips the deploy entirely. No email. No Slack ping. No red bar in the dashboard. The build page just says the previous artifact is still current, and the running container keeps running.
That’s the correct behavior for a monorepo — you don’t want a docs typo to burn a Node build. But the failure mode when I forget it exists is: I look at the promotion, see it landed, assume every service on it deployed, and go to lunch. Meanwhile the API is running whatever commit it last actually built for, which in this case was twelve commits behind main.
The app was speaking a language the server hadn’t learned
Here’s what makes this story specifically ugly, and specifically instructive.
iOS build 6 had already shipped. FreeboAPI.swift:115 was appending mode=bookings to every monthly calendar request. That was intentional — the iOS PR shipped after the server PR merged, on the assumption the server was ahead. Which, on main, it was.
Production was on b4d16da. On b4d16da, the querystring schema for /admin-events had no mode field. Fastify’s validator, given an unknown query parameter, doesn’t 400 by default — it drops it. So the request came in, mode=bookings was silently discarded, and the endpoint ran the default mode=slots path. Which is the slow path. The one the whole PR existed to avoid.
The app is asking the right question. The server it’s asking doesn’t know the question yet.
— A line I said out loud debugging this
There is no error class for “the client is speaking a dialect the server hasn’t learned.” It looks like the old behavior, exactly. Same latency. Same response shape. Same log lines. The only signal that anything was wrong was the operator texting me, again, about a thing I’d told them I fixed.
I could verify it structurally without deploying anything: admin-bookings.service.ts didn’t exist on the production branch, and availability-admin.ts on production had no mode property on its schema. Two file-existence checks and I knew production had the old handler.
The probe I stumbled into
While confirming the diagnosis I sent one unauthenticated request as a sanity check:
curl -s -o /dev/null -w "%{http_code}\n" \
"https://api.freebo.example/v1/availability/loc-x/admin-events?mode=zzz"
On b4d16da this returned 401. Auth ran first, no valid token, request rejected before validation ever saw mode.
I made a mental note. After the deploy, I ran it again. It returned 400. Enum validation now ran before auth — the mode field existed on the schema, zzz was not a valid enum member, Fastify killed the request at the validation layer. The status code flip was the cleanest fingerprint I could imagine for “does this server know the new dialect.” I did not plan it. I got it as a gift.
I’m baking it in next time. If you add a new query parameter, add an intentional invalid value that flips a 401 to a 400 (or 404 to 400, whatever your stack does) so external observers can tell from outside whether the code is present. Health checks confirm liveness. This confirms version.

The ship
The fix was a fast-forward. production was strictly behind main, eight commits, no divergence, no merge commit needed, no database migrations in the batch.
git fetch origin
git push origin origin/main:refs/heads/production
The Railway build actually ran this time, because the filter now saw changes under @freebo/api. The 502 window during the container swap was ten seconds — first failed health poll at 16:01:21Z, first successful poll at 16:01:31Z, six clean polls after that. The probe flipped 401 → 400 immediately. The operator’s next month load took under a second. Same operator, same complaint, second time was the charm.
What I’m keeping
- TextOperator: still slowSecond report of a bug I'd already fixed. That gap is the whole story.
- First checkWhat is production actually running?Not 'is the fix merged.' The right question is 'is the fix running.' Different question.
- Root causeTurbo filter skipped the buildPromotion touched web + docs, not API packages. Railway correctly built nothing. I incorrectly assumed a deploy.
- CompoundiOS shipped ahead of the serverNew query param sent by client. Old server dropped it silently. Failure mode = 'the old behavior, quietly.'
- Probemode=zzz flipped 401 to 400Accidental version fingerprint. Now standard in the runbook.
- ShipFast-forward, ten seconds of 502Clean FF, no merge commit, no migrations, rollback one-liner in the runbook.
Three things I’m keeping:
-
A build that gets skipped is not the same as a build that succeeds. Any deploy pipeline that can correctly choose to build nothing needs an explicit “was this service actually rebuilt in this promotion?” step. The turbo filter is right to skip. My mental model of “the promotion deployed everything” was wrong.
-
When the client can outrun the server, silent parameter handling is a landmine. The Fastify default of dropping unknown query params is fine for a stable API. It is dangerous the moment you have a mobile app you can’t downgrade in a hurry, because “the new client sent us a hint the old server didn’t understand” becomes indistinguishable from “the new client is running fine on the new server.”
-
Version fingerprinting from outside beats every dashboard. A one-line curl that flips status codes based on whether a code change is live is more reliable than any deploy log I have access to. It runs from anywhere. It doesn’t care about caches. It reads the truth off the running binary.
The operator got their fast month view. I got a promotion checklist with three new lines on it, and a probe I can run from my phone to answer “is the fix out yet?” without opening a laptop.