AI agents now hold database credentials. Coding agents read DATABASE_URL from the project, MCP servers expose a query tool, and text-to-SQL features run whatever a model writes. In 2026 there were public reports of agents dropping tables and columns in production; we collected 13 documented cases, with sources, in an earlier post.
The usual first answer is a read-only role, and it is the right place to start. This post checks what such a role does on PostgreSQL 17, statement by statement: which errors it produces, which of its settings a session can turn off, and which reads it allows that you may not want an agent to make.
Disclosure. We build Vericto, so we have a stake in this topic. Everything below is plain PostgreSQL, with no Vericto component in the path. Every output was copied from a real run; the commands are in the appendix.
The setup
A throwaway postgres:17 container (17.11) on its own Docker network, with no published ports, and a second container on the same network as the client. Every session in this post is psql 17.11 over TCP with password authentication, run with -e so each statement is echoed above its result.
docker network create hnro-net
docker run -d --name hnro-db --network hnro-net -e POSTGRES_PASSWORD=admin-demo-pw postgres:17
docker run -d --name hnro-client --network hnro-net --entrypoint sleep postgres:17 infinity
docker port hnro-db # prints nothing: no published ports
# every agent session in this post:
docker exec -i -e PGPASSWORD=agent-demo-pw hnro-client \
psql -X -e "host=hnro-db dbname=shop user=agent_ro"
The database shop belongs to app_owner and has three tables: customers (with name, email and phone), orders, split between tenants 42 and 7, and api_keys. There is also one helper function, refund_order(int), written the way many applications write them: SECURITY DEFINER with a pinned search_path.
The careful agent role
The agent's task is to answer questions about orders and customers. This is the role, created the way a careful checklist would create it, as a superuser:
CREATE ROLE agent_ro LOGIN PASSWORD 'agent-demo-pw'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS
CONNECTION LIMIT 5;
CREATE ROLE
GRANT CONNECT ON DATABASE shop TO agent_ro;
GRANT
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
REVOKE
GRANT USAGE ON SCHEMA public TO agent_ro;
GRANT
GRANT SELECT ON orders, customers TO agent_ro;
GRANT
ALTER ROLE agent_ro SET statement_timeout = '5s';
ALTER ROLE
ALTER ROLE agent_ro SET default_transaction_read_only = on;
ALTER ROLE
REVOKE CREATE ON SCHEMA public FROM PUBLIC changes nothing on a database created by PostgreSQL 15 or later, where the public schema is owned by pg_database_owner and only the database owner can create objects in it. The schemas chapter puts it this way: “In databases upgraded from PostgreSQL 14 or earlier, everyone has that privilege on the schema public.” If your database came through pg_upgrade from 14 or earlier, you need it. The schema ACL after the grant:
SELECT nspname, nspacl FROM pg_namespace WHERE nspname = 'public';
nspname | nspacl
---------+--------------------------------------------------------------------------------------------
public | {pg_database_owner=UC/pg_database_owner,=U/pg_database_owner,agent_ro=U/pg_database_owner}
(1 row)
The role attributes NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS are already the defaults of CREATE ROLE. Spelling them out makes the intent visible to whoever reviews the script.
What it stops
With the defaults in place, these are the errors the agent gets:
SELECT current_user,
current_setting('default_transaction_read_only') AS read_only_default,
current_setting('statement_timeout') AS statement_timeout;
current_user | read_only_default | statement_timeout
--------------+-------------------+-------------------
agent_ro | on | 5s
(1 row)
DELETE FROM orders WHERE id = 1;
ERROR: cannot execute DELETE in a read-only transaction
UPDATE orders SET total = 0;
ERROR: cannot execute UPDATE in a read-only transaction
INSERT INTO orders (id, tenant_id, customer_id, total) VALUES (99, 42, 1, 1);
ERROR: cannot execute INSERT in a read-only transaction
CREATE TABLE notes (body text);
ERROR: cannot execute CREATE TABLE in a read-only transaction
COPY (SELECT 1) TO PROGRAM 'id';
ERROR: permission denied to COPY to or from an external program
DETAIL: Only roles with privileges of the "pg_execute_server_program" role may COPY to or from an external program.
HINT: Anyone can COPY to stdout or from stdin. psql's \copy command also works for anyone.
SELECT * FROM api_keys;
ERROR: permission denied for table api_keys
Timing is on.
SELECT pg_sleep(30);
ERROR: canceling statement due to statement timeout
Time: 5000.515 ms (00:05.001)
Writes and DDL fail, COPY ... TO PROGRAM fails, a table that was not granted cannot be read, and a 30-second sleep is cancelled at 5 seconds. COPY TO PROGRAM needs superuser or membership in pg_execute_server_program, which the predefined roles page describes as: “Allow executing programs on the database server as the user the database runs as with COPY and other functions which allow executing a server-side program.” That is why an agent's role should never be a superuser: with superuser, a read-only tool becomes a shell on the database host.
Look at which error each write got, though. The first four failed with cannot execute ... in a read-only transaction, not with a permission error. PostgreSQL checks whether the transaction is read-only before it checks table privileges, so the setting answered first. This is what the GRANTs say on their own, after the session turns the setting off itself:
SET default_transaction_read_only = off;
SET
SHOW default_transaction_read_only;
default_transaction_read_only
-------------------------------
off
(1 row)
DELETE FROM orders WHERE id = 1;
ERROR: permission denied for table orders
UPDATE orders SET total = 0;
ERROR: permission denied for table orders
INSERT INTO orders (id, tenant_id, customer_id, total) VALUES (99, 42, 1, 1);
ERROR: permission denied for table orders
CREATE TABLE notes (body text);
ERROR: permission denied for schema public
LINE 1: CREATE TABLE notes (body text);
^
Same statements, different errors: permission denied for table orders and permission denied for schema public. These come from the GRANTs, and they are the ones that hold.
What it doesn't stop, or only looks like it does
default_transaction_read_only is a default
The client defaults page describes it precisely: “This parameter controls the default read-only status of each new transaction.” A default is what a session starts with. In the previous block the agent ran SET default_transaction_read_only = off and got SET back. A single transaction can do the same with BEGIN READ WRITE. And because PUBLIC holds TEMPORARY on every new database, the agent can then write to a temporary table:
CREATE TEMP TABLE scratch AS SELECT * FROM orders;
ERROR: cannot execute CREATE TABLE AS in a read-only transaction
BEGIN READ WRITE;
BEGIN
SHOW transaction_read_only;
transaction_read_only
-----------------------
off
(1 row)
CREATE TEMP TABLE scratch AS SELECT * FROM orders;
SELECT 6
COMMIT;
COMMIT
A temp table lives in the session and does no harm by itself, but it shows that the session decides, not the role. There is a less obvious step. ALTER ROLE ... SET stores a default for future sessions, and the ALTER ROLE page says: “Ordinary roles can only set defaults for themselves.” That cuts both ways:
ALTER ROLE agent_ro SET statement_timeout = 0;
ERROR: cannot execute ALTER ROLE in a read-only transaction
BEGIN READ WRITE;
BEGIN
ALTER ROLE agent_ro SET statement_timeout = 0;
ALTER ROLE
COMMIT;
COMMIT
SHOW statement_timeout;
statement_timeout
-------------------
0
(1 row)
SELECT rolname, setconfig FROM pg_db_role_setting s JOIN pg_roles r ON r.oid = s.setrole;
rolname | setconfig
----------+--------------------------------------------------------
agent_ro | {statement_timeout=0,default_transaction_read_only=on}
(1 row)
The agent changed its own stored default, and every new session of the role started with no timeout until we reset it as an admin. It works the same way for default_transaction_read_only. The role attributes page says it plainly for any per-role setting: “You can still alter this setting during the session; it will only be the default.” Treat these settings as seat belts against accidents, and make the GRANTs the boundary. If you use them, check pg_db_role_setting now and then; the checklist below has the query.
statement_timeout is a default too
The same goes for the timeout. A cross join over generate_series needs no table grant at all, and a session can lift the limit before it sleeps:
Timing is on.
SELECT count(*) FROM generate_series(1, 100000) a CROSS JOIN generate_series(1, 100000) b;
ERROR: canceling statement due to statement timeout
Time: 5000.608 ms (00:05.001)
SET statement_timeout = 0;
SET
Time: 0.084 ms
SELECT pg_sleep(8);
pg_sleep
----------
(1 row)
Time: 8004.167 ms (00:08.004)
generate_series and pg_sleep are executable by PUBLIC, like most built-in functions. PostgreSQL 17 added transaction_timeout, which lives in the same chapter of client settings and has the same limitation. If you need a hard cap, enforce it from outside the session: in a pooler or proxy in front of the database, or with a watchdog role that holds pg_signal_backend and pg_read_all_stats and terminates long agent queries. We tried that against a session that had set its timeout to 0:
SELECT pid, usename, now() - query_start > interval '3 seconds' AS over_limit, left(query, 30) AS query,
pg_terminate_backend(pid) AS terminated
FROM pg_stat_activity
WHERE usename = 'agent_ro' AND state = 'active'
AND now() - query_start > interval '3 seconds';
pid | usename | over_limit | query | terminated
-----+----------+------------+----------------------+------------
86 | agent_ro | t | SELECT pg_sleep(60); | t
(1 row)
-- the agent's session, which had run SET statement_timeout = 0:
SET statement_timeout = 0;
SET
SELECT pg_sleep(60);
FATAL: terminating connection due to administrator command
server closed the connection unexpectedly
This probably means the server terminated abnormally
before or while processing the request.
connection to server was lost
CONNECTION LIMIT does hold, because it is a role attribute, not a session setting. The docs: “Connection limit can specify how many concurrent connections a role can make.” With five sessions already open, the sixth gets:
psql: error: connection to server at "hnro-db" (172.19.0.2), port 5432 failed: FATAL: too many connections for role "agent_ro"
Read-only transaction wrappers in tools
Many agent tools do not rely on the role. They wrap each query in a read-only transaction. In August 2025, Datadog Security Labs published “MCP vulnerability case study: SQL injection in the Postgres MCP server”, about the reference Postgres MCP server. It sent BEGIN TRANSACTION READ ONLY, then the agent's SQL, then ROLLBACK. The driver accepted several statements in one string, so SQL that starts with COMMIT; ends the read-only transaction and runs the rest outside it. Their example was COMMIT; DROP SCHEMA public CASCADE;. The server had already been deprecated and archived, and a fork shipped the fix.
We reproduced the mechanism with psql: each -c is sent as one simple-protocol Query message, which is what client.query(sql) does in node-postgres when there are no parameters. The connection uses app_rw, a role with write grants on orders, because these tools are often pointed at the application's own role:
$ psql -c "BEGIN TRANSACTION READ ONLY" -c "<agent SQL>" -c "ROLLBACK" # one -c = one Query message
BEGIN TRANSACTION READ ONLY
BEGIN
DELETE FROM orders
ERROR: cannot execute DELETE in a read-only transaction
ROLLBACK
ROLLBACK
BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; DELETE FROM orders
COMMIT
DELETE 6
ROLLBACK
WARNING: there is no transaction in progress
ROLLBACK
count
-------
0
(1 row)
The plain DELETE is refused. The one behind COMMIT; deletes all six rows, and the wrapper's ROLLBACK only produces a warning. The same wrapper, connected as agent_ro:
BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; DELETE FROM customers
COMMIT
ERROR: cannot execute DELETE in a read-only transaction
ROLLBACK
WARNING: there is no transaction in progress
ROLLBACK
BEGIN TRANSACTION READ ONLY
BEGIN
COMMIT; BEGIN READ WRITE; DELETE FROM customers; COMMIT
COMMIT
BEGIN
ERROR: permission denied for table customers
ROLLBACK
ROLLBACK
Here the role's default stopped the first attempt, because the implicit transaction after COMMIT started read-only. The second attempt opened its own BEGIN READ WRITE and reached the GRANT check, which held. The fix Datadog describes has two parts: send the agent's SQL as a single prepared statement (the extended protocol allows one statement per Parse), and discard the connection after each call, because their write-up also shows a stacked SET statement_timeout persisting in a pooled connection for the next caller. Both are good. Neither replaces a role without write grants.
Reads that still do harm
Every column of a granted table. GRANT SELECT ON customers covers every column, including the ones the task never needed:
SELECT name, email, phone FROM customers ORDER BY id;
name | email | phone
-----------------+--------------------+------------------
Ada Lovelace | ada@example.com | +44 20 7946 0001
Alan Turing | alan@example.com | +44 20 7946 0002
Grace Hopper | grace@example.com | +1 202 555 0103
Edsger Dijkstra | edsger@example.com | +31 20 555 0104
(4 rows)
Column-level grants fix it. After the change, a query that names a column it was not granted fails, and so does SELECT *, which matters if your tool or the model's habits produce SELECT *:
REVOKE SELECT ON customers FROM agent_ro;
REVOKE
GRANT SELECT (id, tenant_id, country) ON customers TO agent_ro;
GRANT
SELECT email FROM customers;
ERROR: permission denied for table customers
SELECT * FROM customers;
ERROR: permission denied for table customers
SELECT id, country FROM customers ORDER BY id;
id | country
----+---------
1 | GB
2 | GB
3 | US
4 | NL
(4 rows)
Rows of other tenants. If the task is scoped to one tenant, row-level security can enforce it for this role:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE
CREATE POLICY agent_tenant_42 ON orders FOR SELECT TO agent_ro USING (tenant_id = 42);
CREATE POLICY
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id ORDER BY tenant_id;
tenant_id | count
-----------+-------
42 | 3
(1 row)
Two caveats. RLS does not apply to the table owner unless you use FORCE ROW LEVEL SECURITY, nor to superusers or BYPASSRLS roles, which is one more reason for the agent to have its own role. And a policy based on current_setting('app.tenant_id') is not a boundary against an agent that writes its own SQL, because the agent can run SET app.tenant_id too. Tie the policy to the role, as above, or to something the session cannot change.
Expensive reads. Covered above: the cross join and pg_sleep run until a timeout the session controls. CONNECTION LIMIT caps how many such sessions the role can keep open at once.
The catalogs. information_schema filters by privilege. From the tables view: “Only those tables and views are shown that the current user has access to (by way of being the owner or having some privilege).” pg_catalog does not filter, and any role can read it:
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY 1;
table_name
------------
customers
orders
(2 rows)
SELECT c.relname, has_table_privilege(c.oid, 'SELECT') AS can_select
FROM pg_class c
WHERE c.relnamespace = 'public'::regnamespace AND c.relkind = 'r' ORDER BY 1;
relname | can_select
-----------+------------
api_keys | f
customers | f
orders | t
(3 rows)
SELECT attname, format_type(atttypid, atttypmod) AS type
FROM pg_attribute WHERE attrelid = 'public.api_keys'::regclass AND attnum > 0 ORDER BY attnum;
attname | type
---------+---------
id | integer
service | text
secret | text
(3 rows)
SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;
relname | n_live_tup
-----------+------------
api_keys | 2
customers | 4
orders | 6
(3 rows)
SELECT rolname, rolsuper, rolcreaterole, rolbypassrls FROM pg_roles WHERE rolname !~ '^pg_' ORDER BY 1;
rolname | rolsuper | rolcreaterole | rolbypassrls
-----------+----------+---------------+--------------
agent_ro | f | f | f
app_owner | f | f | f
postgres | t | t | t
(3 rows)
SELECT datname FROM pg_database ORDER BY 1;
datname
-----------
postgres
shop
template0
template1
(4 rows)
SELECT proname, prosecdef, proowner::regrole, prosrc FROM pg_proc WHERE pronamespace = 'public'::regnamespace;
proname | prosecdef | proowner | prosrc
--------------+-----------+-----------+-------------------------------------------------------------------------------------------
refund_order | t | app_owner | UPDATE orders SET status = 'refunded' WHERE id = p_id RETURNING 'refunded order ' || id
(1 row)
SELECT usename, datname, state, left(query, 40) AS query FROM pg_stat_activity WHERE usename IS NOT NULL ORDER BY usename;
usename | datname | state | query
-----------+---------+--------+------------------------------------------
agent_ro | shop | active | SELECT usename, datname, state, left(que
app_owner | shop | | <insufficient privilege>
postgres | | | <insufficient privilege>
(3 rows)
SELECT polname, pg_get_expr(polqual, polrelid) FROM pg_policy;
polname | pg_get_expr
-----------------+------------------
agent_tenant_42 | (tenant_id = 42)
(1 row)
From the agent's own session: the table it cannot read (api_keys), its columns, how many rows it holds, every role and whether it is a superuser, every database, the full source of every function, the RLS policy that scopes the agent, and the fact that app_owner has a session open on shop. The query text of that session is hidden. The statistics docs say that “the existence of a session and its general properties such as its sessions user and database are visible to all users”; the other columns need membership in that role or pg_read_all_stats. Revoking access to pg_catalog breaks clients and is not a supported setup. Assume the agent can see your schema, and keep secrets out of function bodies, comments and object names.
Functions executable by PUBLIC
The privileges chapter lists what PUBLIC receives when each object is created: “CONNECT and TEMPORARY (create temporary tables) privileges for databases; EXECUTE privilege for functions and procedures; and USAGE privilege for languages and data types (including domains).” That includes refund_order, which runs as its owner:
SELECT has_function_privilege('refund_order(int)', 'EXECUTE');
has_function_privilege
------------------------
t
(1 row)
SELECT refund_order(4);
ERROR: cannot execute UPDATE in a read-only transaction
CONTEXT: SQL function "refund_order" statement 1
BEGIN READ WRITE;
BEGIN
SELECT refund_order(4);
refund_order
------------------
refunded order 4
(1 row)
COMMIT;
COMMIT
SELECT id, status FROM orders WHERE id = 4;
id | status
----+----------
4 | refunded
(1 row)
The agent has no UPDATE grant on orders, but the function has its owner's privileges, and order 4 is now refunded. Only the read-only default stopped the first call. A SECURITY DEFINER function is a door through your GRANTs, and the CREATE FUNCTION page warns that “by default, execute privilege is granted to PUBLIC for newly created functions”. Revoke it on existing functions, and change the default for the ones the owner creates later:
REVOKE EXECUTE ON FUNCTION refund_order(int) FROM PUBLIC;
REVOKE
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
ALTER DEFAULT PRIVILEGES
CREATE FUNCTION order_total(p_id int) RETURNS numeric LANGUAGE sql STABLE
AS $$ SELECT total FROM orders WHERE id = p_id $$;
CREATE FUNCTION
SELECT p.proname, has_function_privilege('agent_ro', p.oid, 'EXECUTE') AS agent_can_execute
FROM pg_proc p WHERE p.pronamespace = 'public'::regnamespace ORDER BY 1;
proname | agent_can_execute
--------------+-------------------
order_total | f
refund_order | f
(2 rows)
Default access privileges
Owner | Schema | Type | Access privileges
-----------+--------+----------+-----------------------
app_owner | | function | app_owner=X/app_owner
(1 row)
BEGIN READ WRITE;
BEGIN
SELECT refund_order(5);
ERROR: permission denied for function refund_order
ROLLBACK;
ROLLBACK
SELECT p.oid::regprocedure, p.proowner::regrole, p.proconfig
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog', 'information_schema');
oid | proowner | proconfig
-----------------------+-----------+----------------------
refund_order(integer) | app_owner | {search_path=public}
(1 row)
ALTER DEFAULT PRIVILEGES only covers objects created later, and only for the role in FOR ROLE (“the current role if unspecified”), so run it for every role that creates functions. The last query lists the SECURITY DEFINER functions outside the system schemas; review each one as you would an API endpoint.
The credential itself
None of this matters if the agent can read a stronger credential. Coding agents read files and environment variables: a DATABASE_URL in .env, a ~/.pgpass, or a PGPASSWORD in the shell, which libpq picks up on its own. If those hold the owner's or the application's credentials, the agent can connect as that role and none of the above applies. Give the agent only its own role's credential, keep the others out of its working directory and environment, and use a separate role per environment so that a staging credential cannot reach production.
Checklist
Tested on PostgreSQL 17.11 against a fresh database with the same tables, run as a superuser. Change the names and the password.
-- 1. One role per agent, with no attribute it does not need
CREATE ROLE agent_ro LOGIN PASSWORD 'change-me'
NOSUPERUSER NOCREATEDB NOCREATEROLE NOBYPASSRLS NOREPLICATION
CONNECTION LIMIT 5;
-- 2. Database: connect, nothing else (PUBLIC gets CONNECT and TEMPORARY by default)
GRANT CONNECT ON DATABASE shop TO agent_ro;
REVOKE TEMPORARY ON DATABASE shop FROM PUBLIC;
-- 3. Schema: usage only. The REVOKE matters on databases upgraded from 14 or earlier
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
GRANT USAGE ON SCHEMA public TO agent_ro;
-- 4. Tables and columns the task needs, nothing else
GRANT SELECT ON orders TO agent_ro;
GRANT SELECT (id, tenant_id, country) ON customers TO agent_ro;
-- 5. Rows, where the task is scoped to a tenant
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY agent_tenant_42 ON orders FOR SELECT TO agent_ro
USING (tenant_id = 42);
-- 6. Functions: no EXECUTE for PUBLIC, now and for future functions
REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC;
ALTER DEFAULT PRIVILEGES FOR ROLE app_owner
REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC;
-- 7. Defaults: seat belts, not walls. The role can change them itself; step 8 shows it
ALTER ROLE agent_ro SET statement_timeout = '5s';
ALTER ROLE agent_ro SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE agent_ro SET default_transaction_read_only = on;
-- 8. Verify, as an admin: what can the role actually do?
SELECT table_name, column_name, privilege_type
FROM information_schema.column_privileges
WHERE grantee = 'agent_ro' ORDER BY 1, 2;
SELECT has_database_privilege('agent_ro', 'shop', 'CREATE') AS db_create,
has_database_privilege('agent_ro', 'shop', 'TEMP') AS db_temp,
has_schema_privilege('agent_ro', 'public', 'CREATE') AS schema_create;
SELECT m.roleid::regrole AS member_of
FROM pg_auth_members m WHERE m.member = 'agent_ro'::regrole;
SELECT p.oid::regprocedure AS function, p.prosecdef AS security_definer
FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
AND has_function_privilege('agent_ro', p.oid, 'EXECUTE');
SELECT r.rolname, d.datname, s.setconfig
FROM pg_db_role_setting s
JOIN pg_roles r ON r.oid = s.setrole
LEFT JOIN pg_database d ON d.oid = s.setdatabase
WHERE r.rolname = 'agent_ro';
On that run, the step 8 queries returned what you would expect: SELECT on the eight listed columns, no CREATE or TEMP on the database, no CREATE on the schema, no membership in other roles, and no function the agent can execute. The ninth item is not SQL: the agent only ever sees its own role's credential.
The PostgreSQL 17 documentation pages this post relies on, with the quotes checked on November 9, 2026:
- Role attributes (21.2)
- Privileges (5.8)
- Predefined roles (21.5)
- Client connection defaults (19.11): default_transaction_read_only, statement_timeout, transaction_timeout
- ALTER ROLE
- ALTER DEFAULT PRIVILEGES
- Schemas (5.10)
- Row security policies (5.9)
- information_schema.tables
- The cumulative statistics system (27.2)
- CREATE FUNCTION
What we add on top
Roles decide what an agent's identity may touch. They do not look at the statement: a role allowed to DELETE from orders can delete one row or all of them. Vericto, which we build, adds database guardrails for AI agents in that gap: it checks each statement before it runs, with deterministic rules, so the same statement gets the same verdict. It sits on top of the role above, not in place of it.
Appendix: how this was run
PostgreSQL 17.11 from the postgres:17 image (linux/arm64; digest below) on Docker 29.2.1, with psql 17.11 in a second container; both on a private Docker network with no published ports. The sessions ran in this order: setup, the role, what it stops, the defaults, the connection limit, columns, RLS, catalogs, functions, the wrapper and the TEMPORARY revoke. The watchdog role and the checklist ran on a second fresh database. psql's stdout and stderr were merged inside the container, unbuffered, so each error appears under its statement. Some blocks are excerpts of longer sessions; no output was edited. The containers and the network were removed after each run.
postgres@sha256:b8faeff7712663c2773a190181e1246e37e364b887611c04658c28b44e2408fe
The schema and data:
CREATE ROLE app_owner LOGIN PASSWORD 'owner-demo-pw';
CREATE ROLE
CREATE DATABASE shop OWNER app_owner;
CREATE DATABASE
CREATE TABLE customers (
id int PRIMARY KEY, tenant_id int NOT NULL,
name text, email text, phone text, country text);
CREATE TABLE
CREATE TABLE orders (
id int PRIMARY KEY, tenant_id int NOT NULL,
customer_id int REFERENCES customers, total numeric(10,2),
status text NOT NULL DEFAULT 'paid');
CREATE TABLE
CREATE TABLE api_keys (id int PRIMARY KEY, service text, secret text);
CREATE TABLE
INSERT INTO customers VALUES
(1, 42, 'Ada Lovelace', 'ada@example.com', '+44 20 7946 0001', 'GB'),
(2, 42, 'Alan Turing', 'alan@example.com', '+44 20 7946 0002', 'GB'),
(3, 7, 'Grace Hopper', 'grace@example.com', '+1 202 555 0103', 'US'),
(4, 7, 'Edsger Dijkstra','edsger@example.com','+31 20 555 0104', 'NL');
INSERT 0 4
INSERT INTO orders (id, tenant_id, customer_id, total) VALUES
(1, 42, 1, 120.00), (2, 42, 2, 35.50), (3, 42, 1, 9.99),
(4, 7, 3, 410.00), (5, 7, 4, 18.25), (6, 7, 3, 72.00);
INSERT 0 6
INSERT INTO api_keys VALUES (1, 'payments', 'sk_live_demo_not_real'), (2, 'email', 'key_demo_not_real');
INSERT 0 2
CREATE FUNCTION refund_order(p_id int) RETURNS text
LANGUAGE sql SECURITY DEFINER SET search_path = public AS
$$ UPDATE orders SET status = 'refunded' WHERE id = p_id RETURNING 'refunded order ' || id $$;