Contents
Changelog
Versions are released on PGXN.
Each upgrade script (pg_living_assertions--OLD--NEW.sql) documents, in its own
header, exactly what changed and why; that is the authoritative per-version
record.
0.5.10 – 2026-10-09
SET ROLE is not a boundary, so a check no longer runs under one (external audit, round 5).
- A check cannot leave the role it runs as. From 0.5.8 it ran after
SET ROLEto its author, and a function it called ranRESET ROLE,SET SESSION AUTHORIZATION DEFAULTorset_config('role', ...)and was the caller again – a superuser, in the usual cron – then ran a program or cancelled the caller’s backend (F6 too). The check now runs in a temporarySECURITY DEFINERfunction its author owns, created and rolled back inside the seal; there PostgreSQL refuses to change role or session authorization at all. Skipped only where it cannot change anything: the owner’s cron running the owner’s checks costs what it cost; a check in a frame costs about 0.5 ms more. - A
SECURITY DEFINERcaller runs another role’s checks as that role when its owner may act as it; until 0.5.9 they wereerroringthere. _evaluateisVOLATILE: the seal, not the volatility, is what keeps a check from writing.test/sql/frame.sqlexercises the frame in installcheck, so CI covers it on every version;test/audit.shadds S1, each way back red on 0.5.9 with its control.ci/upgrade_check.shcompares column comments too (F17 stays closed, now watched).- README: who reads
status(the owner’s to grant: it carriesdetail) and who readsstate()/stale()(anyone given the schema).
0.5.9 – 2026-10-09
The Medium and Low findings of the external audit of 0.5.5 left open, each measured on 0.5.8
first (test/audit.sh: every tooth red there with its control green).
- F8: a fingerprinted value that disappears is
broken.declare_unchangedcompared with=, so a value gone to NULL readunknown, “not a failure”. - F16:
declare_unchangedapproves inside the seal, read-only and rolled back, under the caller’s path withpg_templast: an expression that wrote, wrote at approval. The fingerprint is sha256; assertions already declared keep their md5 check. - F12: the server dates an assertion and a check, and an assertion is not inserted already
retired: a backdated successor vanished from
renegotiated, and a check row could carry any date. A superuser keeps what it inserts (that ispg_restore). - F13:
TRUNCATEis refused on both tables. - F14:
state(),assert_holds()andstale()run as the owner, so anyone given the schema reads a verdict, as the README said; the tables stay closed. - Retiring or replacing an assertion is for its author (or a role that may act as it): a tenant with UPDATE replaced the DBA’s watch with
select true(external audit of pg_grammar_guard, GG-07). - F17: every installation documents
assertions.search_path. - F18: a trailing
;or--comment no longer makes a checkerroringforever. - F19: README corrections – seven answers, not six; the seal’s limits as they are since 0.5.8; which functions pin a path.
0.5.8 – 2026-10-09
- A check runs as the role that declared it (external audit: F9, F6; the same class
as pg_plan_guard’s PG-S1). Up to 0.5.7 it ran with the privileges of whoever called
run()– documented, and demonstrated bytest/privilegios.shreading the owner’s secret through a trusted role’s check. The seal bounded writes to the database and nothing else:COPY ... TO PROGRAMis a read, so a check ran a program as the caller; a session advisory lock stayed in the caller’s session; and a check that cancelled its own backend abortedrun_all()for every assertion. Inside the seal the evaluator now doesSET ROLEtodeclared_by(not when that is the current user), so a check can do what its author could and no more; what needs more is that assertion’serroring, and cancelling the caller’s backend is refused the same way. Advisory locks taken in the seal are released (test/sql/read_only.sqlmeasured the lock held until 0.5.7). declared_bycannot be forged at insert: a trigger accepts a name other than the declaring role only from a role that maySET ROLEto it (a superuser restoring a dump).- Behaviour change for callers. A caller must be able to
SET ROLEto each author; a superuser can. ASECURITY DEFINERcaller – pg_agent_gate binding an assertion – runs the checks its owner declared; the others areerroring, with the reason. test/audit.sh: the F9/F6 teeth, andtest/privilegios.shinverted – red on 0.5.7 with their controls green.
0.5.7 – 2026-10-08
- Metadata only. The PGXN description is two sentences now; the longer explanation it carried is in this README. No code changed: the upgrade script 0.5.6 -> 0.5.7 changes no object.
0.5.6 – 2026-10-08
From an external audit of 0.5.5, each finding measured on 0.5.5 before it was changed
(test/audit.sh, make check-audit, in make check-suites: every tooth red on 0.5.5
with its control green).
- The recorded
search_pathno longer stays in the caller’s session (F1).run()applied it withset_config(..., false), and the 0.5.5 comment said the function’sSETclause would restore it on exit. It does not: a plainSETinside a function with aSETclause overrides the clause and persists after the function. Afterrun_all()a runner’s next unqualified call reached a function in a schema the author of an assertion wrote, and aSECURITY DEFINERwrapper with its ownSET search_pathcontinued under the author’s path oncerun()returned. run()’s bookkeeping no longer runs under the author’s path (F2). With a recorded path ofevil, pg_catalog, the author’sclock_timestamp()ran as the runner in a session that only calledrun_all().- Both closed in one place: the path is applied inside
_evaluate’s sealed subtransaction withset_config(..., true), right after read-only is switched on, and the rollback that undoes the check undoes it too._evaluatehas its ownSET search_path = pg_catalog, pg_tempfor everything outside the seal, and the calls around the check are schema-qualified.run()no longer touches the path. - An unparsable recorded path is that assertion
erroringinstead ofrun_all()raising for everyone (F7), and the path is split the way PostgreSQL splits it: 0.5.5 broke a quoted schema name containing a comma (F15), and did not recognise an unquotedPG_TEMPaspg_temp.SETstores the path lower-cased, but a path set withset_config()orALTER ROLE ... SETis recorded as written, and withPG_TEMPfirst a temporary table of the evaluating session answered for the check. - A forged verdict cannot be pinned (F3). The latest verdict was the one with the
latest
checked_at, and a role allowed to run checks needsINSERTonchecks: a row dated'infinity'outranked every honest check forever. The latest verdict is now the last row written (byid), andchecked_atmust be finite. Such a role can still write a row; it lasts until the next honest check (README). - An assertion is not edited in place, all of it (F4). The trigger compared five
columns;
search_path,declared_by,why_changedandidare fixed now, and a retirement is written once – not undone, not re-dated, its reason not rewritten. - A
NULLreason no longer passes the checks that make retiring and replacing cost one (F5). - Two concurrent replacements of one assertion no longer both retire it, the second reason overwriting the first (F11): the predecessor is locked.
- The upgrade adds the new constraints
NOT VALIDand validates them; an installation already holding rows they refuse upgrades, gets aWARNINGnaming them, and keeps them, since the record is append-only.
0.5.5 – 2026-10-08
- A temporary table of the session that evaluates an assertion can no longer
change what it reads. PostgreSQL searches
pg_tempfirst for tables wheneversearch_pathdoes not name it, and no path here named it.run()evaluated the check under the declarer’s path ("$user", public), sofrom cuentasread the evaluating session’spg_temp.cuentas; andrun()looked the assertion up withFROM assertions, so a temporaryassertionswith a forged row – a failing assertion’s name, the id of one that holds – made it answerholds.retire()andrun_all()read and wroteassertionsthe same way. That matters when the check runs in someone else’s session with the owner’s rights: aSECURITY DEFINERfunction of the owner that callsrun(), which is what pg_agent_gate does inside an agent’s commit. Measured on 0.5.4 with the real table broken (test/pg_temp.sh,make check-pgtemp): both ways answeredholds, and the record saidholds. Now every function namespg_templast,run(),run_all()andretire()name the registry by its schema, and the declared path is applied withset_config()– with anypg_tempin it moved to the end – instead of being concatenated into aSETstatement.
0.5.4 – 2026-10-06
- License: Apache License 2.0, replacing the PostgreSQL License, from this release on. Every version up to and including 0.5.3, already published, stays under the PostgreSQL License it was released with. No code changed.
0.5.3
Completes the copyright and licensing files: the copyright holder’s full legal name in LICENSE and README, and a per-file SPDX header on every SQL source file. No schema change.
0.5.2
No schema change. Adds project governance and legal files (NOTICE, AUTHORS,
SECURITY, CONTRIBUTING, TRADEMARK). The database objects are byte-for-byte those
of 0.5.1; the 0.5.1--0.5.2 upgrade is empty on purpose.
0.5.1 and earlier
See the header of each pg_living_assertions--*--*.sql upgrade script and the
release notes on PGXN.