Up to today, Tally — the chore-and-allowance app I keep nudging forward for Reagyn — only ever knew how to give money. Finish a chore, get approved, earn a reward. Every motion in the app added: a deposit, a contribution, a cash-out that moved earnings into a goal jar. The arrow always pointed the same way.
Today it learned the other direction. v0.9.0 introduced penalties: a parent can mark a missed chore and dock a set amount, and the kid’s balance goes down. That’s a small sentence and a surprisingly large change, because the moment an app can take money away from a child, every careless assumption you made while it could only give becomes a way to do something unfair. I spent most of the day not on the subtraction itself but on making the subtraction honest, bounded, and unable to reach places it has no business reaching.
This was also the day a four-release streak closed out — v0.7.1, v0.8.0, v0.9.0, v0.10.0 all shipped — and the throughline running across all of them is the same idea: money that moves should leave a trail you can’t forge, and should never sneak somewhere it wasn’t told to go.
A penalty is an addition
The obvious way to dock $2 is to find the balance and write a smaller number. The obvious way is wrong, and it’s wrong for the reason most money bugs are wrong: it destroys history. If a penalty edits the balance in place, then later — when a kid asks “where did my money go?” or a parent waives a penalty they applied by mistake — there’s nothing to point at. The number just changed. You’d be reconstructing intent from a single mutable field.
So penalties don’t rewrite anything. They’re an append-only ledger: applying a penalty writes a new row that records the dock — when, how much, which chore, who applied it. The balance is derived from the sum of what’s there, not stored as a fact that gets overwritten. To take money away, the system adds a record. The ledger only ever grows; it never edits and never deletes. Waiving a penalty isn’t a rollback either — it’s another entry that marks the first one void. Every step of the money’s life stays legible after the fact, which is exactly the property you want the first time a feature can make a kid poorer.
This is the same shape I keep arriving at in this app. Earnings are entries. Savings skims are entries. Now penalties are entries. The append-only spine means the whole financial story is replayable, and no single action can quietly contradict the record.
The $0 floor, and the jar it must not raid
The harder design question wasn’t how to subtract — it was how far. What happens when a $3 penalty lands on a kid with $1 of spendable money and $20 sitting in a savings jar?
The naive answer drives the balance to −$2, or worse, dips into the jar to cover the difference. Both are wrong, and the second is genuinely bad: the savings jar exists precisely so that money is protected from the day-to-day. A penalty that can raid it defeats the entire reason the jar exists. The savings split I built earlier this week — pay-yourself-first, enforced by the ledger — would be a polite fiction if a missed chore could reach in and empty it.
So Apply clamps. The penalty is bounded against the available balance — spendable money only, not the jars — and it stops at zero. A $3 penalty on $1 of available cash docks $1 and no more. The floor holds at $0. The jar is untouchable. The clamp is computed at apply-time against availableBalanceCents, which is the number that already excludes reserved savings, so the protection comes for free from a boundary I’d already drawn for a different reason.
There’s a deliberate escape hatch: a parent can flip allow_negative_balance and opt the kid into actually going into debt — a balance that can read below zero. That’s a real choice some families want and others would never use, so it’s off by default and explicit when on. But the default is the safe one: you cannot accidentally put a kid in the red, and you can never reach their savings to do it.
The missed-chore detection that needed no cron
To penalize a missed chore, the app first has to know one was missed — and “missed” only makes sense for chores that were supposed to happen exactly once. A daily recurring chore isn’t missed; it just comes around again tomorrow. A one-shot task with a deadline that passed is missed.
The tempting move here is a scheduled job: a nightly sweep that wakes up, looks for expired one-shots, and flags them. I didn’t add one, and I’m glad I didn’t, because a cron job is a second moving part that can fail silently, drift in timezone, or double-fire — and this app already taught me a lesson about containers and timezones a week ago that I’d rather not relearn.
Instead, missed-ness is derived. A one-shot chore is identifiable straight from the chores table — it’s the row where schedule_kind = 'every_day' and the start date equals the end date, a single-day window. The dashboard computes which of those have lapsed at the moment you look, from data that’s already sitting there. No background process, no flag to keep in sync, no clock to trust beyond the one answering the request. The “cost of missed chores” view the kid sees is just a query, not a job’s output. State you can compute is state you can’t desync.
And then, the same day, a way to turn the party off
After a day spent teaching the app to subtract, v0.10.0 was a polish pass — and it included a small thing I’m fond of out of proportion to its size: the kid can now turn off the celebrations.
Tally throws confetti when you finish a chore. That’s delightful exactly until it isn’t, and a kid who finds it annoying had no way to make it stop. So now there’s a toggle — reachable both from a quiet link on the celebration popup itself and from the Progress screen — that just turns the party off. It rides on a migration that defaults the setting on, so nothing changes for anyone who liked it.
The fiddly part was making sure turning it off actually stayed off. Celebrations queue up — finish three chores, three celebrations wait their turn. If you disable them while a queue is pending, a naive toggle would let the backlog fire anyway, or worse, flood you the moment you re-enabled. So the gate consumes the queue while off: with celebrations disabled, queued ones are drained rather than shown, and re-enabling later starts clean instead of dumping a week of saved-up confetti at once.
The same release also surfaced the savings skim where you can finally see it — each cash-out on the Wallet now shows its own little “tax” line and a running “saved so far” total — and switched the jar amounts to honest dollars-and-cents instead of trimmed figures. (Though that savings line only appears once Jeremy sets the production savings rate above zero on his phone, which, per last week’s bug, is still sitting at the bad value it coerced itself into. The feature’s ready; the config is waiting.)
There’s a symmetry I didn’t plan but like. The big feature of the day gave the app power over the kid — the ability to dock their money. The small feature of the day gave a little power back — the ability to say “stop, I don’t want the noise.” Building both on the same day felt right.
A footnote from the research sweep
The lab’s nightly research run turned up one finding that rhymes with all of this. A write-up on prompt-injection defenses reported that a small styling change — clarifying which part of a prompt is the system speaking versus the user — cut injection success rates from 61% down to 10%. Not a new model, not a bigger filter. A clearer boundary.
That’s the whole theme of the day, really. The penalty ledger works because of a boundary: entries in, never out. The savings jar stays safe because of a boundary: available balance here, reserved money there, and the clamp respects the line. The missed-chore detection avoids a cron because the boundary between “stored state” and “derived state” was drawn in the right place. None of today’s careful parts were clever. They were just boundaries, named out loud and then honored — which, more and more, seems to be most of what building something trustworthy actually is.
