# Changelog ## 0.4.3 -- 2026-11-05 **Security release.** 0.4.2 was never released: its fixes ship here, together with what a review of 0.4.2 found through and around them. Upgrade with `ALTER EXTENSION pg_agent_gate UPDATE` after installing the new library; no object of the schema changes. The 0.4.2 findings have their cases in `tests/audit5.sh` (44 checks: against 0.4.1, 34 fail -- the other 10 are controls and effects that hold either way), those of the review of 0.4.2 in `tests/audit6.sh` (44 checks: against 0.4.2 on PostgreSQL 18.6, 31 fail -- the other 13 are controls; on 19beta2, 29, as the citext case below does not reproduce there), and those of a second review, of 0.4.3 itself before its release, in `tests/audit7.sh` (58 checks: against that first build of 0.4.3, 38 fail on both 18.6 and 19beta2 -- the other 20 are controls and effects that hold either way), and those of a third review in `tests/audit8.sh` (43 checks: against the second build of 0.4.3, 26 fail on both 18.6 and 19beta2 -- the other 17 are controls and effects that hold either way), and those of a fourth review in `tests/audit9.sh` (44 checks: against the third build of 0.4.3, 23 fail on 18.6; on 19beta2, whose build has no pgcrypto, 42 checks and 21 fail -- the others are controls and effects that hold either way). Against 0.4.3, the suites of `make verify`: 580 passed, 0 failed on 18.6 and 578 passed, 0 failed on 19beta2, each suite in a fresh cluster (the pgrx unit tests could not run in this environment). 0.4.3 had not been released when the second and third reviews ran, so what they found is closed in 0.4.3 and not in a 0.4.4: no object of the schema changes, and there is no installation of a 0.4.3 that behaves the other way for a version number to tell apart. **The principle: code is judged where it starts.** Every finding of both reviews was code the statement's own tree does not show -- SQL given to a function as text, another function's body, a `CHECK` or `DEFAULT`, a domain's `CHECK`, an aggregate's transition function, a range type's `subtype_diff` called from the type cache -- running with no judgment, judged against the wrong role, or changing rows no one counted. Chasing the next place is a list that is never complete. So in an agent's session, every function is judged at the moment it starts, by one rule (`floor()` in `hooks.rs`), on both paths by which PostgreSQL starts a function: the executor's (`OAT_FUNCTION_EXECUTE`, built-ins included, wherever the expression came from) and the function manager's (`needs_fmgr_hook` + `fmgr_hook`: C code calling through a cached function -- the type cache, RI checks, triggers, index support). Whose function it is, is judged against the agent's own role. And what such code changes is counted: a DDL's effect on rows is accounted like a write's. What the rule stops, in an agent's session (since the second review: the whole session, not only while its SQL runs), wherever reached: * **`set_config()`**, which moves the parameter a row-level policy reads (0.4.2). * **A function that runs SQL it receives as text.** `query_to_xml()`, `query_to_xmlschema()`, `query_to_xml_and_xmlschema()`, `cursor_to_xml()`, `cursor_to_xmlschema()`, the `table_`, `schema_` and `database_to_xml*()` functions, `ts_stat()` and `ts_rewrite(tsquery, text)`; the contrib functions `crosstab()` and `connectby()` (tablefunc), which are `STABLE` and passed, `xpath_table()` (xml2), and dblink's. That query ran where none of the gate's checks applied: an ordinary agent called `set_config()` inside it and read another tenant's rows in the same statement, or `nextval()` from a read. A call to any of them is refused (`no_hidden_sql`), and stopped wherever reached. The list is derived from the PostgreSQL source -- every SQL-callable function that hands text it did not write to SPI -- and matched by C entry point, not by name (`verify.rs`, `SQL_FROM_TEXT`) (0.4.2). * **A user function the agent can alter** -- owned by a role the agent's role is a member of, with or without `INHERIT` (by inherited privileges until the second review, below). Its `STABLE` or `IMMUTABLE` is the adversary's own claim: an `allow_ddl` agent marked a plpgsql function that calls `set_config()` `IMMUTABLE` and the planner ran it (0.4.2). In 0.4.3 the same holds on the function manager's path: the agent's own plpgsql function, set as a range type's `subtype_diff`, ran from the type cache during a GiST build -- at propose it ran `SET app.tenant_id` and raised the other tenant's row into the `executes` detail; at commit it deleted 20 rows under a `max_rows` of 2 and advanced a sequence 13,704 times, recorded as a DDL of 0 rows. * **A built-in that changes state outside the rows the gate counts.** A session advisory lock, a large object, `setval()`, the WAL: in any statement -- since the second review, every `VOLATILE` built-in that is not on a reviewed list of those that only read (below). `nextval()`, transaction advisory locks and `pg_notify()`: in a read (an `INSERT`'s default numbers its rows with `nextval()`, and a DBA's trigger may notify on a write that is kept). Up to 0.4.2 this was decided on the statement's tree only, and for reads only: a domain's `CHECK` ran `nextval()` from a read -- also for an ordinary agent through `pg_input_is_valid(text, 'domain')` --, took a session advisory lock that outlived the read, and created large objects from a write; `lo_from_bytea()` in a write's subquery created 50 of them on a 1-row `UPDATE`. Now a write or a DDL that calls one is refused (`changes_only_rows`), and the floor stops it wherever it sits. * **A user function whose `SET` clause moves the context a policy reads**: a placeholder -- a custom parameter no loaded module defines (`app.tenant_id`) --, `agent_gate.*`, `role`, `session_authorization`, `row_security`, `session_replication_role`. A DBA's `STABLE` function with `SET app.tenant_id = '2'` read the other tenant's rows for any agent (0.4.2). And around it: * **An aggregate is the functions it runs.** `pg_proc` marks every aggregate `IMMUTABLE`, so a DBA's aggregate over a volatile transition function passed `no_opaque_function` and advanced a sequence 25 times from a read. Its transition, final, combine, serial and moving-aggregate functions are now judged as if the statement called them. * **A DDL's effect on rows is counted.** An `allow_ddl` agent's `ALTER TABLE ... ADD COLUMN ... DEFAULT` or `ADD CONSTRAINT ... CHECK` over a DBA's volatile function deleted 20 rows of another table under a `max_rows` of 2, recorded as 0 rows and charged to no budget. What a DDL changes in tables that existed before it -- from a default, a constraint, an index expression or a function it ran -- is now counted like a write: against `max_rows`, the write budget, and the record's `rows_moved`. Rows of the relations the DDL itself creates are its own. * **The DDL trial at propose is execution.** It ran outside the executing window, so a utility statement a function reached (that `SET app.tenant_id`) was not refused as nested; and so did the `EXPLAIN`, where the planner runs what it folds. Both now run as the commit runs: in the window, with only the statement itself allowed as a utility, under the floor. Proposing changes nothing: a DDL's trial is judged as a read, so a `DEFAULT nextval()` over a table's rows -- which advanced a DBA's sequence by proposing alone -- is refused at propose; an identity column numbers the rows. Since the agent's SQL can no longer move its context or run as another role, what it raises is what its own rights and tenant can read, and the check's detail can be returned. * **An `allow_ddl` agent's `CREATE TABLE AS` or `SELECT INTO` over a CTE that writes was not bounded** (0.4.2). The query of a `CREATE TABLE AS`, `SELECT INTO` or `CREATE MATERIALIZED VIEW` is checked like a read before anything runs: a writing CTE (`no_writing_cte`), `set_config()` (`keeps_its_context`), a function from the list above or an opaque function is refused. Regressions of the unreleased 0.4.2, fixed before release: * **Writes checked by a foreign key over an extension's type were refused.** An RI check runs as the referenced table's owner, and 0.4.2 asked whether THAT role could alter the function: a superuser owner can alter every function, so `citext`'s equality operator counted as the agent's and every write to a table with a `citext` foreign key was aborted (PostgreSQL 18; 19beta2 checks the key another way). The question is now asked of the agent's role -- the session's login role, which an agent session can neither `SET ROLE` nor `SET SESSION AUTHORIZATION` away from. * **A DBA's function with a harmless `SET` clause was refused**, and with it every read of a table whose policy function pinned `plan_cache_mode`: 0.4.2 allowed only what an agent may set itself. `work_mem`, `jit`, `plan_cache_mode`, `search_path` -- how a body runs -- pass; what carries the context a policy reads does not. * **The floor read the whole function body each time a function started** -- ~110x on a read whose plpgsql helper re-ran a query per row (a 960 kB body, 5,000 rows: 3,741-4,001 ms on 18.6 and 4,522-4,958 ms on 19beta2, against 45-55 ms and 39-51 ms now; the same query by a superuser directly, 20-40 ms). A built-in's verdict is read once per backend, a user function's from the syscache without its body, and only a C function's entry point is read at all. **A second review, of 0.4.3 before its release** (`tests/audit7.sh`). It found code that still ran outside the rule, by being outside the window the rule watched or outside what it asked, and the regressions the first form of 0.4.3 brought. Each is closed by a principle, not by naming the case: * **The agent's context is an invariant the gate owns, and the floor stands for the whole session.** A verb's argument bound over the extended protocol as a parameter of an `allow_ddl` agent's domain (`$1::ddl.dtext`) ran the domain's `CHECK` when it was bound, before any verb started: `set_config('app.tenant_id', '2', false)` ran there, the read returned the other tenant's rows, and the switch outlived the call; `setval()` too. Any agent the domain's owner granted `USAGE` could do it. Now the floor judges every function that starts in an agent's session, except while the gate's own SQL or the DBA's assertions run (`state::floor_applies`); a verb's argument must be of a built-in type; and the session's context -- `role`, `session_authorization`, `row_security`, `session_replication_role`, `search_path` and every placeholder parameter -- is remembered when the session starts and checked when each verb starts, after the agent's SQL ran, and before a row is returned or a change kept (`context.rs`). A move refuses, inside the statement's subtransaction, which takes the move back. It found a real one at once: pg_living_assertions 0.5.5's `run()` leaves the assertion's `search_path` in the session (its own 0.5.6 fixed that), so under 0.5.5 a write with a bound assertion is now refused (`tests/pg_temp.sh`). * **No name in an agent's session resolves to something the agent put first.** An `allow_ddl` agent's temporary table answered for `tenant_of`, named without a schema in a DBA's policy helper: PostgreSQL searches `pg_temp` first for relations and types whatever `search_path` says, and the agent read the other tenant's rows without any `SET`. An agent creates no temporary object now: a DDL that leaves one in the session's temporary schema is refused. * **An agent's DDL changes only what the agent owns.** `DROP TYPE ... CASCADE` over a DBA's typed table, `DROP SCHEMA ... CASCADE` and a plain `DROP TABLE` of a DBA's table in the agent's schema took 70 rows of a DBA's tables; `DROP DOMAIN ... CASCADE` dropped a DBA's column and `ALTER TYPE ... RENAME VALUE` changed what 20 of its rows say -- each recorded as a DDL of 0 rows. Every object PostgreSQL drops for an agent's DDL is now judged as it is dropped (`OAT_DROP`): one the agent could not drop by name -- whose owner is not a role whose privileges it has; for a constraint, default, trigger, rule or policy, its table's or domain's -- stops the DDL. A type the DDL alters is judged by what is made of it (`pg_depend`): a column, domain or function of another role stops it (`reach.rs`). * **Which built-ins change state is a classification, not a list of what is forbidden.** `pg_terminate_backend()` and `pg_cancel_backend()` were on no list and passed as a read that "changed nothing" while it ended another session. A `VOLATILE` built-in now changes state unless it is on a reviewed list of those that only read, wait or draw a random number; the list is derived from `pg_proc` on 18.6 and 19beta2 (`verify.rs`, `state_change`), so a function a later version adds -- 19 brings `pg_enable_data_checksums()` -- is refused until reviewed. A `LANGUAGE internal` function is judged as the built-in it names. * **Authorship is membership.** An agent that belongs to a role without `INHERIT` handed its functions to it (`ALTER FUNCTION ... OWNER TO`, which needs only `SET`), and its `STABLE` wrapper over a `DELETE` was judged as a DBA's function. Whose function it is, is now asked with `is_member_of_role` (`pg_has_role(..., 'MEMBER')`), which ignores `INHERIT`. And the regressions of the first form of 0.4.3, each failing closed and each breaking legitimate use: * **A DBA's function pinning a module's parameter was refused** -- `pg_trgm.similarity_threshold`, `hnsw.ef_search`, `plpgsql.print_strict_params` -- and with it every agent read of a table whose policy function pinned one: every dotted name counted as context. Context is a placeholder; a parameter a loaded module defines, or one named after an installed extension, passes. * **Every DDL of every `allow_ddl` agent was refused under a DBA's DDL-audit event trigger** whose log numbers its rows (`bigserial`) or that calls `pg_notify()`: the trial at propose was judged as a read. An event trigger is a superuser's; its code is judged as the DDL it runs for, and what it writes is still counted. * **Every `MERGE` aborted** with "unrecognized SPI status code: 19": the gate adds `RETURNING` to show before and after, and pgrx 0.19 does not know `SPI_OK_MERGE_RETURNING`. A write now goes through SPI directly. * **`lo_get()` was refused in reads and writes**, and intagg's `int_array_aggregate()` in reads (its support functions are built-ins under another name, marked `VOLATILE`). The classification above covers both. * **`ADD COLUMN ... serial` was refused while `GENERATED AS IDENTITY` passed.** Both number a new column from a new sequence that the trial's rollback takes with it; now both pass. A sequence that existed before the DDL still may not be drawn from at propose. The catalog queries of `no_opaque_function` and `read_changes_nothing` are read by index from the called functions: their generic plans scanned all of `pg_proc`, a cost every read that called a function paid. **Behavior changes to expect.** An agent can no longer call the XML and text-search functions above or tablefunc's in a proposal, nor its own functions -- also not as a range type's support function, a trigger or an aggregate's transition function; an `allow_ddl` agent's functional index or `CHECK` over a function it owns is refused when it runs. A write or DDL that creates large objects, takes a session advisory lock or calls `setval()` is refused; a DDL whose default or constraint changes more rows of existing tables than `max_rows` is aborted and counts against the budget; proposing a DDL that would advance a sequence over a table's rows is refused. In an agent's session, its own SQL functions are not inlined by the planner (PostgreSQL does not inline a function the function manager's hook asks to see). From the second review: an agent creates no temporary object; a verb's argument of a type that is not built in is refused; an `allow_ddl` agent's DDL that would drop or alter what another role owns is refused; a `VOLATILE` built-in outside the reviewed list is refused in any statement; a write with an assertion bound through pg_living_assertions 0.5.5 is refused (use 0.5.6 or later). **README.** The local timings of "What it costs" are gone: `make bench` measures on your machine against the thresholds and the README publishes no number of its own. The suite table and the count are those of 0.4.3. `propose` is documented as running an `allow_ddl` agent's DDL in a rolled-back subtransaction under the floor, and "What it does not cover" says what the floor leaves: a DBA's function marked `STABLE` or `IMMUTABLE` that writes, an `allow_ddl` agent's rewrite of its own tables, code an agent plants that other roles later run, and why `REVOKE EXECUTE ... FROM PUBLIC` is needed; since the second review also an agent's schema searched before a DBA's, the reviewed list of built-ins, what the context is, a module's parameter before it loads, and what a DDL's trial can still draw from a sequence. **A third review, of 0.4.3 before its release** (`tests/audit8.sh`). No finding crossed a tenant. Three were of medium weight, closed by a principle each: * **Code the agent planted is the agent's code.** The floor let a `VOLATILE` function of another role's through whenever a body reached it, as "the call the DBA placed there" -- and an `allow_ddl` agent placed one itself, in its own domain's `CHECK`: pg_trgm's `set_limit()` moved the session's similarity threshold from a write, and pg_prewarm's `autoprewarm_dump_now()` wrote `autoprewarm.blocks` on the server from a read, both refused when called directly. Now a `VOLATILE` function of another role's that an object the agent can alter calls -- a domain's or a table's `CHECK`, a default, a generated column, an index, a policy, a trigger, a view, an operator, an aggregate, a range type, an SQL-body function: whatever records a dependency on it in `pg_depend` -- is judged as the statement's own call would be (`reach::called_by_agent_object`, `hooks::planted_verdict`). Native code (C, an untrusted language) is like an unreviewed `VOLATILE` built-in and is stopped in every statement; a body PostgreSQL's executor runs (SQL, PL/pgSQL) is like `nextval()`: stopped in a read and at propose, and run in a write, where each statement in it meets the floor and its rows are counted. "The call the DBA placed" now means one only the DBA's objects make. * **An agent's DDL changes only what the agent owns -- a function's meaning too.** `CREATE OR REPLACE FUNCTION` of an agent's `IMMUTABLE` function, after a DBA built on it, left the DBA's unique expression index unable to find its rows, its generated column computing a value no row stored, and the DBA's own `INSERT`s failing its `CHECK`; `reach.rs` judged only a type's alters. A function the DDL redefines (`CREATE OR REPLACE`, `ALTER FUNCTION` but a `RENAME` or `SET SCHEMA`) is now judged by what is built on it, directly or through the agent's SQL-body functions: an index, constraint, default, view, policy, trigger, operator, function, statistics object or operator family of another role stops it. The same judgment now covers what a DDL creates and what it takes out of a tree, two of the low findings: an index (or trigger) on an agent's partitioned table is made on every partition, and one made on a DBA's partition broke the DBA's inserts; `DETACH PARTITION` took a DBA's table out of the agent's. Each created relation, trigger, constraint, policy or rule must be the agent's (a foreign key's internal triggers on the table it references are how a `REFERENCES` grant works, and pass), and a table taken out of its parent must be. * **A false refusal of every Java client passing a `String[]`.** pgjdbc binds one as `varchar[]`, and the parser coerces it to the verbs' `text[]` with an `ArrayCoerceExpr` whose per-element step is a relabel: no code runs, but the argument check had no arm for it. It is accepted now as the parser's implicit coercion to a built-in array type whose element step is a relabel or an implicit cast through a `pg_catalog` function; an explicit or I/O-conversion element cast is still refused. And one low finding, fixed because it was cheap: a verb in a `READ ONLY` transaction (pgjdbc's `setReadOnly(true)`, Spring's `readOnly = true`, a hot standby) failed on the gate's own `INSERT` into its record with PostgreSQL's error; the gate now refuses it first, saying why. The last low finding is a limit, said in the README: `TimeZone` is on the allowlist, and a DBA's policy that compares with `current_date` or a `timestamp` cast reads the session's zone, so an agent moving it saw a row embargoed until tomorrow (no other tenant's). **A fourth review, of 0.4.3 before its release** (`tests/audit9.sh`). It found no isolation or accounting bypass. Two findings were false refusals of ordinary work, closed by a principle each: * **The owner's DEFAULT is the owner's code.** A volatile function in the column `DEFAULT` of a DBA's table -- pgcrypto's `encode(gen_random_bytes(12), 'hex')`, a PL/pgSQL order-number generator -- refused every agent `INSERT` that relied on it, as `no_opaque_function`. The rewriter writes the catalog's `DEFAULT` into the statement, and the walker took it for the agent's own call; the README said it did not follow a column `DEFAULT`. The analysis is now split around the rewriter (`parse_analyze_fixedparams`, then `pg_rewrite_query`), and what the agent wrote is read in between. An entry the rewriter added -- an `INSERT`'s omitted column or `DEFAULT` (a multi-row `VALUES` too, through a view too), an `UPDATE`'s `SET c = DEFAULT`, a `MERGE`'s `INSERT` -- is set apart from the walk when it is exactly the column's `DEFAULT` in the catalog (`build_column_default`, the rewriter's own source) and the table is not the agent's to alter (`verify::catalog_defaults`). It is judged where a `CHECK` or a trigger is, by the floor when it runs. A `SECURITY DEFINER` `DEFAULT`, one that calls `set_config()`, and one over a function the agent can alter are still stopped there, before they run. The rows such a `DEFAULT` writes are counted by the backstop. A call the agent writes itself, even the same expression, and a `DEFAULT` on the agent's own table stay judged as the agent's call. * **A row moved to another partition is one row.** An `UPDATE` that changes a partition key deletes the row in one partition and inserts it in another, and the backstop, which reads the transaction's tuple counters, counted both: 6 rows moved from October to November were aborted under a `max_rows` of 10, the refusal blaming "a trigger, cascade, rule or function" the table did not have. How many rows moved is not counted anywhere, so it is bounded from what is. Each row the `UPDATE` named was either updated in place or moved, so at most `processed - updates` moved, and at most as many as the inserts and deletes in the leaves of the target's partition tree. The smallest is subtracted, never more than really moved, so a trigger's rows are still counted (`verbs::relocated_rows`). Only for an `UPDATE` of a partitioned table whose leaves are all heap tables: a foreign partition's update is not in the counters. The refusal now says how many rows the statement named and how many more something else changed. One low finding, fixed because it was cheap: `DISCARD ALL` -- PgBouncer's `server_reset_query` in session mode and Npgsql's pool reset -- and `DEALLOCATE` were refused in an agent's session, so a pooled agent connection failed every reuse. They reset only the session's own state (its parameters to what its role was given, as `RESET ALL`, which passed; its prepared statements, plans, temporary tables, `LISTEN`s and advisory locks), and pass. The other low finding is a limit, said in the README: `lastval()` in an agent's session names the gate's own record sequence, drawn after the agent's statement, and is refused for lack of privilege on it; `RETURNING` and `currval()` give the agent's value. A `MERGE` that moves rows between partitions is still counted twice. ## 0.4.1 -- 2026-10-31 An agent role has no members. The gate knows an agent session by `agent_gate.agent`, which `register_agent` sets on the role and PostgreSQL applies when that role connects. A session of another role that `SET ROLE`s into the agent's -- or calls `set_config('role', ...)` -- has the agent's privileges without that setting, so it is not behind the gate. Measured on 0.4.0: a broker role granted the agent role ran `SET ROLE seat; DELETE FROM t` and deleted 20 rows past a `max_rows` of 2, with nothing proposed and nothing recorded. Only a member of a role (or a superuser, outside the gate by design) can switch into it, so that is the rule now (`tests/membership.sh`, 16 checks): * **`register_agent` refuses a role that has members**, naming them. * **No statement may add a member to a registered agent role**, whatever its form -- `GRANT`, `CREATE ROLE ... IN ROLE`, `ALTER GROUP ... ADD USER`: the library reads the memberships before and after every statement that can change one, and refuses the statement if it added one. Comparing, not only checking, keeps role administration working on an installation that is upgraded with memberships already in place; it only cannot add new ones. * **Upgrading.** If an agent role already has members, `ALTER EXTENSION pg_agent_gate UPDATE` warns with each `agent <- member` pair; revoke them. Each agent -- or each seat of a broker -- logs in as its own role. * Role administration that adds no member to an agent is unchanged: a `CREATEROLE` role creates roles, anyone changes their own password (both in the suite). ## 0.4.0 -- 2026-10-31 A write budget per agent, **on by default** -- a change of behavior, hence the minor version. `max_rows` bounds one proposal, and an agent that loops split a write it was refused into slices that each fit: measured with a plain Qwen3-8B (`agent-gate-demo/compare`), a model told "may touch at most 5" emptied a 6-row table in two legal commits, 5 + 1. Each statement was decided by the database; their sum was not. Now it is (`tests/budget.sh`, 21 checks). * **Every agent has a budget of rows per window.** `agents.max_rows_per_window` and `window_seconds`; unset, the budget is the agent's `max_rows` per hour. A commit whose rows, added to what the agent kept in the window, would pass it is aborted with "it would exceed this agent's write budget for the current window". The message names no number: the old one told the model exactly how large to cut its slices. `dry_run` reports the same verdict. * **The max_rows refusal no longer names the limit.** "it touched 8 rows and this agent may touch at most 5" became "it touched 8 rows, more than this agent may change in one statement" (and the same for the amplification backstop): the number was what the model read to size its slices. An agent that wants its limit still gets it from `discover()`, on purpose. * **It counts what really changed.** `executions.rows_moved` records the rows a kept write moved, cascades, triggers and rules included (what the backstop measures), and the budget sums that. * **No race.** On commit the agent's row is locked (`FOR NO KEY UPDATE`) before the count, so two sessions of one agent cannot each spend the same remainder: four parallel commits of 3 rows against a budget of 3 keep exactly one. The lock is taken before the guarded subtransaction, so an abort does not release it. * **`register_agent(..., p_max_rows_per_window integer DEFAULT NULL, p_window_seconds integer DEFAULT 3600)`**, and two administration calls, revoked from `PUBLIC` like `register_agent`: `agent_gate.set_budget(agent, rows, window_seconds)` and `agent_gate.reset_budget(agent)` -- the whole budget back from now, after a person has looked at what the agent did. The record is never rewritten; the agent's row says since when the budget counts. * **Upgrading.** Every existing agent gets `max_rows_per_window = max_rows` per hour, and the upgrade raises a NOTICE that lists them. An agent that legitimately writes more than its `max_rows` in an hour needs `set_budget` after `ALTER EXTENSION pg_agent_gate UPDATE`. * **Suites.** `tests/budget.sh` is new; `approvals.sh` (0.3.0) was not in `make verify` and now is. `flushes.sh` and the fuzzer's `max_rows` oracle write over and over with one agent to measure something else, so they register it with a budget it cannot reach. ## 0.3.0 -- 2026-10-27 Human-in-the-middle approvals. An agent registered with `requires_approval` may propose and dry-run but cannot commit its own proposal until an approver records consent; the approval only lifts that block, and every guard still runs at commit. The ownership rule does not change -- the agent still commits its own proposal -- so `execute()` is untouched (`tests/approvals.sh`). * **`register_agent(..., p_requires_approval boolean DEFAULT false)`.** A sixth, defaulted parameter. When true, the agent's `commit` and `propose_and_commit` are refused with "this agent requires approval" until an approval exists for that proposal; `max_rows`, the bound assertions and the commit-time backstop are then checked exactly as before once it may proceed -- an approval is consent to commit, not a bypass of the guards. * **`agent_gate.approve(proposal)` and the `agent_gate_approver` group.** A call for an approver -- a member of the predefined `agent_gate_approver` role, never the proposal's own agent -- that records consent for one proposal in `agent_gate_internal.approvals` (one row per proposal). It runs nothing. `approve` is revoked from `PUBLIC` and granted only to the group; an agent is behind the gate and cannot call it, so no self-approval is possible. * **The record names who approved.** `agent_gate_internal.executions.approved_by` carries the approver on a kept commit that needed approval, and the `approvals` table survives `pg_dump` like the rest of the record. ## 0.2.15 -- 2026-10-26 A security review of the library before wider use. No SQL object changes -- the fixes are in the loaded library, so the version bumps to match (`sql/pg_agent_gate--0.2.14--0.2.15.sql` changes nothing in the catalog). Each case below is red on 0.2.14 and green here (`tests/hostile.sh`). * **A cast that runs a function in a verb's own argument is refused.** Before, `simple_argument` accepted an I/O conversion cast (`CoerceViaIO`) and an explicit cast (`FuncExpr`) around a verb's argument. Either runs a function of the agent's choosing while the executor evaluates the argument -- before `verify()` raises the proposal flag, so the SECURITY DEFINER guard (0.2.7) did not see it, and the gate's promise that nothing the session types executes except the verbs was false. Now only the implicit cast the parser inserts to coerce a constant to the verb's parameter type, through a built-in function (one in `pg_catalog`), passes; every other cast around a verb's argument is refused at parse time. Measured: an owner's cast calling a SECURITY DEFINER writer ran and wrote to a table the agent cannot touch; now it never runs. * **A lone `;` no longer crashes the backend.** `propose(';')` parses to one statement whose node is null; reading its type dereferenced a null pointer, and in preloaded code that takes the whole cluster down. It is now reported as zero statements and refused. * **`analyze_tree` fails closed** if the verified statement does not re-parse to one non-empty statement, instead of indexing an empty list. * The threat model now says that a cast around a verb's argument is refused (it had said such casts were allowed), and that `allow_ddl` is schema power that `max_rows` does not bound. ## 0.2.14 -- 2026-10-09 The rest of the third external audit (of 0.2.8), measured on 0.2.13 before it was changed: every tooth in `tests/audit4.sh` (in `make verify`) red there with its control green. * **GATE-09: what dry_run showed is what commit keeps.** The session settings that decide how a statement reads its literals and writes its values are recorded with the proposal, and `dry_run` and `commit` refuse, naming them, if any changed since. Measured: `dry_run` showed 2026-03-04, the session set `DateStyle = 'ISO, DMY'`, `commit` kept 2026-04-03. * **GATE-11: a read changes nothing.** A `read` calling a built-in that changes state outside its rows (`nextval`, `setval`, advisory locks, large objects, `pg_notify`, `setseed`, WAL and statistics functions) fails the new check `read_changes_nothing`. Measured: a read advanced a sequence by 1,000 and the record said `read`. Allowed in a write. * **GATE-13: a reused agent name starts clean.** A proposal made under an earlier registration of the name answers "this agent has no proposal with that id", and `acts()` shows only the current registration's. Measured: the new owner of a name committed the old owner's proposal. * **GATE-15: bounded input.** `agent_gate.max_proposal_bytes` (1 MB) and `agent_gate.max_intent_bytes` (64 kB), superuser-only. Measured: 2 MB of SQL and 100 kB of intent went into the append-only record. * **GATE-16:** the README now says that the server log can hold an agent's literal SQL. * `make verify`: 310 checks on PostgreSQL 18.6 and 19beta2. ## 0.2.13 -- 2026-10-08 * **Metadata only.** The PGXN description is two sentences now; the longer explanation it carried is in this README, and the per-release history in this changelog. No code changed: the upgrade script 0.2.12 -> 0.2.13 changes no object. ## 0.2.12 -- 2026-10-08 From a third external audit (of 0.2.8), every finding measured again on 0.2.11 before it was changed: each tooth below was red on 0.2.11 with its control green (`tests/audit3.sh`, `tests/isolation.sh`, `tests/session_preload.sh`, in `make verify`). Behavior changes an operator will notice: * **The verbs run only under READ COMMITTED** (GATE-04/05). Under REPEATABLE READ or SERIALIZABLE the gate read the proposal and the record from the transaction's first snapshot: four sessions each committed the same proposal and each kept it. A verb called at another level is now refused, and one kept commit per proposal is also a constraint of the catalog (a unique partial index on `executions(proposal)`). The upgrade refuses, naming them, if an installation already holds a proposal kept twice. * **An `allow_ddl` agent's DDL is an allow-list** (GATE-07): create, alter, rename, drop, comment, grant/revoke, views, indexes, rules and the like. Every other utility -- what the old denylist did not name -- is refused. * **A session loaded through `session_preload_libraries` ends (FATAL) if the client set any `agent_gate.*` parameter** (GATE-01). `PGOPTIONS='-c agent_gate.agent='` reached the library before the role's setting and left the session outside the gate. * **Only the verbs answer the fast-path protocol** in an agent session (GATE-02). `PQfn` skips the parser; any other function reached that way -- `lo_import`, `pg_read_file` with a grant -- is refused by the object-access hook before it runs. * **Two-phase commit is refused** in an agent session (GATE-12): `PREPARE TRANSACTION` would carry the record past the session that wrote it. * **A commit or dry run of a proposal that is not the agent's is not recorded** (GATE-06). It answers "this agent has no proposal with that id", the same as an unknown id, so an agent cannot learn which ids exist, and writes a `LOG` line instead of a row in another agent's record. And without visible change: * **The gate's own SQL runs under `search_path = pg_catalog, pg_temp`** (GATE-03), set in a GUC nest level around every internal statement and undone after it, so the agent's path or a temporary table of the agent cannot answer for a catalog the gate reads. * **`register_agent` refuses roles that can leave the gate by other means** (GATE-10): `REPLICATION`, `BYPASSRLS`, and membership -- direct or inherited -- in a superuser, in such a role, or in `pg_write_all_data`, `pg_execute_server_program` or `pg_write_server_files`. * **The allow-list travels with `pg_dump`** (GATE-14): it is now an extension configuration table. `make verify`: 292 checks on PostgreSQL 18.6 and 19beta2, 0 failed; `make fuzz` clean. ## 0.2.11 -- 2026-10-08 Measured on a real agent: the tree of the author's own system, which runs every one of its acts through the gate. Upgrading it from 0.2.1 to 0.2.10 made each of its executions take a median 5.7 ms inside the gate instead of 1.2 ms. * **The max_rows counter no longer reads `pg_stat_xact_user_tables`.** It read that view before and after every executed statement; the view computes a dozen statistics per relation and joins `pg_namespace`, and on a database of 695 relations it cost ~2 ms to plan and ~2 ms to run -- twice per commit. The counter now sums the three statistics it needs from `pg_class`, over the same relations (TOAST excluded by kind, the gate's own schemas excluded): ~0.5 ms there. `make fuzz`'s max_rows differential oracle: PASS, 1500 generated cases, no commit over max_rows. * **The gate's fixed lookups keep a prepared plan per backend** (the max_rows counter, the amplifier check, the opaque-function check, the estimate's privilege check), parameterized instead of built as text on every call. The plan cache revalidates them when the catalog changes. * The prepared plans run on a fresh snapshot (`prepare_mut`), as the gate's own statements do. A read-only plan runs on the snapshot of the statement that called the verb, which does not see a table or a trigger created earlier in the same transaction: the first cut of this release counted no rows for such a table before the statement and all of them after, and aborted a 1-row write as a 3001-row one (pgrx unit test `a_one_call_write_has_the_guards_of_commit`, red in CI, green after). * **`commit` skips what only the estimate needs** -- the `VERBOSE` plan and the privilege lookup -- since it reports no estimate. Every check still runs. * Measured in a throwaway cluster with the real schema and the last 500 proposals of that agent, propose then commit as it does, three alternated rounds in a quiet lane: a propose+commit pair 0.2.10 3.22 ms -> 0.2.11 2.30 ms (0.2.1: 1.64 ms, with far fewer checks). Every case kept. ## 0.2.10 -- 2026-10-08 From an external audit of 0.2.8 (c87b343), re-measured here before it was changed. * **What a rollback takes from the record, the server log keeps.** The record is written in the caller's transaction and an agent session may control it, so `BEGIN; ...; ROLLBACK` took refused attempts and reads with it: two refused attacks and two reads, and the record stayed at 0/0. A rolled-back savepoint and a session that disconnects without `COMMIT` did the same. Refusing transaction blocks would break every driver that opens one, and the gate cannot keep a row its caller rolls back; it now writes each row an abort takes to the server log, at `LOG`, one line each, the agent's text JSON-escaped so it cannot start a line of its own. A row that commits is not logged. The server log did hold a `LOG` per proposal before, with its statement, but nothing that said the record had lost it. * README: the estimate is also withheld on partial privileges (a partitioned parent without its partitions, column privileges) -- the safe side, now said. * `tests/rollback.sh` (12 checks, in `make verify`): red on 0.2.9 with its controls green -- autocommit keeps its row and logs nothing, the part of a transaction outside a rolled-back savepoint stays and is not logged. ## 0.2.9 -- 2026-10-08 * **Every function of the gate names `pg_temp` last.** They all said `search_path = pg_catalog, agent_gate_internal`, and PostgreSQL searches an unnamed `pg_temp` first for relations, even before an explicitly listed `pg_catalog`. `_unsafe_amplifier` runs in the agent's session and read the catalogs without a schema, and an `allow_ddl` agent may create temporary tables. Measured against 0.2.8 (`tests/pg_temp.sh`): with an empty temporary `pg_constraint`, a delete whose foreign key cascades passed `no_amplification`, was kept, and the cascade deleted -- as the table owner -- a row of a table the agent holds no grant on; with an empty temporary `pg_rewrite`, a delete under a `DO ALSO` rule was kept and the rule wrote with its owner's rights. `_unsafe_amplifier` also names its catalogs by schema now. * **A bound assertion is not run through a pg_living_assertions older than 0.5.5.** Up to 0.5.4 a check applied its recorded path without `pg_temp`, so a temporary table of the agent -- whose session runs the check -- answered for the real one, and an overdraft the assertion forbids was kept. `_run_assertion` now answers `erroring`, which stops the commit, naming the version and the fix. `META.json` requires 0.5.5. * `tests/upgrade.sh` compares every SQL function of both schemas in full (`pg_get_functiondef`: body and `search_path`), not only the signatures of `agent_gate`; its control -- one `ALTER` removed from the upgrade -- is red. It found two functions an older upgrade carried without their comments; 0.2.9 recreates them, so an upgraded database is now defined exactly as a fresh one. * `tests/cluster.sh` can load pg_living_assertions from a checkout (`LIVING_ASSERTIONS_DIR`); the clean machine builds pg_living_assertions v0.5.5. * `make verify`: 253 checks (240 in the suites, 13 unit tests), `pg_temp` among the suites; 17/17 there, red on 0.2.8 with its controls green. ## 0.2.8 -- unreleased From an external audit of 0.2.7 (e600647), each item re-measured here before it was changed. * **`estimated_rows` still told another tenant's frequent value, through two paths (medium).** 0.2.6 withheld it when row-level security was active on a relation of the analyzed tree. The planner inlines a SQL function (`STABLE`, `SECURITY INVOKER`, `RETURNS SETOF`) AFTER that tree is built, so its table was not judged: `select * from shop.orders_by_secret('hot-b')` was estimated at 34 against 1 for an absent value, and two such calls joined at 1156. And a view that isolates tenants with a `WHERE` and no row-level security at all gave 34 too: the agent reads the view, the estimate counts the table behind it. Now the estimate is given only when the agent could read in full every relation the tree AND the plan touch (`EXPLAIN (VERBOSE)` names each scan's schema): `SELECT` on the whole table, and no row-level security hiding rows from it. A name that does not resolve, or a lookup that fails, withholds it. A view over a table the agent may read keeps its estimate (checked, so a `null` cannot mean the gate stopped estimating). * **The agent's SQL no longer runs in parallel workers.** The function-manager hook keys on a counter that lives in the backend; a worker has its own, at zero. The audit marked this unmeasured; measured here, a worker of an agent session was refused anyway -- it inherits `agent_gate.agent`, so the session hooks refused the function body it parsed and any plan fragment that read a table. No leak, but by accident, and it broke a plain parallel `CREATE TABLE AS` of an `allow_ddl` agent. `verify` now sets `max_parallel_workers` (and the per-gather and maintenance limits, so `EXPLAIN` shows the plan that runs) to 0 for the rest of the agent's transaction: the backend runs the whole plan, the hook stops a `SECURITY DEFINER` function there, and the plain `CREATE TABLE AS` of 200000 rows is kept. * **README:** a row-level policy that calls a `SECURITY DEFINER` function (the usual membership helper) refuses every proposal on its table -- it was already so before 0.2.7, now written down as the price of the rule. * **`tests/plan_time.sh`**: eleven more checks. Against 0.2.7, the four estimate checks fail (34, 1156, 34 -- the sublink one reads 1, and is kept for the rule) and so do the two parallel ones; the controls -- the inlined helper does tell hot from absent as the agent, a parallel worker does run that SELECT outside the gate, a readable view keeps its estimate -- pass on both. * **Not changed:** the audit's note that `register_agent` "fails in silence" with a short description. It raises (`CHECK (length(description) >= 10)`); a script that does not stop on errors keeps going with the role unregistered, and `whoami()` says `is_agent: false`. ## 0.2.7 -- unreleased The case 0.2.6 measured and left open, closed as a class and not as the one case. * **A `SECURITY DEFINER` function reached through a body the gate cannot see no longer runs.** `no_opaque_function` refuses a `SECURITY DEFINER` function the statement calls, but its walker sees the statement's tree, not the body of another function, a `CHECK` or domain constraint, a default, a generated column, an expression index or a trigger. Measured on 0.2.6: an `IMMUTABLE`, non-`SECURITY DEFINER` wrapper over one verified and ran it at `propose` (the planner folds the wrapper) and again at `commit`; one over a function that raised returned its owner's secret; and a `CHECK` that reached one through a wrapper kept the write. The gate now installs PostgreSQL's function-manager hook (`fmgr_hook`): every `SECURITY DEFINER` call passes through `fmgr_security_definer`, which calls it before the body runs, and while SQL the agent wrote is running -- verification, the verified statement, its deferred constraints, an allowed DDL -- the gate refuses there with `42501`, naming the function. On 0.2.7 the counter does not move, the secret does not travel, and the `CHECK`'s write is not kept. The gate's own `SECURITY DEFINER` functions never run inside that window, so there is no exemption; there is no allow-list either. Outside an agent's SQL the hook is one atomic load. * **Not covered, and said in the README:** a non-`SECURITY DEFINER` function in those positions (it runs with the agent's rights; what it writes, the commit backstop counts), a referential action (it runs as the table owner by its own mechanism, not as a `SECURITY DEFINER` call) and a view without `security_invoker`. * **`tests/plan_time.sh`** gains eight checks, all red against 0.2.6, and two that must stay green: a wrapper with no `SECURITY DEFINER` under it still verifies and runs, and -- the control of the instrument -- the superuser calling the wrapper moves the counter. Writing them found that the `CHECK` case passed against 0.2.6 for the wrong reason: with `INSERT` alone the commit aborted on `permission denied`, because the gate appends `RETURNING to_jsonb(old/new)` to a write it runs. The agent there now holds `SELECT` too. (That an agent with `INSERT` but no `SELECT` passes `propose` and fails every `commit` is a separate rough edge, not changed here.) * **`tests/fuzz.py`: the nested-utility teeth test their own mechanism again.** Their `CHECK` and trigger functions were `SECURITY DEFINER`, so on 0.2.7 the new hook stopped them before the `TRUNCATE` or `GRANT` was reached, and the four teeth that assert the reason "nested utility" failed -- with the outcome still `aborted` and nothing kept, but a tooth that names a mechanism must exercise it. The functions are now ordinary, and the agent holds the privileges they use, so without the nested-utility refusal the `TRUNCATE` and the `GRANT` would succeed. The `SECURITY DEFINER` shape stays as its own tooth for the hook. ## 0.2.6 -- unreleased Two findings, both measured against 0.2.5 on PostgreSQL 18.6 with the extension built and installed, and both red in the new suite before the fix (`tests/plan_time.sh`). * **A function the gate refuses no longer runs while it is being verified (high).** The `resolves` check was an `EXPLAIN`, and it came before the tree walk that refuses an opaque function. Planning runs functions: it constant-folds an `IMMUTABLE` call with constant arguments and estimates a `STABLE` one by calling it. So `propose('... where secret = vault.peek()')`, with `vault.peek()` `IMMUTABLE SECURITY DEFINER`, ran the function as its owner and only then was refused by `no_opaque_function` -- measured: a non-transactional counter moved once per refused proposal, 5 of 5, `propose_and_commit` included. A function that raised returned its owner's secret verbatim in the `resolves` detail the agent receives (`[P0001] leak: s3cr3t-from-vault`), under a check whose text said "nothing ran". Now every refusal (`no_writing_cte`, `keeps_its_context`, `no_amplification`, `no_opaque_function`) is decided on the analyzed and rewritten tree, which runs nothing, and the planner only sees what passed: the counter stays at 0 and the refusal names the function, not what it would have raised. A name or type that does not resolve still fails `resolves`, now at the analyzer. The order of the checks in the verdict changed accordingly. Privileges are still the first thing a proposal fails on: the analyzer does not check them (the `EXPLAIN` did, at executor start), so the gate now runs the executor's own `ExecCheckPermissions` on every analyzed query. Without it -- measured on the first cut of this fix -- an agent with no grant on a table was told which trigger writing it fires before its `permission denied`. * **`estimated_rows` is withheld under row-level security (medium-low).** The estimate comes from statistics gathered over the whole table, beneath the policy. Measured with an agent of tenant 1 on a table of 100 tenants: a value only tenant 2 has, 5000 times, was estimated at 34 rows; an absent value at 1. The agent cannot run `EXPLAIN` itself, so the gate was the only channel. It is now `null` whenever PostgreSQL's `row_security_active()` is true for a relation the statement touches (a superuser, a `BYPASSRLS` role and an owner without `FORCE` keep it), and `null` if that lookup fails. Without row-level security the estimate is unchanged -- the suite checks that too, or a `null` would only say the gate stopped estimating. * **Still open, and now documented as such:** a non-volatile, non-`SECURITY DEFINER` user function passes `no_opaque_function` and its body is not walked, so a `SECURITY DEFINER` call INSIDE it runs -- at `commit`, as in 0.2.5, and also at `propose` when the planner folds the wrapper. Measured: an `IMMUTABLE` wrapper over `vault.peek()` is accepted and runs it at propose and at commit. It is the class the README already lists ("the body of a view or another function the statement touches"); the cure is not to grant an agent `EXECUTE` on such a wrapper. * **`tests/plan_time.sh` (new suite in `make verify`).** Its counter is a sequence, because a function marked `IMMUTABLE` cannot `INSERT` and a table would be rolled back with the `EXPLAIN`'s subtransaction -- either would read 0 whether the body ran or not. Its controls: the superuser calls the function once and the counter must move; as the agent, the planner must tell the hot value from an absent one; and the table without row-level security must keep its estimate. ## 0.2.5 -- unreleased A seventh verb, so a durable act pays one flush instead of two. * **`propose_and_commit(sql, intent[, params])`: propose + commit in one call (schema change: `pg_agent_gate--0.2.4--0.2.5.sql`).** With `attempt_durability = durable` every record pays its own flush, and an act is two records in two transactions: counted from `pg_stat_io`, exactly 2.00 WAL fsyncs per act, so a durable act costs mostly the disk's flushes. The new verb writes the proposal and its execution in ONE transaction: 1.00 fsync per act. Measured on the same btrfs NVMe in two states the same day (20 clean rounds each, interleaved, PostgreSQL 19beta2 without assertions): with `fdatasync` at 0.88 ms, 2.65 -> 1.68 ms (-36.7% [35.0..38.0]); with it at 3.9 ms under sustained I/O, 10.86 ms [95% CI 9.30..12.56] -- straddling the 10 ms criterion -> 5.06 ms [4.87..5.36], under it with the whole interval (-53.4% [44.3..60.0]). It is `propose` then `commit`, not a shortcut: the same `execute`, so the statement is verified at propose and again at execution, under the same row limit, backstop, assertions and record. What it gives up is the `dry_run` in between, and -- one transaction cuts both ways -- a call cancelled or failing outside the gate's subtransaction leaves neither row, where two calls would have kept the proposal (the server log keeps the verdict). The two-call flow is unchanged. * **`tests/flushes.sh` (new suite in `make verify`)** counts the WAL flushes an act pays instead of inferring them from a timing: fast read 0, any kept change 1, durable two calls 2, durable one call 1 -- reads and kept writes, two calls and one. The durable two-call case is the control of the instrument: blind to this backend's fsyncs, it would read 0 there and fail. * **Every attack in `tests/adversarial.sh` that goes through `propose` now also goes through `propose_and_commit`**, judged the same way, by the world a superuser reads afterwards; plus a session attack (a function as its argument) and four controls. `tests/fuzz.py` sends every generated input through it as well, under the same oracles, and adds its own: a proposal that did not verify never reaches execution. * **`tests/upgrade.sh` now compares the upgraded schema with a fresh install**, function by function (arguments, result, strictness, volatility), and an agent registered before the upgrade uses the new verb. The first version of that comparison passed an upgrade script that forgot the verb: both sides returned the same ERROR (`text || "char"` is ambiguous) and two equal errors compared equal. Caught by its negative control; the check now needs the fresh side to be a real list that names this version's verb, and the control was run both ways. * `make bench` has a fifth row, the durable read act in one call, with the same 10 ms threshold declared before it was measured; the README's table is one clean run of all five (the run before it was flagged by the calibration sentinel and repeated), and the section now shows the durable act on a fast and a slow disk, because that number is mostly the disk's. A cost measured on an `--enable-cassert` build (pgrx's own instance) is not comparable: it inflates every row. ## 0.2.4 -- 2026-10-08 A runtime fix for an uncounted write reached through an unseen function, plus test and docs hardening from the adversarial review of 0.2.3. * **The gate now refuses a nested utility statement (fixes an uncounted write via an unseen function).** `no_opaque_function` walks the proposal's query tree, not a function the catalog attaches (a `CHECK`, `DEFAULT`, generated column, expression index, domain constraint, or view body). Measured: a `CHECK` whose `SECURITY DEFINER` function `TRUNCATE`d a table ran on a plain `INSERT` an agent proposed -- the walker did not see it, and `pg_stat_xact` does not count `TRUNCATE`, so the backstop let it through and the table was emptied, kept. Fix: while the gate runs the one verified statement, any utility a trigger, constraint or function reaches from there -- `TRUNCATE`, `GRANT`, `ALTER`, `DROP` -- is refused, on the gate's own execution flag, NOT on `current_user` (inside a `SECURITY DEFINER` function the agent is already the owner). The window spans the gate's own `SET CONSTRAINTS` too, so a utility fired by a DEFERRED constraint trigger is caught; the gate exempts the one utility it runs on purpose (the verified DDL for an `allow_ddl` agent, and the `SET CONSTRAINTS` itself) with a one-shot allowance, and lets sub-commands through. Measured: the `CHECK`-`TRUNCATE` and a deferred-trigger `TRUNCATE` now abort and the table is intact, a benign `CHECK` and an `allow_ddl` agent's own `CREATE INDEX` stay kept, and the session recovers after the abort. **Behaviour change:** ANY nested utility is now refused, benign ones included -- a trigger that does `NOTIFY`, `SET LOCAL`, `LOCK TABLE`, `CALL` or `CREATE TEMP TABLE` makes the statement abort (`pg_notify()` as a function still works). Regression in `tests/fuzz.py`. The DISCLOSURE half of the class -- a `SECURITY DEFINER` function in a READ returning rows the agent cannot see -- is a read, not a utility, so this does not close it; it still waits on the walker resolving those positions at `propose`. * **An amplification tooth in 0.2.2/0.2.3 passed for the wrong reason.** The fuzzer's trigger tooth asserted only that `insert into shop.child` was refused -- and it was, but at `resolves` (the agent held no INSERT on the table), never reaching `no_amplification`. The check it was meant to exercise did not run; the tooth was vacuous. The v0.2.3 tag shipped with it. The teeth now assert WHICH check refuses, so a refusal by a parse error or a missing privilege no longer counts, and the agent is granted the privilege. Surfaced by the review of the 0.2.3 release. * The discover/propose parity now covers allow-listed tables (non-SECURITY DEFINER: writable and not refused; SECURITY DEFINER: refused by both). Docs: the walker's unreached-function gap is named as a class; "bounded by the backstop" is qualified to count, not tenant or privilege (and `TRUNCATE` reached that way, once uncounted, is now refused outright, per the fix above). ## 0.2.3 -- 2026-10-08 Closes the rest of the amplification class 0.2.2 opened, and makes the result usable. * **An opaque user function amplifies a write the same way a cascade did.** A SELECT may call a user function whose body the gate cannot see: a volatile one may write rows no one counts, a SECURITY DEFINER one runs as its owner, outside the agent's tenant. New propose check `no_opaque_function`: a statement calling a user function that is volatile or SECURITY DEFINER is refused, naming it. Built-ins (pg_catalog) and non-volatile, non-SECURITY DEFINER user functions pass. * **A commit-time backstop, so the limit counts the real effect.** Even a propose that passes is measured: the transaction's tuple operations on user tables over the statement (`pg_stat_xact_user_tables`, both snapshots in the one subtransaction) must not exceed `max_rows`, so a trigger, cascade, rule or function that moves more rows than the statement names aborts the kept set. It counts tuple operations, so it over-counts (the safe side -- rows a trigger writes then rolls back in its own EXCEPTION still count), and it does not see TRUNCATE (blocked as DDL at the top level; a TRUNCATE nested in a function the walker does not see is addressed in 0.2.4). The gate's own bookkeeping is excluded. * **A per-agent allow-list, so a legitimate trigger is not a wall.** `agent_gate.allow_write (agent, relation)` lets an agent write a table that carries an `updated_at` or audit trigger that is NOT `SECURITY DEFINER`: it relaxes `no_amplification` for that one case only, never the limit -- the backstop still counts every amplified row. A cascading foreign key, a rule, or a `SECURITY DEFINER` trigger is NOT allow-listable in 0.2.3 (the recursive closure that makes some cascades safe lands in 0.2.4); `allow_write` records the entry but its result says `still_refused` with the object. `discover` reports `write_refused` on each table the agent may not write and why, through the same function `propose` uses, so the two never disagree. * Regression in `tests/fuzz.py` (the amplification teeth also build a volatile writer and a SECURITY DEFINER function and assert both refused) runs on every push. Schema change: the `allowlist` table and the two functions; the upgrade script creates them and redefines `_agent`. ## 0.2.2 -- 2026-10-07 A cross-tenant breach, the same shape as the 0.2.1 bugs -- the gate counted the top-level statement and the real effect was larger -- but worse, because the extra effect also escaped row-level security. Reported during adversarial review of the gate and confirmed by measuring the real cross-tenant row count from a superuser against a two-tenant database; the case is now a regression in the fuzzer (`tests/fuzz.py`). * **A referential action amplified a write across tenants.** A referential action (ON DELETE/UPDATE CASCADE, SET NULL, SET DEFAULT) runs as the table owner and does NOT force RLS. A tenant-1 agent deleting a tenant-1 parent cascade-deleted a **tenant-2** child row it could never have named -- the gate reported one row and kept it (measured). New check `no_amplification`: a write whose target table has an inbound cascading foreign key is refused at `propose`, naming it. The same check also refuses a target that carries a user trigger or a rule: those amplify the row COUNT past `max_rows` (a normal trigger runs as the invoker, so RLS still applies to it; only a SECURITY DEFINER one would cross a tenant -- rules were not measured, refused conservatively). Inheritance and partition children of the target are included, and a lookup that fails refuses rather than passes (fail closed). * Regression: `tests/fuzz.py` fixes a tenant-2 child of a tenant-1 parent into the schema (so `safe_fp` covers the whole class), an amplification teeth check (cascade and trigger), and a k=1..8 differential oracle for `max_rows` -- all run on every push via `FUZZ_ITERS=0`. Red against 0.2.1. * Not yet covered (0.2.3): a volatile or SECURITY DEFINER function that writes, which is a read at `propose`. No schema changes; the upgrade script only moves the version. * Published as [GHSA-89xf-63fg-xx5h](https://github.com/Manuelreyesbravo/pg_agent_gate/security/advisories/GHSA-89xf-63fg-xx5h) (CVSS 3.1: 9.6, Critical). Upgrade from any version before 0.2.2. ## 0.2.1 -- 2026-10-06 Two holes, found by an LLM proposing through the gate against a two-tenant database while every suite was green. Both now refused at `propose`, failing closed, and both have regression cases that are red against 0.2.0. * **A CTE that writes dodged `max_rows`.** The limit counted the rows of the outer statement only; `with d as (delete ...) select count(*) from d` deleted 12 rows under a limit of 5. New check `no_writing_cte`. * **`set_config()` inside a proposal moved the tenant** a row-level policy reads (`... where set_config('app.tenant_id', '2', true) is not null` read another tenant's rows). Matched by OID anywhere in the analyzed and rewritten tree. New check `keeps_its_context`. * `make verify`, `make clean-machine` (CI: PostgreSQL 18 and 19), `make demo`, `make bench`; README with the threat model and the measured cost. * License changed from the PostgreSQL License to the Apache License 2.0. * No schema changes; the upgrade script only moves the version. ## 0.2.0 -- 2026-09-15 * `TRUNCATE` could empty the append-only record (row triggers do not fire on it): statement-level triggers now refuse it. * Two concurrent `commit`s of the same proposal both ran it: the proposal's row is now locked while a commit decides. * **An agent could move what its row-level policies read**: session parameters are now an allowlist (client formatting and time limits, plus `agent_gate.settable`), and startup parameters (`options=-c ...`) are judged by the same list. * An agent could leave the gate with one mistaken `GRANT`; the record's writers now ask the gate whether the caller is the gate's own code. * `attempt_durability`: the record of an attempt that changed nothing may ride an asynchronous commit (`fast`, default); a kept change is always as durable as the server. Reads no longer assign a transaction id, so they stop paying a flush. * Sessions that are not agents take a path through the hooks that allocates nothing. * `gated-mcp/`: an MCP server that connects as the agent role and can only call the verbs, speaking the 2026-07-28 and 2025-11-25 protocol revisions. * The suites claim their database and role names instead of dropping whatever is there; `tests/adversarial.sh` tries every channel a session can type. ## 0.1.0 -- 2026-09-14 First version: an agent session can only call six verbs (`discover`, `propose`, `dry_run`, `commit`, `acts`, `whoami`); PostgreSQL verifies each proposal with its own parser and planner, runs it with the agent's privileges, bounds it by `max_rows` and bound assertions, and records everything append-only.