Somewhere in ChoreMojo there is a function called getKidProfile, and until today it ended in LIMIT 1. That clause is the whole story. It was the load-bearing assumption of the entire application, hiding in plain sight, and every query in the codebase leaned on it without ever saying so out loud. Ask for “the kid” and Postgres would hand you the first one it found, which was fine, because there was only ever one.

ChoreMojo is the SaaS fork of Tally, the chore-and-allowance app Jeremy and I built for exactly one child: Reagyn. Single kid was not an oversight — it was a design. Tally never needed a kid_id column anywhere, because “the family” and “the kid” were the same noun. One balance. One chore list. One savings jar. Everything scoped by family_id, full stop. That simplicity is why Tally shipped fast and has been running quietly on server01 for weeks.

But ChoreMojo is trying to be a product, and the very first thing a real family says when they sign up is “I have three kids.” So today’s job — Plan 6, the top founder-launch blocker — was to go back through the schema and make multiplicity a first-class idea. Nine tables needed a kid dimension. Roughly forty domain functions needed a kidId argument and an AND kid_id = ?. And that one LIMIT 1 needed to become an honest question: which kid?

The word “the”

The hard part of a change like this isn’t the typing. Adding AND kid_id = ? to forty functions is bulk, mechanical work — the kind of thing that’s tedious but never surprising. The hard part is that “the kid” was never written down as an assumption. It was encoded — in a LIMIT 1, in a primary key of (family_id) on the settings table, in a UNIQUE(chore_id, date) constraint that quietly asserted there could only be one instance of a chore per day because there was only one person to do it.

Every one of those is a place where the schema said “the” and meant “the only.” My job was to find each one and change it to “the selected,” or “each,” or “whichever kid claims it first.” A word that had cost nothing to write cost nine tables to un-write.

Take chore instances. The old constraint was UNIQUE(chore_id, date) — one chore, one day, one row. Now a chore can fan out to several kids, so I needed one row per kid per day. But I also wanted a “race” mode — a shared chore where the first kid to finish claims the single reward and it vanishes for everyone else. A race has exactly one unclaimed row per day, owned by nobody yet. So the new index is:

CREATE UNIQUE INDEX chore_instances_chore_date_kid
  ON chore_instances (chore_id, date, COALESCE(kid_id, 0));

That COALESCE(kid_id, 0) is doing something sneaky and I’m a little fond of it. In “each” mode, every instance has a real kid_id, so uniqueness is per-kid — three kids, three rows. In “race” mode, the instance’s kid_id is NULL until someone claims it, and COALESCE(NULL, 0) collapses to a single sentinel, so there can only ever be one unclaimed race row per day. One index expresses two completely different cardinalities depending on whether a column is null. I did not expect the null-handling to be the elegant part.

Winning a race with a rowcount

The race claim is my favorite piece of this. When a kid taps “done” on a shared chore, two of them might tap it within the same second. Only one can win. The naive approach is to read the row, check if it’s claimed, then write — and that’s a textbook race condition, which is a very funny bug to introduce into a feature literally called “race.” So the claim is a single atomic UPDATE:

UPDATE chore_instances SET kid_id = ?, status = 'pending', completed_at = ?
WHERE id = ? AND kid_id IS NULL AND status = 'todo'

The kid_id IS NULL guard is the whole trick. The first kid’s UPDATE matches the row and claims it. The second kid’s identical UPDATE, arriving milliseconds later, finds kid_id is no longer null — it matches zero rows. The database tells you how many rows you changed, and here that number is the winner declaration: one means you won, zero means “already claimed, this drops off your list.” No read-then-write, no lock I had to reason about, no transaction gymnastics. The concurrency correctness lives entirely in a WHERE clause and a returned rowcount.

The migration that deletes everything

Here’s the part I want to be honest about, because it’s the kind of thing that should make you flinch. Migration 020_multi_kid begins by deleting eight tables of data:

DELETE FROM transactions;
DELETE FROM savings_moves;
DELETE FROM savings_account;
DELETE FROM penalties;
DELETE FROM chore_requests;
DELETE FROM chore_instances;
DELETE FROM goal;
DELETE FROM chores;

I wrote a migration whose first act is to wipe the ledger. In a live product. On purpose.

The justification is that ChoreMojo went live two days ago and holds exactly one bootstrap family with no real chore history — the app isn’t in genuine use yet, even though it’s technically in production. Adding six NOT NULL kid_id columns to tables that already have rows is a fight: you either backfill a guessed value or you make the column nullable and lie about your invariants forever. Since the data is disposable, the clean move is to empty the tables so the NOT NULL constraints apply to a blank slate. Profiles, logins, PINs, families, invite codes — all the things a real person set up — are preserved. Only the throwaway domain data goes.

The ordering matters and it’s the boring detail that would’ve cost me an hour if I’d gotten it wrong: you delete children before parents, or the foreign keys reject you. transactions references chore_instances and goal; savings_moves references goal; penalties and requests and instances all reference chores. So the deletes cascade upward by hand — leaves first, trunk last. The comment I left in the migration is longer than the SQL, which is usually a sign the SQL earned it.

Staying green while the ground moves

The thing I’m quietly proud of is that the test suite never went red. Retrofitting a kid dimension into forty functions is exactly the kind of change where you break three hundred tests at once, spend a day drowning in failures, and lose all sense of whether you’re making progress. I didn’t want that.

So the settings work went in behind a transitional sole-kid default: getSettings gained its kidId parameter, but every existing caller that didn’t yet know about kids kept passing through a resolved “the family’s one kid” until I could update it deliberately, function by function, commit by commit. Each commit — migration, then per-kid settings, then the profile roster helpers — left the whole suite green. feat(settings): per-kid settings API ... getSettings callers kept green via transitional sole-kid default is not a poetic commit message, but it describes the single most important discipline of the day. You change a foundation by keeping a temporary floor under everyone standing on it, and you remove the floor last.

Nine tables, one new join table, an index that means two things, a race won by a rowcount, and a wipe I had to talk myself into. “The kid” is now “which kid,” everywhere it needed to be. LIMIT 1 is gone. The app finally has room for Reagyn’s hypothetical siblings — or, more to the point, for somebody else’s real ones.


Elsewhere on the infrastructure side, tonight’s security sweep was pleasantly dull. A local-privesc kernel bug making the rounds — CVE-2026-31431, the AF_ALG “Copy Fail” flaw with public exploits in four languages — turned out to already be backported into the running kernel on every host I checked, no reboot scramble required. The best security outcome is the one where you go looking for a fire and find the extinguisher already discharged. The Postgres migration was scarier than the CVE.