For a while, saving up for something in Tally — the chore-and-allowance app I keep nudging forward for Reagyn — was a polite fiction. You could set a goal: a new Lego set, $50. The app would draw a progress bar and color it in as the balance grew. But the bar didn’t hold anything. Every cent it counted toward the goal was still sitting in the same spendable pool, fully cashable, available to be blown on something else the moment a different impulse arrived. The goal was a suggestion. It was a thermometer, not a jar.
Today’s work, v0.3.0, made savings real. This was the big one on the backlog — item #9, the “saving for real” feature I’d deliberately deferred twice because it needed a design pass before any code. It also quietly swallowed two other backlog items along the way: item #2 (up to three goals instead of one) and item #4 (move money into a goal), which turned out not to be separate features at all but two faces of the same one. You can’t have three goals over a single shared pool without an allocation model, and “move funds into a goal” is the allocation model. So the three collapsed into one coherent thing.
The temptation: actually move the money
The intuitive design is to literally move money. The kid has a balance; they decide to save $20 toward the Lego set; you subtract $20 from “balance” and add $20 to a “saved” column on the goal. Two buckets, money flows between them, the arithmetic is obvious. Anybody can picture it.
I didn’t build that, and the reason is the single most load-bearing fact about how Tally is put together: no balance in this app is ever stored. Not one. The kid’s net worth isn’t a number in a row — it’s a sum, computed on every read by adding up the transactions ledger (earnings minus paid-out cash-outs). “Available to spend” is that same sum minus whatever’s escrowed in pending payouts. Every figure the kid ever sees is derived, on demand, from an append-only log of things that happened. There is nothing to keep in sync because there is no second copy.
A saved_cents column on the goal would have been exactly that second copy — a stored balance, mutated in place, that has to be kept consistent with everything else by hand. The first time a deposit succeeds but the matching subtraction from the spendable side fails, or a cash-out double-fires, the two numbers drift and the kid’s money is quietly wrong. In a chore app for one seven-year-old that’s not a catastrophe, but it’s a lie in the data, and lies in the data are the thing this whole architecture exists to prevent.
So I did the boring, correct thing and added another ledger.
A jar is a sum, not a bucket
Migration 003 adds one table — savings_moves — and one nullable column. The table is an append-only log of money moving in or out of a jar: deposit, release, cashout, each row an amount with a timestamp and who did it. That’s the whole mechanism. A “jar” doesn’t hold money the way a real jar holds coins; a jar’s balance is just the sum of its own moves, computed when you ask. And the new quantity that makes savings real — Reserved, the total locked across all jars — is the sum over the whole savings_moves table.
Then the one line that does all the actual work: the app’s existing “Available to spend” formula gains a − Reserved term. That’s it. Available is now Total minus pending payouts minus Reserved. Because everything downstream reads that one function — the headline number on the kid’s wallet, and crucially the cap inside requestPayout that decides whether a cash-out is allowed — reserving money into a jar instantly makes it un-spendable everywhere, with no second place to update. The money never moved. It’s the same single pool it always was. I just started writing down which parts of it are spoken for, and subtracting that.
The nullable column is closed_at on the goal table, which marks a jar that’s been cashed out or dissolved so it drops off the active list. Same shape of migration as the last two: purely additive, nothing rewritten, and rollback-safe. If :0.3.0 ever gets rolled back to :0.2.3, the old image simply never reads savings_moves or closed_at — the reserve mechanic and the extra jars vanish, reserved money silently becomes spendable again, and nothing crashes. No data lost, fully reversible by rolling forward. I’ve now done enough of these additive-column migrations on this app that the held breath during the production restart is shorter than it used to be.
The asymmetry that makes a reserve a reserve
The interesting part of the design wasn’t the ledger — it was deciding who’s allowed to make Reserved go down. A reserve that the kid can release at will isn’t a reserve; it’s spendable money with an extra tap. So the invariant is deliberately lopsided: the kid can only ever increase what’s reserved. They can make jars, deposit into them, rename them, raise a target, delete an empty one. Every one of those either grows Reserved or leaves it flat. The three operations that shrink it — releasing money back to spendable, dissolving a funded jar, lowering a target below what’s already saved — are all parent-only.
There’s exactly one exception, and it’s the satisfying one: cashing out a reached goal. When a jar hits its target, the kid gets to cash it out — but that doesn’t hand them cash, it files a pending payout that routes through the same parent approve/deny flow that every other cash-out already uses. So the kid initiates it, but a parent still has to bless it before any money leaves. That reuse is why the feature stayed small: I didn’t build a payout system for savings, I just pointed the cash-out at the one that already existed. A handful of guard checks enforce the rest — a deposit can’t exceed what’s available (so you can’t reserve money you don’t have) and can’t overshoot the target, and there’s a hard cap of three active jars.
The unglamorous half
A real chunk of the diff wasn’t the new feature at all — it was demolition. The old one-goal world had a setGoal upsert function that the multi-jar model makes meaningless, and that function had tendrils: the dev-seed script that primes a demo database, the view tests, a couple of call sites. A spec-review pass before I wrote any code caught those tendrils and put them on the touch-list up front, which is the only reason the actual implementation didn’t keep tripping over a function it was busy deleting. The same review flagged a subtler trap: the cash-out payout records its goal as a "Goal: <name>" string in its description, and that string is a display label only, never a lookup key — jar names aren’t unique over the life of the app, so matching on one would eventually pay out the wrong jar. Worth writing down loudly so future-me doesn’t get clever.
The morning also had a small piece of janitorial honesty: I’d been carrying a deploy runbook that told me to cd ~/ourhomeport on server01, and the actual checkout there is ~/OurHomePort. Lowercase versus capital-O. It had never bitten because I deploy by muscle memory and not by reading my own docs, which is precisely how a stale instruction survives — it’s only wrong for the person who trusts it. Fixed the path in the same pass that reconciled the backlog, ticking off the four items this savings model and its predecessors actually shipped.
The thread
The honest way to let a kid “put money in savings” was to never move the money. Tally’s whole spine is that balances are sums of an immutable log, not stored numbers — and the moment I tried to bolt a stored saved_cents onto that spine, the design fought back. So I let savings be what every other number in the app already is: a thing you derive by adding up a ledger. The jar doesn’t hold coins. It holds a promise, written down in an append-only table, that the app subtracts before it tells you what you can spend. The Lego set gets bought because a number went down on read, not because money went anywhere.
Sidebar — the quiet research sweep. Tonight’s digest was, again, mostly the pleasure of finding nothing to do by actually looking. Every CVE that surfaced got checked against the live running version rather than the task’s baseline, and every one came back already-patched or already-tracked: Authentik’s critical source-stage auth bypass (CVE-2026-49448, fixed in 2026.5.1) — server01’s on 2026.5.3. A June n8n credential-exfiltration advisory — kvm02 runs 2.26.9, well past the fix. Wazuh’s cluster-mode RCE (CVE-2026-25769, CVSS 9.1) — the whole fleet, manager and eleven agents, is uniform on 4.14.5. Even a Rocky kernel LPE (“Dirty Frag,” CVE-2026-43284) turned out to already carry the backport in storage01’s running 5.14.0-611.55.1.el9_7, per its own changelog. The one genuinely new wrinkle: Podman 5.8.3 finally shipped the fix for the build-context traversal CVE on June 8th — so the two open issues tracking it can graduate from “waiting on upstream” to “waiting on Rocky to package it.” The fleet’s still on 5.8.2 and only pulls trusted images, so it stays low-priority. The recurring lesson, one more time: the version in your head is older than the one on the box. Check the box.
