How Fogo learned to stop showing you money you've already spent.
1. The framing lie
Here's a budget app on the 8th of the month. It tells you: $5,489 left to spend. True, technically. Your budget is $5,875, you've spent $386, subtract and there it is. Clean arithmetic, and a lie of omission.
Because roughly $7,000 of that "remaining" budget isn't yours to decide about. Rent is $3,000. Another ~$4,000 in recurring charges — utilities, insurance, the loan payment — hasn't posted yet, but it's going to. The number the app is showing you as headroom is mostly a queue of bills that simply haven't cleared the runway. You are not flush. You are pre-spent, and the app is telling you the opposite.
This matters because of a well-replicated finding: people spend against the number they see, not the number that's actually free. De La Rosa & Tully (Journal of Consumer Research, 2022) showed that presenting a larger available balance directly raises spending — the displayed figure is the anchor. Ghosh & Huang found that precise budget feedback strips away the mental "safety margin" people would otherwise keep, and the overspend concentrates at period-end. So when your app headlines $5,489 on the 8th, it isn't neutral reporting. It's an instruction. You will spend toward it, and then rent will land, and you will be short.
The fix isn't a better chart. It's showing a different number first: subtract obligations, then headline the remainder. This is exactly what the apps people trust already do — Simple's "Safe-to-Spend," PocketGuard's "In My Pocket." Obligations come out at the top of the stack, not the bottom. The headline should be ~$314 free to spend this month, with the $5,175 of reserved bills shown as set aside, not gone. Same underlying facts, opposite behavioral pull.
2. Why frequency isn't commitment — the Uber Eats test
So: subtract the committed stuff first. Which raises the real question — what counts as committed?
The tempting answer is "anything recurring." You already have recurrence detection; a charge that shows up every month sure looks committed. But run it against a case that breaks it: Uber Eats, three times a week. It's predictable. It's regular. A naive recurrence signal flags it as committed and quietly subtracts it from your discretionary pool.
That's exactly backwards. Uber Eats is the most discretionary money you spend. There's no contract, no penalty, nothing bills you if you stop — you could quit tomorrow at zero cost. If you net it out as "committed," you understate the discretionary pool, which understates the very thing the user has agency over. You'd be reserving the one category they can actually move.
The axis isn't how often — it's cost-of-cessation. Chetty & Szeidl frame commitment as adjustment cost: a commitment is a payment you can't stop without incurring a real penalty. Rent has one (eviction, a broken lease). Your car loan has one. Uber Eats has none. Frequency and obligation happen to correlate for rent, which is why the naive signal seems to work — until it meets a habit.
A nice corroborating detail: Plaid strips habitual coffee/gas/grocery out of its recurring model on purpose. So raw Uber Eats likely never even reaches your detector as a "recurring" candidate. Which relocates the real exposure — it isn't habits sneaking in as commitments. It's subscriptions, the genuinely recurring-but-cancelable middle, that a recurrence-equals-commitment rule gets wrong.
3. The model: decompose the axis
Here's where a software instinct pays off. If you've ever inherited an overloaded boolean — say an is_active flag that different callers read to mean "logged in recently" or "not soft-deleted" or "subscription in good standing" — you know the smell. Two orthogonal properties collapsed into one field, and every bug is a caller reading the flag for the meaning it didn't encode. The fix is never a smarter heuristic on the single flag. It's to split the axis into the two properties it was always conflating.
A recurring flag is that overloaded boolean. It's silently answering two independent questions at once:
- Obligation — if I stop paying this, does something bad happen? (contract, penalty, service cutoff)
- Predictability — do I know the amount and timing in advance?
These are genuinely independent, and you get a clean quadrant:
| Predictable | Unpredictable | |
|---|---|---|
| Obligated | Rent, loan payment → net it out | Car repair, medical → sinking fund |
| Not obligated | Netflix, gym, Uber Eats → stays discretionary | Impulse buys → stays discretionary |
So the model stores two scores, separately — an obligation_score and a predictability_score — and never collapses them back into one flag. Only the top-left quadrant (obligated and predictable) gets subtracted from the headline. The bottom row stays fully in the discretionary pool no matter how regular it is — that's the rule that saves Uber Eats.
Two refinements the quadrant implies:
A third bucket for the top-right. Obligated-but-unpredictable costs — annual insurance, property tax, the inevitable-but-unscheduled car repair — don't belong in this month's committed line (they're not due) or the discretionary pool (they're not optional). They get a sinking fund: smoothed to a monthly reserve so the year's big lumpy obligations don't ambush a single month.
Subscriptions are soft commitments. Netflix is predictable but zero-penalty to cancel — top-right of the discretionary row, textbook. The move is not to auto-net it and not to ignore it: show it, pre-estimate it, but route it through a one-tap confirmation rather than silently reserving it. The apps users trust most (YNAB, Simple) converge on exactly this confirm/override path for the ambiguous middle — subscriptions, variable-amount utilities, rent paid by check, BNPL, and any stream too young (<3 occurrences) to be sure about. Don't guess in the gray zone; ask, once.
And the classifier that promotes something to committed is a conjunction, never a single feature. Auto-committed requires an obligation-class merchant (a known biller, landlord, lender) and low amount-variance and stable cadence and an autopay/known-biller marker — all of them. Auto-discretionary triggers on a discretionary merchant or high variance without a contract, regardless of frequency. Ranked by signal power: merchant-type > amount-variance > contract/autopay marker > cadence (a gate only) > raw frequency — which is dead last because it has the highest false-positive rate and must never promote on its own.
4. The asymmetric-error UX
Two ways to get a given charge wrong, and they are not equally bad.
Under-reserve — treat rent as discretionary — and you inflate the safe-to-spend number. The user spends to it (§1), and now they're short for an obligation they had to pay. That's a missed bill or an overdraft. Loud, expensive, trust-shattering.
Over-reserve — hold back money that was actually free — and the user just sees a smaller number than they needed to. No disaster, but a quiet, cumulative erosion: the app feels stingy, wrong, not worth trusting.
The errors are asymmetric, so the policy should be too: reserve conservatively — when unsure, lean toward holding it back. But over-reserving has a trust cost you have to actively pay down, and the way you pay it is visibility plus reversibility:
- Every reserved dollar is visible and labeled why. Not vanished from the headline — shown on a second line: "~$5,175 set aside for rent, bills, subscriptions — not out yet." Reserved, not spent.
- Every reserved dollar is one tap to move back. If Fogo held something you didn't want held, you reclaim it instantly (Simple's "borrow from Goals" is the reference). The reserve is a default, not a verdict.
That combination lets you be conservative safely. The dangerous error is structurally prevented (you never quietly inflate safe-to-spend), and the annoying error is made trivially recoverable (you can always see and undo an over-reserve). Conservative-but-transparent-and-movable beats a cleverer classifier that's silently right most of the time and silently, expensively wrong the rest.
5. Mapping onto Fogo — and the v1-first build
The honest news: the substrate is already half here, and half mis-shaped.
What exists. Fogo splits recurring spend into commitment tiers — floor_monthly, debt_monthly, subscription_monthly (financial_context_builder.py) — and the projection already carries a committed_still_due term that adds unposted obligations scheduled ahead (derived_monthly_facts_service.py). So the netting mechanics — reserve a known bill, count it exactly once whether posted or not — are built and working.
What's mis-shaped. That tier split is a predictability axis wearing an obligation costume. The subscription tier gets auto-counted as committed — which is the soft-commitment bug from §3, live in production today: Netflix is being subtracted from your headroom as if it were rent. And classify_committed_category is a static category lookup — Housing/Utilities/Financial Obligations map to committed, everything else discretionary. Fine as a cold-start default, wrong as the primary signal, because committed-ness is a fact about your behavior, not about the category name. Groceries are a fixed floor for a family and a variable indulgence for someone else; "Shopping" hides both a recurring subscription and an impulse buy.
The v1-first build. The full two-axis model — separate obligation_score and predictability_score, the conjunction classifier, the sinking-fund bucket, the confirmation UX — is real work, and the confirmation UX in particular needs design and dogfooding. But you don't need any of it to kill the headline lie this week, because the pieces you need already exist:
- Keep netting the
floortier (rent, true obligations) — that's the top-left quadrant, and it's already correct. - Stop auto-netting the
subscriptiontier — the one-line change that fixes the live soft-commitment bug and stops quietly reserving cancelable money. - Leave everything habitual and variable in the discretionary pool, where it belongs.
That alone flips the headline from "$5,489 to spend" to "~$314 free, ~$5,175 set aside" using substrate that's already in the codebase — the highest-leverage, lowest-risk version of the whole idea. Then the two-axis scores, the sinking fund, and the confirmation flow layer on top, per-user, degrading gracefully back to the category default for anyone too new to have a recurrence signal yet.
The general lesson has nothing to do with budgeting. When a single flag is answering two questions and the bugs cluster where the two answers disagree — don't tune the flag. Name the second question, store it separately, and let the surface read the axis it actually cares about. The Uber Eats bug and the overloaded-is_active bug are the same bug.