Review history (0.16.0 to 0.20.0)
This is the record of what each internal review and red-team pass of pg_phasevec found, how it was fixed and which test plays it, from the README of 0.20.0 before the package review moved it here. The README describes how the extension behaves now; where the two differ, the README is right. “The Nth review” is a review round; “cut” is a build made for one round and never released; the commits named are in this repository’s history. The reviews ran on the author’s machine; the timings and sizes quoted are those runs' and are not benchmarks.
Security reviews
0.16.0 is a security-hardening release. The SPI cursors that drive index build and maintenance skip
rows with a NULL code instead of dereferencing it; every count, dimension and probe argument is
bounded before it reaches an allocation, and an overflow in k * dims is caught; the proof clock
cannot re-enter itself; a path built for the reader socket is refused if it would truncate; and the
maintenance functions are revoked from PUBLIC. The checks have a regression test in
test/sql/security.sql.
0.20.0 fixes what fourteen reviews of 2026-11-05 and 2026-11-06 found in the truth layer and the
coarse index. The last thirteen ran attacks against thirteen cuts of this release, which were never
published; what they broke is fixed here too. The eighth found that each of the four before it had
closed one piece of the verifier’s session that decided verdicts and left the next one open, and a
proof no longer runs in its verifier’s session at all. The third one’s findings came from one root
– phasevec ran code that was not its own (a watch’s recipe, a proof) in more than one way, some of
them with the rights or the settings of whoever happened to call it – and there is now one way
(src/phasevec_guard.c):
- One guard for code that is not phasevec’s, and it is a sealed room. A recipe and a proof run
in a subtransaction of their own, always rolled back, as one named role, in a security-restricted
operation, read-only. Nothing that existed before the guard is in reach. The session’s open
cursors: the ones
pg_cursorslists are frozen while it runs (PostgreSQL itself then refuses to run or close them), a query begun before it cannot be run inside it, andFETCH,MOVEandCLOSEtake only cursors opened inside it. Of the commands onlySET,SHOW,EXPLAIN,DECLARE,FETCH,MOVE,CLOSE,CALLandDOrun (NOTIFY,LISTEN,PREPARE,DEALLOCATE,DISCARD,LOADand the rest are refused). Of the system’s own functions every one that only reads (stable or immutable) runs, and of the volatile ones only a reviewed few: the clock,pg_sleep, random numbers,set_config, transaction-level advisory locks (all of them end with the guard’s rollback).pg_notify, session-level advisory locks,setseed,currvalandlastval, the large-object descriptors,pg_cancel_backend,pg_terminate_backendand every other volatile system function are refused, and so arepg_cursorandpg_prepared_statement, which read the session’s own statements,EXPLAIN EXECUTE, which shows one of them (and withANALYZEruns it), andtxid_current()andpg_current_xact_id(), which are stable but give the session’s transaction an id no rollback takes back. The session’s temporary objects are its own too: inside the guardpg_tempis searched last (appended to the author’ssearch_pathwhen it does not name it), and every temporary object is refused, by what a query uses and not by how it names it: every query parsed or planned inside the guard is walked for the objects it depends on (tables and views, types and domains, functions, operators, collations, and what aregclass-like constant names), as PostgreSQL walks a view’s query to decide whether the view is temporary, and one in a temporary schema is refused – named, found throughsearch_path, as the child of a permanent table it inherits from, inside a SQL function (inside the guard no SQL function but the system’s is inlined, so its body is parsed as a query of its own; a body writtenBEGIN ATOMICis checked when it is planned), or in a plan the session cached before the guard ran (a session whose functions made a plan which may hold one of its temporary objects has its cached plans parsed again before a guard runs code; a session with no temporary object, or no such plan, pays nothing, and so does a statement the client sends, which no code in a guard can run: in the sixth cut a session that wrote to its own temporary table threw away every cached plan at its next write to a watched table, the watch trigger’s and its own prepared statements, 0.56 ms per single-row write against 0.09 ms, now 0.09-0.11 ms,test/sql/truth_lookup.sql). The walk is phasevec’s own on PostgreSQL 18 and runs besidecollectDependenciesOfExpron 19: in the sixth cut theRETURNINGtype ofJSON_VALUEandJSON_QUERY(withNULL ON ERROR, in no other node) went past both, and aregconfigorregdictionaryconstant past 18’s. The session’s timeouts (transaction_timeout,statement_timeout,lock_timeout,idle_in_transaction_session_timeout,idle_session_timeout) andexit_on_errorcannot be changed, byset_config,SET, or a function’sSETclause. In the second cut a proof settransaction_timeoutand ended the verifier’s connection with aFATALbefore any verdict was written, so every laterverify()of the watch died the same way; another released the verifier’s own advisory locks; another chose itsrandom()seed. In the third, which had no subtransaction for a recipe, a table owner’s recipe read a superuser’s open cursor over a table the owner could not read and sent the rows out withNOTIFY, delivered at the superuser’sCOMMIT(inbrief(), and in the watch trigger of every writer); and a proof read, used up and printed in its error the verifier’s open cursor (test/sql/truth_guard.sql,test/sql/truth_sealed.sql). In the fourth, a verifier’s temporary tables named like the author’s took their place in the proof (pg_tempwas searched first: the author’ssearch_pathdid not name it), andverify()recorded, andbrief()served,verifiedfor claims the real tables refute; a temporary function did the same, and a proof printed in its error a statement the verifier had prepared, its constants included (test/sql/truth_session.sql). In the fifth, only temporary tables and functions were refused, by the kind of range-table entry they came in: a verifier’s temporary view (a subquery entry since PostgreSQL 16), enum, domain, operator or collation decided a proof the author’s own objects refute, andverify()recorded andbrief()served it asverified; a PL/pgSQL function with its ownsearch_path, run once by the verifier beforeverify(), did it through its cached plan (test/sql/truth_temp.sql). - A proof runs in a session of its own (eighth review). Four reviews in a row found one more
piece of the verifier’s session that decided a verdict, each closed where it was found: its
settings' values (third), its temporary objects (fourth to sixth: in the sixth a name looked up
while a proof ran,
to_regclass('pg_temp.v')or a cast of text toregclass, found the verifier’s, andverify()was then refused in any session that had one), which custom placeholders it had (seventh); and in the eighth its uncommitted writes and DDL (a verifier deleted the overdue invoice, verified, put it back and committed: the proof had judged a state no other session ever saw, and the verdict was recorded and served), itssession_user(afterSET SESSION AUTHORIZATION, or under a policyUSING (tenant = session_user)) and itsapplication_name. Nowverify()andverify_one()start a background worker (RegisterDynamicBackgroundWorker) connected to the database as the claim’s author: a new session of the author, as a client would get one – itssession_useris the author, it has whatpostgresql.conf,ALTER SYSTEMandALTER DATABASE/ALTER ROLE ... SETgive every new session of that author, and itssession_preload_libraries(a role that does not log in is let in: the verifier may act as it). There each claim is judged in a transaction of its own, at READ COMMITTED, in the guard, with what the author’s session had changed on top, and reads only what is committed: no row or DDL the verifier has not committed, no temporary object, cursor or prepared statement of the verifier’s, none of its settings or its identity. The verifier chooses the claims and sends them to the worker (shm_mq), up to eight ahead of their answers; one worker serves a call and an author. Since the ninth review the worker also takes each claim’s row and records its verdict, in the transaction that judges it (below). A verifier that is canceled, fails or ends ends its worker. With no background worker free, a claim is not judged: nothing is recorded, it stays due, and its row inverify()says why (verify_one(): an error). What this took away: the refusal to verify in a session with temporary objects, the two passes over a call’s claims and the refusal to judge in a session with a placeholder the author did not have (below), the halt marks in shared memory (below), and the settings pinned for a wholeverify()call (test/sql/truth_own_session.sql,test/truth_race.sh). What a recipe needs stays: it renders in the session that reads or writes the row (the watch trigger in the writer’s,brief()in the reader’s), so the guard’s frozen cursors and its walk for temporary objects still guard it. - A verdict is about a committed state that does not depend on its verifier (ninth review). In
the eighth cut the verifier took and recorded the claims in its own transaction. A verifier with
an uncommitted write that refuted a claim had the proof judge the committed state before that
write (a proof’s session cannot see it), and committed, together with the write, a
verifiedthat no committed state ever made true;brief()served it. Now the worker takes the claim’s row, judges it and records the verdict in a transaction of its own, and commits it before it answers. The verifier writes nothing (verify()gives its transaction no transaction id): aROLLBACKof the verifier keeps the verdicts its proofs reached, and it may verify again in the same transaction or statement. A transaction that has written may not verify, and since the sixteenth review neither may a read-only one (below):verify()andverify_one()refuse one with a transaction id (a write, DDL,LOCK TABLE ... ACCESS EXCLUSIVE, anassert()), whose writes would be judged as if they were not there. A worker commits withsynchronous_commit = off: a crash can lose its last verdicts, and their claims are due again (test/sql/truth_verifier.sql). - A verifier never waits without its deadlines (ninth review). A claim goes to the worker
without waiting (
shm_mq_sendwithnowait), in the loop that keeps the proof’s clock and 5 s more, the start of a worker,p_budgetand the lock check; a claim larger than half the queue (64 kB) goes alone, in pieces. In the eighth cut a claim over 64 kB blocked the verifier inside the send: behind a proof that catchesquery_canceled, or a worker waiting for a lock, nothing ended the call but a cancel. A proof that waits for a lock the verifier’s transaction holds, directly (a session’s advisory lock) or through other sessions (anALTER TABLEqueued behind the verifier’s read of a table, with the proof’s read queued behind it), would wait as long as the verifier waits for it: the verifier walks the waits (pg_blocking_pids, session to session) and stops with an error. The eighth cut saw only a direct wait: the proof waited out its clock and was recordedrefuted, and theALTER TABLEand every reader of the table waited a clock per claim. A proof still waiting for another session’s lock when its clock ends (or past its author’slock_timeout) is not judged, with the reason: a lock another session holds is not the proof’s answer (test/truth_worker.sh); since the twelfth review neither is one that waited for such a lock and then ended after its clock (below). Verdicts move forward in time, and an author’s settings do not end its workers (tenth review). In the ninth cut
verify_one()’s worker judged before it took the claim’s row (onlyverify()’s took it first), then waited for the row averify()worker held while it judged; when that one recordedrefuted(the claim had turned false in between),verify_one()wrote its ownverified, read before, over it,brief()served it andverify()left the claim alone forp_every. Now every worker takes the claim’s row before it judges it, whatever it was sent for (verify_one()takes it due or not, and waits for it), so of two judgments of a claim the one that reads later commits later; and_truth_setdoes not replace a verdict read after the judgment it records, nor_truth_halteda verdict read after the proof began (a record of an earlier run, a proof that took too long or ended its session, can still arrive after a newer verdict, and is dropped) (test/truth_order.sh,test/sql/truth_order.sql). The worker is phasevec’s session, not its author’s: before it connects it pinstransaction_timeout,statement_timeout,lock_timeout,idle_in_transaction_session_timeout,idle_session_timeoutandexit_on_errorover whatever the database and the role set (an ordinary role mayALTER ROLEitselfSET transaction_timeout = '1ms': in the ninth cut every worker of its claims then ended before it judged, nothing was recorded, its claims kept their oldverifiedinverify(),verify_one()andbrief(), and withp_nthey held the head of the queue, so other authors' false claims were never refuted). The proof’s clock is its only time limit; a proof still waiting for a lock at its clock is not judged, as before, and neither is one the deadlock check makes give way. A claim that still cannot be judged for its author’s sake – a new session of its author ends before it judges, twice, without another process ending it; an error outside the proof, twice; a placeholder its author’s every new session gets – is recorded as tried, and once no session of an author starts in a call, the rest of that author’s claims in it are recorded the same way without trying again (test/truth_unjudged.sh). The tenth cut kept such a claim’s verdict for onep_everymore with marks of its own (truth.unjudged_at,unjudged_until,unjudged_why,tried_at); the eleventh review found that this, like every other way judgment can stop, needed one rule instead, below, and the marks are gone. This closed one way for an author to end its own workers, not every way: it could still terminate them itself (pg_terminate_backendworks on a role’s own sessions), and until the twelfth review its claims then held the head of the queue (below).Safety does not depend on liveness (eleventh review). The eleventh review found four more ways to stop judgment from moving while the old verdict was still served: a role with no grants filled the shared lock table with session advisory locks, a proof failed
out of shared memoryand a true claim was recordedrefuted; anassert()of a key left open, by any role that may assert, held the claim’s row for as long as it stayed open,verify()skipped the claim and recorded nothing,verify_one()gave up, andbrief()served the oldverifiedafter the committed state refuted it; oncep_nclaims were stale (their rows edited and not asserted again) they sorted first in every call (the due list put a stale claim’stried_atfirst, and the stale path set it toNULL; the comment that said such a claim “never holds the head of p_n” was wrong), and no other claim was judged again; and an author holding its own claims' rows in an open transaction had them skipped, unrecorded, at the head of the queue, so other authors' false claims were never refuted. Three rules replace the patches:- A verdict serves while it is fresh. A
verifiedorrefutedof a proof serves only while it was judged within its watch’s horizon,phasevec.watches.max_age(1 hour by default; the owner sets it:UPDATE phasevec.watches SET max_age = '3 hours' WHERE name = 'notes'). Pastverified_at + max_age,_verdict(),brief(),recall()and the rest readunproven– judged at the moment they read (clock_timestamp(); until the twelfth review the start of the reader’s transaction, below) – whatever kept the claim from being judged again: a row held open, a lock, the server, no worker free, a verifier that does not run. The word staysunproven(SQL that readsphasevec.truthcompares it), and since when isverified_at + max_age. A halted claim (its proof ends its session: not run again) and arefute()of a key with no proof (nothing judges it again) have no horizon. Choosemax_agelonger than the timeverify()takes to come round every claim:ceil(claims / p_n)calls, eachp_everyor the cron’s interval apart, whichever is longer, and one call more (a claim judged just after the last call’sp_everywaits for the next). With 1000 claims,p_n100 and a call every 10 minutes that is 100 minutes or more, and at the default hour about 400 of the 1000 readunprovenat any time (measured at 60x speed, below); withp_n2000 every claim is judged every 10 minutes and none does. - The due queue is a fair rotation. A claim’s turn is spent before it is taken, judged or
recorded, in a transaction of its own committed at once, in
phasevec.attempts(unlogged, the owner’s, written only by phasevec’s own sessions, so no other session can hold its rows; after a crash the rotation starts again from when each claim was asserted), and since the twelfth review by a session its author cannot end:verify()’s keeper, a session of the extension’s owner, spends the turns of the claims bound for their author’s session before that session gets them (in the eleventh cut that session spent them itself, below)._truth_duetakes the claims whose turn is oldest first, a claim never tried from when it was asserted. Whatever becomes of an attempt (judged, the row held by another transaction, a stale text, an error that is not the proof’s, a session that ends, before or while its proof runs), the claim goes behind every claim that waits: of N claims due, each is tried withinceil(N / p_n)calls that run theirp_n. It is one queue, not a round per author: with a round per author, an author of few claims is tried more often and one of many waits longer thanceil(N / p_n)calls; with one queue no author holds its head. Anassert()does not give a claim a new turn. A call that finds no worker free (for the keeper or for the author’s session) tries nothing, and the claims it did not reach keep their turn: since the thirteenth review both are started before any turn is spent (below). - Only the proof’s own logic decides its verdict. An error in a proof is its answer (
refuted) when it comes from what the proof computes: its data (22), constraints (23), SQL and rights (42), what it raises (P0), a feature it may not use (0A), a cardinality (21), a program limit it reaches (54: too deep a recursion, too complex a statement, which the same proof reaches again), and every class not listed next. It is not an answer when it comes from the server or from other sessions: connection (08), transaction rollback (40: serialization, deadlock), insufficient resources (53: out of shared memory, out of memory, disk full, too many connections), lock not available (55P03), operator intervention (57: shutdown, cancel), system error (58), configuration file (F0), foreign data wrapper (HV), internal error (XX, the server’s or phasevec’s own). Then nothing is judged, the verdict stays as it was, and the horizon decides how long it serves. One of those classes that the proof raises itself (PL/pgSQLRAISE ... USING ERRCODE) is its answer. A lock wait at the clock, a deadlock and alock_timeoutare not answers either: until this review they recordedunproven, now nothing (verify_one()says it could not judge the key, and why).
test/truth_liveness.shplays each of the four attacks, and a property: 60 claims of three authors,p_n6, with a row holder, anassert()holder, a stale editor and a worker-slot hog at once: every claim is tried withinceil(60 / 6)= 10 calls and every claim no one holds is refuted in them; then the hog takes every worker, nothing can be judged, andbrief()serves no verdict past its horizon (checked 20 times). On 07b019f every part fails, on PostgreSQL 19 and on an assertion-enabled 18.- A verdict serves while it is fresh. A
- What is the proof’s own, and when (twelfth review). The twelfth review found three places
where something that was not the claim’s decided what was recorded or served, each against a rule
the eleventh had stated:
- Only the proof’s own work can refute it by time. A wait for a lock another session held
counted against the proof’s clock: a lock let go just before the clock ended (a writer’s
LOCK TABLE, or a session advisory key the proof takes, held by a role with no grants) left the proof to finish after its clock, and a true claim was recordedrefuted(“the proof took longer than 1000 ms”). The eleventh cut told a wait apart only if it was still going when the timer fired. Now a proof that waited for a heavyweight lock at all (a relation, a row’s transaction, an advisory key) and ended after its clock is not judged: PostgreSQL arms its deadlock timer for every such wait, and the time it was armed stays after the wait ends, so a wait that began after the clock did is known exactly. The wait’s time is not taken off the clock instead: PostgreSQL records when a wait began, not how long it lasted, and sampling misses short waits. A proof slow by itself is still refuted for its time. - A turn is spent by a session its author cannot end. The worker that spent a claim’s turn was
the author’s own session, and an author may terminate its own sessions: one that terminated its
proof sessions before they spent anything (a loop of
pg_terminate_backendover its ownpg_stat_activityrows, 610 workers in 25 s) kept its claims unspent at the head of the queue, and no other author’s claim was tried again – 20 false claims of another author were servedverifiedthrough eight calls, against a bound of five. Nowverify()starts a keeper, a session of the extension’s owner, which spends the turns of the claims queued for an author’s session, a batch at a time, in a transaction of its own, before any of them is sent; the author’s session is started only once the first claim’s turn is spent. A claim that goes to a session of the owner (a record, a claim the owner asserted) is spent by that session, andverify_one()’s session spends its own (it names its claim: ending it leaves that claim’s turn where it was, with no claim behind it). - Freshness is judged at the moment of reading.
_verdict()comparedverified_at + max_ageandexpires_atwithnow(), the start of the reader’s transaction: in a READ COMMITTED transaction begun before the horizon,brief(),truth_coverage()and_verdict()servedverifiedpast it, after that transaction itself could see the claim was false. Now every read path compares withclock_timestamp():_verdict(), the filter that hides an expired claim fromfind(),find_hybrid(),find_npu(),find_ivf(), the replacement chain and the recall measure. Notstatement_timestamp(): one long statement (aDOblock, a function that readsbrief()in a loop) is a reader too.
It also found that a clock near the
intlimit overflowed the verifier’s deadline (p_timeout_ms- 5 s): every claim was “not taken within -2147478649 ms”.
test/truth_own_time.shplays each one: a lock held 0.80-1.10 s against a 1 s clock (a table and an advisory key: on 4f966eb five or six of eight holds recorded a true claimrefuted; now none, and those that let go before the clock are not judged), a proof slow by itself (refuted, before and after), the author terminating its own sessions throughout (on 4f966eb 0 of 20 false claims refuted in 8 calls; now all 20 withinceil(25 / 5)= 5), a reader 4 s into a transaction past a 3 s horizon, and the large clock. On 4f966eb every part but the slow proof fails, on PostgreSQL 19 and on an assertion-enabled 18.
- Only the proof’s own work can refute it by time. A wait for a lock another session held
counted against the proof’s clock: a lock let go just before the clock ended (a writer’s
- Every wait a proof makes is seen, and a turn is spent only when its claim can be tried
(thirteenth review). The thirteenth review found two places where the twelfth review’s rules
held only in the common case:
- A proof runs in one process. The lock rule above reads the proof’s session: when its deadlock
timer was last armed, and the lock it waits for when its clock ends. A proof’s query could run
in parallel workers, and a wait for a lock there was the worker’s: the proof’s session waited on
the
Gather’s queue, and a lock another session held past the clock got a true claim recordedrefuted(“the proof took longer than 1000 ms”). The author needed only settings of its own role (debug_parallel_query, or a parallel plan withparallel_leader_participationoff); a role that mayLOCKa table the proof’s functions read did the rest. Now the proof’s session pinsmax_parallel_workers_per_gatherandmax_parallel_workersto 0 anddebug_parallel_queryoff before it connects, over the database’s and the role’s settings, aSETin the proof and a function’sSETclause (all of a lower source): no part of a proof runs in another process, and every wait it makes is its session’s. The parallel workers' waits are not watched instead: a worker’s deadlock timer and the lock it waits for are its own, and samplingpg_locksfrom the leader misses short waits. A proof’s plan, and its estimated cost (p_max_cost), are those of a plan with no parallelism. - A turn is spent only once a session can try its claim.
verify()needs two background workers at a time: the keeper, which spends the turns, and a session of the claims' author, which tries them. The keeper started first: when it took the last free slot, it spent the turns of the whole batch, no session of the author could start, and every claim of the batch was ‘not judged’ with its turn spent. An ordinary author holding one slot on every other call (its own slow proof, underverify_one()) kept chosen false claims of another author from ever being tried, against the bound ofceil(N / p_n)calls and against this file, which said such claims keep their turn. Now the author’s session starts first, then the keeper, and no turn is spent before both run; when either cannot start, the claim is not judged, nothing is recorded, it keeps its turn, and the call goes on without waiting for a slot. When the next author’s claims come up, the session of the one before is waited for (up to 5 s) until it has left its slot: with no other slot free, the next one did not start, and its claims were ‘not judged’ with their turns spent, with no one holding anything. As in the twelfth review the author’s session gets no claim before its turn is spent: ending it then only starts another.
test/truth_parallel.shplays the first: a table held 3 s against a 1 s clock while the proof’s nested query runs in a parallel worker (bydebug_parallel_query, and in aGatherthe leader takes no part in), and a claim that the proof’s session runs no parallel worker whatever its author’s role sets; on 9dd019f each true claim is recordedrefutedand the last is refuted, now each is not judged and staysverified, and the last is verified.test/truth_slots.shplays the second:max_worker_processes2, 20 false claims,p_n10, another author holding one slot on every other call; on 9dd019f each held call spends 10 turns and tries none, and 10 of the 20 are never refuted; now a held call spends none, and the two free calls refute all 20. With two slots and no one else, one call over claims of two authors interleaved left 1 to 10 of 50 ‘not judged’ on 9dd019f; now none. Both fail on 9dd019f and pass now, on PostgreSQL 19 and on an assertion-enabled 18. - A proof runs in one process. The lock rule above reads the proof’s session: when its deadlock
timer was last armed, and the lock it waits for when its clock ends. A proof’s query could run
in parallel workers, and a wait for a lock there was the worker’s: the proof’s session waited on
the
- Cleanup that can run twice, a judgment that is one instant, and an update in its writers' order
(fourteenth review). The fourteenth review found:
- A cancelled verifier restarted the server. When
verify()goes from one author’s claims to the next, it waits (up to 5 s) for the session of the one before to leave its slot. That wait checks for interrupts, and it ran with the session’s queues already freed but the session still the call’s: a cancel or astatement_timeoutthere reachedverify()’s cleanup, which freed the queues again. Shared memory was corrupted, the backend PANICked (“stuck spinlock detected at shm_mq_detach_internal”) and the server ended every session and restarted: any role that may callverify()could do it by cancelling its own call. Now the cleanup of a proof’s session can run again on whatever is left of it: each queue, the segment and the session are cleared before they are freed, and the session leaves the call before the wait, waiting where the call’s cleanup ends and frees it if the wait is interrupted. The call’s cleanup lets go of every session it has (its author’s, its keeper, one still leaving), on an error and at its end alike. The other cleanup paths were checked for the same pattern: the keeper is stopped without a wait, a worker’s own error ends its process, and a verifier that exits ends every session it has. - A judgment’s clock, snapshot and stamp are one instant.
verify_one()waits for its claim’s row when another verifier is judging it. Its worker began the transaction that judges (whose start is the proof’snow()) and then waited for the row in it: the proof read a clock from before the wait, a snapshot from after it, and its verdict was stamped after it. With two verifiers of one claim, an invoice that fell due during the wait was not past due by the proof’snow():verified, stamped after the due date, and served fresh for a wholemax_age; and a heartbeat written during the wait was “in the future”: a true claim storedrefuted. Now the worker takes the claim without waiting (SKIP LOCKED), in the transaction that judges it; when another session holds it, it waits for it in a transaction of its own that judges nothing, and tries again in a new one. Andverified_atis the proof’s ownnow(), the start of the transaction that judged it, not a moment later: a verdict is never fresher than the clock it read. Each statement of a proof still reads a snapshot taken when it starts, afternow(), as every statement under READ COMMITTED does; between the two there is no wait for the claim any more (since the fifteenth review not even a read of the claim’s row: below). - The update takes the extension’s tables in its writers' order. A writer takes the watched
table, then
phasevec.watches(the watch trigger reads its watch), thenphasevec.truth(anassert()in the same transaction, from a trigger). The update script alteredphasevec.truthand thenphasevec.watches: a writer holdingwatcheson its way totruthand the update holdingtruthon its way towatchesdeadlocked, and one was aborted. Now the script lockswatches,sources,lexstats,lexdocsandtruth, in that order, before anything else: the update waits for such a writer, and neither fails. DDL on a watched table in the same transaction as the update can still deadlock with that table’s writers (it waits for them while the update holds what they wait for): run the update alone (Upgrading from 0.19.0, below).
test/truth_cancel.shplays the first: 60 claims of two authors alternating (a session per claim, each waited for),verify()1000 times under a randomstatement_timeoutof 1-80 ms and 1000 times cancelled by another session of the same role at random moments; then the server must be the same, its log free of PANIC, TRAP and crashes, no proof’s session left, as many shared memory segments as before, and oneverify()must judge all 60. On b17c072 the server PANICked in the first round, on PostgreSQL 19 and on an assertion-enabled 18 (‘stuck spinlock detected at shm_mq_detach_internal’ on 18); now it stays up, with 680 calls cancelled on 19 and 1567 on 18.test/truth_claim_clock.shplays the second with the review’s two claims and a proof that logs itsnow(): on b17c072 the invoice is recordedverifiedwith a stamp after its due date, the true heartbeat claimrefuted, andverified_atis not the proof’snow(); now the first isrefuted, the secondverified, and the stamp is the proof’s clock.test/upgrade_locks.shplays the third 20 times, with a session that loaded 0.19.0 writing a watched table and then, 1.2 s later in the same transaction, a table whoseSECURITY DEFINERtrigger asserts, and the update started in between: on b17c072 20 of 20 deadlocked; now none, and every update and write commits. - A cancelled verifier restarted the server. When
- Every reader of a verdict can read it, nothing phasevec reads sits inside a proof’s instant, and
a verifier keeps nothing per call (fifteenth review). The fifteenth review found:
_verdict(t)needed grants no one was told about. It was SQL that ran as its caller: it readphasevec.watchesandphasevec.sourcesand calledphasevec._bind(), which this release closes toPUBLIC. A reader grantedphasevec.truthand the watched table – a production deployment’s read-only role,NOLOGINwithpg_read_all_data, and the queries and views it reads through – got “permission denied for function _bind” as soon asverify()wrote a fresh verdict (rows that readunprovennever reached the call, so it looked fine right after the update), and one withoutpg_read_all_datagot “permission denied for table watches” too._bind()itself is a pure hash of its arguments, and opening it would have fixed only the first error; openingphasevec.sourceswould have given every reader ofphasevec.trutha way to test what any watched row says._verdict(t)is now a door likerecall()and the others: it shows a claim’s verdict to a role that maySELECTthe watch’s table (and, when row-level security filters that table for the role, see the claim’s row), andNULLto any other; then it computes it as the extension’s owner (phasevec._verdict_of, the owner’s, which every function of the extension now reads). Its argument is whatever row the caller passes: one made up for a table the caller may not read getsNULL, so it is no oracle for a hidden row. Every other function this page gives a reader (brief,recall,disputes,replacement,truth_coverage,search,find,find_ivf,find_npu,alike,find_hybrid,coverage,coverage_check,truncations,dupes,recall_check) was run the same way, as such a role in a read-only session with only the grants this page names, and none refused.- Nothing phasevec reads sits between a proof’s
now()and the proof. The fourteenth review moved the wait for a claim’s row out of the transaction that judges it, but phasevec still read the claim’s watched row (its recipe) first thing in that transaction. A recipe whose function reads another table waited there for a lock another session held (a role that mayLOCKthat table, or a queuedALTER,VACUUM FULLorREINDEXof it), within the proof’s clock: the proof read anow()from before the wait and a snapshot from after it, and a heartbeat written during the wait was “in the future”: a true claim recordedrefuted, throughverify_one()andverify()alike. Now the claim’s row is read (and, for an author that row-level security filters, checked as that author) in a transaction of its own, under a clock of its own, before the transaction that judges; that transaction only takes the claim and runs the proof; and a take that waited for a lock (phasevec.truthheld by aLOCKor anALTER) is let go and tried again in a new transaction, as the wait for the claim’s row is. The row is read again after the proof, in the transaction that judged, and a row that changed in between is recorded as moved, as before. The proof’s own statements still take their snapshots afternow(), as every statement under READ COMMITTED does (a wait for a lock in its own statements is the proof’s: above). - A verifier’s session grew with every call. Each
verify_one()started its proof’s session inTopMemoryContext:dsm_create()withdynamic_shared_memory_type = mmapleaves an 8 kB buffer in the current context and never frees it, and the worker’s name was looked up into a new string there. 1000 calls left 8.2 MB behind withmmapand 24-32 kB withposix, for as long as the session lived. Only what a session keeps (the segment, its queues, the worker’s handle) is inTopMemoryContextnow.
test/sql/truth_read_path.sqlplays the first, as aNOLOGINrole withpg_read_all_data, one granted only the watched table andphasevec.truth, and one granted onlyphasevec.truth, each in a read-only transaction, against a watch with row-level security too; on fc37e9f_verdictis refused to the first two (“permission denied for function _bind”, “… for table watches”); now the first two read every verdict they may andNULLfor a row a policy hides, the third readsNULL, and the owner reads what it read before.test/truth_claim_clock.shplays the second (claims 4 and 5): a lookup table its recipe reads held 1.5 s whileverify_one()andverify()read the claim’s row, a heartbeat written during the wait; andphasevec.truthheld 2 s while the worker takes the claim after a slow read of its row. On fc37e9f the first is recordedrefutedby both, stamped before the heartbeat; now both areverified, stamped after it; with the read moved but without the retry of the take, the second is recordedrefuted.test/truth_memory.shplays the third: 1000verify_one()and 20verify()calls in one session, once withmmapand once withposix; on fc37e9fTopMemoryContextgrows 8.2 MB (mmap) and 24-32 kB (posix), now not at all. All three fail on fc37e9f and pass now, on PostgreSQL 19 and on an assertion-enabled 18. On a mirror of a production deployment’s layout (its schema as it is, 1000 claims of one watch and 150 of another,pg_cron), updating from 0.19.0 by its written procedure under two writers, a session that loaded 0.19.0 and a reader idle in its transaction for 25 s took five tries ofALTER EXTENSION(each one held the writers up to itslock_timeoutof 5 s); afterwards its read-only role (NOLOGIN,pg_read_all_data) read every path a production deployment reads (every read path of the application) with no other grant, and withpg_cronverifying both watches every 3 s for 60-90 s no verdict was served wrong against the proofs run again as that role. - What phasevec runs as its owner reads nothing through anyone’s settings, who may see a verdict
is decided on every call, and a verdict’s horizon is the same in every zone (sixteenth review).
The sixteenth review found:
- An author’s operator ran as the extension’s owner.
become(), which every statement phasevec runs as its owner goes through, changed only the user id: names were looked up through thesearch_pathof whoever was connected. A proof’s session is a new session of its author, so an author’sALTER ROLE ... SET search_path = own_schema, pg_catalogput its own=forbyteain front ofpg_catalog’s, and theIS DISTINCT FROMof the claim’s take (the one operator of that statement not named by its schema) ran it as the owner, a superuser: the author made itself a superuser and wrote averifiedfor a false proof, which_verdict()andbrief()served. The principle now: no statement phasevec runs as its owner resolves a name, or computes a value, through its caller’s or a proof author’s settings.become()opens a nest level with the pins a recipe renders with (search_path = pg_catalog, pg_temp,TimeZoneUTC,DateStyle,IntervalStyle,extra_float_digits,bytea_output, thelc_*that format,standard_conforming_strings,xmloption,row_security, …) andtransform_null_equalsoff, andunbecome()closes it: the same names and the same values in a verifier’s session, a writer’s trigger and a proof’s session, for the plans kept across calls too (PostgreSQL plans a kept statement again when thesearch_pathit was planned in is not the current one, and every kept plan is prepared and run as one role: the owner’s inbecome(), the table owner’s in the guard). The pins are not set over the whole proof session (atPGC_S_OVERRIDE): a proof is its author’s and keeps its author’ssearch_pathand settings, and every statement of phasevec’s own in that session goes throughbecome(). On top of that every operator in phasevec’s C statements names its schema (IS DISTINCT FROMis written withOPERATOR(pg_catalog.=));_term()and_reads(), inlined into their callers with noSETof their own, name theirs (and write their backslashes inE''strings, whichstandard_conforming_stringsdoes not change); and the IVF functions that run SQL with their caller’s identity carrySET search_path = pg_catalog, pg_temp, as every SQL function of the extension does. The audit: every SPI statement of the library (inbecome(): the watch’s lookup, the claim’s choice, take and record,brief()’s keys, the doors' bodies,_verdict_of, the trigger’s note,adopt()’s orphans; in the guard: the row reads, renders and the visible keys; with the caller’s identity: the IVF statements), every SQL and PL/pgSQL function (all but_term,_readsand_lexemescarry theSET;_lexemesnames everything already), and every cast, implicit operator,IN,CASE,COALESCE,NULLIFandIS DISTINCT FROMin them:CLAIM_LOCK_SQLwas the only one that ran with no pinnedsearch_path(_term()and_reads()were reached only from functions that pin it). _verdict(t)kept who may see. It decided once per watch and role and kept the decision infn_extra, which a PL/pgSQL expression keeps for its whole transaction: after aREVOKEor anENABLE ROW LEVEL SECURITYcommitted by another session (which a new query, andhas_table_privilege(), already saw) the kept expression went on answering, and a row made up with a guessed text told whether the guess was the hidden row’s text (10000 guesses in 0.1 s). Now nothing about who may see outlives the call: the caller’sSELECTon the table and whether row-level security filters it are checked on every call (two syscache lookups), and the row’s key under that security; what is kept is only which table a watch reads (a watch never changes its table). A made-up row is answered only for a caller that may read the row it names, which can compute the answer itself.- A query over many watches grew with its rows. The same cache kept a whole copy of a watch (its
recipe, its statement) in the query’s memory for each miss of its 16 slots: a query whose rows
came from 17 or more watches in turn grew by ~0.8 kB a row (1 GB for 40000 rows of 20 watches
with an 8 kB recipe), and any role granted
phasevec.truthcould run it. Now a slot holds a watch id and a table, and under row-level security the watch is read for the call in memory that ends with it. - The horizon followed the reader’s
TimeZone.verified_at + max_agewas computed in the session’s zone: under a POSIX rule a reader may set (<A>+12<B>-12,J1/0,J310/12) its day was 48 hours long and a verdict 27 hours old under amax_ageof'1 day'readverified; across a real DST change a day is 23 or 25 hours. Now the horizon (_verdict_of,_truth_due, the claim’s take) is computed in UTC, whoever reads: a day is 24 hours, a month a calendar month in UTC.max_agewas not restricted to hours instead: days and months are what an owner writes, and the zone, not the unit, was the reader’s to choose. - A read-only transaction recorded verdicts.
verify()writes nothing in its caller’s transaction (its sessions commit each verdict), soREAD ONLYstopped nothing: a read act of a production deployment (a read-only role withdefault_transaction_read_only) started workers, ran every proof and committed 610 verdicts. Nowverify()andverify_one()refuse a read-only transaction, session or standby, as PostgreSQL refuses a write; a session that is not read-only verifies as before (a production deployment’spg_cronjob,SET ROLEto a read-only role andverify()). - A dropped author’s proofs kept running. A call whose author was dropped (
DROP OWNED,DROP ROLE) while it ran went on running that author’s queued proofs in its session and stampedverifiedin the name of a role that no longer existed. Now the worker locks the author’s role before it judges, until the verdict commits (DROP ROLEtakes it exclusively, and waits for that verdict), and a role dropped before is found gone: the claim is recordedunproven, as for any gone author, until it is asserted again or adopted.
test/sql/truth_review16.sqlplays the first, the fourth, the fifth and the second (aREVOKEand anENABLE ROW LEVEL SECURITYin the transaction, and a made-up row); on ad275f3 the author’s operator runs as the owner and key 2’s false proof readsverified, a 27-hour-old verdict readsverifiedunder the rule, the kept expression answers after theREVOKEand for the hidden row, andverify()andverify_one()commit verdicts in a read-only transaction; now none of it.test/truth_verdict_memory.shplays the third: one query over 20 watches in turn, at 5000 and 40000 rows; on ad275f3 its peak grows by ~570 MB, now by ~3 MB (the sort).test/truth_author_dropped.shplays the sixth: 40 claims of 0.1 s, the author dropped after 1 s; on ad275f3 30 are stampedverifiedafter the drop, now none, and 30 areunprovenwith the reason. All of them fail on ad275f3 and pass now, on PostgreSQL 19 and on an assertion-enabled 18, and so do the review’s own scripts played across two sessions. On the mirror of a production deployment’s layout (as in the fifteenth review, with the real reader process), the update by its corrected procedure (a dump first, the five functions rewritten by one statement with a check, the sessions holding the extension’s tables found by their locks, active or idle, the sessions opened before the update ended, the reader restarted, a cron job for each watch) ran under two writers (5800 writes, none failed), a report running overphasevec.truth, a reader idle in its transaction and a session that loaded 0.19.0; withpg_cronverifying both watches every 15 s for 90 s under amax_ageof 60 s, every run succeeded, no verdict was served wrong against the fixed world the proofs read, no claim from before the soak readunproven, every read path of the application worked as a read-only role in a read-only session (the application’s search by meaning once the reader had caught up with the writers: until thenrecall()refuses on coverage and the command falls back to its literal search, as it is written to), andverify()there was refused. The dump restored into a database of 0.19.0 that verified and took writes. - An author’s operator ran as the extension’s owner.
- A claim’s identity tells every claim apart, a proof has only what its author’s session could
set, a key names its row byte for byte, and a refuted claim is retired (seventeenth review). The
seventeenth review found:
- Two author environments bound alike.
phasevec._bind, the claim’s identity that a verdict is recorded and served for, joined the recorded settings with a newline, and a placeholder’s value may hold one:{x.a=1, x.b=2}and{"x.a=1<newline>x.b=2"}bound alike, though the proof readsx.b = 2under the first and nox.bunder the second. A verdict judged for the first was recorded, and servedverifiedby_verdict()andbrief(), for the second when it was asserted while the first was judged (verify_one()waited for the author’s open re-assert;verify()judged an earlier claim of the call meanwhile)._bindis now C: every part has its length before its bytes (-1 for NULL), the author is an 8-byte integer, the settings their count; the sha256 of that is the claim’s identity, as the sha256 of a row’s text is the text’s (asserted_for). It costs 0.7 us a call against 2.2 for the unframed SQL (4.4 framed in SQL), on every read of averifiedverdict. - A proof’s session had what its author could not set. The recorded settings were replayed as a
superuser (
PGC_SUSET): a non-superuser whose session named a superuser-only setting of a library it had not loaded yet (SET plpgsql.variable_conflict, a placeholder until PL/pgSQL loads, when PostgreSQL drops it with a WARNING) had it recorded, and set in its proof’s session, where it took effect. Now a setting is replayed as the author would set it (PGC_USERSET, the author as the role that set it), sopg_parameter_acland a library’s own definition decide as they would in the author’s session;assert()andadopt()load PL/pgSQL (through which the claim is written) before they read the session’s settings, and record only those the author may set (a superuser’s session that calls an author’sSECURITY DEFINERfunction does not lend it itssession_replication_role). - A function’s
SETclause was recorded as the session’s. What a proof records is what its author’s session changed; aSECURITY DEFINERfunction withSET search_paththat calledassert()(an author’s wrapper function) had itssearch_pathrecorded too: the claim, re-asserted by a trigger, ran withpg_catalog, pg_tempand was refuted (function sigue_abierto() does not exist) where the same claim, adopted, was verified. Now the value recorded is the session’s, read down PostgreSQL’s own stack of settings: a level saved for a call (GUC_SAVE: a function’sSETclause) gives back its prior value; aSET LOCALinside it goes with it; aSETinside it outlives it (PostgreSQL’s rule), and is recorded. - A key matched in its column’s collation. Rows were found by
key::text = $1, and a cast to text keeps the column’s collation: with a nondeterministic one (case-insensitive ICU) and a unique index declaredCOLLATE "C",'alice'and'ALICE'were two rows, and a reader that row-level security showed only'alice'had_render()show it'ALICE'too, with its text, and_verdict()serve the hidden row’s verdict (an oracle on its text). Now every match on the watched table compares bytes (COLLATE "C"); a text orvarcharkey also goes through the column’s own equality, for its index. phasevec’s own tables hold keys in the database’s default collation, which is always deterministic. - A dropped author failed the whole call. The message that a claim’s author may no longer read
the table looked the author up by OID, and an author dropped after the call read its claims
ended
verify()with “invalid role OID”: no claim after it was judged, any author’s. A role’s name in a message is never an error now, and an author that is no longer a role is recordedunprovenas an unknown one is. DROP TABLEof a watched table deadlocked withverify(). The proof’s session took the claim’s row, then read the table;DROP TABLEtakes the table, then (itssql_droptrigger deletes the watch, which cascades to the claims) the row; the deadlock detector aborted theDROP. The proof’s session now locks the table first: aDROPwaits for the claim being judged, or the claim waits for theDROPand finds its row gone.- A refuted claim came back.
refute()left the proof in place: the nextverify()pastp_everyjudged the claim again and served itverified, andadopt()asserted every refuted claim again (a production deployment retires a deleted memory withrefute(): after the upgrade every deleted memory was served as current and verified).refute()now retires the claim: its proof goes (proofNULL), so noverify(),verify_one()oradopt()judges it, and it readsrefuted, with no horizon, while its text is the refuted one; anassert()makes it a claim again.
It also found (in the audit) that the proof’s session hashed a halted claim’s key in the database’s encoding and
_truth_haltedin UTF-8: in a LATIN1 database a claim whose key has a character outside ASCII and whose proof ended its session was never recorded halted.test/sql/truth_review17.sqlplays the identity, the replay, theSETclause and the retirement;test/sql/truth_collation.sqlthe collation (it needs ICU:truth_collation_1.outwithout it);test/truth_bind_race.shthe two races (verify_one()andverify()),test/truth_author_gone.shthe dropped author,test/truth_drop_watched.shtheDROP TABLE(three runs),test/truth_halt_encoding.shthe key. On ee7e426 each fails (the colliding claim readsverified,use_variableandreplicareach the proof, theSETclause’ssearch_pathis recorded and refutes, the hidden row is rendered and its verdict served,verify()ends with “invalid role OID”, theDROPis aborted in each run, the retired claim is verified again and adopted, the key is not halted), on PostgreSQL 19 and an assertion-enabled 18 (the collation only on 18: the 19 build has no ICU); now none of it. - Two author environments bound alike.
- A retired claim stays retired on every path, a key names at most one row of everything phasevec
reads, and phasevec lives in a UTF8 database (eighteenth review). The eighteenth review found
that three of the seventeenth’s rules held only on the paths it had looked at:
- A claim refuted under 0.19.0 came back after the update. “Retired” was only “
proofis NULL”, and only the newrefute()made it so: a claimrefute()had refuted under 0.19.0 kept its proof through the update,adopt()asserted it again and the nextverify()served itverified(a production deployment: every memory deleted before the update came back current). A claim with no text recorded (asserted before 0.20.0 and never adopted, or with no proof) readunprovenafter arefute(), notrefuted, so a memory deleted after the update was served too; andrefute()of a replaced claim did nothing, no error, andunsupersede()brought it backverified; a retired claim superseded and unsuperseded readunprovenfor good. Retired is now a state of its own:truth.retired_at, kept by a constraint (truth_retired_check: a retired claim has no proof and readsrefutedorreplaced), andphasevec._claim_state()names the four states (judged,halted,bare,retired) that every transition goes through.refute()retires any claim, a replaced one too (behind its replacement), and records the text it refutes (the row’s text when it ran);supersede()keeps the state andunsupersede()shows a retired claimrefutedagain;verify(),verify_one(),adopt(), recording a verdict, a halt or an interruption never touch a retired claim; anassert()is the only way back. The update retires whatrefute()refuted under 0.19.0, told apart by its columns (below, Upgrading). - A key named two rows.
watch()took any unique index, and every read saidFROM table. UnderUNIQUE ... DEFERRABLEa writer limited by row-level security inserted, in its own transaction, a row under the key of one it could not see: its own row made the key visible to it, and_render()(as the table’s owner, by key) showed it both rows, the hidden one’s text included. A row anINHERITSchild held, under a key the parent’s index does not cover, was rendered the same way. A watch now reads only a plain table outside any inheritance tree, by a unique btree index enforced at once (Supported configurations);watch()checks it, every read checks it again, and every read saysONLY. - One author’s byte stopped a watch. A claim’s identity and a halted key’s hash converted to
UTF-8 first: in a
SQL_ASCIIdatabase a byte that is not UTF-8 (0xe9), inWIN1252one with no UTF-8 equivalent (0x81), in a proof, a recorded setting or a key raised in_truth_due, so one author’sassert()stoppedverify()for every claim of the watch,refute()could not retire it, and a proof that ended its session was never recorded halted and ran again on everyverify(). 0.20.0 now lives in a UTF8 database only (CREATE EXTENSIONand the update refuse any other), and every hash takes a text’s bytes as they are: nothing to convert, nothing that fails. - An owner’s
refute()and then DDL deadlocked withverify_one()(low). A proof’s session waited for a claim’s row holding the watched table (the orderDROP TABLEtakes them in); an owner’s transaction that took the row (refute(),assert()) and then altered the table waited for the table. The wait for a row another session holds now holds nothing else (it judges nothing and reads no row of the table), and averify_one()whose claim is retired while it waits returnsNULL, as for one that had no claim when it was called.
test/sql/truth_review18.sqlplays the update (claims refuted byrefute(), by a proof’s false, by a proof’s error, behind a replacement, never asserted),refute()of a claim with no text, the supersede interleavings, the constraint,_key_shape()on ten shapes, and both leaks, before and after a laterALTER TABLEandCREATE TABLE ... INHERITS;test/sql/truth_partition.sqlthe partitioned table, its partitions, and a table attached as a partition afterwatch();test/truth_halt_encoding.shthe refusal inLATIN1,SQL_ASCIIandWIN1252and, in UTF8, a halted key, a proof and a setting outside ASCII;test/truth_retire_ddl.shthe deadlock (refute()andassert()). On a400376 each fails (key 1, refuted by hand under 0.19.0, is adopted and servedverified, so are the replaced key unsuperseded and a deleted key with no text, the retired key superseded and unsuperseded readsunproven, every shape is watched, the hidden row’s text is rendered through aDEFERRABLEkey and through a child, 0.20.0 is installed in all three encodings, both runs deadlock), on PostgreSQL 19 and an assertion-enabled 18; now none of it. - A claim refuted under 0.19.0 came back after the update. “Retired” was only “
- A verdict is its author’s while the author is that role, and
verified_atonly moves forward (nineteenth review). The nineteenth review was a model-based fuzzer of the claim lifecycle (assert(),refute(),supersede(),unsupersede(),adopt(),verify(), row writes, row-level security,DROP ROLE, the update from 0.19.0, dump and restore): after every step it checks what_verdict(),brief(),truth_coverage()andreplacement()serve against a model of the lifecycle and against invariants that need no model. It found the four the eighteenth review fixed (on a400376), and two more on 8902c9b:- A dropped author’s verdict was served. After
DROP ROLEof a claim’s author (and a new role under its name),_verdict(),brief()andtruth_coverage()went on serving itsverified(orrefuted) untilmax_agepassed or the nextverify()recordedunproven: this README said it readunproven, and onlyverify()checked. Now_verdict_ofchecks it when read, asverify()does when it takes a claim: a verdict of a proof (a halted one too) readsunprovenas soon as no role has its author’s OID and name (dropped, renamed, or a new role under the name), and as before once that name is back on that OID. Not an event trigger:DROP ROLEfires none (a role is the cluster’s, not a database’s), and a role dropped from another database is gone from this one too. A retired claim is nobody’s proof and still readsrefuted. It is one catalog cache lookup a claim (to_regrole): about 0.8 us, andbrief()of 1000 verified claims went from ~30 ms to ~32 ms on PostgreSQL 19 (medians of 15, the two measured together; a join withpg_rolescost 3.5 us a claim,brief()+50%). refute()movedverified_atback. In a transaction that began before averify()recorded a verdict,refute()stampedverified_at(andretired_at) with its ownnow(), before that verdict (about 0.2 s in the fuzzer’s replay)._truth_setrefused that already;refute()did not, nor did_truth_haltedunder its lock (it checked before it took the row). Every writer ofverified_atnow keeps it from moving back:refute()stamps the later of itsnow()and the verdict it replaces,unsupersede()shows a retired claim as of the later of its retirement and its last verdict, and_truth_haltedchecks again under the row’s lock.assert(),adopt()andunsupersede()of a claim that is not retired clear it, as before: a claim to be judged again has no verdict time.
test/sql/truth_review19.sqldrops an author of a true and a false claim and makes a new role under its name, renames another and renames it back, keeps a third (a name that must be quoted) and a retired claim, and reads_verdict(),brief()andtruth_coverage(); then it refutes a claim in a transaction older than averify_one()verdict, and supersedes and unsupersedes it. On 8902c9b it fails (keys 1 and 5 readverifiedandbrief()serves them, key 2 readsrefuted, andverified_atmoves back by the transaction’s age), on PostgreSQL 19 and an assertion-enabled 18; now none of it. The fuzzer, its specification and its findings are in a production deployment (aportes/phasevec-fuzz). - A dropped author’s verdict was served. After
- Every read-then-write of a claim is read again under its lock, a claim comes back from behind
its replacement in the state it had, a
TRUNCATEis a write the watch sees, and a proof has the settings its author had where it was asserted (nineteenth review, second pass). The review’s lenses and a one-hour run of the fuzzer on 8902c9b found, besides the two above:adopt()brought back a claim retired, or asserted again, while it ran (R19-F7). It read the orphans with no lock and asserted each again from that read: arefute()that committed meanwhile was undone (the claim got its proof back, and the nextverify()served itverified, andbrief()to the reader), and another author’sassert()that committed meanwhile was replaced by the stale orphan proof, in the adopter’s name. Nowadopt()takes the orphans' rows as it reads them and reads each again once it holds it (PostgreSQL re-reads a row it waited for, but not the roles it was joined with, so the lock alone was not the check): a claim that is no orphan any more is left alone, and returned with the reason. The rule is every writer’s: a read-then-write of a claim reads it again under the claim’s row lock and writes only the claim it read. The take of a claim for its proof checks the claim’s identity (its bind) under the lock (R19-F10: averify_one()that waited for a transaction that refuted the claim and asserted it again took the new claim, judged the old proof, recorded nothing and answeredunproven; now it says it could not judge it);_truth_halteddoes not halt a claim asserted again since its proof began, and an interruption is recorded only on the claim that was tried;_truth_setdid already.assert(),refute()andsupersede()write blind (the last one stands), andunsupersede()is one statement on the locked row.- A halted claim superseded and unsuperseded read
unprovenfor good (R19-F8).unsupersede()gave every claim that was not retired back asunproven, to be judged;verify()does not run a halted claim again, so it stayedunproven, andbrief()served a claim its own proof had refuted. Now a claim comes back in the state it kept (supersede()changes none of what decides it), with that state’s verdict: retired or halted,refutedas of then; judged,unprovenand due at once (verify()judges it again); bare,unproven. - After
TRUNCATE,_verdict()andtruth_coverage()servedverifiedfor rows that were gone (R19-F9). The watch trigger did not fire onTRUNCATE, so the watch’s notes (phasevec.sources) kept every row’s text untilmax_ageor the nextverify(), andtruth_coverage()counted those rows after that too. Now a watch has aTRUNCATEtrigger too (phasevec_watch_<id>_truncate, made bywatch(), and by the update for every watch made before it), and aTRUNCATEforgets every row the watch noted. A trigger, not a look at the row on every read: the notes are the trigger’s, every other read path already reads the live row, and a read of the table in_verdict()would cost every reader a query, with its rights. The update takes every watched table before the extension’s tables (all of them or none, again until no writer holds one), so a writer of two watched tables is never waited for while it holds the other. - A caller chose the search path of the author’s proof (R19-PRIV-1). A claim recorded the
session’s settings read past every function’s
SETclause (the seventeenth review’s answer to an author’s wrapper function), so a role that could call an author’sSECURITY DEFINERwrapper hardened withSET search_pathdecided the search path of the author’s proof: its own operator>forbigintandint, first on its path, ran as the author when the proof was judged, read a table the caller could not, decided the verdict, and throughdblinkmade the caller a superuser. Now a claim records what is in effect whereassert()is called, as the author has it there: theSETclause of the author’s own function is the author’s, and nothing a role without the author’s privileges set is recorded (the caller’ssearch_path, startup options, or its own function’sSETclause around an unhardened wrapper). MIND-ENV-1 under the same rule: a proof means what it means where it is asserted, so a claim asserted through a wrapper and its copy adopted through a wrapper with the sameSETclause (or by the author’s session in the same settings) record the same settings and the same verdict;adopt()records the settings where it is called, byassert()’s rule, for a claim from before 0.20.0 too. _key_shape()locked any table for anyone (R19-PRIV-2). It was open toPUBLICand took a reader’s lock on the table it was given, kept to the end of the transaction, with no privilege check: a role with onlyUSAGEon the schema held it, and anALTER TABLEqueued behind it stopped every later reader of the table. Now it answers only a role that may watch the table (its owner, or a role with the owner’s privileges:watch()’s rule), before it locks it.- A
DROP TABLEwhile a verifier read the table’s shape was an internal error (RT19-CRASH-1). The check that every read makes without a lock opened the table’s relcache entry, and aDROP TABLEthat committed meanwhile gaverelation N deleted while still in useorpg_attribute catalog is missing 3 attribute(s)(XX000), and the verifier’s privilege checkrelation with OID N does not exist, instead of the refusal “the table is gone”. The indexes are now read frompg_index, and a table gone is that refusal. No verdict changed either way.
test/sql/truth_review19b.sqlplays R19-PRIV-1 (a hardened wrapper and an unhardened one called by a role with its own operator and settings), the same rule against MIND-ENV-1 (a wrapper’s claim, its copy from before 0.20.0 adopted through a wrapper with the sameSETclause, and one adopted by the author’s session in the same settings), R19-F8 (a halted, a retired and a judged claim superseded and unsuperseded), R19-F9 (aTRUNCATEby a writer granted it, the rows inserted again, a trigger on another table, and a watch made under 0.19.0 and updated) and R19-PRIV-2.test/truth_adopt_race.shplays R19-F7 (arefute()and another author’sassert()committing whileadopt()waits) and R19-F10;test/truth_drop_race.shchurns watched tables for 20 s under four verifiers (RT19-CRASH-1: 138 internal errors on f1d8136, none now). All of them fail on f1d8136 and pass now, on PostgreSQL 19 and an assertion-enabled 18. A record about a run is the run’s claim’s, by the row’s version and not by a clock; a setting is the author’s whole or not at all, and set again exactly or not judged; the update takes only phasevec’s tables (twentieth review). The review’s lenses and a one-hour fuzzer run on 35dfa69 (768 sequences, no violation; it never changed a session setting, so the claim settings went untested, now
fuzz.pydoes) found:- A claim asserted again was halted by the proof it outlived (RT20M-1).
_truth_haltedleaves alone a claim asserted again since its proof began, and told it bychanged_at <= p_started; but every writer stampedchanged_atwithnow(), the start of its transaction. Anassert()in a transaction that began before the proof, which waited for the proof’s row and committed after the proof’s session ended itself, passed for the claim that ran: it was halted (refuted, andverify()never ran it again), and theassert()that is the documented way out of a halt was undone. The principle: a write-order guard compares the version of the row, not a time. A record made apart from the run it is about (a halt, a proof that overran its clock, an interruption, each written by another transaction than the one that took the claim) carries the row version that run took (itsxmin) and is written only while that is still the row’s (_truth_halted,_truth_set,_truth_tried). Every write of the row makes a new version, whoever writes it, and a proof’s session writes none; no clock is read. The audit: the other comparisons of a stamp with a time order verdicts by the moment their state was read (verified_at, a worker’s own transaction start after it took the row), and the due list’snow() - p_everyis an age, not an order. - A wrapper’s
SET DateStyle = ISOrecorded its caller’s field order as the author’s (RT20M-2).DateStyleis made of two parts, and aSETthat names one keeps the other from the value it replaces; PostgreSQL stamps the merged value with the role of the lastSET. A caller without the author’s privileges setDMYin its own session, called the author’s hardened wrapper, and the claim recordedISO, DMYas the author’s: the same proof over'03/04/2026'wasverifiedwhen the author asserted it andrefutedwhen that caller did. A composite setting is now recorded only when every value under it in the session’s stack (and the session’s own, with its role’sALTER ROLE ... SETcounted as that role’s) was set by the author, a role with its privileges, or the configuration every session of the database starts from; otherwise nothing of it is recorded and the proof has its author’s own value. Not refused: refusing would let any caller make the author’s function fail (a trigger that asserts: every write) by setting its ownDateStyle. An author that wants aDateStylein its proofs whatever its callers do sets it for its role (ALTER ROLE ... SET), which its proofs' sessions have. A limit (found by this round’s review): the stack holds only the values saved since the transaction began, so a session-levelSET DateStylemade inside one of the author’sSECURITY DEFINERfunctions (a plainSETin its body, notSET LOCALand not itsSETclause) leaves the caller’s session a merged value stamped as the author’s, which a later assert in that session records; an author’s function setsDateStyleonly withSET LOCALor itsSETclause. - A recorded setting that could not be set again was dropped, and the proof judged anyway
(RT20P-ENV-1). The proof’s session set each recorded setting with a
WARNINGon failure: when the author lost theSETprivilege it had through a role (another role withADMINon that role revoked it), or the value named an object its owner dropped since (default_text_search_config = libs.plain), the proof ran with the author’s default andverifiedwas recorded for a bind that names the setting. Now every recorded setting is set again with an error on failure and must then read as recorded, or the proof is not judged:unproven, with the setting and the reason (not judged: the setting ... cannot be set again as its author had it (...): assert it again); also when a library the proof loads applies its own definition to a recorded placeholder and does not take the value. A recorded placeholder with no value (app.x=) whose library is loaded is in effect at its default, as in the author’s own session. - The update’s NOWAIT loop starved, ignored
lock_timeoutand deadlocked a writer (RT20M-3, R21-MIND-3, RT20-CRASH-1, RT20-CRASH-2). To make theTRUNCATEtriggers, round 21’s update took every watched table first, withNOWAIT, again until all were free at once: under steady single-row writers that moment rarely came (not done after 60 s with twopgbenchclients),NOWAITnever waits solock_timeoutnever ended it, a writer idle in its transaction held it forever (and with its client gone it committed later, unseen), and a transaction that had read phasevec (brief(),next(),coverage()) and then wrote a watched table deadlocked with it and was aborted. The update now takes only phasevec’s own tables, in one queuedLOCK(behind the writers that hold them, which end), bounded bylock_timeout, and when that ends it stops withphasevec: the update to 0.20.0 could not take phasevec's tables within lock_timeout, the sessions that hold them, and what to do; nothing is changed, and it is run again. It never holds a watched table while it waits, and never waits for one while it holds phasevec’s: a watch’sTRUNCATEtrigger is made only where its table is free at that moment (NOWAIT, once), and the update names every watch whose table was busy with the statement that makes the trigger apart,phasevec.watch_truncate(watch), which waits for the table’s writers like anyCREATE TRIGGER(bounded bylock_timeout) holding nothing else of phasevec’s. The update also reads each watched table (its key’s shape, its rows' texts) under a reader’s lock, taken withNOWAITtoo: in the first cut of this round it waited for that lock while it held phasevec’s tables, and a transaction that altered a watched table (ALTER TABLE,TRUNCATE) and then wrote it was aborted by the deadlock detector (found by this round’s review); now the update stops, named, and that transaction commits. ThatNOWAITcovered the watched table’s own lock only; since the twenty-first review the update waits for no lock on anything a watch reads while it holds phasevec’s tables (below). - A table owner’s trigger with the name of the watch’s
TRUNCATEtrigger stopped the update (RT20P-UPD-1). It failed with a bare “trigger … already exists”. A trigger of that name that is phasevec’s own (AFTER TRUNCATE,phasevec._observe(<watch>)) is the watch’s; another one is left alone and named, by the update (aWARNING, the update goes on) and bywatch_truncate()(an error with what to do). adopt()deadlocked withDROP TABLE(RT20M-4, RT20-CRASH-3). Since R19-F7 it held the orphans' rows before it rendered the first one, which is when it took the table;DROP TABLEtakes the table and then, through_on_drop, the rows. It now takes the watched table first, as every verifier does (watched_table_first): aDROPwaits foradopt(), oradopt()waits for theDROPand then says the table is gone.- A
verify_one()that waited for asupersede()ran the replaced claim (RT20M-5). The take checked the bind, whichsupersede()does not change, and not the verdict: it ran the proof, recorded nothing and answeredreplaced. The take leaves a replaced claim:verify_one()answersNULL, as for a key with no claim to run. - A
TRUNCATEof a watched table costs per row (RT20-CRASH-4). Documented under Limits, not changed: forgetting the rows is aDELETEof the watch’s notes, under theTRUNCATE’s lock.
test/sql/truth_review20.sqlplays RT20M-2 (a hardened wrapper withSET DateStyle = ISO, and one withSET LOCAL DateStylein its body, called by the author in its default and inDMY, and by another role inDMY), RT20P-ENV-1 (a parameterSETgranted through a role then revoked, and a text search configuration dropped by its owner, with a control that is set again) andwatch_truncate()with the update over a table owner’s trigger of the same name;test/truth_review20.shplays RT20M-1 (a proof that ends its session on a lock while a transaction begun before it asserts the claim again), RT20M-4 (adopt()while a transaction that took the table drops it), RT20M-5, and RT20M-2 with the caller’sDMYfromALTER ROLE ... SET;test/upgrade_writers.shplays the update under twopgbenchwriters (20 s allowed), behind a writer idle in its transaction withlock_timeout = 1s, and with a transaction that readphasevec.watchesand then writes a watched table. All fail on 35dfa69 and pass now, on PostgreSQL 19beta2 and an assertion-enabled 18.6 (the update under writers depends on timing on 35dfa69: on 19beta2 it ran into the 20 s, on the 18 it got through once in 3 s; the other two cases fail there every time). Intest/sql/truth_review17.sqla setting the author may not set, written into a claim by hand, now readsunproveninstead of being judged without it.- A claim asserted again was halted by the proof it outlived (RT20M-1).
While the update holds phasevec’s tables it waits for no lock on what a watch reads, and a claim it retires is bound to the text as it renders it (twenty-first review).
- The update’s
NOWAITcovered only the watched table’s own lock (RT21P-UPD-1, RT21P-UPD-2). A writer of a watched table holds that table and then waits for phasevec’s tables (its watch trigger reads its watch), so any lock the update waits for while it holds them can close a cycle through such a writer. The twentieth review’s update took the watched table withNOWAITbefore it read it; but it also reads the table’s indexes and TOAST table, and whatever the recipe reads, in a subquery or inside a function of its own. AREINDEX INDEXof the key’s index (it takes the table inSHAREmode, which a reader’s lock does not conflict with), or anALTER TABLEorLOCK TABLEof a table the recipe reads, followed by a write to the watched table, still had the writer aborted by the deadlock detector, and the update went on to 0.20.0. Now every step in which the update reads a watch (its key’s shape, itsTRUNCATEtrigger, its rows through the recipe) runs under alock_timeoutof 1 ms, theSETclause of the update’s own step function (phasevec._update_step, dropped when the update ends; the caller’slock_timeoutis back as soon as the step returns), so every lock such a step takes, whatever it is on, is taken at once or not at all. A try that meets a held lock lets go of what it took (its subtransaction) and is made again four times over 0.3 s, pausing without awaiting any lock, so a short lock (an autovacuum’s on a catalog) passes; a lock still held then stops the update withphasevec: the update to 0.20.0 found what watch <watch> reads (table ..., its indexes, what its recipe reads) locked by another session: nothing was changed, the sessions that hold or wait for anACCESS EXCLUSIVElock on what that step reads (in the database, until the twenty-second review), and what to do; that transaction commits, and the update is run again. The rest of the update writes only phasevec’s own tables, which it holds, and the system catalogs (the first cut of this fix bounded every lock of the rest of the script too, and a freshCREATE EXTENSION, which runs this script, failed on a catalog lock on the assertion-enabled 18). TheTRUNCATEtriggers keep their ownNOWAIT, which skips a busy table and names it, as before. Listing the relations beforehand and taking each withNOWAITwas the other way; it was not taken because a recipe may read through a function of its own, which neither its plan nor its dependencies show. The cost: an update that finds anything a watch reads locked for longer than that (ALTER TABLE,REINDEX,VACUUM FULL,LOCK TABLEin an open transaction) stops instead of waiting, and is run again. - The update unbound the claims it retired (RT21M-1). It bound each
refutedclaim to the row’s text as 0.19.0 had noted it (truth.asserted_forfromphasevec.sources) and retired thoserefute()had refuted, then (step 9) rendered every row again in the pinned settings and noted the text again where it read otherwise. 0.19.0 rendered a row in its writer’s settings: with a recipe that prints a date, a time or a float, on a server whoseTimeZoneorDateStyleis not the pinned one, every row was noted again, every retired claim then readunproven(its text was not the one it was bound to), andbrief()andtruth_coverage()served every memoryrefute()had retired before the update (a replaced one readunprovenafterunsupersede()). Now the update binds and retires them after step 9, to the text as every reader renders it: they stayrefuted, retired. (a production deployment’s recipes print text columns only: there 0.19.0’s text and the pinned one are the same.) watch_truncate()needed more than its own grant. It runs with its caller’s rights, because it makes a trigger on the caller’s table, and it reads the watch: a role granted onlywatch_truncate()got “permission denied for table watches”. Documented, and narrowed: it is the extension owner’s to run, or to grant withEXECUTEonphasevec._truncate_trigger(int, boolean)andSELECT (id, name, source)onphasevec.watches(that role then sees every watch’s name and table, not its recipe); the caller must also own the table (Upgrading from 0.19.0).- A caller can still pass its own
DateStyleorder through an author’s function that setsDateStylefor the session (RT20M-2, residual). Not changed: under Limits.
test/upgrade_writers.shcase 5 plays RT21P-UPD-1 (REINDEX INDEXof the key’s index, then a write) and RT21P-UPD-2 (ALTER TABLEof a table the recipe reads in a subquery, andLOCK TABLEof one it reads through a function of its own, then a write): on 127001e the writer is aborted by the deadlock detector each time; now the update stops, named, and nothing changed, the writer commits, and the update then goes through. Its case 4 now looks for the new message.test/sql/truth_review21.sqlplays RT21M-1 (atimestamptzrecipe under the suite’sPST8PDT: claims refuted, one replaced, before the update; on 127001e they readunprovenandbrief()served them), checks that the caller’slock_timeoutcomes back after the update inside its transaction, and playswatch_truncate()by a table owner granted it alone, with what it reads, and by a member that does not inherit the owner’s rights. Both pass on PostgreSQL 19beta2 and an assertion-enabled 18.6.- The update’s
- A recipe renders a text or nothing: an error that is not its own ends the render even when the
recipe catches it (twenty-second review).
- The update noted a recipe’s fallback text and bound the claims it retired to it (RT22P-UPD-1).
The update reads every watch under a
lock_timeoutof 1 ms (twenty-first review). A recipe that reads another table through a function of its own with anEXCEPTIONclause (a defensive lookup:EXCEPTION WHEN OTHERS THEN RETURN '(label unavailable)') never fails in normal use, because a lock wait there has no timeout. During the update, with another session holding that table, the function caught the lock failure and returned its fallback: the update saw no error, noted the fallback text for every row (step 9), bound everyrefutedand retired claim to it, and went through without a word. Coverage then read every row as pending, and the first write of such a row, evenUPDATE ... SET body = body, noted the real text: the retired claim readunprovenandbrief()andtruth_coverage()served the memoryrefute()had retired, the RT21M-1 outcome by another path. The root is not the update’s: a render took a text past an error that was not the recipe’s. While a recipe runs, phasevec’s guard now has a callback on PostgreSQL’s error context stack, which the server calls for every error raised below it before any handler sees it. It notes the firstERRORof a class that comes from the server or another session (the classes that are no verdict for a proof since the eleventh review, now one function: a lock another session holds, a deadlock or serialization failure, a cancel or timeout, the server short of a resource, an I/O, configuration, foreign-data-wrapper or internal error), unless it is aRAISEof the recipe’s own. When the recipe returns after such an error was caught, the render ends with that error and its code:phasevec: the recipe caught an error that was not its own (SQLSTATE 55P03): what it rendered then is not the row's text, with the table whose lock it was waiting for (on PostgreSQL 19, whose error for a lock wait says which; 18 does not). The update’s step retries it like a lock failure the recipe did not catch, and then stops, named, with nothing changed; it goes through once that lock is gone, and the claims stayrefuted. The same holds wherever a recipe renders: a write whose recipe caught its own session’slock_timeoutorstatement_timeoutfails and notes nothing (it noted the fallback as the row’s text), andbrief()or_render()fails instead of serving the fallback. An error of the recipe’s own data (division_by_zero) or aRAISEof its own that it catches is still its text, as before. (a production deployment’s recipes print text columns only, with no function of their own: it was not exposed.) - The update’s message named every
ACCESS EXCLUSIVElock in the database. ItsDETAILlisted every session that held or waited for one, on any relation. It now lists only those on the relation the step was waiting for (the error’s schema and table: the guard names it for a lock failure in a recipe, caught or not, from the server’s own context line for the wait, since the wait is over when the error is raised), the watched table, its indexes and its TOAST table. PostgreSQL 18 gives no context line for a lock wait: there the relation goes unnamed, and the message lists the locks on every relation a recipe could read (not another session’s temporary relation, not one not yet committed, not phasevec’s own). - A recipe that retries a lock failure in a loop holds the update until its
statement_timeout(RT22-ATK-1). Not changed, and not new (83cfa95 does the same): a recipe that catcheslock_not_availableand tries again forever spins while the update holds phasevec’s tables, and a writer of the watched table waits for them. Nothing deadlocks, nothing is noted, and the update’sstatement_timeoutends it (under Limits). - The update’s own settings can be read by a recipe (RT22-ATK-2). Not changed: a recipe that
reads
current_setting('lock_timeout')renders otherwise during the update, with no error, and the update notes that text. Only the table’s owner writes a recipe, and a write to the row that changes its text does the same to its claims (under Limits).
test/upgrade_writers.shcase 6 plays RT22P-UPD-1 (a recipe that reads a table through a function withEXCEPTION WHEN OTHERS, while another session holds that table and a third holds a table apart, two claims refuted before the update): on 83cfa95 the update goes through, the 20 rows are noted with the fallback, and after a write that changes nothing the two refuted claims readunprovenand are back inbrief(). Now the update stops, named, with the held table in itsDETAILand (on 19) not the one apart; once the lock is gone it goes through, every row noted as_render()renders it, and the claims stayrefutedafter the same write. Case 7 plays the same recipe for a writer with alock_timeoutof its own after the update: on 83cfa95 the write commits and notes the fallback; now it fails and notes nothing.test/sql/truth_review22.sqlplays astatement_timeoutthe recipe catches, for_render(),brief()and a write (on 83cfa95 all three take the fallback, and the write notes it), and an error of the recipe’s data and aRAISEof its own that it catches, which stay its text. All three pass on PostgreSQL 19beta2 and an assertion-enabled 18.6. - The update noted a recipe’s fallback text and bound the claims it retired to it (RT22P-UPD-1).
The update reads every watch under a
- The update waits only while it holds nothing of phasevec’s, and once it holds something it waits
for nothing; a recipe’s own error is its own (twenty-third review).
- The update’s first lock took phasevec’s tables one by one (RT23P-UPD-1).
LOCK TABLE phasevec.watches, phasevec.sources, phasevec.lexstats, phasevec.lexdocs, phasevec.truthtakes them in that order and holds each one while it waits for the next. The script called that safe because it is the order of phasevec’s writers, which takephasevec.watchesfirst. A transaction that first readphasevec.truthorphasevec.sourcesdirectly (a report by the owner, a role grantedSELECT, or aGRANTon the table in a migration, which locks it) and then wrote a watched table waited forphasevec.watches, which the update held while it waited fortruth. The deadlock detector then aborted the transaction (and the update went on to 0.20.0) or, when the write came sooner, the update, with a baredeadlock detected. Now each try of the update waits, queued and bounded by the caller’slock_timeout, for one thing only, holding nothing of phasevec’s: firstphasevec.watches(once the update has it, no write starts). Then it takes everything else it will change without waiting: the five tables withNOWAIT, and the catalog entries below. The first one that is busy ends the try: its subtransaction lets go of all it took, and the next try waits for that one, again holding nothing (50 tries at most). So a transaction in the update’s way never waits for the update while the update waits for it. Writers queue behind the update only once it holdsphasevec.watches, as before. - After it held phasevec’s tables, the update waited without a bound for catalog rows
(RT23P-UPD-2). The update rewrites the catalog entries of phasevec’s functions (
CREATE OR REPLACE,GRANT/REVOKE,ALTER FUNCTION,COMMENT,DROP). A transaction that had changed one and not committed (aGRANT EXECUTEonphasevec.brief()in a deploy) and then wrote a watched table closed a cycle: the update waited for that transaction’s row, and the transaction waited for phasevec’s tables. The deadlock detector aborted one of them, and the update went on. Now these are part of what each try takes without waiting: the catalog row of every function of phasevec’s (FOR UPDATE NOWAIT) and the function itself (its comment is written again, unchanged, under alock_timeoutof 1 ms: the lock aCOMMENTor aDROP FUNCTIONtakes). A busy one is waited for like a table, holding nothing. The rest of the script runs under alock_timeoutof 100 ms, or the caller’s if it is shorter. Everything it locks there it holds already, or takes in_update_stepwithout waiting. Any wait nothing above foresaw (a catalog that aVACUUM FULLholds) is bounded, and it ends the update with PostgreSQL’s owncanceling statement due to lock timeout, nothing changed. The caller’slock_timeoutis set again at the end of the script. The first cut of the twenty-first review bounded the whole rest of the script at 1 ms, with no retry, and a freshCREATE EXTENSIONon the assertion-enabled 18 failed on a catalog lock. Here a try that meets a short lock is simply made again, and 100 ms outlasts an autovacuum’s truncation lock, which autovacuum gives up within 20 ms of a waiter: 40CREATE/DROP EXTENSIONruns under catalog churn passed on both versions. The update stops withphasevec: the update to 0.20.0 could not take phasevec's tables within lock_timeout (...), as before, or... could not take phasevec's function phasevec.brief(p_watch text, p_budget integer) within lock_timeout (...). TheDETAILnames what it waited for and the sessions holding it. - A recipe’s own
RAISEof 55P03 was blamed on another session, and one recipe’s error hid the rest (RT23P-UPD-3). The update’s step took anylock_not_availablefrom a render for a lock: a table owner’s recipe that raised that code itself was retried for 0.3 s and then blamed on “another session” (DETAIL: none any more). Any other error of a recipe stopped the update with only the recipe’s message, and the first such watch hid the rest. The codes phasevec keeps for the server’s errors (a lock another session holds, a deadlock, a serialization failure, a cancel:guard_error_not_its_own) now come out of a recipe only when the server raised them. A recipe’s own PL/pgSQLRAISEof one of them leaves its guard as the recipe’s own error,P0001, with the code it raised in theDETAIL(guard_catch,src/phasevec_guard.c). This applies wherever a recipe renders: a write,brief(),_render(). And an error of its own that a recipe raises after it caught one of the server’s leaves the guard as the server’s, as the twenty-second review already did when the recipe returns a text. The update’s render step now returns a recipe’s own error instead of raising it. The update names every watch with one, with its error, in one message:phasevec: the update to 0.20.0 could not render the rows of these watches: nothing was changed. It still stops: a watch left as 0.19.0 noted it would bring the claims retired below back asunprovenat the first write that renders its row in the pinned settings (RT21M-1). The table owner fixes the recipe or the rows (their writes fail the same way under 0.19.0), or the watch is dropped, and the update is run again (under Limits).
test/upgrade_writers.shcase 8 plays RT23P-UPD-1 and RT23P-UPD-2. A transaction does one of: readsphasevec.truth, readsphasevec.sources, grants onphasevec.truth, grants onphasevec.brief(), comments onphasevec._verdict(), or altersphasevec.brief(). Then it writes a watched table 0.6 s or 1.5 s later, during the update. On 15d67b7 one of the two is aborted by the deadlock detector in all twelve runs, on both versions. Now both commit, and the update reaches 0.20.0. Case 9: a transaction that granted on a function and stays open stops the update withinlock_timeout, named, while a writer of a watched table goes on (on 15d67b7: a barecanceling statement due to lock timeout). Case 10: two watches whose recipes raise 55P03 and P0001 themselves are named together, with nothing changed. On 15d67b7 the first was blamed on another session and the second was not reached. A recipe that catches the update’s lock failure and raises an error of its own is a lock failure, named; on 15d67b7 the update stopped with only the recipe’s message.test/sql/truth_review23.sqlplays a recipe’s ownRAISEof 55P03, 40001 and 57014 (including a re-raise of one it caught) for a write,brief()and_render(), an error of its data that keeps its code, and astatement_timeoutcaught and followed by an error of the recipe’s own. All three fail on 15d67b7 and pass on PostgreSQL 19beta2 and an assertion-enabled 18.6. - The update’s first lock took phasevec’s tables one by one (RT23P-UPD-1).
- The update locks everything it will change before it changes anything, waits queued and bounded,
runs only at READ COMMITTED, and names every object in the way of its drops (twenty-fourth
review). Four reviews in a row found a hole in the update’s lock protocol, each patched by a
special case. The principle that closes the family: before it changes anything the update takes
every lock it will need, and every wait that could close a cycle with another session is one that
the update loses.
- Under REPEATABLE READ or SERIALIZABLE the update missed what committed while it waited
(RT24P-RR). Its snapshot was the transaction’s, taken when
ALTER EXTENSIONbegan, before it waited for phasevec’s tables: arefute()committed in that window was not seen, the claim was not retired, and afteradopt()andverify()it was servedverified. The update now refuses to run unless the transaction is READ COMMITTED (phasevec: the update to 0.20.0 runs only at READ COMMITTED, and this transaction is REPEATABLE READ), asverify()already did. At READ COMMITTED every statement after its locks reads what committed before it held them. - The function lock the update took was not the one
DROP FUNCTIONtakes (RT24P-DEP3). The update wrote each function’s comment again to lock it: aCOMMENT’s lock, which does not conflict with the lock PostgreSQL takes on a function to record a dependency on it. A transaction of any role withUSAGEon the schema that made a view overphasevec._brief_keys()held such a lock, and the update met it only at itsDROP FUNCTION, after rendering every watch: a barecanceling statement due to lock timeout, or, with a shortdeadlock_timeout, that transaction aborted as a deadlock and the update went on. Now the update takes each function’s lock in the modeDROP FUNCTIONdoes (ALTER FUNCTION ... OWNER TOthe owner it already has, which changes nothing), with the other locks. - The update gave up after 50 fast tries (RT24P-LIVE). Each try waited for one table and took
the rest
NOWAIT. Under steady writers of watched tables (who takephasevec.watches) and steady readers ofphasevec.truth(who do not), the two were never free at once, and the update stopped in about a second even withlock_timeout = 0. Now each try waits first, holding nothing of phasevec’s, forphasevec.watches(after a failed try: the lock that ended it), for as long as the caller’slock_timeoutlets it. Then it waits, queued, for each of the other locks in turn: phasevec’s tables, the catalog row of each function, each function’s lock. Each wait lasts at most 100 ms times the try’s number, and never more than half thedeadlock_timeoutthe update’s session reads, with the update’s own deadlock check after 1 ms. A session that holds one of these locks and then waits for the update closes a cycle. If it began waiting first, the update’s check finds the cycle within 1 ms and the update’s try gives up. If it began later, the update’s wait ends before that session’s deadlock check runs. Either way the try lets go of what it took, and the other session goes on. Tries go on until the caller’slock_timeoutis spent (0: until the update has everything, or is canceled); then it stops, named, nothing changed. A deadlock in a try’s first wait, which holds nothing of the try’s, is the caller’s own transaction’s (a lock it took before the update), and ends the update withdeadlock detected. - A role with
USAGEon the schema could block the update with an object over a function it drops, one name per run (RT24P-DEP). A view, a column default, a policy or aBEGIN ATOMICfunction that refers to_truth_due,_truth_set,_prove_c,_noteor_brief_keys(internals of 0.19.0, gone in 0.20.0; referring to one needs noEXECUTE) madeDROP FUNCTIONfail after the full render, and each run named only the first such object. The update now looks for all of them before it locks anything, and again once it holds everything, and stops naming every one:phasevec: the update to 0.20.0 drops functions that objects outside the extension depend on: nothing was changed, withDETAIL: view m.v depends on function phasevec._brief_keys(text); .... Drop them (or change them not to refer to these functions) and run the update again. - A wait after the locks still ends with PostgreSQL’s own error (RT24P-INFO-1). Not changed,
documented: after the update holds its locks, the rest of the script runs under the same bound
as a try (a
lock_timeoutof half thedeadlock_timeout, at most 100 ms, at most the caller’s; the update’sdeadlock_timeoutat 1 ms). A wait nothing above foresees (aVACUUM FULLof a catalog) ends the update withcanceling statement due to lock timeoutordeadlock detected, nothing changed, before any other session’s deadlock check runs. Run it again. The caller’slock_timeoutanddeadlock_timeoutare set again at the end of the script.
test/upgrade_review24.shplays the four findings. Case 1: under REPEATABLE READ and SERIALIZABLE the update now refuses to run; on 868251d it goes through. Case 2: a view over_brief_keys()and aBEGIN ATOMICfunction over_truth_due(), made by a role with onlyUSAGE: both are named in one message; on 868251d one per run. Case 3: a transaction of that role makes the view, then writes a watched table 0.6 s or 1.2 s later, every session’sdeadlock_timeoutat 20 ms: the write commits and the update names the view; on 868251d the update stops with a bare lock timeout atDROP FUNCTION. Case 4: 4 steady writers and 6 steady readers ofphasevec.truth: 6 of 6 updates (rolled back) get through, each within 0.2 s, and no client fails; on 868251d 3 of 6 (PostgreSQL 19beta2) and 1 of 6 (18.6) stopped after 50 tries. Case 5: the caller’slock_timeoutanddeadlock_timeoutcome back after the update. All five pass on PostgreSQL 19beta2 and an assertion-enabled 18.6, and so dotest/upgrade_writers.sh(cases 8 and 9 included) andtest/upgrade_locks.sh. Played apart, not in the suite: writers, readers oftruthand transactions that readtruthor comment on a function and then write a watched table, together, atdeadlock_timeout1 s and 20 ms: 10 of 10 updates through and no client failed; writers holdingwatches300 ms and readers holdingtruth1 s: 4 of 4 through. - Under REPEATABLE READ or SERIALIZABLE the update missed what committed while it waited
(RT24P-RR). Its snapshot was the transaction’s, taken when
- What phasevec keeps for a call ends with it. A call that opens many guards (
brief(),_render(), …) reads the session’s settings and cursors once, into a pin. A pin belongs to the subtransaction and the transaction it was made in: it ends when its call ends, when that subtransaction is rolled back, and with the transaction, however it ends; its memory is its own. In the fourth cut pins were cleared only when a transaction aborted: a call whose error a savepoint or an exception block caught left its pin behind pastCOMMIT, and the watch trigger of a later write inside a savepoint (subtransaction ids start over in every transaction) took it, with its lists in freed memory. The backend crashed and the server restarted every session: any role withINSERTon a watched table could do it, after astatement_timeoutor an error inside_render()(test/sql/truth_pin.sql; against that cut it ends the backend at its firstINSERT). A transaction can no longer commit with a guard open. - A recipe renders one text: as the table’s owner, at default settings. The watch trigger,
watch(),assert(),verify(),brief(),next(),lexical()andaudit_sample()all read a row’s text throughphasevec._render(): the recipe runs as the owner of the watched table (as PostgreSQL runs an index expression inVACUUMorREINDEX), with every setting that can change what a value reads, prints or computes at its built-in default (below) and these pinned on top:DateStyleISO,IntervalStylepostgres,TimeZoneUTC,extra_float_digits1,bytea_outputhex,search_pathpg_catalog, pg_temp,lc_monetary/lc_numeric/lc_timeC,standard_conforming_stringson,xmloptioncontent,xmlbinarybase64,timezone_abbreviationsDefault,row_securityon.FORCE ROW LEVEL SECURITYdoes not apply to the owner there (as for a foreign-key check): in the third cut a watch over such a table rendered no row at all, or the rows a custom setting of whoever called selected. Who may read that text is the reader’s own right:SELECTon the table, and with row-level security only the rows it sees (found as the reader, in the guard, with the reader’s own settings). In the second cutbrief()ran the recipe as its caller with full rights: a table owner swapped a column’s type for one whose cast was its own code, and a superuser reading the brief made it superuser. And a row’s text depended on the session that read it: a writer in anotherTimeZoneedited a row so that it printed as the text its author had vouched for, andbrief()served itverified. - A proof means what it meant to its author.
assert()records, intruth.proof_env, what the author’s session changed over the configuration a new session of the author starts from: every setting it set (SET,set_config, its startup options) of those that can change what a value reads, prints or computes – every setting a user can set (andRESET ALLresets) of the groups statement behavior, locale and formatting, other client defaults and compatibility, and every custom setting (app.tenant, an extension’s) –, itsapplication_name, and every custom placeholder the session made, with a value or not (app.tenant=), since whether one exists is part of whatcurrent_setting('app.tenant', true)reads (NULL, or''). The proof runs in a new session of its author (above) with those on top, so the verifier’s own settings decide nothing. What the configuration gives (postgresql.conf,ALTER SYSTEM,ALTER DATABASEandALTER ROLE ... SET) is not recorded: it is the database’s, as its rows are, and a proof reads it as it is when it runs; a change to it can change a verdict, as a change to a row can. In the eighth review a custom setting made at database or role level (ALTER DATABASE ... SET app.x) was in every new session, and the seventh cut judged no claim asserted before it again, in any session, and they filled the head of the queue (p_n): a new session of their author has it now, and judges them. A claim recorded before the eighth review recorded every setting off its built-in default, the configuration’s too: those are set on top as recorded. Not recorded: resources, the planner, logging, statistics, replication and connections (they change how fast, not what), the session’s timeouts,client_encoding,client_min_messages,lc_messages,dynamic_library_pathandextension_control_path. In the second cut a verifier in anotherDateStyleread the author’s'01/11/2026'as 11 January and recordedverifiedfor a date the proof, as written, refutes; the third recorded twelve named settings, and a verifier’sdefault_text_search_config,timezone_abbreviations,transform_null_equals,row_security, or the custom setting a row-level security policy read, decided the verdict (test/sql/truth_sealed.sql). Since the seventeenth review only what the author may set itself, set in the proof’s session as the author would set it (test/sql/truth_review17.sql). Since the nineteenth: what is in effect whereassert()(oradopt()) is called, as the author has it there – theSETclause of the author’s own function included, and nothing a role without the author’s privileges set (PostgreSQL keeps who set each value): through aSECURITY DEFINERwrapper, the wrapper’sSETclause and whatever its body set are the author’s; its caller’s own settings are recorded only when the caller could have asserted as the author anyway (a superuser, a member that inherits it), and otherwise the proof has the author’s own value for them (test/sql/truth_review19b.sql). Since the twentieth: a setting whose new value is made of the part aSETnames and the rest of the value before it (DateStyle:SET DateStyle = ISOkeeps the session’sDMYorMDY) is recorded only when every value it was built on is the author’s too (set by the author or a role with its privileges, or by the configuration every session of the database starts from); otherwise nothing of it is recorded, and the proof has its author’s own; and every recorded setting is set again exactly in the proof’s session, or the proof is not judged: the claim readsunproven, with the setting and PostgreSQL’s reason inlast_error(test/sql/truth_review20.sql). - A placeholder made for one claim decides no other. A custom setting no library defines
(
app.tenant) exists in a session once anything named it (SET,set_config,RESET, the configuration), and nothing removes it: a rollback takes back its value, not it, andcurrent_setting('app.tenant', true)then reads''where it read NULL. In the seventh cut the guard set such a setting “to its default”, which read''too, so it could not tell one that exists from one that does not: a proof that only calledset_config('app.tenant', 'acme', true), judged first in averify(), changed what a later author’s row-level security policy let its proof see, and a pooled verifier connection that once ranSET LOCAL app.tenantrecorded, andbrief()served,verifiedfor a claim the persistent state refutes. The seventh cut judged a claim only in a session whose placeholders were its author’s and left the others “to a new session”; in the eighth review a claim asserted again between runs, whose proof calledset_configon a new name, came first in every run (it had no verdict yet) and kept every later claim of the run from being judged, in every new session, while their data changed. Now a worker notes the placeholders it started with (its configuration’s) and judges a claim only while it has no other one than those and the ones the claim’s author had; a worker that made one more (a proof’sset_config, a recorded state that names one, a recipe that sets one) ends, and the claim goes to a new worker. A call sends each author’s claims that name fewer placeholders first, so few workers end that way (test/sql/truth_placeholder.sql,test/sql/truth_own_session.sql). A session that ends is the proof’s failure only when the proof ended it. A proof that ends the session it runs in (a
FATAL, a C function’s; since the tenth review no longer its author’stransaction_timeout, which the worker does not take, below) ends its worker, not the verifier: the claim is recordedrefutedand halted (truth.halted), is not run again until it is asserted again, and the rest of the call is judged in a new worker. Until the ninth review any end counted: a role withpg_signal_backend(or the author, or the verifier from another session of its own) terminated the worker, and a true claim was refuted and halted for good and leftbrief(). The worker notes who sent itSIGTERM(the sender’s process id, which the kernel fills in and the sender cannot choose): ended by another process, the claim was interrupted. Nothing is judged, its verdict stays as it was (until its horizon), and it stays due, its turn spent (truth.interrupted_at,interrupted_why;phasevec.attempts.why). A signaller can keep a claim from being judged, never decide its verdict; halting a claim whose worker was ended some number of times would let a signaller that ends it that many times decide it. Until the eighth review that session was the verifier’s own: nothing of the call was written, the claim was marked in shared memory, and the nextverify()recorded it (in the second cut nothing marked it, and every laterverify()died the same way; in the third the mark had no key, and one ended session halted every claim of the watch whose text, proof and author read alike, and of more than eight ended sessions the ninth mark was dropped; in the sixth a rolled-backverify_one()erased one). There are no marks now: a worker of the verifier that ran the proof records the halt (or the interruption) in a transaction of its own, for a verifier that may act as the claim’s author; one that may not neither writes nor locks the claim, and the author’sassert()of that key does not wait on it. A claim asserted again after its proof began is not the one that ran: it runs (test/truth_race.sh,test/truth_worker.sh).A claim is its author’s, and what 0.19.0 wrote carries its version wherever it goes, a dump and restore included (twenty-fifth review). Two principles close this review’s findings. State an older version wrote is marked as such, and is brought to this version (or refused) whenever it enters a newer install, not only by
ALTER EXTENSION UPDATE. And a claim belongs to its author: changing another role’s claim takes that role’s privileges, or the table owner’s.- A 0.19.0 dump restored where 0.20.0 is installed skipped the update’s data steps
(RT25PU-1).
pg_dumpwritesCREATE EXTENSIONwith no version, so the restore made 0.20.0 on empty tables and then copied in the rows 0.19.0 had written. A claimrefute()refuted kept its proof,adopt()asserted it again, andverify()served itverified; the watch had noTRUNCATEtrigger. The README itself sent users this way (to move to a UTF8 database). Now every row ofphasevec.watchesandphasevec.truthsays which version wrote it (data_version): every write of 0.20.0 says0.20.0, and the column’s default,0.19.0, is what a row from before the column reads, whether the update or a restore brings it in. The update brings every row as before, through one function (phasevec._migrate_claims()). After a restore of a 0.19.0 dump, the extension’s owner runsSELECT * FROM phasevec.migrate();once. It takes each watch it brings inSHARE ROW EXCLUSIVEmode, makes itsTRUNCATEtrigger, renders its rows as the update does, and calls the same function. It returns one row per watch (claims brought, what became of the trigger). Until it runs,adopt(),verify(),verify_one(),assert(),refute(),supersede()andunsupersede()of such a watch refuse, naming it andphasevec.migrate(). A claim 0.19.0 wrote never readsverifiedbefore then: it has no bound verdict. - Any role granted the author surface changed any claim (RT25PC-AUTH-1). A tenant under
row-level security replaced, retired and hid another tenant’s verified claims on rows it could
not see. A role with no
SELECTon the table did the same throughassert()with no proof,refute(),supersede()andunsupersede(). One tenant’s brief went from 6 verified claims to 0.refute(),supersede()andunsupersede()are now C, likeassert(): each takes its caller (its current user), refuses one that may not read the table, and checks, with row-level security, whether the caller sees each row it names. The owner’s SQL then decides, on the claim’s row as it writes it (ON CONFLICT ... WHERE, or the row takenFOR UPDATE), whether the caller may change it. A claim is changed by a role with the privileges of the watched table’s owner, or by a role with its author’s privileges while the author is still that role (OID and name). A key with no claim of an author still there (none, one asserted before 0.20.0, one whose author was dropped or renamed) is changed by a role that sees its row. The role that retires a claim withrefute(), or makes a placeholder with asupersede()of a key that had none, is its author. A refusal reads alike for another author’s claim, a hidden row and a missing one:phasevec: role "ta" may not change the claim on key 11 of watch notas. Asupersede()whose new row the caller does not see reads as one whose row does not exist. p_max_costNaN or negative switched the cost cap off; a negativep_nraised PostgreSQL’s own error (RT25PC-INFO-1).verify()andverify_one()now refuse ap_max_costthat is NaN or not above 0 ('Infinity'is no cap), andverify()a negativep_n, with phasevec’s own message.supersede()accepted cycles (RT25PC-INFO-2). 2 -> 3 and then 3 -> 2 hid both claims. Asupersede()whose new key’s chain leads back to the old key is refused (row 2 of watch notas is replaced, in the end, by row 3). The supersedes of a watch take its row ofphasevec.watches(FOR NO KEY UPDATE, which no claim’s write waits for) and read the chain after it, so of two at once the second sees the first.supersede()runs only at READ COMMITTED, asverify()does.- A column storing the row type of
phasevec.truthorphasevec.watchesvetoed the update, one table per run (RT25PU-2). The update adds columns to both, and PostgreSQL refuses that while any column elsewhere stores the row type, directly, as an array, or through a domain or a composite type. Types areUSAGEtoPUBLIC. The check that names every object in the way of the update now names these columns too, all at once, before anything is locked. - A prepared transaction in the update’s way was “none any more” (RT25PU-3, RT26-PREP-1). Its
locks have no pid in
pg_locks, and it is not inpg_stat_activity. The update now names it (prepared transaction 'p1'), and itsHINTnamespg_prepared_xacts. - A tiny
lock_timeoutgave up before the first try (RT26-LT-1, RT25PU-4). Withlock_timeout = 1msthe time spent before the first lock (the dependents check) used up the budget, and the update stopped on an idle database, saying it had waited. The clock now starts at the first try, and the first try is always made, with at least 1 ms. Two updates at once: the second still fails with PostgreSQL’stuple concurrently updated. That is raised before its script runs, whenALTER EXTENSIONwrites the extension’s row, so the script cannot name it; nothing of the second update is changed (below, Limits).
test/review25.shplays them. Case 1: a 0.19.0 database with a refuted, a verified, a never asserted and a replaced claim, dumped and restored where 0.20.0 is installed. Every door refuses beforemigrate(), and nothing readsverified. Aftermigrate(), the claims, the texts, the watch and its triggers read as on the same database updated in place, and afteradopt()andverify()the refuted claim stays refuted. On e905fbaadopt()takes it andverify()serves itverified. Case 2: a freshCREATE EXTENSIONand the update from 0.19.0 make the same extension, object for object (every function’s definition, table, column, default, constraint, index, trigger, comment, grant and dump mark). Case 3: 18 changes by another tenant, a role with noSELECT, and a role that sees every row but is not the author, are all refused, withphasevec.truthunchanged; the author, a member of its role and the table’s owner go through. On e905fba all 18 go through. Cases 4 to 8 play RT25PC-INFO-2, RT25PC-INFO-1, RT25PU-2, RT25PU-3 and RT26-LT-1, each failing on e905fba. The fuzzer (aportes/phasevec-fuzz) has arestore_upgradestep (half of its 0.19.0 sequences move bypg_dump/pg_restoreandmigrate(), with the model of the update) and checks that a cross-author change is refused withphasevec.truthunchanged (INV-AUTHOR).- A 0.19.0 dump restored where 0.20.0 is installed skipped the update’s data steps
(RT25PU-1).
A claim is changed by its author, by the watched table’s owner, or by a steward the owner named, and nothing is taken by coming first; what arrives by replication, or loses its triggers, is brought again; the update queues behind no one who is waiting (twenty-sixth review). The project chose the rule (b) below over the twenty-fifth review’s “first come”, which the review showed was the root of three of its findings.
- A claim with no live author went to the first role that saw its row (RT26PA-4,
RT27-DECISION),
adopt()skipped the check (RT26PA-1, RT27-ADOPT-1), and a key with no row could be reserved (RT26PA-2). After the update from 0.19.0, a restore into another cluster, or aDROP ROLE, every such claim was taken by whichever role granted the author surface came first and saw its row, and kept from the role that wrote it. A tenant’sadopt()with no keys took every orphan of the watch, on rows row-level security hid from it, and was handed their proofs; the tenant who saw the row was then refused. Without row-level security a reader grantedrefute()orsupersede()claimed keys no row had yet, and the author who then wrote the row was refused. Now a claim (assert()over it,refute(),supersede(),unsupersede(),adopt()) is changed only by a role with the privileges of its author, while the author is still that role; of the table’s owner; or of a steward of the watch, an explicit grant:SELECT phasevec.steward('notas', 'auditor');by the table’s owner (falseas a third argument takes it back), kept inphasevec.stewardsby OID and name. A claim whose author is gone or unknown is the owner’s and the stewards' to settle, andadopt()is theirs: it takes no orphan on a row the caller does not see and returns nothing of it, and leaves (and says so) one whose row the new author does not see. A key with no claim gets one from a role that sees its row, and the row must exist, with or without row-level security. supersede()told a tenant a hidden key had a claim (RT26PA-3). Its cycle check ran before the claim check, and its error put a key row-level security hid in the chain of a row the tenant saw. The old key’s claim is now taken and checked first.- A 0.19.0 write that reached a migrated row by logical replication kept the label
0.20.0(RT26PL-1). The label was a default onINSERT; a 0.19.0refute()arriving as anUPDATEleft the proof under it,migrate()skipped the row, andadopt()plusverify()served the refuted claimverified. A replica trigger onphasevec.truthandphasevec.watchesnow labels0.19.0every row a logical replication worker writes;migrate()after cut-over brings them. A session of phasevec’s own that setssession_replication_rolewrites as 0.20.0. DROP EXTENSION ... CASCADE,CREATE EXTENSIONand a data-only restore left every watch without its triggers (RT26PL-INFO-1).migrate()now also takes a watch whose row trigger orTRUNCATEtrigger is gone, makes them again and notes its rows again (row_trigger).- A supersede cycle 0.19.0 took survived the update and
migrate()(RT27-CYCLE-019). Every key on it readreplacedand leftbrief(). Both now undo the newest link of each cycle, asunsupersede()would, with aWARNINGthat names it. - With
lock_timeout = 0the update made 1 ms tries out of the queue, for ever (RT27-LT0-1). The twenty-fifth review’sgreatest(1, NULL)is 1; overlapping readers ofphasevec.watchesstarved it. With 0 the first wait has no bound again, and waits queued (for one table: against steady holders of bothphasevec.watchesandphasevec.trutheach try still waits at most the bound for the other, RT28PU-INFO-1). - A three-party cycle aborted a client (RT26PU-1). The update queued behind a holder that was already waiting for a third session; when that session then waited for the update, the holder’s own deadlock check, armed before the update queued, found the cycle and aborted the holder. Each lock is now tried first without queueing, and when a holder of it is itself waiting for a lock the try ends there, and the next try waits for it first, holding nothing.
- After the caller wrote in the same transaction, the session that then waited for it was the one aborted (RT26PU-2). The update reads, before it writes anything, whether its transaction already has an id; if so its first wait is bounded like the others, and a deadlock in it ends the update (the caller’s transaction), not the other session.
- Lows (RT26PU-3, RT26PU-4, RT26PU-5). A column storing a range (or multirange) over the row
type of
phasevec.truthorphasevec.watchesis named with the update’s other dependents. A function’s catalog row changed in a savepoint is held under the savepoint’s own transaction id, gone once it is released: the update names the transactions that may hold it (a subtransaction of one of: ...) instead of “none any more”. A function’s lock is taken by the library (LockDatabaseObject, asDROP FUNCTIONtakes it), not byALTER ... OWNER TOits own owner: event triggers no longer see oneALTER FUNCTIONper function and per try.
test/review26.shplays them, 15 checks, 14 of them failing on 0e5afba (the fifteenth is a control: a session that setssession_replication_rolestill writes as 0.20.0). Six regression tests and two shell suites that had another role take an orphaned claim, or adopt one, now name that role a steward first (truth_guard,truth_review17,truth_review18,truth_review19,truth_review19b,upgrade_020,review25.sh,truth_adopt_race.sh);dump_configandupgrade_020count one more dumped table. The fuzzer’sINV-AUTHORfollows the rule: astewardstep names an author role a steward or no longer, and anadopt()by a role that is neither the owner nor a steward must fail and change nothing.- A claim with no live author went to the first role that saw its row (RT26PA-4,
RT27-DECISION),
No claim by coming first; a steward is the owner’s, and reaches the rows it sees; an author is its cluster’s; pg_upgrade takes the database; the update’s first wait is queued again (twenty-seventh review). The project made rule (b) precise: nobody gets a claim by arriving first, and reading a row is never enough.
- The first claim on a row went to whoever came first and saw it (RT27PA-1,
RT28-DECISION-FIRSTCLAIM). A role with
SELECTalone, or a tenant reading a shared row, claimed every row that had no claim yet (onerefute()per row, orassert()with no proof), and the role that wrote the row was then refused. A key with no claim now gets one only from a role with an explicit privilege over its row: the table’s owner’s, a steward’s (on a row row-level security does not keep it from), orUPDATEon the watched table with the row passing the caller’s row-level security for anUPDATE(itsSELECTandUPDATEUSINGpolicies). The row is takenFOR KEY SHAREas the caller, in the guard, and let go at once.UPDATEon some columns only is notUPDATEon the table. - The owner and the stewards claimed keys no row had (RT28-RESERVE-PRIV). A first claim, by
anyone, needs the row to exist (
phasevec.sourceshas it). - A steward under row-level security learned the replacement chain of hidden keys
(RT28-STEWARD-ORACLE). Its
supersede()of a hidden key ran the cycle check, whose error named hidden keys, or succeeded. A steward’s right now reaches the rows it is not kept from by row-level security; on any other it is refused like any other role, alike in the chain or not. (adopt()already left such rows.) - A tenant learned that a hidden key held a claim 0.19.0 wrote (RT28-MIGRATED-ORACLE). The per-key 0.19.0 check ran before the claim check. Every door now takes the claim, checks it, and only then says it is 0.19.0’s.
- Revoking a steward (RT27PA-2) and changing the table’s owner (RT27PA-4, RT27PL-INFO-1). A
steward counts while the role that named it, as the table’s owner, still owns the table
(
phasevec.stewards.named_by):ALTER TABLE ... OWNER TOends every steward the former owner named, until the new owner names it. A revoked steward (steward(..., false), or a membership revoked) changes nothing from then on; a claim it was the last to make (anassert(), arefute()) stays in its name, and its author, the owner and the stewards change it, as any claim. The owner chose this. - A restore into another cluster gave a re-created role the claims and the stewardships of the
role it named (RT27PA-3, RT27PL-1). Roles were matched by OID and name, and a role with
CREATEROLEthere made one under the stored OID and name (lo_create()to move the OID counter; or roles made in the same order in two fresh clusters). Every author and steward is now also bound to where it was recorded: the cluster (phasevec._cluster(), its system identifier, which every database of it and a physical replica share) or this installation (phasevec._here(), a value made with the extension, whichpg_upgradekeeps andpg_dumpdoes not). Recorded in neither, it is no one: the claim is an orphan for the owner toadopt(), the steward is named again. A restore into another database of the same cluster keeps them. pg_upgraderefused every database with pg_phasevec (RT27PL-2).phasevec.watches.lexicalwasregconfigsince 0.6.0, andpg_upgraderefuses a table column of areg*type other thanregclass,regroleandregtype, whatever it holds: 0.19.0 had the same problem. The column is now the configuration’s schema-qualified name (text), found when it is used; the update converts it. Authors, stewards and verdicts come throughpg_upgrade. A configuration renamed or dropped since names none: rows are noted without their lexical reading (find_hybrid()says the reading is partial) untilphasevec.lexical()names one again; a write is not refused for it (a dropped one refused every write until now).- Replication between two 0.20.0 servers labelled every row 0.19.0’s (RT27PL-3). Every write
of 0.20.0 now leaves a new mark in the row (
written); an apply whose row has none, or keeps the one it had (a publisher with no such column), is 0.19.0’s and reads so, as before (RT26PL-1); one with a new mark keeps the publisher’sdata_version, andmigrate()finds nothing to bring. - With
lock_timeout = 0the update still starved (RT28-LT0-1, RT27PU-1). The twenty-sixth review’s “the caller wrote” was “the transaction has an id”, andALTER EXTENSIONwritespg_extensionbefore it runs the script: the id was always there, so every first wait was bounded (half thedeadlock_timeout), and readers that each heldphasevec.watches1.5 s and overlapped kept it out untilstatement_timeout. The script now askspg_lockswhether the caller’s transaction holds a lock of its own (anything but its transaction ids): a loneALTER EXTENSIONholds none, and its first wait is queued with no bound again.
test/review27.shplays them, 13 checks; 11 fail on b197a13 (a revoked steward’s own claims, and 0.19.0 refused bypg_upgrade, are controls). Case 8 runs a realpg_upgradefrom 18 to 19 whenPG_OLDnames the older binaries. Regression tests and shell suites whose authors held onlySELECTnow grant themUPDATEon the watched table (andtruth_read_pathanUPDATEpolicy); inreview26.shthe steward of case 5 sees the rows it changes.review25.shcompares a fresh install and the update without_here()’s value, which is each installation’s own. The fuzzer’sINV-AUTHORfollows the rule (Model.writes,steward_reaches), with a step that revokes and grants the author role’sUPDATE.- The first claim on a row went to whoever came first and saw it (RT27PA-1,
RT28-DECISION-FIRSTCLAIM). A role with
A claim is about its row; a steward’s right ends with it; a probe waits for no one;
phasevec.rebind()after a copy; a lexical configuration by its owner; the update gives way to an event trigger (twenty-eighth review). For copies of a cluster the direction was: state the limit, and give the owner a way to cut what a copy inherited (one function and some text, easy to take back).- A claim outlived its row (RT28PA-REUSE-1). A tenant claimed its own row, deleted it, and
kept the claim on another tenant’s later row under that key: it refuted that row out of the
other tenant’s
brief()(the refutation bound to the new text) and asserted proofs on it, and the tenant that wrote the row was refused.phasevec.sources.bornis now new each time the watch notes a key it did not have, and a claim records the one of its row (truth.asserted_row:assert(),refute(),adopt(), asupersede()placeholder; the nil UUID when the key had no row). Once another row is under the key, the claim is dead (phasevec._claim_dead()): its author no longer changes it, its proof does not run, it readsunproven(a refutation of the same words too),adopt()takes it, and its key is claimed again as a key with no claim is (by the owner, a steward, or a role that mayUPDATEthe new row). The watch trigger writesphasevec.sources, as before, and neverphasevec.truth. - A revoked steward kept the claims it took as a steward (RT28PA-REVOKE-1). A steward with
SELECTalone claimed rows first, and after the owner revoked it went on changing them while the role that wrote them was refused. Of the two readings of the owner’s rule (“revoking a steward revokes its power going forward; claims it made stay authored by it but the owner or other stewards can change them”) this cut takes the one with no takeover: a claim made what it is through a steward’s right alone is marked (truth.by_steward); it stays in its author’s name and its proof runs as it, but its author changes it only as a steward, while it is one. A claim a steward made with a right of its own (the row’s writer, the claim’s author) stays its own to change after it is revoked. - A probe of a hidden key waited for its claim’s lock (RT28PA-LOCK-ORACLE). The claim was
taken
FOR UPDATEbefore the check, so a probe of a key row-level security hid waited for another session changing it (or ended in a lock timeout). The check now runs first on the claim as committed, with no lock, and again on what the lock takes. - Copies of a cluster (RV29-1, RV29-2, RT28PA-CLONE-1, RT28PL-1). A
pg_basebackupcopy, a standby promoted,pg_createsubscriber, orpg_upgrade --copyor--clonekeep the system identifier or_here()(or both) and the OID counter, and a role withCREATEROLEthere can make one under an author’s OID and name (in order, or moving the counter withlo_create()). No value the copy carries tells it from the original, so this cut binds an author no further than the twenty-seventh did, and states the limit (Limits, and “What it trusts”). New:phasevec.rebind([watch]), for the table’s owner (a superuser: every watch), makes every claim of the watch whose author is not the table’s owner an orphan, and ends its stewards; the owner thenadopt()s what it keeps and names its stewards again. It returns, per watch, how many of each it cut. The same-cluster andpg_upgradebehaviour is the twenty-seventh’s. - A lexical configuration was found by its name alone (RV29-4). After a rename or a drop, a
role with
CREATEon its schema could make one under the old name, and new rows were read with it.phasevec.lexical()now records the owner’s name of the configuration it names (watches.lexical_owner; the update andmigrate()record the one it has), and one found under that name with another owner is none: rows go without their lexical reading untillexical()names one again. One inpg_catalogis taken as it is. - An event trigger’s write was held through the update’s first wait (RT28PU-1). The script
read what the caller held before its own DDL, and a
ddl_command_endtrigger that wrote a row for eachCREATE FUNCTIONleft the caller’s transaction holding it while the first wait queued with no bound: a session in the update’s queue that then wrote that row was the one aborted. With an enabledddl_command_startorddl_command_endevent trigger the first wait is now bounded, and the update gives way (here, with “deadlock detected”), not that session. - Locks taken before the script (RT28PU-2). A write a
ddl_command_starttrigger makes onALTER EXTENSION, and a session-level advisory lock, are locks another session may wait for while it holds phasevec’s tables: counting them is what keeps that session from being aborted, so they still bound the first wait, and against steady holders ofphasevec.watchesthe update gives up (at itslock_timeout) instead of starving. Its HINT now says which, and what to do; the plain HINT no longer offerslock_timeout = 0when two of phasevec’s tables are busy (RT28PU-INFO-1). - RV29-3, RV29-5. Limits says that a logical read replica serves no proof verdict until the
owner adopts its claims, and that a
pg_createsubscribersubscriber is a copy; one Limits bullet went past 100 characters.
test/review28.shplays them, 7 checks; all 7 fail on 542fb8e. Case 4 runsrebind()on apg_basebackupcopy in which aCREATEROLErole makes the author’s OID and name. Behaviour changes: a row deleted and written again under its key, or aTRUNCATEand the same rows again, leave the claims on them dead (truth_staleandtruth_review19bexpected the old verdict back);review27.shcase 6 expects a revoked steward’s claim made as a steward to be refused. The fuzzer’s model follows the rules (Model.how,Model.dead,Model.rebind), with areinsertstep (the writer deletes a row and writes it again under its key, in one transaction or two) and arebindstep, and checks each key’sborn(INV-BORN) and each claim’s row, steward mark and binding (INV-MODEL/meta).- A claim outlived its row (RT28PA-REUSE-1). A tenant claimed its own row, deleted it, and
kept the claim on another tenant’s later row under that key: it refuted that row out of the
other tenant’s
A verdict is about a claim, and the claim is the text its author read.
assert()reads the row through the watch’s recipe (rendered as above) and records the hash of that text (truth.asserted_for) with its author. A verdict judges that text, the proof, the author and its settings together, and records a binding of the four (truth.verified_for), each part framed so that no two claims bind alike (seventeenth review). A writer who edits the row afterwards changes the text, not the claim: the key readsunproven,verify()does not judge the new text whatever the proof reads, and it is a claim again when its author (oradopt()) asserts it again. Until 0.19.0 a row edited after it was verified keptverified, andbrief()served its new text as verified; the first cut verified any new text again when the proof did not read the row (test/sql/truth_binding.sql,test/sql/truth_claim.sql). An author must be able toSELECTthe table it asserts about (and, with row-level security, see the row), atassert()and at everyverify().- The row is read, not a cache.
verify()reads the live row before and after the proof, each time with a new snapshot.brief()hashes the line it serves and serves it asverifiedonly if it is the text the verdict judged.phasevec.sources.text_hash, kept by the watch trigger, is not evidence: a change the trigger never saw (ALTER TABLE ... DISABLE TRIGGER,session_replication_role = replica,TRUNCATEand a newINSERT,ALTER COLUMN ... TYPE ... USING, a renamed column with a new one under its name, a hand-edited dump) readsunproveninbrief()and is not verified byverify()(test/sql/truth_claim.sql,test/dump_roundtrip.sh). - A writer never waits on a verifier, and two verifiers do not wait on each other. Whether a
verdict still holds is read, not written: a
verifiedorrefutedwhose row’s text is not the one it judged readsunproven(_verdict()), andverify()takes such a claim first.verify()takes each claim’s row before it judges it, and leaves one another transaction holds (anotherverify(), an uncommittedassert()): twoverify()calls of one watch share it out. In the fifth cut a trigger onphasevec.sourceswrote the verdict as the text changed, so a write waited on every open transaction that had judged that key, and twoverify()calls of one watch locked its verdicts in different orders. Under writes (three clients editing texts, one editing them and back, two closing tasks) and twoverify()loops over 1000 proofs, 20 s: a writer took 14.6-23.2 ms per edit (0.19.0: 0.17 ms; now 0.18-0.19 ms), 44-60% of theverify()calls died as deadlocks, each losing every verdict it had reached (0.19.0 and now: none; now 876-900 calls in 20 s, 0.19.0: 552) (test/truth_locks.sh). Measured again for the seventh review (PostgreSQL 19, 1000 claims each reading its row, three clients editing texts, one editing them and back, two closing tasks, twoverify()loops over all of them, 15 s, three interleaved rounds on a loaded host): an edit took 0.18-0.19 ms (sixth cut 0.19-0.28 ms, 0.19.0 0.14-0.15 ms), with no deadlock and no failed call in any of the three; the two loops made 730-822verify()calls (sixth cut 904-1150, 0.19.0 348-359). A claim whose row comes back to the text it judged is judged again now, where the sixth cut had written it off asunproven. Without writers averify()of the 1000 claims took ~69 ms then (sixth cut ~65 ms, 0.19.0 ~30 ms; medians of four); for what it takes since the eighth review, see the cost below. - A verdict stays bound to the text it judged. A claim whose row says something else (or is
gone) reads
unprovenwhile it does (_verdict());verify()looks at it once for each text it reads, records that it looked (truth.looked_at,looked_for) and leaves the verdict, which readsverifiedorrefutedagain if the text comes back. In the sixth cutverify()wroteunprovenover it: a writer that edited a row for a moment, while a routineverify()ran, erased a refutation until the claim was due again (p_every), while_verdict()readrefutedas soon as the text was back (test/sql/truth_stale.sql). - The watch trigger’s plan follows its table. The trigger prepares its reading of a row once per
session and watch, and keeps it with the table’s row type (the columns' names, types, typmods,
collations, and the dropped ones): when those change, it prepares it again. In the fifth cut it
kept it until the session reconnected, and the plan reads the row by column number: a session that
had written before a
RENAME, aDROPandADD COLUMNor anALTER COLUMN TYPEhashed the old column (an edited row stayed fresh,next()handed out nothing andfind()served the old reading), or failed every later write with “attribute N has wrong type” – every connection of a pool, after a migration (test/sql/truth_plans.sql). A partition’s row is read as a row of the watch’s table, in its columns and their order: in the sixth cut the trigger read it in the partition’s own row type, and_render()andverify()in the table’s, so on a partition whose columns stood in another order (attached, or after aDROPandADD) a recipe that uses the whole row (r::text,row_to_json(r)) noted another text: its claims never read as asserted, asserted again or not, and its rows were indexed with that text. (Since the eighteenth review a partitioned table is not watched at all: Supported configurations.) - One state. Under READ COMMITTED each statement of a proof can see another snapshot. The row’s
version (
xmin,ctid) is read before and after the proof; if it moved, nothing is recorded and the key stays due. A text that changed and changed back while its proof ran was recordedverifiedfor a text the proof never saw (test/truth_race.sh). A proof is judged at READ COMMITTED, in its own session, each statement in a new snapshot of what is committed; the verifier chooses, takes and records its claims at READ COMMITTED too (it refuses REPEATABLE READ and SERIALIZABLE);verified_atis when the row was read for the verdict: in the third cut a verifier in a REPEATABLE READ transaction judged every proof against its first snapshot and recorded the verdicts as reached then (test/sql/truth_sealed.sql). - A proof runs as its author, by OID.
assert()records its caller’s current user (truth.asserted_by_oid, and intruth.asserted_bythe name it had); aSECURITY DEFINERwrapper vouches with its owner’s rights.verify()runs each proof with that role’s rights, in the guard, only when the caller already holds them;verify()skips the others,verify_one()refuses them. A dropped author, or one whose name now belongs to another role, runs nothing: the claim readsunprovenuntil it is asserted again, from the moment the role is gone (nineteenth review; until then from the nextverify()or its horizon). A verdict is recorded only for the author it ran as: anassert()of the same proof by another role while it runs leaves nothing recorded. Until 0.19.0 a proof ran with the rights of whoever calledverify()(test/sql/truth_author.sql,test/sql/truth_door.sql,test/truth_race.sh). - One door.
verify()andverify_one()are C: they know who calls them and pass that to the internals, which carry no rights of their own (only the owner can read and writephasevec.truth). A verifier other than the owner is grantedverify()orverify_one()and nothing else. The first cut had verifiers grantedSECURITY DEFINERinternals, and with them a verifier forgedverifiedand read every proof (test/sql/truth_door.sql). - The clock bounds the verdict. Every proof has a clock:
p_timeout_msmust be above 0, because the cost cap is a planner estimate and not a bound. A verdict counts only if the proof ended inside its clock, so a proof that catchesquery_canceledruns on but is refuted for its time. A proof cannot run another proof. Aquery_canceledthat a proof raises itself is that proof’s error (it aborted everyverify()of the watch); the caller’sstatement_timeoutand a user’s cancel still cancel the statement. A session-level advisory lock a proof takes is released when it ends (test/sql/truth_deadline.sql). The worker runs each proof under its clock; the verifier waits for its answer that long and 5 s more, then ends the worker, and a new one records the claim refuted for its time. - A proof is one expression, under the cost cap and the clock. A proof that closed its own
parenthesis made two statements: the cost step ran the second one with no clock and no cap. The
wrapped proof is now parsed and must be one
SELECT; a cost that cannot be read is refused; and the proof’s clock runs from before the estimate, which can run immutable functions while planning (test/sql/truth_cost.sql). - Closed to
PUBLIC, and to the wrong writer._forget()(callable from any user’s own trigger) is the owner’s only. The watch trigger (phasevec._observe()) is C: it renders the row it fires for, and refuses to run on any table but its watch’s._note(), which took a key and a hash from whoever called it, is gone: in the first cut whoever was granted it for one table forgot, rewrote and invented the keys of every other watch, and in the second a writer limited by row-level security, or holdingUPDATEon one unrelated column, did it to every key of its own watch.recall(),disputes(),replacement()andtruth_coverage()are one C door that shows a watch only to a role that maySELECTits table, and with row-level security it computes over the rows that role sees, so no field carries another (in the third cutrecall()’sdisputed_bynamed keys of hidden rows, andreplacement()followed a chain through a hidden row and showed its reason;truth_coverage()now counts that role’s rows); until 0.20.0 any role withUSAGEon the schema read every watch’s keys, verdicts, topics, reasons and support through them.brief()is the same kind of door; it is revoked fromPUBLICand granted alone (_brief_keys()is its internal, the owner’s) (test/sql/privileges.sql,test/sql/truth_guard.sql,test/sql/truth_sealed.sql). A watch’s key is a column with a unique index of its own: in the third cut two rows could share a key, and a writer limited by row-level security, deleting its own row, forgot the other’s key and its verdict. - The coarse index is checked as it loads, and every time it is used. Every blob of
phasevec.ivf_listsandphasevec.ivf_addedis checked forNULLand for its exact length, every count for its sign, and every key offset for its place inside the keys, before anything is copied: those rows travel inpg_dumpsince 0.19.0, and a truncated or edited one made the loader read out of bounds. A copy kept in memory (by the connection or shared by the cluster) is checked against its row ofphasevec.ivf_centsevery time it is used, andivf_absorbgrows it only if it is the index it read: a row edited under the same version madeivf_absorbsize its lists from the row and grow the copy with them, a write past the end of a buffer (test/sql/ivf_blobs.sql).ivf_absorbchecks it with the shared copy or without one: until the sixth cut only with it, so on PostgreSQL 18, where there is none, an edited row was absorbed in its own shape.
Upgrading from 0.19.0 (test/sql/upgrade_020.sql). The update refuses a database that is not
UTF8, and one with a watch outside the supported configurations, naming each (above). Every
verified goes back to unproven, with the reason in last_error: it was reached with the
verifier’s rights, about no claim. refuted stays refuted, about the text the row has now. A claim
refute() refuted is retired, as refute() retires one now (eighteenth review): 0.19.0 recorded
refuted in two places that its columns tell apart. refute() always gave a reason (last_error)
and stamped verified_at and changed_at with one now(); verify() recorded a proof’s false
with no reason, and its error at a verified_at later than the assert() that set changed_at. So
a refuted claim with a reason judged in the instant it last changed is retired, and so is one with
no proof; a replaced claim whose last verdict had a reason is retired too (0.19.0 cannot tell its
refute() from a proof’s error once supersede() changed changed_at). The safe side: the only
claims this retires that refute() did not are those a proof refuted by an error in the transaction
that asserted it, and an assert() judges them again. What the columns cannot show is a claim
refute() refuted under 0.19.0 that the next verify() judged verified again: it is a verified
claim, back to unproven above, and adopt() asserts it again; the application that retired it
must refute it again after the update (a production deployment does, in step T2c of its procedure).
Every row of every watch is rendered again in the pinned settings; a text that reads otherwise now
(a recipe that prints a date, a time, a float or a money value, or calls a function the table’s
owner may not run) is noted again: its reading turns stale and its verdict unproven. A refuted
claim, and every claim the update retires, is bound to the text as rendered here, so it stays
refuted (twenty-first review). The upgrade fails if a recipe cannot run as its table’s owner,
read-only. Proofs asserted before 0.20.0 have no author and do not run until they are asserted
again. phasevec.adopt(watch, author) asserts them again in the name of the role it is given: the
caller must hold that role’s privileges (be it, SET ROLE to it, or be granted it); it is the
owner’s to run or to grant; it returns every proof with its former author; and a superuser becomes
the author only of the keys named in p_superuser_keys (an orphaned proof may be anyone’s SQL, and
a role can orphan its own on purpose). Adopt as the least-privileged role that can read what the
proofs read. Naming an author in phasevec.truth by hand is not enough, a claim is also the text
its author read. Calls of verify() and verify_one() keep working, except with p_timeout_ms =>
0. brief() is granted alone; grants on _brief_keys are gone. A trigger of your own that asserts
claims must fire on every column the recipe reads: an edit of a column it does not watch leaves the
claim unproven until it is asserted again. A recipe that writes, or that the table’s owner cannot
run, now fails wherever it runs, including in the watch trigger on every write to the table. A
database that ran an unpublished cut of 0.20.0 cannot be updated in place to this one (same
version): drop and create the extension, or dump and restore the database (its proofs then wait for
adopt()). Reconnect after the update (or restart the server): a session that loaded the
library of 0.19.0 keeps it, and the new watch trigger is not in it. Its writes to a watched table
are refused with a message that says so (the trigger is in a library of its own,
pg_phasevec_observe, installed with the extension, which no older session has loaded); its calls
of the new C functions (verify(), brief(), recall(), …) fail with “could not find function”
(test/upgrade_reconnect.sh). Run the update alone: ALTER EXTENSION pg_phasevec UPDATE in a
transaction of its own, at READ COMMITTED (twenty-fourth review: it refuses REPEATABLE READ and
SERIALIZABLE), with a lock_timeout that says how long it may wait (it waits for the writers of
watched tables and for any open transaction that holds a lock on phasevec’s tables or functions,
holding nothing of phasevec’s while it waits; it then waits for each of the rest at most half the
deadlock_timeout, giving up the try rather than any other session; 0 waits until it has
everything), retried if it times out (it then stops with phasevec: the update to 0.20.0 could not
take phasevec's tables within lock_timeout, or ... phasevec's function ..., naming the sessions
that hold it, and nothing is changed); with no object outside the extension that refers to a
function it drops (it names them all, and stops); not in one transaction with DDL on a watched table
(CREATE TRIGGER, DROP TRIGGER, ALTER TABLE): that DDL waits for the table’s writers while the
update holds the extension’s tables they are waiting for, a deadlock that aborts one side. The
update itself takes the extension’s tables in the order its writers do (test/upgrade_locks.sh),
and no watched table (twentieth review, test/upgrade_writers.sh): it gives each watch its
TRUNCATE trigger only where the table is free at that moment, and for each table that was being
written it prints (a WARNING) the statement to run afterwards, one per watch, each in its own
transaction: SELECT phasevec.watch_truncate('<watch>'); (it waits for that table’s writers,
bounded by lock_timeout; true when it made the trigger, false when it was there; it is the
extension owner’s to run, or to grant to the table’s owner together with EXECUTE on
phasevec._truncate_trigger(int, boolean) and SELECT (id, name, source) on phasevec.watches,
which shows that role every watch’s name and table). While it holds phasevec’s tables the update
waits for no lock on what a watch reads (twenty-first review): when another session holds a lock on
any of it (a watched table, its indexes, what a recipe reads) for more than 0.3 s, it stops with
... locked by another session: nothing was changed and the sessions to wait for, and is run again
once they end. The watches that still lack it: SELECT w.name FROM phasevec.watches w WHERE NOT
EXISTS (SELECT 1 FROM pg_trigger t WHERE t.tgrelid = w.source AND t.tgname = 'phasevec_watch_' ||
w.id || '_truncate'). A trigger or function of yours that asserts claims as SECURITY DEFINER
makes its owner the author of every claim it asserts from the update on (a superuser owner: proofs
that run as superuser, and a verifier that may not act as it never judges them); adopt() does not
take those back, its author is known. Make it assert through a function owned by the role that
should author the proofs before the update (on 0.19.0 assert() has the same signature), then
adopt() what was asserted before, and check that every claim with a proof, not replaced, has that
author. A proof or a recipe that called a volatile system function outside the reviewed list
(nextval, currval, pg_read_file, …) or txid_current(), ran a command other than those
above, read a cursor its caller had open, or read a temporary table or called a temporary function,
now fails (a proof is refuted with the reason). A temporary table cannot be watched. A watch over a
table with FORCE ROW LEVEL SECURITY now renders every row (as the owner, without the policies).
(twenty-sixth review) adopt() changes claims: its caller must also be the watched table’s owner
or a steward of the watch, and it takes only claims on rows the caller sees. After a restore into
another cluster every steward is no one until the table’s owner names it again, and every claim an
orphan until it adopts it (twenty-seventh review: also when a role there has the same OID and
name). pg_upgrade keeps them (twenty-seventh review: until then it refused every database with
pg_phasevec, 0.19.0’s too, for the type of watches.lexical).
Moving a 0.19.0 database by pg_dump (twenty-fifth review, test/review25.sh). Restore the
dump where 0.20.0 is installed (pg_dump names no version: the restore makes 0.20.0 and copies in
the rows 0.19.0 wrote), then, as the extension’s owner, before the database takes writes again:
SELECT * FROM phasevec.migrate();. It does to those rows what the update does (the claims
refute() retired, verified not carried over, the rows rendered again, each watch’s TRUNCATE
trigger)
and returns one row per watch. Until then every door that changes or judges a claim of such a
watch refuses, naming phasevec.migrate(). Proofs then wait for adopt(), as after the update.
The update also stops, naming them all, on a column outside the extension that stores the row
type of phasevec.truth or phasevec.watches; when it stops on a lock it names the sessions,
and the prepared transactions (pg_prepared_xacts), that hold it.
Moving it by logical replication (twenty-sixth review): every row a 0.19.0 publisher’s
subscription writes reads as 0.19.0’s, also after a first migrate(); run migrate() again after
cut-over, with the subscription dropped (twenty-seventh review: a 0.20.0 publisher’s rows keep
their version). After DROP EXTENSION ... CASCADE, CREATE EXTENSION and a data-only
restore of phasevec’s tables, migrate() makes each watch’s triggers again.
Who changes a claim (twenty-seventh and twenty-eighth reviews). A key has one claim, about
one row, and nobody gets it by coming first: a key with no claim, or whose claim is about a row
that is gone (another row is under the key now), gets one only from a role with an explicit
privilege over its row, which must exist, namely the watched table’s owner (or a role with its
privileges), a steward of the watch, or a role that holds UPDATE on the table and whose
row-level security lets it UPDATE that row; reading a row is never enough. A claim that exists
belongs to the role that last made it what it is (the one that asserted it, retired it with
refute(), or made it a placeholder with supersede()), and is changed by that role or one with
its privileges, while it is still that role (same OID and name, recorded in this cluster or this
installation, not cut by phasevec.rebind()), by the table’s owner, and by a steward: a role the
table’s owner names with SELECT phasevec.steward('watch', 'role'); and takes back with
phasevec.steward('watch', 'role', false), which counts while that owner still owns the table and
reaches only the rows row-level security does not keep it from. A claim whose author is gone
(dropped, renamed, restored into another cluster, cut by rebind(), asserted before 0.20.0) waits
for the owner or a steward, who settles it or gives it to an author with adopt(). A claim a role
made what it is as a steward and nothing else stays in its name, but the role changes it only
while it is a steward; one it made with a right of its own stays its own after it is revoked. A
SECURITY DEFINER function owned by the table’s owner acts as the owner for whoever calls it,
first claims included (RT28PA-INFO-DEFINER).
Limits.
- A key has one claim. The first claim on a row goes to a role that may
UPDATEit (twenty-seventh review), so a writer that onlyINSERTs, or holdsUPDATEon some columns, does not make it: grantUPDATEon the table (row-level security still decides which rows), or let the owner or a steward make it. The check takes the rowFOR KEY SHAREas the caller and lets it go: it waits, as a foreign key check does, for a transaction that is deleting the row or changing its key. A steward changes the claims of its watch on the rows row-level security does not keep it from, and is refused on the others like any role; the cycle check of itssupersede()reads the chain through rows it does not see, and its error names only the two keys it gave. Name a login role a steward, not a group: whoever may grant the group makes new stewards (RT27PA-INFO-1). A steward counts while the role that named it owns the table. The extension’s owner readsphasevec.stewards. A steward, like an author, is a role by OID and name in its cluster (or its installation, throughpg_upgrade): after a restore into another cluster, name the stewards again andadopt()the claims. A claim recorded before apg_upgradeand restored after it into another database is an orphan there (another cluster, another installation):adopt()it.supersede()runs only at READ COMMITTED. - A copy of a cluster in which an untrusted role has
CREATEROLEcan recreate an author: runphasevec.rebind()after making a copy you do not fully trust (twenty-eighth review). Apg_basebackupcopy or a standby promoted keeps the system identifier and_here();pg_createsubscriberkeeps_here();pg_upgrade --copyor--cloneleaves the old cluster able to start with the new one’s_here(); and each keeps the OID counter. A role made there under an author’s OID and name (in the same order, or moving the counter withlo_create()) is that author, and one under a steward’s that steward, untilrebind(), which keeps the claims of the table’s owner. A dump of the original restored into the copy is bound by the shared identifier too: rebind after it. On apg_createsubscribersubscriber the rows that keep arriving carry the publisher’s_here(), which is the subscriber’s: rebind after the claims you do not trust arrive, or make the subscriber from a schema dump instead (CREATE EXTENSIONmakes a_here()of its own). A publisher’s superuser can write any author into a subscriber’sphasevec.truth: subscribephasevec.truthandphasevec.stewardsonly from a publisher trusted as the subscriber’s superuser is (RT28PL-INFO-1). - A claim is about the row it was made on (twenty-eighth review): a
DELETEand anINSERTunder its key (in one transaction or two), aTRUNCATEand the rows written again, or anUPDATEthat gives a row the key another had, make another row, and the claim on the key is dead (unproven, no one’s) until it is claimed again. An application that rewrites a row by deleting and inserting it loses its claims each time:UPDATEit, orINSERT ... ON CONFLICT DO UPDATE. A dump restored,pg_upgrade,VACUUM FULLandCLUSTERkeep the rows (phasevec.sourcescomes with them). While a key has no row its claim is as it was. - A lexical watch reads with the configuration under the name its owner gave, while that
configuration’s owner is the one it had then (twenty-eighth review, RV29-4): after
ALTER TEXT SEARCH CONFIGURATION ... RENAMEorOWNER TO, or a drop, rows go without their lexical reading untilphasevec.lexical()names one again; so they do after a restore with--no-owner. Two watches that name one configuration with different owners recorded read with it only once both are named again. A configuration inpg_catalogis always found. - Two
ALTER EXTENSION pg_phasevec UPDATEat once: the second fails with PostgreSQL’stuple concurrently updated, raised before its script runs (when it writes the extension’s row), and changes nothing. While the update waits for a lock (withlock_timeout = 0, for as long as it takes) its queuedACCESS EXCLUSIVErequest makes every writer of a watched table, and every reader of the table it waits for, queue behind it (twenty-sixth review, RT26-DOC-1). phasevec.migrate()takes each watched table it brings inSHARE ROW EXCLUSIVEmode, one after another in one transaction: run it before the database takes writes again, or a transaction that writes two of those tables can deadlock with it (one of the two is aborted, nothing of it changed).- A row a 0.19.0 publisher’s subscription writes into
phasevec.truthorphasevec.watchesreads as 0.19.0’s untilmigrate()(twenty-sixth review, RT26PL-1): runmigrate()after cut-over, with the subscription dropped. A 0.20.0 publisher’s rows keep their version (twenty-seventh review); their authors and stewards are another cluster’s, so on a subscriber in another cluster every claim is an orphan and readsunprovenuntil the owner adopts it: a logical read replica serves no proof verdict until then (RV29-3). A subscriber made withpg_createsubscriberis a copy of the publisher’s files (above): its claims keep their authors. - A recipe renders a text or nothing (twenty-second review): an
ERRORthat comes from the server or another session (a lock another session holds, a deadlock, a cancel or timeout, the server short of a resource, an internal error) raised while it renders ends the statement that rendered it, even when the recipe catches it and returns a fallback; a write to the table then fails, where before it noted the fallback as the row’s text. ARAISEof the recipe’s own with such a code is its own. A message of class 53 (insufficient resources) is taken by its code alone, so one that is only logged (out of file descriptors) also ends the render. A recipe that catcheslock_not_availableand tries again in a loop holds the update to 0.20.0, which reads under alock_timeoutof 1 ms, until the update’sstatement_timeout: run the update with one. A recipe that reads the session’s timeouts (current_setting('lock_timeout')) can render otherwise during the update, which notes that text. - The update to 0.20.0 (twenty-fourth review) takes, before it changes anything, phasevec’s tables
and every function of phasevec’s (its catalog row, and its object lock in
DROP FUNCTION’s mode). Once it holds them, it waits at most half thedeadlock_timeoutits session reads, at most 100 ms (or the caller’slock_timeout, if shorter), for anything else, with its own deadlock check after 1 ms. A wait that lasts longer ends it with PostgreSQL’s owncanceling statement due to lock timeout(ordeadlock detected), nothing changed, and it is run again. Examples: aVACUUM FULLorREINDEXof a system catalog, or another transaction’s uncommitted catalog entry with the same name as one the update creates. The bound protects the other sessions from the deadlock detector only if theirdeadlock_timeoutis at least the update’s session’s: a role given a shorter one (ALTER ROLE ... SET deadlock_timeout, a superuser’s) can still be the one aborted, in the window of a wait that began before it. The deadlock check that finds a cycle as the update begins to wait runs 1 ms in; another session’s check that falls inside that millisecond aborts that session. Under holders that keep every lock longer than the bound reaches (half thedeadlock_timeout) on both sides, the update tries on until itslock_timeout: end them, or run it when they are shorter. An object made over a function the update drops between its check and itsDROP(it holds the function’s lock by then, which PostgreSQL takes to record such a dependency) is not expected; if one is, theDROPfails with PostgreSQL’s own message, nothing changed. A watch whose recipe fails on its rows during the update (an error of its own, or of its rows) stops the update for the whole database, every such watch named, until its owner fixes it or the watch is dropped. A recipe’s ownRAISEof the out-of-resources class (53) is still taken for the server’s: the guard reads that class by its code alone. (twenty-sixth review) Once it holds something, it tries each lock first without queueing, and when a holder of it is itself waiting for a lock it does not queue behind it: the try lets go of everything and the next waits for that lock first, holding nothing (RT26PU-1). What stays: a session queued ahead of the update (not a holder) closes a cycle only through queue order, which the deadlock detector rearranges without aborting anyone. If the caller’s transaction holds a lock it took before the update (it read or wrote a table, took an advisory lock: a migration tool’s one transaction, or an update from before 0.19.0 in oneALTER EXTENSION; twenty-seventh review: read frompg_locks, not from the transaction’s id), the first wait is bounded too and a deadlock in it ends the update; withlock_timeout = 0holders ofphasevec.watchesthat each keep it longer than the bound and overlap can then starve it: run the update in a transaction of its own. Event triggers see the script’s own DDL (CREATE FUNCTION, sixALTER FUNCTION ... SET search_path, …), not one per function lock: a policy trigger that forbidsALTER FUNCTIONstill stops the update. Every event trigger fires for the script’s DDL (some 75CREATE FUNCTION, 25COMMENT, 18DROP FUNCTION, 11REVOKE): one that raises stops the update, and one that names its own table without a schema fails inside it, under the script’ssearch_path(RT27PU-INFO-3). (twenty-eighth review) An enabledddl_command_startorddl_command_endevent trigger bounds the first wait too, since what it writes on the script’s own DDL is held while the update waits (RT28PU-1); so do a session-level advisory lock and a write addl_command_starttrigger makes onALTER EXTENSION(RT28PU-2). Each is a lock another session may wait for: the update gives way, and against steady holders it gives up, its HINT naming why. Disable such triggers for the update (ALTER EVENT TRIGGER ... DISABLE) and run it from a session that holds no advisory lock. - A render evaluates the recipe twice for each row (once for the text, once for its hash: PostgreSQL
inlines the subquery that names it). A recipe that is not deterministic (
random(),clock_timestamp()) can have its text and its hash disagree. - A proof that catches
query_canceledkeeps its worker until it returns: the clock decides the verdict and cannot stop such a proof. The verifier waits for it its clock and 5 s more, records it refuted, and ends the worker (TerminateBackgroundWorker), which code that ignores interrupts (an extension’s C function) can outlast, holding its slot ofmax_worker_processesuntil it returns. Grantassert()only to roles whose code you would run as them. assert()takes its author from whoever calls it. A trigger function that is notSECURITY DEFINERruns with the rights of whoever fires it, so its owner can assert in the name of anyone who writes that table, as it can do anything else with those rights: PostgreSQL’s own trigger trap. phasevec cannot see who wrote the calling code._verdict(t), for SQL that readsphasevec.truthdirectly, compares the claim with the trigger’s cache, not with the row: after a change the trigger never saw it saysverifieduntil the nextverify()of the key, or its horizon.brief()andverify()read the row. It answersNULLfor a claim whose watched table the caller may notSELECT(or whose row a policy hides from it): SQL that readsphasevec.truthas such a role readsNULLwhere it read a verdict before 0.20.0 (coalesce(phasevec._verdict(t), 'unproven')reads it as unproven). It decides who may see on every call, from the catalogs as they are (sixteenth review): under row-level security it reads the watch and looks the row up as the caller, two queries a row.next()andaudit_sample()(fill and audit, not reading) run as their caller and use the extension’s own internals and tables (_todo,_watch,readings,kstate): they are the owner’s, or a role’s that is granted those too. Granted alone, they are refused.- A restore trusts its dump: a dump edited by hand can also rewrite
phasevec.truth. After a restore into another cluster every proof reads its author as gone untiladopt(), whatever OIDs the roles have there (twenty-seventh review). - The guard refuses functions when an expression is set up (PostgreSQL’s function-execute hook). A
PL/pgSQL expression a session’s transaction already ran before a guard is cached without that
check: a recipe that reaches that same cached expression in the writer’s or reader’s session is
not refused (a proof’s session runs nothing outside a guard). Functions that are not the system’s
(an extension’s C function such as
dblink) are not classified: they run with the role’s rights.random()draws from the generator of the session the code runs in (a proof’s worker’s; a recipe moves its writer’s sequence), which it cannot seed. - A cursor a client opened through the extended protocol (a named portal, as JDBC uses with a fetch
size) is not listed by
pg_cursors: the guard refuses to run its query, but rows it already holds in memory (a portal overINSERT ... RETURNINGread in part) can be read by a recipe that renders in that session, and it can close it; a proof’s session has no such cursor. - The settings a proof does not take from its author (resources, the planner,
max_stack_depth,work_mem, the session’s timeouts) are its worker’s – but for parallelism, which the worker pins off (thirteenth review) –, a new session’s of its author: they can decide whether a proof runs out of memory, stack or time (refuted), not what it computes. - Cost: every recipe or proof runs in a subtransaction of its own with its settings set, and every
proof in a background worker. On a watch of 1000 proofs, each reading a row of another table,
verify()from a new session takes ~105 ms on PostgreSQL 19 (0.19.0: ~30 ms; the eighth cut, 2f42e62: ~54 ms; medians of six, the three measured together) and ~450 ms on an assertion-enabled 18 (0.19.0: ~150 ms; 2f42e62: ~250 ms). Since the ninth review the worker takes, judges and records each claim and commits its verdict, where the eighth cut’s worker judged a claim while the verifier took and recorded the one before, in its own transaction: what the verifier did alongside is the worker’s now, about 50 us a claim. Starting a worker costs ~3 ms:verify_one()takes ~7 ms (2f42e62: ~6 ms; 0.19.0: ~1 ms),verify(p_n => 100)~23 ms (2f42e62: ~15 ms; 0.19.0: ~5 ms). The tenth review’s changes cost nothing measurable:verify()of those 1000 claims takes ~117 ms on 19 against ~117 ms at c1461cf (the same cluster, the library swapped between runs, medians of twelve; 0.19.0: ~40 ms),verify_one()~6 ms; on the a production deployment mirror ~115 ms (0.19.0: ~39 ms), and ~470 ms on an assertion-enabled 18 (0.19.0: ~175 ms). Of a claim’s ~117 us in the worker, taking its row is ~9 us, judging it ~42, recording it ~42 (anUPDATEof the claim through_truth_set) and committing ~2: committing several claims in one transaction saved about 6% when measured, and is not done – a verdict is committed before its answer goes back, and a session that ended in the middle of a batch would take the verdicts judged before it. On a mirror of a production deployment’s layout (claims asserted bySECURITY DEFINERtriggers, verified bypg_cronas a read-only role),verify()of one watch of 1000 claims takes ~120 ms (0.19.0: ~55 ms; 2f42e62: ~61 ms). Before each claim the worker reads which custom placeholders it has (walking every setting: PostgreSQL offers no cheaper way);brief()~18 ms (0.19.0: ~42 ms). Choosing the due claims reads every claim of the watch:verify(p_n => 100)costs more as the watch grows (the seventh review measured ~18 ms at 1000 claims and ~77 ms at 20000; 0.19.0 stayed at ~12 ms). The watch trigger renders each written row in its own guard: anUPDATEof 1000 watched rows, whose recipe joins another table, takes ~57 ms (0.19.0, which noted the row in PL/pgSQL without a guard: ~46 ms; the fourth cut of 0.20.0, which parsed and planned three statements for every row: ~210 ms), as much from a session that has a temporary table, and since the sixth cut from one that writes to it in the same transaction (a single-row write withpgbench -M prepared: 0.09-0.11 ms, the sixth cut 0.55-0.56 ms, PostgreSQL 19; on an assertion-enabled 18: 0.58 ms, the sixth cut 1.58 ms). Inside the guard no SQL function but the system’s is inlined: a recipe or a proof that calls one runs it as a call. The statements it runs for a row are prepared once per session and watch; what a row still costs is its guard (a subtransaction, the settings set and rolled back) and reading the session’s settings, which it does for every row (~17 us of the ~60): PostgreSQL offers no cheap way to know exactly that none changed since the last row, and a cache could miss one a writer set between two statements. The eleventh review’s rules cost one more transaction per claim (the worker spends the claim’s turn, ~20 us) and a lookup of the watch’smax_ageper verdict read:verify()of those 1000 claims takes ~148 ms on 19 against ~128 ms at 07b019f and ~42 ms for 0.19.0,verify(p_n => 100)~31 ms (07b019f: ~28; 0.19.0: ~9),verify_one()~9 ms (07b019f: ~9; 0.19.0: ~1.4), and on an assertion-enabled 18 ~594 ms (07b019f: ~554; 0.19.0: ~245), the three interleaved on one machine, medians of 21 (14 on 18);_verdict()over 2000 claims ~13 ms against ~11,brief()of 1000 lines ~45 ms against ~40. Measured at 60x speed (a call every 10 s for every 10 minutes,p_everyandmax_agescaled alike), 1000 claims withp_n100 settle at 600 fresh and 400unproven(each claim comes round every 100 minutes, past the default hour); withp_n2000 all 1000 stay fresh, each judged every 10 minutes. The twelfth review’s keeper costs nothing measurable: it spends turns in batches (when the worker is about to run out of spent claims, or half a queue waits), while the worker, which no longer spends them, judges the claims ahead.verify()of those 1000 claims takes ~117 ms on 19 against ~115 ms at 4f966eb and ~29 ms for 0.19.0,verify(p_n => 100)~21 ms (4f966eb ~21; 0.19.0 ~5),verify_one()~6 ms (4f966eb ~6; 0.19.0 ~1.2); on an assertion-enabled 18 ~480 ms (4f966eb ~483; 0.19.0 ~138),verify(p_n => 100)~75 ms (~77; ~21) (the three interleaved, medians of twelve). On the a production deployment mirror under load (writers, cancelled and terminated verifiers, DDL,pg_cronrunningverify('notes', 5000)),verify()of one watch of 1000 claims takes ~140 ms (0.19.0 in the same run ~50 ms), with no verdict wrong against the committed state as of when it was judged. The thirteenth review’s changes cost nothing measurable either: pinning parallelism off sets three settings when a worker starts, and the author’s session is started before the keeper rather than after it.verify()of those 1000 claims takes ~162 ms on 19 against ~156 ms at 9dd019f and ~45 ms for 0.19.0 (a busier machine than the twelfth review’s),verify(p_n => 100)~32 ms (9dd019f ~37; 0.19.0 ~7),verify_one()~10 ms (9dd019f ~11; 0.19.0 ~2.4); on an assertion-enabled 18 ~475 ms (9dd019f ~504; 0.19.0 ~148),verify(p_n => 100)~88 ms (~100; ~23). The same build without the pin measured ~166 ms on 19 and ~477 ms on 18: the pin itself changes nothing measurable, as expected for proofs that read a row by its key, whose plans have no parallelism anyway. A proof whose plan would have been parallel now runs as one process, and may take longer for it. (The three builds and 0.19.0 interleaved on one machine, medians of twelve.) On the a production deployment mirror under the same load, now also with the proofs' author set todebug_parallel_queryandparallel_leader_participationoff, a session holdingthe deployment.cosas(every proof’s table) for 0.1-3 s 27 times, two parallel-query loops and an author holding slots with slow proofs,verify()of one watch of 1000 claims takes ~122 ms (0.19.0 in the same run ~61 ms), with no verdict wrong against the committed state as of when it was judged and none served wrong after the finalverify(). The fourteenth review’s changes cost nothing measurable: the cleanup of a session clears its pointers as it goes,verify_one()’s worker takes the claim withSKIP LOCKED(one more transaction only when another session holds it), and the update script takes five locks first.verify()of those 1000 claims takes ~122 ms on 19 against ~123 ms at b17c072 and ~31 ms for 0.19.0,verify(p_n => 100)~29 ms (b17c072 ~27; 0.19.0 ~7),verify_one()~9 ms (b17c072 ~9; 0.19.0 ~2.2); on an assertion-enabled 18 ~484 ms (b17c072 ~479; 0.19.0 ~148),verify(p_n => 100)~83 ms (~87; ~23),verify_one()~25 ms (~28; ~7) (the three interleaved on one machine, medians of twelve). On the a production deployment mirror (1000 claims of one watch and 150 of another asserted bySECURITY DEFINERtriggers, six writers and threepg_cronwriter jobs,pg_cronverifying both watches every 2-3 s as a read-only role, two sessions that loaded 0.19.0 left open), updating from 0.19.0 took 50-90 ms and never needed a retry in eight runs, and after it no verdict was served wrong against the proofs run again as a read-only role. The fifteenth review’s changes cost nothing measurable either: the claim’s row is read in a read-only transaction of its own (one more start and commit a claim), and_verdict()looks up who may read a watch once per query and role.verify()of those 1000 claims takes ~119 ms on 19 against ~117 ms at fc37e9f and ~30 ms for 0.19.0,verify(p_n => 100)~29 ms (fc37e9f ~27; 0.19.0 ~7),verify_one()~8.9 ms (~8.7; ~2.1); on an assertion-enabled 18 ~471 ms (~478; ~145),verify(p_n => 100)~84 ms (~81; ~21),verify_one()~29 ms (~28; ~5)._verdict()over the 1000 claims takes ~12 ms as the owner on 19 (fc37e9f ~12; 0.19.0 ~2.5) and ~11 ms as aNOLOGINrole withpg_read_all_datain a read-only session, which fc37e9f refused (0.19.0 ~2.7); on 18 ~61 ms (fc37e9f ~57; 0.19.0 ~7) and ~62 ms. (The three interleaved on one machine, medians of 21 on 19 and 11 on 18.) The sixteenth review’s changes:become()sets and restores the pinned settings that differ from the session’s (usuallysearch_pathandTimeZone) around each statement phasevec runs as its owner, and_verdict()decides who may see on every call. On 19, against ad275f3 on the same machine (each cut on a fresh cluster, medians of 5 to 15):verify()of the 1000 claims ~127 ms (~118-123),verify(p_n => 100)~23 ms (~23),verify_one()~6 ms (~5-9), anUPDATEof the 1000 watched rows ~68 ms (~67),brief()~42 ms (~40),_verdict()over 1000 claims as a reader ~11 ms (~10) and under row-level security ~28 ms (~19); on an assertion-enabled 18 ~573 ms (~532), ~89 (~86), ~25 (~23), ~412 (~407), ~334 (~336), ~80 (~82) and ~192 (~101). On the a production deployment mirror the update (T2) took ~30 ms once the sessions holding the extension’s tables were ended. The seventeenth review’s changes cost little, and_bindin C pays for them: on 19, against ee7e426 (each on a fresh cluster, the two interleaved, medians of three):verify()of 1000 claims ~104 ms (~109),assert()of 1000 claims ~110 ms (~105: the session’s settings are read down their stack and checked against the author’s rights),_verdict()over 1000 verified claims ~10 ms (~12),brief()of 1000 lines ~33 ms (~38),verify_one()within the noise of a worker’s start (~5-7 ms a call, both); on an assertion-enabled 18 (medians of two) ~433 ms (~490), ~343 (~332), ~68 (~83), ~245 (~300), ~14 ms averify_one()(~16)._binditself: 0.7 us a call (the unframed SQL 2.2). On the a production deployment mirror the procedure’s T1 to T6 ran in ~5 s, the update (T2) in ~25 ms. The eighteenth review’s changes: a read checks its table’s shape (one relcache entry, one syscache lookup per index of the table; the read paths of the caller’s own session lock the table first, as their read does), a claim’s state is three columns (_claim_state()is inlined), and the hashes take a text’s bytes. On 19, against a400376 (each on a fresh cluster, the two interleaved, medians of five, three rounds):verify()of 1000 claims ~113 ms (~106),assert()of 1000 claims ~118 ms (~110),_verdict()over 1000 claims as the owner ~10.5 ms (~10.5) and as a reader under row-level security ~34.5 ms (~32),brief()of 1000 lines ~31 ms (~31),_render()of 1000 rows ~1.1 ms (~1.1),verify_one()~6 ms (~6); on an assertion-enabled 18 (two rounds) ~457 ms (~448), ~364 (~336), ~52 (~52), ~229 (~208), ~165 (~163), ~2.5 (~2.4), ~16 (~18). assert(),verify(),brief()and the rest find a row by its key’s text, byte for byte (key_col::text COLLATE "C" = key, seventeenth review), through the key column’s index when its type is an integer,oid,numeric,uuid,textorvarchar(the column’s own equality, in its collation: a unique index declared with another collation is not used), by a scan otherwise.refute()retires a claim: its proof is gone, and only anassert()makes it a claim again (seventeenth review);phasevec._claim_state()says which state a claim is in (eighteenth review). A refutation meant as “false until the proof says otherwise” is a proof that says so. A retired claim readsrefutedwhile its row’s text is the onerefute()saw; when that text changes it readsunproven(the refutation was about the old text), and it is still retired: nothing judges it until anassert(). Itsasserted_foris then the refuted text, not what its author last vouched for.- A verdict is bound to its author by OID and name (
asserted_by_oid,asserted_by), asverify()binds a proof to it: a role dropped and created again under the same name with the same OID (after the OID counter wraps around) is the same author to phasevec.refute()in a transaction older than the claim’s last verdict stamps that verdict’s time, not its own (nineteenth review):verified_atandretired_atof a retired claim say “not before”, not whenrefute()ran. - A key is matched by its text, byte for byte, under a unique btree index of the key column; the index’s equality is the column type’s default one or the one its operator class names. An operator class whose equality is finer than the type’s text output (two values that print alike but the index holds apart) would let a key name two rows: phasevec checks the index’s shape, not its operator class.
- The shape of a watch’s table is checked again by every read, but a verifier (
verify(),verify_one()) checks it without taking the table’s lock (it does not read the table, and must not wait for it in its own session): aDEFERRABLEkey or a child committed between that check and the proof’s read is not seen by that call. The proof’s session reads withONLYand only committed rows, which such a change cannot make ambiguous; the next call refuses._render(),brief(),assert()and the row-level security check of_verdict()check under the lock their read takes. - A table that had an inheritance child keeps
relhassubclassuntilVACUUMorANALYZEclears it: until then every read of it also lists its children inpg_inherits(one index scan), and reads as before once there are none. - A setting the author may not set itself (a superuser-only one, with no
SETgranted on it) is not recorded. One recorded that cannot be set again in its proof’s session (twentieth review: the author lost the privilege, the value names an object dropped since, a claim’s settings written by hand) leaves the claimunproven, with why, until it is asserted again; so does a recorded placeholder that a library the proof loads does not take. A recorded placeholder with no value whose library the proof’s session has loaded is that library’s default there, as in the author’s session. - A proof means what it means where it was asserted (nineteenth review): through a
SECURITY DEFINERwrapper withSET search_path, that search path is the proof’s, and a proof that names an object outside it is refuted, as it would fail inside the wrapper. an author’s wrapper function (SET search_path = pg_catalog, pg_temp) must name the schemas its proofs use (afterpg_catalog), or the proofs qualify their names; claims recorded by an earlier cut of 0.20.0 keep what they recorded until asserted again.adopt()records the settings where it is called: to keep the meaning a wrapper gives its claims, adopt through a wrapper with the sameSETclause, or in a session with the same settings. - A caller with the author’s privileges (a superuser, a member that inherits the author) lends a
claim its own session’s settings, as it could assert as the author itself; a caller without them
lends none, and the proof has the author’s own value for each. A custom placeholder that only
exists (made by
RESET, with no value: PostgreSQL records no setter for it) counts as the session’s role’s. ADateStylethat aSETof the author’s merged into a value a caller without its privileges set (twentieth review) is not recorded at all: the proof has its author’s own; a wrapper that sets the whole value (SET DateStyle = 'ISO, MDY') is in the same case when its caller set its own, so an author that needs oneDateStylein its proofs sets it for its role. adopt()holds the rows of the claims it adopts until its transaction ends, and waits for one another transaction holds; a claim that transaction made no orphan (refute(), anassert()) is returned not adopted, with the reason.unsupersede()gives a retired or a halted claim back itsrefuted; a judged claim comes backunprovenand is judged again by the nextverify()(its verdict beforesupersede()is not kept:supersede()replaces it).- A
TRUNCATEof a watched table whoseTRUNCATEtrigger the update could not make yet (its table was busy; the update names it) forgets nothing, as under 0.19.0, untilphasevec.watch_truncate()makes it. ATRUNCATEis no longer O(1) on a watched table (twentieth review, RT20-CRASH-4): the watch forgets its rows with aDELETEof its notes (phasevec.sources, one row per key, with their per-row trigger and foreign-key checks), under theTRUNCATE’sACCESS EXCLUSIVElock, so readers, writers andverify()of that table wait for time proportional to its rows (100000 rows: about 3 s on a release build, against 0.4 ms for a table no watch reads;DELETE FROMthe table costs more, about 8 s). A cancel rolls it back whole. - A
TRUNCATEwith the watch’s triggers off (session_replication_role = replica,ALTER TABLE ... DISABLE TRIGGER) is a change the trigger never saw (above):_verdict(t)reads the claims of the rows it removed as before until the nextverify()of each, andtruth_coverage()counts them. DROP TABLEof a watched table waits for the claim averify()is judging (up to its clock), as it waits for any reader of the table.- A recipe sees, beyond its row, what the table’s owner may see (a subquery in it runs with the
owner’s rights), and its text goes to any role that may
SELECTthe table. verify()leaves a claim whose row another transaction holds (anassert()not committed, by any role that may assert, or anotherverify()), its turn spent: a later call judges it, and until then its verdict serves until its horizon.verify_one()waits for it instead (since the tenth review before it judges: below). Anassert()of a claim waits for the worker judging it, up to the proof’s clock and the record. Each verdict is committed by its proof’s session as it is reached: averify()that is cancelled, or whose transaction aborts, keeps the verdicts it reached (p_budgetends a call cleanly). A transaction that has written may not verify: anassert()and averify()of its claim go in two transactions.- A worker that made a placeholder the next claim’s author did not have ends, and a new one starts
(~3 ms): a watch whose proofs each make a new one (
set_configof a name per key) pays a worker’s start per claim. A claim recorded before the seventh cut of 0.20.0 names only the placeholders that had a value: it reads as if its author’s session had no other. A setting a library defines (plpgsql.*, an extension’s) exists once the library is loaded in the session; whether it is loaded is not compared. - A recipe reads custom placeholders as the session that renders it has them (the writer’s, for the
watch trigger), each set to
'': a recipe that readscurrent_setting('app.x', true)renders a text that depends on whether that session ever namedapp.x. Where that text is not the one its author vouched for, the claim readsunproven; it is never verified, nor served, for it. - A recipe runs in the writer’s session (the watch trigger) and a row-level security policy in the
reader’s, as PostgreSQL runs an index expression or a policy: the walk refuses what they name of
that session’s temporary objects, but a name they look up while they run (
to_regclass(text), a cast of text toregclass) can find one. The text noted then is not the one_render()reads, and the claim readsunproven; it is never verified that way, becauseverify()judges only in a session without them. A proof or a recipe that reads the catalogs as data (pg_class,pg_type) sees every session’s temporary objects there, as any query does. - The walk for temporary objects covers relations, types (a domain’s base, an array’s element),
functions, operators and collations named in expressions, range tables and sort clauses, the
RETURNINGtype of the SQL/JSON functions and the type ofXMLSERIALIZE, andregclass-like constants (text search configurations and dictionaries, schemas); on PostgreSQL 19 PostgreSQL’s own walk (collectDependenciesOfExpr) runs too. A node kind neither knows is not refused by it: in a proof that is moot (no temporary object exists where one is judged), in a recipe it is the limit above. - A halt, an interruption, a claim refuted for its time, the
unprovenof a gone author and a claim its author’s sessions could not judge are recorded by a worker too (a new one after one ended; one connected as the extension’s owner when no author’s is at hand): with no slot ofmax_worker_processesfree for 5 s it is not recorded, and the claim stays due (a halted one runs again, and ends another worker). A proof that crashes the server (C code that dies with a signal) restarts it, verifier and all. - A session another process ends while its proof runs is interrupted, not halted, whoever ended it:
a proof that has another session end its own (through an extension such as
dblink) is interrupted at every run, its turn spent each time, and readsunprovenpast its horizon. A shutdown interrupts the claims being judged. - A proof waits for a lock up to its clock; then it is not judged, and since the twelfth review
neither is one that waited for a lock and then ended after its clock. An author can therefore keep
its own slow proof from being refuted for its time by making it wait for a lock it holds: it is
not judged, and reads
unprovenpast its horizon, neververified. Only waits for heavyweight locks are told apart: a wait for a lightweight lock or a buffer another session holds (short, and not held across statements) counts as the proof’s time. They are the waits of the proof’s session: since the thirteenth review a proof runs in one process (no parallel worker), so every one it makes is seen; a proof that reaches another session (an extension’s code:dblink,postgres_fdw) waits there unseen, and that time counts as the proof’s. A writer that holds a table a proof reads for longer than the proof’s clock keeps its claim from being judged, and its verdict serves until its horizon; until the eleventh review it made the claimunprovenat once, which let any session that can take a lock a proof waits for (an advisory lock needs no grant) erase a true claim’sverified. A verifier whose transaction holds what a proof waits for gets an error (its transaction aborts; the verdicts reached before it stay, committed). - A proof whose author is a superuser runs as a superuser: read-only and security-restricted, but with every right to read. Adopt orphaned proofs with a role of your choosing, not as the owner by default.
- A verifier that may act as an author runs that author’s code, in a session of that author: grant
assert()only to roles whose code you would run as them. - A proof’s session is a background worker: a call uses one at a time, and
verify()one more, its keeper (twelfth review), while it has claims for an author’s session: two slots ofmax_worker_processes, which parallel query, logical replication and other extensions share (a proof itself starts no parallel worker: thirteenth review). Leave two free for everyverify()that may run at once, one for everyverify_one(). Both ofverify()’s are started before a claim’s turn is spent: a claim found with no slot free for either is not judged, nothing is recorded, and it keeps its turn – nothing about the claim or its author kept it from being judged – and the call goes on without waiting for one (until the thirteenth review the keeper could take the last slot and spend the turns of a batch no session could try). Once a claim’s turn is spent, a new session of its author is needed only after the one started for it ended (its proof ended or overran it, a placeholder, its author ended it): if no slot is free then, the claim is not tried, its turn stays spent, and its verdict serves until its horizon, as when its author ends its sessions. While no worker is free nothing is judged, and every verdict serves until its horizon and then readsunproven. The keeper is a session of the extension’s owner: a role that may end the owner’s sessions (a superuser; withpg_signal_backendwhen the owner is not a superuser) can stop averify()call, and nothing more – nothing is judged or recorded, the claims keep their turns, and the verdicts serve until their horizon. verify()andverify_one()refuse a read-only transaction, session or standby (sixteenth review): a job that verifies runs in a session that may write (a production deployment’spg_cronjob does:SET ROLEdoes not make it read-only).- Whether an author may still read the watched table is read once per
verify()call: aREVOKEduring a long call does not stop its proofs from running, and a proof that then reads the table fails with its own permission error, which is its answer (refuted). The next call records itunproven. - A
DROP ROLEof an author waits for the verdict its worker is judging (up to the proof’s clock and 5 s): the worker holds the role until the verdict commits. become()pins the settings that decide what phasevec’s own statements read and compute, notsession_replication_role(a superuser’s): a superuser session set toreplicakeeps the extension’s internal triggers (its counts) from firing, as for any table.- A proof reads what any session of its author reads, the others included (
pg_stat_activity,pg_locks), and its verifier’s session is one of them. - The configuration is the database’s: a role that may change it (
ALTER DATABASE ... SETis the database owner’s;ALTER ROLE ... SETa role admin’s, and any role’s for itself for a setting a user may set) changes what proofs read, as a role that may write a table changes what they read of it. The session timeouts andexit_on_errorare not part of it: a worker pins them before it connects (tenth review; until then this line said atransaction_timeoutset there was a role admin’s and halted the claim, and an ordinary author that set its own to 1 ms had every worker of its claims end before it judged, its claims never judged again and stillverified). What else it sets can keep a new session of the author from starting at all (alocal_preload_librariesthat fails to load): its claims are then tried and not judged, and readunprovenpast their horizon. An author, or anyone who can hold a claim’s row or a lock its proof needs, can keep a claim from being judged, not keep its verdict past the horizon; until then a verdict about the state before can still be served. The horizon is the bound: choosemax_agefor what a stale verdict costs you, and make the rotation fit inside it.