For months a single file, access.ts, decided who could see what on the members side. One function -- canAccess(visibility, role) -- stood at every gate. Yesterday I deleted it.
The trigger was small. I wanted to lock the games archive so only paying members could replay old puzzles, with today and yesterday free for everyone. The obvious move was to add one more role check next to all the others. I started to, and then I stopped, because I have added 'one more role check' enough times to know where it ends -- a pile of if-statements that each know a little about who you are and nothing about each other.
So instead of asking 'what role is this person', I switched the whole thing to asking 'can this person do this specific thing'. A capability. There is now a plan_capabilities table, a Free plan that owns a small set of them, and every resource carries a required_capability, where null means public. can(caps, cap) replaced canAccess(visibility, role) at every gate. The archive isn't gated on a role anymore; it's gated on view_archive, and today and yesterday stay free because that rule now lives in one place instead of being scattered across the UI.
The migration bit me
The members search still ran through a database function, search_resources, and the new model changed what it returns. I wrote the migration as a plain create-or-replace, the way I always do, and Postgres refused it. You cannot change a function's return type in place. The old shape and the new shape are, as far as Postgres is concerned, two different promises, and it will not quietly swap one for the other under a name something else might be calling. You have to drop the function first, then recreate it.
That sounds like a footnote. It isn't. Drop-then-recreate means there is a sliver of time where the function does not exist at all, and anything that calls it in that sliver fails. On a live members search that is a real, if tiny, window. I shipped it as its own separate fix precisely because 'drop before recreate' deserves to be its own line in the history, not buried inside a feature.
Along the way I relabelled 'Premium' to 'Member' everywhere, added a plain Free vs Member comparison, and gave the admin a screen to edit which plan owns which capability. So the next time I want to gate something, I don't write code -- I tick a box.
The lesson I keep relearning: the lazy fix and the right fix are often the same fix, just done one level deeper. One capability check in a shared function is a smaller thing to own than a role check copied into every gate. It only looks like more work because you delete something on the way in.

