Build log
One AI Button
CFO Insights was one button that asked an AI about your money. Here is everything that was wrong with it, and the one rule that would have prevented most of it.
The feature is simple to describe. You press a button, FreelanceCFO looks at your income, spending, runway and taxes, and an AI writes you a short briefing about what matters right now. It was the first AI anything in the app. It was also, by a distance, the buggiest thing I’ve ever shipped, and none of the bugs were where I expected them to be.
the early, embarrassing ones
The first version, in June, showed two status cards at once after you generated: one saying it worked, one saying you had to wait. The cooldown timer ticked every second, forever, on every page that showed it, even with no cooldown running. Small stuff.
Less small: when generation failed, the error that came back to your browser included the raw response from the AI provider, the model’s unparsed output and internal error messages. Useful to me while debugging. Not something anyone else should ever see.
nobody got a single insight for weeks
Then I noticed the timeline was always empty. Not “the AI said something boring”, empty. The function checked that you’d accepted the current privacy policy before spending money on a model call, which is correct. It checked for policy version 1.0. The app’s real policy version was 2026-06-v1. So every single user who had accepted the current policy failed the check with a 403, and the app showed a generic error. The one feature with AI in it was quietly off for everyone.
The fix was one string. The lesson was a test that fails the build if the app and the server ever disagree about that string again. When the same fact lives in two places, they will drift, and the drift will be silent.
the double click that cost money
Each generation is a paid model call, so the button disables itself while one is running. Except it disabled itself a moment too late: the “busy” flag flipped just after the click was handled, so a quick double click started two generations before the button greyed out. The fix was a lock taken in the same instant as the click. Tested by clicking three times as fast as a script can: one call.
you could reset your own limit
Generations had a cooldown, an hour back then, so nobody could run up my AI bill. The cooldown worked by looking at when your latest insight was created. And users were allowed to delete and edit their own insights.
You can see where this goes. Delete your latest insight, or edit its timestamp to last week, and the cooldown is gone. Generate as often as you like, on my bill. An audit in August found it, along with the fact that the admin “kill switch” for AI generation only hid the button and didn’t actually stop the server from calling the model.
you could forge insights
Closing that meant taking away users’ ability to write insight rows directly. A few weeks later, another audit found a back door: a general-purpose “data” endpoint still had a create insight action, running with full server privileges, that nothing in the app even used anymore. Any signed-in user could call it and insert any insight they liked into their own feed. It got deleted, not patched. An action with no caller is just attack surface.
the browser was choosing what mattered
The biggest one was hiding in the design. Before writing a briefing, the server scores “events” in your finances: runway dropped, a client is late, spending spiked. It only spends a model call if something is significant enough. But the browser also computed those events and sent them along, and the server preferred the browser’s list. So a request could hand itself an invented event with maximum significance and choose exactly what the AI would narrate as fact.
If a value decides whether work happens, what gets spent, or what a user is shown as fact, derive it on the server from data the server read.
the rule this whole post is about, written into the codebase after all of the above
Read the bugs again with that sentence in mind. The cooldown came from rows the user could change. The policy version was a copy that drifted. The events came from the browser. The forged rows came through an action the server should never have offered. Almost every one is the server trusting something it didn’t compute itself.
the rebuild: briefings
At the end of August, Insights got rebuilt properly. The detection logic went from two hand-copied versions (one in the browser, one on the server, eight thresholds and two scoring formulas that had to match by hope) to exactly one file. The browser stopped sending events at all. The cooldown moved into its locked table. And the product got better, not just safer: instead of one insight per press, a briefing is up to three, each with a concrete next step and a link to the page it’s about, and you can dismiss what you’ve dealt with.
What the rebuild didn’t know: eleven days earlier, on 18 August, the AI provider had retired the model, and its replacement was quietly spending its whole budget thinking and saying nothing. Briefings stayed silent for three weeks. That one has its own post: When My App Got a Brain.
about that third card
Look at the screenshot up top again. The 9 September briefing says discretionary spend “jumped to $0.8070741890225209 of total outlays”. That’s a ratio, 80.7%, printed as a dollar amount with sixteen decimal places. Everything around the model can be locked down server-side, and the model can still decide that a percentage is money. It’s been sitting on my own case study page this whole time.
The button looks the same as it did in June. Underneath, nothing that decides what it costs or what it says comes from your browser anymore. That’s the whole job.
- FreelanceCFO
- Security
- Build log
- AI