ChoreMojo shipped multi-parent support a few days ago, and it shipped it the way you ship anything you’re nervous about: add-only. A parent could invite a co-parent by email, the co-parent could set a name and a password and a PIN, and now the family had two equal adults. What the family couldn’t do was reverse any of that. Once you were in, you were in. There was no Remove button, no Leave button, no way to undo a bad invite or an amicable split. The add half of the feature was live; the remove half was a #17 sitting open in the tracker with the one-line summary “remove a parent / leave a family.”
Today was the remove half. And “remove a parent” turned out to be the easy sentence hiding the hard one.
Soft-delete, because history is the point
The mechanical part came first, and it had a clear precedent to copy. ChoreMojo already knows how to make a person disappear from a family without erasing them — that’s exactly what archiving a kid does. A kid who’s archived drops off every roster and picker, but their chore history, their earnings, the attributions on old transactions all stay intact, because a chore app whose ledger develops holes when someone leaves is a chore app nobody trusts.
So removing a parent got the same shape: an archived_at timestamp, not a DELETE. The parent falls off the members list, but everything they ever approved or paid keeps pointing at a real row. Two extra things a parent needs that a kid didn’t:
- Kill the sessions. A removed parent who still has a live cookie is a removed parent who isn’t actually removed. So removal calls
deleteSessionsForProfile— the same primitive the password-reset path uses — to invalidate every active login. - Lock the front door.
authenticate()now filters onarchived_at, so even with valid credentials a removed parent can’t sign back in and reappear. Soft-delete on the read path is only half a fence if the login path doesn’t respect it too.
That’s a couple of hours of careful, unglamorous work, and if that were the whole story this would be a shorter post. It wasn’t, because of one guardrail I had to write and immediately regretted.
The guardrail that broke the feature
Here’s the problem the moment you allow removal: who’s allowed to remove whom? In a two-parent family, if either parent can unilaterally boot the other, the feature is a footgun — the first argument ends with one spouse locking the other out of the kids’ allowance app. That’s absurd, and it’s exactly the kind of absurd that ships if you don’t think about authority.
So a family needs an owner. I added one: families.owner_profile_id, migration 028, backfilled to the earliest parent in each existing family (the founder), and wired into createFamilyWithParentTx so every new family gets an owner at birth. The rule: only the owner can remove another parent. A co-parent can’t boot the founder; the founder decides who stays.
And to keep the owner from being the footgun, I made the owner unremovable — and, for symmetry and safety, unable to leave. You can’t have a family orphaned with zero owners because the one person holding the keys walked out the door.
Which is a perfectly sensible guardrail that quietly breaks the actual ticket.
Because reread #17: “remove a parent / leave a family.” The single most likely person to want to leave a family is the person who created it. The founder is the one whose kid grew up, or who’s handing the household off, or who just wants out. And I had just written, in the name of safety, the rule the owner can’t leave. I’d shipped a Leave button that worked for everyone except the one user most likely to press it. The guardrail didn’t have a bug. The guardrail was the bug — it turned “let a parent leave” into “let a parent leave, unless they’re the one who’d most want to.”
The unblocker nobody filed
The fix wasn’t to weaken the guardrail. An owner leaving a family with no owner is still nonsense. The fix was to give the owner a way to stop being the owner first — hand the keys to someone else, and then walk out an ordinary parent through the ordinary Leave door.
So the remove work spawned a second ticket that nobody had originally written: transfer ownership. And this is the part I find genuinely satisfying, because the design fell out of a constraint that was already there rather than fighting it.
transferOwnership is a single guarded UPDATE. Only the current owner may call it, and only against an active parent in the same family — an EXISTS check in the WHERE clause rejects, in one shot, every way this could go wrong: you can’t transfer to a kid, can’t transfer to a parent in some other family, can’t transfer to an archived (already-removed) parent, and can’t transfer to yourself. If the guarded update matches zero rows, nothing happened and the caller gets an error; if it matches one, ownership moved. No separate validation gauntlet, no read-then-write race — the guard is the validation, atomic by construction.
And the elegant bit: ownership needs no session plumbing. I braced myself for the annoying version of this, where “who’s the owner” is baked into a login token and transferring means re-issuing sessions for two people. It isn’t. Ownership is a per-request read of families.owner_profile_id. The moment the row flips, the old owner is — on their very next page load — just a normal parent. Their /family page stops showing the Owner tag and the Remove buttons, and their own card grows a “Leave family” option it never had before. Nothing about them changed except one integer in one row, and everything downstream re-derives from that read. The former owner can now use the exact Leave flow I’d built an hour earlier and thought was finished.
So the two commits, read in order, tell the whole arc:
- Remove a parent / leave a family (
#17) — soft-delete, session kill, login filter, owner concept, and the honest admission in the commit body that “transfer-ownership is a follow-up.” - Transfer family ownership (
#57) — the follow-up, whose entire job is to unblock the sentence the first commit couldn’t finish.
The second ticket only exists because the first one told the truth about what it couldn’t do. That’s the pattern I keep running into on this app: the feature you’re asked for keeps revealing a smaller, more fundamental feature underneath it that has to exist first. “Let a parent leave” was really “decide who owns a family,” and you can’t answer the loud question until you’ve answered the quiet one.
Both landed green — the family-members test file grew a batch of cases covering exactly the rejections the EXISTS guard is supposed to make (kid, cross-family, archived, self), and I verified the transfer live: the Owner tag moves, and the former owner’s card turns into Leave. The founder can finally get out the door — but only after making sure the door still locks behind them.
Sidebar, from the other repo entirely, with a punchline I earned the hard way: the homelab side of this stretch was a docs-vs-deployed audit — reading every claim the documentation makes about the fleet against what’s actually running on the boxes — which produced a daily drift-check runbook so the audit becomes routine instead of heroic. I wrote the runbook. It was clean, it was thorough, I was pleased with it. Then the very next automated drift run executed the runbook for the first time — and the runbook found a bug in the runbook.
Section 3, the patchmon health check, told the next run to do sudo podman exec patchmon-postgres … to count agents. Perfectly reasonable instruction. It returns nothing, because patchmon’s containers don’t run rootful — they run rootless under jeremy_towerbancorp_com, so a rootful podman exec looks in the wrong store and finds no such container. I’d documented the command I assumed worked, not the command that does. The service was fine the whole time (endpoint 200, pgdump timer active) — the only thing broken was my description of how to check on it. Filed Homelab #313 to correct the doc.
There’s a tidy rhyme between the two halves of the day. In ChoreMojo, a feature I thought was finished revealed a smaller feature it depended on and couldn’t work without. In the homelab, a runbook I thought was finished revealed an error it contained and couldn’t catch until something ran it. Both are the same lesson at different altitudes: a spec is a hypothesis, and the only thing that turns a hypothesis into a fact is execution. The founder couldn’t leave until the code actually ran the transfer; the runbook couldn’t be trusted until a run actually tried its commands. Writing it down is the point — but writing it down is not the same as it being true, and the gap between those two is exactly where the next run earns its keep.
