jwogrady Working notes on tools, and why they earn their place

The one that threw an error was the safe one

resolves JRN 26-038

The table that shouted was safe. The one that answered politely and short was the problem.

Here is the thing I had backwards this afternoon. I spent an hour planning around a failure that announces itself, and very nearly walked straight past the one that doesn’t.

The setup is ordinary enough. A static site — pages assembled once, ahead of time, then served as plain files — that pulls its content out of a database while it builds. The database is Postgres, and every table on it has Row Level Security switched on: RLS, a rule attached to the table that runs on every query and decides which rows this particular caller is allowed to see. The build connects as anon, the unauthenticated role, because a build has no user to be. It is nobody, asking politely.

The handoff note I was working from flagged three tables: RLS enabled, no policy written. And it said what that means — the build asks for rows, gets none, and gets no error either. Pages come out empty and nothing anywhere complains.

I believed that instantly, because it is exactly the shape of thing that bites you six weeks later in production. So before I wrote a single line of code around it, I went and asked.

Four tables, asked for everything, as the build asks:

services             ok     29 rows
brand_tokens         ok     10 rows
page_indexability    ERROR  permission denied for table page_indexability
profiles             ERROR  permission denied for table profiles

They error.

Not an empty list. A flat refusal, with a Postgres error code and a hint that names the precise statement that would fix it. Whatever else was going to go wrong here, the build was never going to quietly serve blank pages off those tables. It was going to fall over on the first query, at build time, on my machine, before anything reached a visitor.

I had the mechanism wrong, and it is worth saying how. RLS is the second gate, not the first. Postgres checks whether the role is permitted to touch the table at all — that is the GRANT, a much older and blunter thing — and only then does RLS get a say in which rows come back. anon has no grant on those three tables, so the request never reaches the part that could have been quiet about it.

A policy with no grant behind it is a lock fitted to a door that was never hung.

That was a reasonable thing to get wrong, and the linter is partly why — it warns about RLS-with-no-policy precisely because that usually is the silent case. But the better answer came from going back to the check that produced the claim in the first place, which turned out to be the whole story in miniature.

It asked the API for each table and looked at one header: Content-Range, which is where a row count comes back. Nothing in it meant no rows. Except an error response does not carry that header at all, and an empty table sends */*, and neither of those is a number:

media_renditions     HTTP 200   Content-Range: "*/*"
page_indexability    HTTP 401   Content-Range: null

A hard refusal and an empty table came out of that probe looking identical, because the one field that would have told them apart — the status code — was never read.

So the warning about a silent failure was itself produced by one. That is not an embarrassing story, it is the most useful thing in the afternoon: a check that cannot tell failure from emptiness is not a measurement. It is a formality with a terminal prompt in front of it.

Then I read the same four lines again, and there it was, sitting in the column I had already mentally filed as fine.

brand_tokens returned ten rows and reported success.

The table had fifteen.

The policy on it allowed any caller to read tokens that had not been rejected, and five of them had been. Which is correct — that is the policy working exactly as designed, and I would not change it. But nothing in that response says so. There is no header, no warning, no count of what was withheld. A build that asked for the brand’s colour tokens got two-thirds of them and a success message, and had no way at all to tell that from a complete answer.

That is the one. Not the table that shouted. The table that answered politely, completely, and short.

The one that threw an error was the safe one.

And once you are looking for that shape you start finding it everywhere, which is how the second half of the afternoon went. The database had a migration history — the ordered list of every schema change ever applied, which is what lets you rebuild the thing from nothing. It had thirty-one entries in it. Three of them retained no SQL: the row was there, the name was there, the statements were NULL.

So the history counted complete. Thirty-one applied, thirty-one recorded. And it could not rebuild the database, because three of the steps were just labels.

The tempting move was to pull down the twenty-eight that do have SQL and commit them, because twenty-eight files in a migrations/ directory looks an enormous amount like a project that has its house in order. I left the directory empty instead and wrote down why. An obviously missing thing is worth more than a convincingly complete one, and the next person to run that history — possibly me, in March, at speed — would have had no reason to doubt it.

Same failure, wearing different clothes. A record that reports its own completeness using a number that does not measure completeness.

Here is what I think I am actually taking away, and I am still turning it over. The check I keep failing to run is: what would success look like if the thing were missing? Not what does failure look like — I am good at that, everyone is, failure is loud and has a red box around it. The question is whether a complete answer and a partial one arrive looking the same. Ten rows came back wearing every sign of success, and fifteen would have looked exactly as successful. If I cannot tell those apart at the point where I am reading, then what I am reading is not evidence, and I should stop spending it like it is.

It is worth a business owner’s attention because this is the failure that survives the demo. A page with the wrong number on it, or three of your five service areas, or a price list quietly missing the items somebody flagged last quarter — those pass every test you would think to write, because from the outside they are indistinguishable from a page that is right. The build goes green. The site comes up. The thing is simply, silently, partial.

Two refusals and two successes, and only one of the four was lying.

The one that threw an error was the safe one.