When your assistant deploys a site with a database, the platform reads the schema before anything is published. If a table in it would hand every row to every visitor, the deploy does not happen, and the refusal comes back carrying the statements that fix it, ready to run unchanged.
That is the decision. The rest of this post is why the check is at the door rather than in a warning, the four things it asks, the five statements refused in the window it cannot see, and the one question it deliberately does not answer.
An unprotected table has no symptom
Row security is off by default and it is set per table. A table without it looks exactly like a table with it: the same shape, the same speed, the same correct answers. The difference only appears when someone who should get nothing gets everything, and by then the site has been serving for a while.
An unprotected table throws no error, logs no warning and runs no slower — it answers correctly, to everybody. There is nothing for a careful person to notice, which is the whole problem, because the thing writing the schema is a model that has just made a table and has every reason to move on to the next feature.
So the platform does not ask the assistant to remember. It asks the database.
Four questions, asked of the catalog
At the moment a site is published, the schema is read with four cheap queries against the database's own catalog. Three of the answers refuse a deploy and one is only reported.
| what it looks for | what happens |
|---|---|
| a table with row security off | deploy refused |
| a view that reads past the policies beneath it | deploy refused |
| a materialized view | deploy refused |
| row security on, with no policy at all | deployed, and said out loud |
The three refusals are not exotic cases. Each of them is the default.
A view runs as the role that created it unless it is told otherwise, so it returns every row of the tables underneath it however carefully those tables are protected. There is no database-wide setting for this. It is per view, and it has to be asked for.
A materialized view is worse, because row security does not apply to it at all. Its rows were copied out of the protected table when it was last refreshed, by whoever refreshed it, and no policy written afterwards reaches them. Nothing fixes that in place, so the refusal says to replace it with an ordinary view rather than pretending there is an option to set.
The fourth is the one that is reported and allowed. Row security on with no policy denies everything, which is the safe direction — but an empty result is indistinguishable from a query that found nothing, and somebody will spend an afternoon on it. It is not a refusal, so it does not block a deploy. It is also not silence, because silence is what would cost the afternoon.
When there is nothing to say, the reply says that too, in one sentence:
The refusal hands over the statement, not the lecture
Every refusal here names the alternative, and on this path that matters more than usual, because the reader is a model. A model recovers from here is the statement to run in one turn, and from row-level security is not enabled in several, if at all. So a table with no policy comes back with its policy written out:
The nullif is the part that must survive being copied. Without it, reading a claim that was never set raises an error rather than denying, and it does so only on a connection that has already served an authenticated request — so the same policy denies correctly on one request and returns a server error on the next, depending on which backend the request happened to land on. It fails closed either way. It just fails closed as a random error, which is not something anybody can debug.
No model writes that nullif unprompted, which is why the policy is handed over as a template rather than described. A description is the part that gets paraphrased, and nullif is the part that gets paraphrased away.
The grants work the same way round. The site's own role is granted what it needs at deploy time, over the objects that exist at that moment, and only after the gate has passed. That is what makes an unprotected table unreachable rather than merely policy-less — the site cannot read a table nothing has checked, because the reading privilege arrives after the check.
The gate does not run while the site is serving
Here is the honest limit. The schema is read when a site is published and never again. Between two deploys a live site is answering visitors and nothing is looking at its schema, and that window is most of the time.
Turning row security off on a table the site is already serving does not fail a later deploy. It publishes the table now, to whoever asks next.
So apply_sql refuses five kinds of statement before it opens the transaction, each one chosen because its effect inside that window is immediate and public.
| refused | because |
|---|---|
DISABLE ROW LEVEL SECURITY | it takes effect on the site serving right now, and the gate will not run again until the next deploy |
BYPASSRLS or SUPERUSER on a role | such a role ignores every policy on every table, silently: every query still works, it just returns everybody's rows |
| a grant or revoke reaching the site's role | it hands the live site a table nothing has checked, which is exactly what deploy-time granting exists to prevent |
| dropping or re-passwording the site's role | its password exists only in the deployed site's binding and this platform keeps no copy, so the site goes down and stays down until the credential is rebuilt |
ALTER DEFAULT PRIVILEGES | access to every table created from now on, including ones nothing has looked at, composed with row security being off by default |
A grant to PUBLIC is caught by the third rule even though it never spells the role's name, because PUBLIC is every role there is and the site's is one of them. An ordinary grant to an ordinary role is somebody's own business and is not refused.
Two things about this list are worth saying plainly, because a post is a good place to lose them and the code is where they are written down.
- It is not a privilege boundary and must never be described as one. Anyone holding the database owner credential can open a terminal and run every statement above. What this defends against is a change whose consequence the caller did not see.
- Matching is on text, and text is not a parser. A statement can be written to slip past it and a determined caller will manage that on the first try. The rules are worded for the caller who did not realise.
Because it is all one transaction, a refusal costs nothing either: the call is refused before the transaction is opened, so a list where the third statement is the problem leaves neither the table the first would have made nor the row the second would have inserted. And the schema is read back on every call that does run, so a model that has just created a table learns it needs a policy in that same turn, with the statement in hand, instead of at deploy time several steps later when the connection between the mistake and the refusal has to be reconstructed.
What the gate will not judge
It answers one question: is there anything at all standing between this table and the next visitor. It does not answer whether the thing standing there is any good.
That is not knowable from here. A policy of USING (true) is a policy, it passes, and the site goes live handing every row to every visitor — and a green light on it would be a lie with our name on it. So the check says what it checked and nothing more. A gate that claimed to verify your access rules would be more dangerous than no gate at all, because it would be believed.
The narrow claim is the one worth making: you never switch privacy on here. It is on, or the table does not go live.
The rule, once
Row security is not a setting a customer remembers; it is the condition of going live, read from the database at every deploy and refused with the statement that fixes it. What every tool checks and what each one refuses is listed in the docs.