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_cursors lists are frozen while it runs (PostgreSQL itself then refuses to run or close them), a query begun before it cannot be run inside it, and FETCH, MOVE and CLOSE take only cursors opened inside it. Of the commands only SET, SHOW, EXPLAIN, DECLARE, FETCH, MOVE, CLOSE, CALL and DO run (NOTIFY, LISTEN, PREPARE, DEALLOCATE, DISCARD, LOAD and 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, currval and lastval, the large-object descriptors, pg_cancel_backend, pg_terminate_backend and every other volatile system function are refused, and so are pg_cursor and pg_prepared_statement, which read the session’s own statements, EXPLAIN EXECUTE, which shows one of them (and with ANALYZE runs it), and txid_current() and pg_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 guard pg_temp is searched last (appended to the author’s search_path when 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 a regclass-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 through search_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 written BEGIN ATOMIC is 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 beside collectDependenciesOfExpr on 19: in the sixth cut the RETURNING type of JSON_VALUE and JSON_QUERY (with NULL ON ERROR, in no other node) went past both, and a regconfig or regdictionary constant past 18’s. The session’s timeouts (transaction_timeout, statement_timeout, lock_timeout, idle_in_transaction_session_timeout, idle_session_timeout) and exit_on_error cannot be changed, by set_config, SET, or a function’s SET clause. In the second cut a proof set transaction_timeout and ended the verifier’s connection with a FATAL before any verdict was written, so every later verify() of the watch died the same way; another released the verifier’s own advisory locks; another chose its random() 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 with NOTIFY, delivered at the superuser’s COMMIT (in brief(), 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_temp was searched first: the author’s search_path did not name it), and verify() recorded, and brief() served, verified for 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, and verify() recorded and brief() served it as verified; a PL/pgSQL function with its own search_path, run once by the verifier before verify(), 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 to regclass, found the verifier’s, and verify() 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), its session_user (after SET SESSION AUTHORIZATION, or under a policy USING (tenant = session_user)) and its application_name. Now verify() and verify_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 – its session_user is the author, it has what postgresql.conf, ALTER SYSTEM and ALTER DATABASE/ALTER ROLE ... SET give every new session of that author, and its session_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 in verify() 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 whole verify() 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 verified that 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): a ROLLBACK of 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() and verify_one() refuse one with a transaction id (a write, DDL, LOCK TABLE ... ACCESS EXCLUSIVE, an assert()), whose writes would be judged as if they were not there. A worker commits with synchronous_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_send with nowait), in the loop that keeps the proof’s clock and 5 s more, the start of a worker, p_budget and 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 catches query_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 (an ALTER TABLE queued 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 recorded refuted, and the ALTER TABLE and 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’s lock_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 (only verify()’s took it first), then waited for the row a verify() worker held while it judged; when that one recorded refuted (the claim had turned false in between), verify_one() wrote its own verified, read before, over it, brief() served it and verify() left the claim alone for p_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_set does not replace a verdict read after the judgment it records, nor _truth_halted a 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 pins transaction_timeout, statement_timeout, lock_timeout, idle_in_transaction_session_timeout, idle_session_timeout and exit_on_error over whatever the database and the role set (an ordinary role may ALTER ROLE itself SET transaction_timeout = '1ms': in the ninth cut every worker of its claims then ended before it judged, nothing was recorded, its claims kept their old verified in verify(), verify_one() and brief(), and with p_n they 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 one p_every more 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_backend works 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 memory and a true claim was recorded refuted; an assert() 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, and brief() served the old verified after the committed state refuted it; once p_n claims were stale (their rows edited and not asserted again) they sorted first in every call (the due list put a stale claim’s tried_at first, and the stale path set it to NULL; 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 verified or refuted of 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'). Past verified_at + max_age, _verdict(), brief(), recall() and the rest read unproven – 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 stays unproven (SQL that reads phasevec.truth compares it), and since when is verified_at + max_age. A halted claim (its proof ends its session: not run again) and a refute() of a key with no proof (nothing judges it again) have no horizon. Choose max_age longer than the time verify() takes to come round every claim: ceil(claims / p_n) calls, each p_every or the cron’s interval apart, whichever is longer, and one call more (a claim judged just after the last call’s p_every waits for the next). With 1000 claims, p_n 100 and a call every 10 minutes that is 100 minutes or more, and at the default hour about 400 of the 1000 read unproven at any time (measured at 60x speed, below); with p_n 2000 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_due takes 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 within ceil(N / p_n) calls that run their p_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 than ceil(N / p_n) calls; with one queue no author holds its head. An assert() 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/pgSQL RAISE ... USING ERRCODE) is its answer. A lock wait at the clock, a deadlock and a lock_timeout are not answers either: until this review they recorded unproven, now nothing (verify_one() says it could not judge the key, and why).

    test/truth_liveness.sh plays each of the four attacks, and a property: 60 claims of three authors, p_n 6, with a row holder, an assert() holder, a stale editor and a worker-slot hog at once: every claim is tried within ceil(60 / 6) = 10 calls and every claim no one holds is refuted in them; then the hog takes every worker, nothing can be judged, and brief() serves no verdict past its horizon (checked 20 times). On 07b019f every part fails, on PostgreSQL 19 and on an assertion-enabled 18.

  • 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 recorded refuted (“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_backend over its own pg_stat_activity rows, 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 served verified through eight calls, against a bound of five. Now verify() 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, and verify_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() compared verified_at + max_age and expires_at with now(), the start of the reader’s transaction: in a READ COMMITTED transaction begun before the horizon, brief(), truth_coverage() and _verdict() served verified past it, after that transaction itself could see the claim was false. Now every read path compares with clock_timestamp(): _verdict(), the filter that hides an expired claim from find(), find_hybrid(), find_npu(), find_ivf(), the replacement chain and the recall measure. Not statement_timestamp(): one long statement (a DO block, a function that reads brief() in a loop) is a reader too.

    It also found that a clock near the int limit overflowed the verifier’s deadline (p_timeout_ms

    • 5 s): every claim was “not taken within -2147478649 ms”. test/truth_own_time.sh plays 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 claim refuted; 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 within ceil(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.
  • 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 recorded refuted (“the proof took longer than 1000 ms”). The author needed only settings of its own role (debug_parallel_query, or a parallel plan with parallel_leader_participation off); a role that may LOCK a table the proof’s functions read did the rest. Now the proof’s session pins max_parallel_workers_per_gather and max_parallel_workers to 0 and debug_parallel_query off before it connects, over the database’s and the role’s settings, a SET in the proof and a function’s SET clause (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 sampling pg_locks from 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, under verify_one()) kept chosen false claims of another author from ever being tried, against the bound of ceil(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.sh plays the first: a table held 3 s against a 1 s clock while the proof’s nested query runs in a parallel worker (by debug_parallel_query, and in a Gather the 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 recorded refuted and the last is refuted, now each is not judged and stays verified, and the last is verified. test/truth_slots.sh plays the second: max_worker_processes 2, 20 false claims, p_n 10, 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.

  • 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 a statement_timeout there reached verify()’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 call verify() 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’s now()) 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’s now(): verified, stamped after the due date, and served fresh for a whole max_age; and a heartbeat written during the wait was “in the future”: a true claim stored refuted. 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. And verified_at is the proof’s own now(), 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, after now(), 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), then phasevec.truth (an assert() in the same transaction, from a trigger). The update script altered phasevec.truth and then phasevec.watches: a writer holding watches on its way to truth and the update holding truth on its way to watches deadlocked, and one was aborted. Now the script locks watches, sources, lexstats, lexdocs and truth, 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.sh plays the first: 60 claims of two authors alternating (a session per claim, each waited for), verify() 1000 times under a random statement_timeout of 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 one verify() 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.sh plays the second with the review’s two claims and a proof that logs its now(): on b17c072 the invoice is recorded verified with a stamp after its due date, the true heartbeat claim refuted, and verified_at is not the proof’s now(); now the first is refuted, the second verified, and the stamp is the proof’s clock. test/upgrade_locks.sh plays 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 whose SECURITY DEFINER trigger asserts, and the update started in between: on b17c072 20 of 20 deadlocked; now none, and every update and write commits.

  • 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 read phasevec.watches and phasevec.sources and called phasevec._bind(), which this release closes to PUBLIC. A reader granted phasevec.truth and the watched table – a production deployment’s read-only role, NOLOGIN with pg_read_all_data, and the queries and views it reads through – got “permission denied for function _bind” as soon as verify() wrote a fresh verdict (rows that read unproven never reached the call, so it looked fine right after the update), and one without pg_read_all_data got “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; opening phasevec.sources would have given every reader of phasevec.truth a way to test what any watched row says. _verdict(t) is now a door like recall() and the others: it shows a claim’s verdict to a role that may SELECT the watch’s table (and, when row-level security filters that table for the role, see the claim’s row), and NULL to 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 gets NULL, 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 may LOCK that table, or a queued ALTER, VACUUM FULL or REINDEX of it), within the proof’s clock: the proof read a now() from before the wait and a snapshot from after it, and a heartbeat written during the wait was “in the future”: a true claim recorded refuted, through verify_one() and verify() 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.truth held by a LOCK or an ALTER) 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 after now(), 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 in TopMemoryContext: dsm_create() with dynamic_shared_memory_type = mmap leaves 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 with mmap and 24-32 kB with posix, for as long as the session lived. Only what a session keeps (the segment, its queues, the worker’s handle) is in TopMemoryContext now.

    test/sql/truth_read_path.sql plays the first, as a NOLOGIN role with pg_read_all_data, one granted only the watched table and phasevec.truth, and one granted only phasevec.truth, each in a read-only transaction, against a watch with row-level security too; on fc37e9f _verdict is refused to the first two (“permission denied for function _bind”, “… for table watches”); now the first two read every verdict they may and NULL for a row a policy hides, the third reads NULL, and the owner reads what it read before. test/truth_claim_clock.sh plays the second (claims 4 and 5): a lookup table its recipe reads held 1.5 s while verify_one() and verify() read the claim’s row, a heartbeat written during the wait; and phasevec.truth held 2 s while the worker takes the claim after a slow read of its row. On fc37e9f the first is recorded refuted by both, stamped before the heartbeat; now both are verified, stamped after it; with the read moved but without the retry of the take, the second is recorded refuted. test/truth_memory.sh plays the third: 1000 verify_one() and 20 verify() calls in one session, once with mmap and once with posix; on fc37e9f TopMemoryContext grows 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 of ALTER EXTENSION (each one held the writers up to its lock_timeout of 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 with pg_cron verifying 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 the search_path of whoever was connected. A proof’s session is a new session of its author, so an author’s ALTER ROLE ... SET search_path = own_schema, pg_catalog put its own = for bytea in front of pg_catalog’s, and the IS DISTINCT FROM of 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 a verified for a false proof, which _verdict() and brief() 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, TimeZone UTC, DateStyle, IntervalStyle, extra_float_digits, bytea_output, the lc_* that format, standard_conforming_strings, xmloption, row_security, …) and transform_null_equals off, and unbecome() 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 the search_path it was planned in is not the current one, and every kept plan is prepared and run as one role: the owner’s in become(), the table owner’s in the guard). The pins are not set over the whole proof session (at PGC_S_OVERRIDE): a proof is its author’s and keeps its author’s search_path and settings, and every statement of phasevec’s own in that session goes through become(). On top of that every operator in phasevec’s C statements names its schema (IS DISTINCT FROM is written with OPERATOR(pg_catalog.=)); _term() and _reads(), inlined into their callers with no SET of their own, name theirs (and write their backslashes in E'' strings, which standard_conforming_strings does not change); and the IVF functions that run SQL with their caller’s identity carry SET search_path = pg_catalog, pg_temp, as every SQL function of the extension does. The audit: every SPI statement of the library (in become(): 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, _reads and _lexemes carry the SET; _lexemes names everything already), and every cast, implicit operator, IN, CASE, COALESCE, NULLIF and IS DISTINCT FROM in them: CLAIM_LOCK_SQL was the only one that ran with no pinned search_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 in fn_extra, which a PL/pgSQL expression keeps for its whole transaction: after a REVOKE or an ENABLE ROW LEVEL SECURITY committed by another session (which a new query, and has_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’s SELECT on 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.truth could 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_age was 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 a max_age of '1 day' read verified; 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_age was 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), so READ ONLY stopped nothing: a read act of a production deployment (a read-only role with default_transaction_read_only) started workers, ran every proof and committed 610 verdicts. Now verify() and verify_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’s pg_cron job, SET ROLE to a read-only role and verify()).
    • 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 stamped verified in 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 ROLE takes it exclusively, and waits for that verdict), and a role dropped before is found gone: the claim is recorded unproven, as for any gone author, until it is asserted again or adopted.

    test/sql/truth_review16.sql plays the first, the fourth, the fifth and the second (a REVOKE and an ENABLE ROW LEVEL SECURITY in the transaction, and a made-up row); on ad275f3 the author’s operator runs as the owner and key 2’s false proof reads verified, a 27-hour-old verdict reads verified under the rule, the kept expression answers after the REVOKE and for the hidden row, and verify() and verify_one() commit verdicts in a read-only transaction; now none of it. test/truth_verdict_memory.sh plays 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.sh plays the sixth: 40 claims of 0.1 s, the author dropped after 1 s; on ad275f3 30 are stamped verified after the drop, now none, and 30 are unproven with 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 over phasevec.truth, a reader idle in its transaction and a session that loaded 0.19.0; with pg_cron verifying both watches every 15 s for 90 s under a max_age of 60 s, every run succeeded, no verdict was served wrong against the fixed world the proofs read, no claim from before the soak read unproven, 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 then recall() refuses on coverage and the command falls back to its literal search, as it is written to), and verify() there was refused. The dump restored into a database of 0.19.0 that verified and took writes.

  • 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 reads x.b = 2 under the first and no x.b under the second. A verdict judged for the first was recorded, and served verified by _verdict() and brief(), 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). _bind is 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 a verified verdict.
    • 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), so pg_parameter_acl and a library’s own definition decide as they would in the author’s session; assert() and adopt() 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’s SECURITY DEFINER function does not lend it its session_replication_role).
    • A function’s SET clause was recorded as the session’s. What a proof records is what its author’s session changed; a SECURITY DEFINER function with SET search_path that called assert() (an author’s wrapper function) had its search_path recorded too: the claim, re-asserted by a trigger, ran with pg_catalog, pg_temp and 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’s SET clause) gives back its prior value; a SET LOCAL inside it goes with it; a SET inside 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 declared COLLATE "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 or varchar key 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 recorded unproven as an unknown one is.
    • DROP TABLE of a watched table deadlocked with verify(). The proof’s session took the claim’s row, then read the table; DROP TABLE takes the table, then (its sql_drop trigger deletes the watch, which cascades to the claims) the row; the deadlock detector aborted the DROP. The proof’s session now locks the table first: a DROP waits for the claim being judged, or the claim waits for the DROP and finds its row gone.
    • A refuted claim came back. refute() left the proof in place: the next verify() past p_every judged the claim again and served it verified, and adopt() asserted every refuted claim again (a production deployment retires a deleted memory with refute(): after the upgrade every deleted memory was served as current and verified). refute() now retires the claim: its proof goes (proof NULL), so no verify(), verify_one() or adopt() judges it, and it reads refuted, with no horizon, while its text is the refuted one; an assert() 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_halted in 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.sql plays the identity, the replay, the SET clause and the retirement; test/sql/truth_collation.sql the collation (it needs ICU: truth_collation_1.out without it); test/truth_bind_race.sh the two races (verify_one() and verify()), test/truth_author_gone.sh the dropped author, test/truth_drop_watched.sh the DROP TABLE (three runs), test/truth_halt_encoding.sh the key. On ee7e426 each fails (the colliding claim reads verified, use_variable and replica reach the proof, the SET clause’s search_path is recorded and refutes, the hidden row is rendered and its verdict served, verify() ends with “invalid role OID”, the DROP is 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.

  • 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 “proof is NULL”, and only the new refute() made it so: a claim refute() had refuted under 0.19.0 kept its proof through the update, adopt() asserted it again and the next verify() served it verified (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) read unproven after a refute(), not refuted, so a memory deleted after the update was served too; and refute() of a replaced claim did nothing, no error, and unsupersede() brought it back verified; a retired claim superseded and unsuperseded read unproven for 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 reads refuted or replaced), and phasevec._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 and unsupersede() shows a retired claim refuted again; verify(), verify_one(), adopt(), recording a verdict, a halt or an interruption never touch a retired claim; an assert() is the only way back. The update retires what refute() 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 said FROM table. Under UNIQUE ... DEFERRABLE a 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 an INHERITS child 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 says ONLY.
    • 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_ASCII database a byte that is not UTF-8 (0xe9), in WIN1252 one with no UTF-8 equivalent (0x81), in a proof, a recorded setting or a key raised in _truth_due, so one author’s assert() stopped verify() 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 every verify(). 0.20.0 now lives in a UTF8 database only (CREATE EXTENSION and 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 with verify_one() (low). A proof’s session waited for a claim’s row holding the watched table (the order DROP TABLE takes 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 a verify_one() whose claim is retired while it waits returns NULL, as for one that had no claim when it was called.

    test/sql/truth_review18.sql plays the update (claims refuted by refute(), 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 later ALTER TABLE and CREATE TABLE ... INHERITS; test/sql/truth_partition.sql the partitioned table, its partitions, and a table attached as a partition after watch(); test/truth_halt_encoding.sh the refusal in LATIN1, SQL_ASCII and WIN1252 and, in UTF8, a halted key, a proof and a setting outside ASCII; test/truth_retire_ddl.sh the deadlock (refute() and assert()). On a400376 each fails (key 1, refuted by hand under 0.19.0, is adopted and served verified, so are the replaced key unsuperseded and a deleted key with no text, the retired key superseded and unsuperseded reads unproven, every shape is watched, the hidden row’s text is rendered through a DEFERRABLE key 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 verdict is its author’s while the author is that role, and verified_at only 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() and replacement() 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 ROLE of a claim’s author (and a new role under its name), _verdict(), brief() and truth_coverage() went on serving its verified (or refuted) until max_age passed or the next verify() recorded unproven: this README said it read unproven, and only verify() checked. Now _verdict_of checks it when read, as verify() does when it takes a claim: a verdict of a proof (a halted one too) reads unproven as 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 ROLE fires 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 reads refuted. It is one catalog cache lookup a claim (to_regrole): about 0.8 us, and brief() of 1000 verified claims went from ~30 ms to ~32 ms on PostgreSQL 19 (medians of 15, the two measured together; a join with pg_roles cost 3.5 us a claim, brief() +50%).
    • refute() moved verified_at back. In a transaction that began before a verify() recorded a verdict, refute() stamped verified_at (and retired_at) with its own now(), before that verdict (about 0.2 s in the fuzzer’s replay). _truth_set refused that already; refute() did not, nor did _truth_halted under its lock (it checked before it took the row). Every writer of verified_at now keeps it from moving back: refute() stamps the later of its now() and the verdict it replaces, unsupersede() shows a retired claim as of the later of its retirement and its last verdict, and _truth_halted checks again under the row’s lock. assert(), adopt() and unsupersede() 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.sql drops 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() and truth_coverage(); then it refutes a claim in a transaction older than a verify_one() verdict, and supersedes and unsupersedes it. On 8902c9b it fails (keys 1 and 5 read verified and brief() serves them, key 2 reads refuted, and verified_at moves 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).

  • 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 TRUNCATE is 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: a refute() that committed meanwhile was undone (the claim got its proof back, and the next verify() served it verified, and brief() to the reader), and another author’s assert() that committed meanwhile was replaced by the stale orphan proof, in the adopter’s name. Now adopt() 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: a verify_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 answered unproven; now it says it could not judge it); _truth_halted does not halt a claim asserted again since its proof began, and an interruption is recorded only on the claim that was tried; _truth_set did already. assert(), refute() and supersede() write blind (the last one stands), and unsupersede() is one statement on the locked row.
    • A halted claim superseded and unsuperseded read unproven for good (R19-F8). unsupersede() gave every claim that was not retired back as unproven, to be judged; verify() does not run a halted claim again, so it stayed unproven, and brief() 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, refuted as of then; judged, unproven and due at once (verify() judges it again); bare, unproven.
    • After TRUNCATE, _verdict() and truth_coverage() served verified for rows that were gone (R19-F9). The watch trigger did not fire on TRUNCATE, so the watch’s notes (phasevec.sources) kept every row’s text until max_age or the next verify(), and truth_coverage() counted those rows after that too. Now a watch has a TRUNCATE trigger too (phasevec_watch_<id>_truncate, made by watch(), and by the update for every watch made before it), and a TRUNCATE forgets 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 SET clause (the seventeenth review’s answer to an author’s wrapper function), so a role that could call an author’s SECURITY DEFINER wrapper hardened with SET search_path decided the search path of the author’s proof: its own operator > for bigint and int, first on its path, ran as the author when the proof was judged, read a table the caller could not, decided the verdict, and through dblink made the caller a superuser. Now a claim records what is in effect where assert() is called, as the author has it there: the SET clause of the author’s own function is the author’s, and nothing a role without the author’s privileges set is recorded (the caller’s search_path, startup options, or its own function’s SET clause 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 same SET clause (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, by assert()’s rule, for a claim from before 0.20.0 too.
    • _key_shape() locked any table for anyone (R19-PRIV-2). It was open to PUBLIC and 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 only USAGE on the schema held it, and an ALTER TABLE queued 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 TABLE while 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 a DROP TABLE that committed meanwhile gave relation N deleted while still in use or pg_attribute catalog is missing 3 attribute(s) (XX000), and the verifier’s privilege check relation with OID N does not exist, instead of the refusal “the table is gone”. The indexes are now read from pg_index, and a table gone is that refusal. No verdict changed either way.

    test/sql/truth_review19b.sql plays 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 same SET clause, 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 (a TRUNCATE by 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.sh plays R19-F7 (a refute() and another author’s assert() committing while adopt() waits) and R19-F10; test/truth_drop_race.sh churns 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.py does) found:

    • A claim asserted again was halted by the proof it outlived (RT20M-1). _truth_halted leaves alone a claim asserted again since its proof began, and told it by changed_at <= p_started; but every writer stamped changed_at with now(), the start of its transaction. An assert() 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, and verify() never ran it again), and the assert() 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 (its xmin) 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’s now() - p_every is an age, not an order.
    • A wrapper’s SET DateStyle = ISO recorded its caller’s field order as the author’s (RT20M-2). DateStyle is made of two parts, and a SET that names one keeps the other from the value it replaces; PostgreSQL stamps the merged value with the role of the last SET. A caller without the author’s privileges set DMY in its own session, called the author’s hardened wrapper, and the claim recorded ISO, DMY as the author’s: the same proof over '03/04/2026' was verified when the author asserted it and refuted when 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’s ALTER ROLE ... SET counted 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 own DateStyle. An author that wants a DateStyle in 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-level SET DateStyle made inside one of the author’s SECURITY DEFINER functions (a plain SET in its body, not SET LOCAL and not its SET clause) 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 sets DateStyle only with SET LOCAL or its SET clause.
    • 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 WARNING on failure: when the author lost the SET privilege it had through a role (another role with ADMIN on 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 and verified was 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_timeout and deadlocked a writer (RT20M-3, R21-MIND-3, RT20-CRASH-1, RT20-CRASH-2). To make the TRUNCATE triggers, round 21’s update took every watched table first, with NOWAIT, again until all were free at once: under steady single-row writers that moment rarely came (not done after 60 s with two pgbench clients), NOWAIT never waits so lock_timeout never 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 queued LOCK (behind the writers that hold them, which end), bounded by lock_timeout, and when that ends it stops with phasevec: 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’s TRUNCATE trigger 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 any CREATE TRIGGER (bounded by lock_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 with NOWAIT too: 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. That NOWAIT covered 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 TRUNCATE trigger 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 (a WARNING, the update goes on) and by watch_truncate() (an error with what to do).
    • adopt() deadlocked with DROP 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 TABLE takes the table and then, through _on_drop, the rows. It now takes the watched table first, as every verifier does (watched_table_first): a DROP waits for adopt(), or adopt() waits for the DROP and then says the table is gone.
    • A verify_one() that waited for a supersede() ran the replaced claim (RT20M-5). The take checked the bind, which supersede() does not change, and not the verdict: it ran the proof, recorded nothing and answered replaced. The take leaves a replaced claim: verify_one() answers NULL, as for a key with no claim to run.
    • A TRUNCATE of a watched table costs per row (RT20-CRASH-4). Documented under Limits, not changed: forgetting the rows is a DELETE of the watch’s notes, under the TRUNCATE’s lock.

    test/sql/truth_review20.sql plays RT20M-2 (a hardened wrapper with SET DateStyle = ISO, and one with SET LOCAL DateStyle in its body, called by the author in its default and in DMY, and by another role in DMY), RT20P-ENV-1 (a parameter SET granted through a role then revoked, and a text search configuration dropped by its owner, with a control that is set again) and watch_truncate() with the update over a table owner’s trigger of the same name; test/truth_review20.sh plays 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’s DMY from ALTER ROLE ... SET; test/upgrade_writers.sh plays the update under two pgbench writers (20 s allowed), behind a writer idle in its transaction with lock_timeout = 1s, and with a transaction that read phasevec.watches and 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). In test/sql/truth_review17.sql a setting the author may not set, written into a claim by hand, now reads unproven instead of being judged without it.

  • 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 NOWAIT covered 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 with NOWAIT before 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. A REINDEX INDEX of the key’s index (it takes the table in SHARE mode, which a reader’s lock does not conflict with), or an ALTER TABLE or LOCK TABLE of 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, its TRUNCATE trigger, its rows through the recipe) runs under a lock_timeout of 1 ms, the SET clause of the update’s own step function (phasevec._update_step, dropped when the update ends; the caller’s lock_timeout is 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 with phasevec: 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 an ACCESS EXCLUSIVE lock 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 fresh CREATE EXTENSION, which runs this script, failed on a catalog lock on the assertion-enabled 18). The TRUNCATE triggers keep their own NOWAIT, which skips a busy table and names it, as before. Listing the relations beforehand and taking each with NOWAIT was 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 TABLE in an open transaction) stops instead of waiting, and is run again.
    • The update unbound the claims it retired (RT21M-1). It bound each refuted claim to the row’s text as 0.19.0 had noted it (truth.asserted_for from phasevec.sources) and retired those refute() 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 whose TimeZone or DateStyle is not the pinned one, every row was noted again, every retired claim then read unproven (its text was not the one it was bound to), and brief() and truth_coverage() served every memory refute() had retired before the update (a replaced one read unproven after unsupersede()). Now the update binds and retires them after step 9, to the text as every reader renders it: they stay refuted, 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 only watch_truncate() got “permission denied for table watches”. Documented, and narrowed: it is the extension owner’s to run, or to grant with EXECUTE on phasevec._truncate_trigger(int, boolean) and SELECT (id, name, source) on phasevec.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 DateStyle order through an author’s function that sets DateStyle for the session (RT20M-2, residual). Not changed: under Limits.

    test/upgrade_writers.sh case 5 plays RT21P-UPD-1 (REINDEX INDEX of the key’s index, then a write) and RT21P-UPD-2 (ALTER TABLE of a table the recipe reads in a subquery, and LOCK TABLE of 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.sql plays RT21M-1 (a timestamptz recipe under the suite’s PST8PDT: claims refuted, one replaced, before the update; on 127001e they read unproven and brief() served them), checks that the caller’s lock_timeout comes back after the update inside its transaction, and plays watch_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.

  • 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_timeout of 1 ms (twenty-first review). A recipe that reads another table through a function of its own with an EXCEPTION clause (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 every refuted and 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, even UPDATE ... SET body = body, noted the real text: the retired claim read unproven and brief() and truth_coverage() served the memory refute() 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 first ERROR of 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 a RAISE of 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 stay refuted. The same holds wherever a recipe renders: a write whose recipe caught its own session’s lock_timeout or statement_timeout fails and notes nothing (it noted the fallback as the row’s text), and brief() or _render() fails instead of serving the fallback. An error of the recipe’s own data (division_by_zero) or a RAISE of 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 EXCLUSIVE lock in the database. Its DETAIL listed 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 catches lock_not_available and 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’s statement_timeout ends 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.sh case 6 plays RT22P-UPD-1 (a recipe that reads a table through a function with EXCEPTION 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 read unproven and are back in brief(). Now the update stops, named, with the held table in its DETAIL and (on 19) not the one apart; once the lock is gone it goes through, every row noted as _render() renders it, and the claims stay refuted after the same write. Case 7 plays the same recipe for a writer with a lock_timeout of its own after the update: on 83cfa95 the write commits and notes the fallback; now it fails and notes nothing. test/sql/truth_review22.sql plays a statement_timeout the 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 a RAISE of its own that it catches, which stay its text. All three pass on PostgreSQL 19beta2 and an assertion-enabled 18.6.

  • 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.truth takes 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 take phasevec.watches first. A transaction that first read phasevec.truth or phasevec.sources directly (a report by the owner, a role granted SELECT, or a GRANT on the table in a migration, which locks it) and then wrote a watched table waited for phasevec.watches, which the update held while it waited for truth. 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 bare deadlock detected. Now each try of the update waits, queued and bounded by the caller’s lock_timeout, for one thing only, holding nothing of phasevec’s: first phasevec.watches (once the update has it, no write starts). Then it takes everything else it will change without waiting: the five tables with NOWAIT, 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 holds phasevec.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 (a GRANT EXECUTE on phasevec.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 a lock_timeout of 1 ms: the lock a COMMENT or a DROP FUNCTION takes). A busy one is waited for like a table, holding nothing. The rest of the script runs under a lock_timeout of 100 ms, or the caller’s if it is shorter. Everything it locks there it holds already, or takes in _update_step without waiting. Any wait nothing above foresaw (a catalog that a VACUUM FULL holds) is bounded, and it ends the update with PostgreSQL’s own canceling statement due to lock timeout, nothing changed. The caller’s lock_timeout is 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 fresh CREATE EXTENSION on 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: 40 CREATE/DROP EXTENSION runs under catalog churn passed on both versions. The update stops with phasevec: 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 (...). The DETAIL names what it waited for and the sessions holding it.
    • A recipe’s own RAISE of 55P03 was blamed on another session, and one recipe’s error hid the rest (RT23P-UPD-3). The update’s step took any lock_not_available from 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/pgSQL RAISE of one of them leaves its guard as the recipe’s own error, P0001, with the code it raised in the DETAIL (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 as unproven at 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.sh case 8 plays RT23P-UPD-1 and RT23P-UPD-2. A transaction does one of: reads phasevec.truth, reads phasevec.sources, grants on phasevec.truth, grants on phasevec.brief(), comments on phasevec._verdict(), or alters phasevec.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 within lock_timeout, named, while a writer of a watched table goes on (on 15d67b7: a bare canceling 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.sql plays a recipe’s own RAISE of 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 a statement_timeout caught 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 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 EXTENSION began, before it waited for phasevec’s tables: a refute() committed in that window was not seen, the claim was not retired, and after adopt() and verify() it was served verified. 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), as verify() 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 FUNCTION takes (RT24P-DEP3). The update wrote each function’s comment again to lock it: a COMMENT’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 with USAGE on the schema that made a view over phasevec._brief_keys() held such a lock, and the update met it only at its DROP FUNCTION, after rendering every watch: a bare canceling statement due to lock timeout, or, with a short deadlock_timeout, that transaction aborted as a deadlock and the update went on. Now the update takes each function’s lock in the mode DROP FUNCTION does (ALTER FUNCTION ... OWNER TO the 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 take phasevec.watches) and steady readers of phasevec.truth (who do not), the two were never free at once, and the update stopped in about a second even with lock_timeout = 0. Now each try waits first, holding nothing of phasevec’s, for phasevec.watches (after a failed try: the lock that ended it), for as long as the caller’s lock_timeout lets 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 the deadlock_timeout the 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’s lock_timeout is 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 with deadlock detected.
    • A role with USAGE on 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 a BEGIN ATOMIC function that refers to _truth_due, _truth_set, _prove_c, _note or _brief_keys (internals of 0.19.0, gone in 0.20.0; referring to one needs no EXECUTE) made DROP FUNCTION fail 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, with DETAIL: 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_timeout of half the deadlock_timeout, at most 100 ms, at most the caller’s; the update’s deadlock_timeout at 1 ms). A wait nothing above foresees (a VACUUM FULL of a catalog) ends the update with canceling statement due to lock timeout or deadlock detected, nothing changed, before any other session’s deadlock check runs. Run it again. The caller’s lock_timeout and deadlock_timeout are set again at the end of the script.

    test/upgrade_review24.sh plays 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 a BEGIN ATOMIC function over _truth_due(), made by a role with only USAGE: 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’s deadlock_timeout at 20 ms: the write commits and the update names the view; on 868251d the update stops with a bare lock timeout at DROP FUNCTION. Case 4: 4 steady writers and 6 steady readers of phasevec.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’s lock_timeout and deadlock_timeout come back after the update. All five pass on PostgreSQL 19beta2 and an assertion-enabled 18.6, and so do test/upgrade_writers.sh (cases 8 and 9 included) and test/upgrade_locks.sh. Played apart, not in the suite: writers, readers of truth and transactions that read truth or comment on a function and then write a watched table, together, at deadlock_timeout 1 s and 20 ms: 10 of 10 updates through and no client failed; writers holding watches 300 ms and readers holding truth 1 s: 4 of 4 through.

  • 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 past COMMIT, 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 with INSERT on a watched table could do it, after a statement_timeout or an error inside _render() (test/sql/truth_pin.sql; against that cut it ends the backend at its first INSERT). 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() and audit_sample() all read a row’s text through phasevec._render(): the recipe runs as the owner of the watched table (as PostgreSQL runs an index expression in VACUUM or REINDEX), with every setting that can change what a value reads, prints or computes at its built-in default (below) and these pinned on top: DateStyle ISO, IntervalStyle postgres, TimeZone UTC, extra_float_digits 1, bytea_output hex, search_path pg_catalog, pg_temp, lc_monetary/lc_numeric/lc_time C, standard_conforming_strings on, xmloption content, xmlbinary base64, timezone_abbreviations Default, row_security on. FORCE ROW LEVEL SECURITY does 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: SELECT on 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 cut brief() 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 another TimeZone edited a row so that it printed as the text its author had vouched for, and brief() served it verified.
  • A proof means what it meant to its author. assert() records, in truth.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 (and RESET ALL resets) of the groups statement behavior, locale and formatting, other client defaults and compatibility, and every custom setting (app.tenant, an extension’s) –, its application_name, and every custom placeholder the session made, with a value or not (app.tenant=), since whether one exists is part of what current_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 DATABASE and ALTER 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_path and extension_control_path. In the second cut a verifier in another DateStyle read the author’s '01/11/2026' as 11 January and recorded verified for a date the proof, as written, refutes; the third recorded twelve named settings, and a verifier’s default_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 where assert() (or adopt()) is called, as the author has it there – the SET clause of the author’s own function included, and nothing a role without the author’s privileges set (PostgreSQL keeps who set each value): through a SECURITY DEFINER wrapper, the wrapper’s SET clause 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 a SET names and the rest of the value before it (DateStyle: SET DateStyle = ISO keeps the session’s DMY or MDY) 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 reads unproven, with the setting and PostgreSQL’s reason in last_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, and current_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 called set_config('app.tenant', 'acme', true), judged first in a verify(), changed what a later author’s row-level security policy let its proof see, and a pooled verifier connection that once ran SET LOCAL app.tenant recorded, and brief() served, verified for 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 called set_config on 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’s set_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’s transaction_timeout, which the worker does not take, below) ends its worker, not the verifier: the claim is recorded refuted and 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 with pg_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 left brief(). The worker notes who sent it SIGTERM (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 next verify() recorded it (in the second cut nothing marked it, and every later verify() 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-back verify_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’s assert() 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_dump writes CREATE EXTENSION with no version, so the restore made 0.20.0 on empty tables and then copied in the rows 0.19.0 had written. A claim refute() refuted kept its proof, adopt() asserted it again, and verify() served it verified; the watch had no TRUNCATE trigger. The README itself sent users this way (to move to a UTF8 database). Now every row of phasevec.watches and phasevec.truth says which version wrote it (data_version): every write of 0.20.0 says 0.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 runs SELECT * FROM phasevec.migrate(); once. It takes each watch it brings in SHARE ROW EXCLUSIVE mode, makes its TRUNCATE trigger, 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() and unsupersede() of such a watch refuse, naming it and phasevec.migrate(). A claim 0.19.0 wrote never reads verified before 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 SELECT on the table did the same through assert() with no proof, refute(), supersede() and unsupersede(). One tenant’s brief went from 6 verified claims to 0. refute(), supersede() and unsupersede() are now C, like assert(): 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 taken FOR 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 with refute(), or makes a placeholder with a supersede() 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. A supersede() whose new row the caller does not see reads as one whose row does not exist.
    • p_max_cost NaN or negative switched the cost cap off; a negative p_n raised PostgreSQL’s own error (RT25PC-INFO-1). verify() and verify_one() now refuse a p_max_cost that is NaN or not above 0 ('Infinity' is no cap), and verify() a negative p_n, with phasevec’s own message.
    • supersede() accepted cycles (RT25PC-INFO-2). 2 -> 3 and then 3 -> 2 hid both claims. A supersede() 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 of phasevec.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, as verify() does.
    • A column storing the row type of phasevec.truth or phasevec.watches vetoed 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 are USAGE to PUBLIC. 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 in pg_stat_activity. The update now names it (prepared transaction 'p1'), and its HINT names pg_prepared_xacts.
    • A tiny lock_timeout gave up before the first try (RT26-LT-1, RT25PU-4). With lock_timeout = 1ms the 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’s tuple concurrently updated. That is raised before its script runs, when ALTER EXTENSION writes the extension’s row, so the script cannot name it; nothing of the second update is changed (below, Limits).

    test/review25.sh plays 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 before migrate(), and nothing reads verified. After migrate(), the claims, the texts, the watch and its triggers read as on the same database updated in place, and after adopt() and verify() the refuted claim stays refuted. On e905fba adopt() takes it and verify() serves it verified. Case 2: a fresh CREATE EXTENSION and 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 no SELECT, and a role that sees every row but is not the author, are all refused, with phasevec.truth unchanged; 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 a restore_upgrade step (half of its 0.19.0 sequences move by pg_dump/pg_restore and migrate(), with the model of the update) and checks that a cross-author change is refused with phasevec.truth unchanged (INV-AUTHOR).

  • 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 a DROP 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’s adopt() 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 granted refute() or supersede() 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 (false as a third argument takes it back), kept in phasevec.stewards by OID and name. A claim whose author is gone or unknown is the owner’s and the stewards' to settle, and adopt() 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 on INSERT; a 0.19.0 refute() arriving as an UPDATE left the proof under it, migrate() skipped the row, and adopt() plus verify() served the refuted claim verified. A replica trigger on phasevec.truth and phasevec.watches now labels 0.19.0 every row a logical replication worker writes; migrate() after cut-over brings them. A session of phasevec’s own that sets session_replication_role writes as 0.20.0.
    • DROP EXTENSION ... CASCADE, CREATE EXTENSION and a data-only restore left every watch without its triggers (RT26PL-INFO-1). migrate() now also takes a watch whose row trigger or TRUNCATE trigger 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 read replaced and left brief(). Both now undo the newest link of each cycle, as unsupersede() would, with a WARNING that names it.
    • With lock_timeout = 0 the update made 1 ms tries out of the queue, for ever (RT27-LT0-1). The twenty-fifth review’s greatest(1, NULL) is 1; overlapping readers of phasevec.watches starved it. With 0 the first wait has no bound again, and waits queued (for one table: against steady holders of both phasevec.watches and phasevec.truth each 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.truth or phasevec.watches is 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, as DROP FUNCTION takes it), not by ALTER ... OWNER TO its own owner: event triggers no longer see one ALTER FUNCTION per function and per try.

    test/review26.sh plays them, 15 checks, 14 of them failing on 0e5afba (the fifteenth is a control: a session that sets session_replication_role still 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_config and upgrade_020 count one more dumped table. The fuzzer’s INV-AUTHOR follows the rule: a steward step names an author role a steward or no longer, and an adopt() by a role that is neither the owner nor a steward must fail and change nothing.

  • 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 SELECT alone, or a tenant reading a shared row, claimed every row that had no claim yet (one refute() per row, or assert() 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), or UPDATE on the watched table with the row passing the caller’s row-level security for an UPDATE (its SELECT and UPDATE USING policies). The row is taken FOR KEY SHARE as the caller, in the guard, and let go at once. UPDATE on some columns only is not UPDATE on 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.sources has 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 TO ends 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 (an assert(), a refute()) 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 CREATEROLE there 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, which pg_upgrade keeps and pg_dump does not). Recorded in neither, it is no one: the claim is an orphan for the owner to adopt(), the steward is named again. A restore into another database of the same cluster keeps them.
    • pg_upgrade refused every database with pg_phasevec (RT27PL-2). phasevec.watches.lexical was regconfig since 0.6.0, and pg_upgrade refuses a table column of a reg* type other than regclass, regrole and regtype, 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 through pg_upgrade. A configuration renamed or dropped since names none: rows are noted without their lexical reading (find_hybrid() says the reading is partial) until phasevec.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’s data_version, and migrate() finds nothing to bring.
    • With lock_timeout = 0 the update still starved (RT28-LT0-1, RT27PU-1). The twenty-sixth review’s “the caller wrote” was “the transaction has an id”, and ALTER EXTENSION writes pg_extension before it runs the script: the id was always there, so every first wait was bounded (half the deadlock_timeout), and readers that each held phasevec.watches 1.5 s and overlapped kept it out until statement_timeout. The script now asks pg_locks whether the caller’s transaction holds a lock of its own (anything but its transaction ids): a lone ALTER EXTENSION holds none, and its first wait is queued with no bound again.

    test/review27.sh plays them, 13 checks; 11 fail on b197a13 (a revoked steward’s own claims, and 0.19.0 refused by pg_upgrade, are controls). Case 8 runs a real pg_upgrade from 18 to 19 when PG_OLD names the older binaries. Regression tests and shell suites whose authors held only SELECT now grant them UPDATE on the watched table (and truth_read_path an UPDATE policy); in review26.sh the steward of case 5 sees the rows it changes. review25.sh compares a fresh install and the update without _here()’s value, which is each installation’s own. The fuzzer’s INV-AUTHOR follows the rule (Model.writes, steward_reaches), with a step that revokes and grants the author role’s UPDATE.

  • 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.born is 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(), a supersede() 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 reads unproven (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 may UPDATE the new row). The watch trigger writes phasevec.sources, as before, and never phasevec.truth.
    • A revoked steward kept the claims it took as a steward (RT28PA-REVOKE-1). A steward with SELECT alone 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 UPDATE before 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_basebackup copy, a standby promoted, pg_createsubscriber, or pg_upgrade --copy or --clone keep the system identifier or _here() (or both) and the OID counter, and a role with CREATEROLE there can make one under an author’s OID and name (in order, or moving the counter with lo_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 then adopt()s what it keeps and names its stewards again. It returns, per watch, how many of each it cut. The same-cluster and pg_upgrade behaviour 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 CREATE on 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 and migrate() record the one it has), and one found under that name with another owner is none: rows go without their lexical reading until lexical() names one again. One in pg_catalog is 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_end trigger that wrote a row for each CREATE FUNCTION left 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 enabled ddl_command_start or ddl_command_end event 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_start trigger makes on ALTER 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 of phasevec.watches the update gives up (at its lock_timeout) instead of starving. Its HINT now says which, and what to do; the plain HINT no longer offers lock_timeout = 0 when 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_createsubscriber subscriber is a copy; one Limits bullet went past 100 characters.

    test/review28.sh plays them, 7 checks; all 7 fail on 542fb8e. Case 4 runs rebind() on a pg_basebackup copy in which a CREATEROLE role makes the author’s OID and name. Behaviour changes: a row deleted and written again under its key, or a TRUNCATE and the same rows again, leave the claims on them dead (truth_stale and truth_review19b expected the old verdict back); review27.sh case 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 a reinsert step (the writer deletes a row and writes it again under its key, in one transaction or two) and a rebind step, and checks each key’s born (INV-BORN) and each claim’s row, steward mark and binding (INV-MODEL/meta).

  • 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 reads unproven, verify() does not judge the new text whatever the proof reads, and it is a claim again when its author (or adopt()) asserts it again. Until 0.19.0 a row edited after it was verified kept verified, and brief() 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 to SELECT the table it asserts about (and, with row-level security, see the row), at assert() and at every verify().

  • 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 as verified only 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, TRUNCATE and a new INSERT, ALTER COLUMN ... TYPE ... USING, a renamed column with a new one under its name, a hand-edited dump) reads unproven in brief() and is not verified by verify() (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 verified or refuted whose row’s text is not the one it judged reads unproven (_verdict()), and verify() takes such a claim first. verify() takes each claim’s row before it judges it, and leaves one another transaction holds (another verify(), an uncommitted assert()): two verify() calls of one watch share it out. In the fifth cut a trigger on phasevec.sources wrote the verdict as the text changed, so a write waited on every open transaction that had judged that key, and two verify() 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 two verify() 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 the verify() 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, two verify() 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-822 verify() 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 as unproven. Without writers a verify() 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 unproven while 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 reads verified or refuted again if the text comes back. In the sixth cut verify() wrote unproven over it: a writer that edited a row for a moment, while a routine verify() ran, erased a refutation until the claim was due again (p_every), while _verdict() read refuted as 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, a DROP and ADD COLUMN or an ALTER COLUMN TYPE hashed the old column (an edited row stayed fresh, next() handed out nothing and find() 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() and verify() in the table’s, so on a partition whose columns stood in another order (attached, or after a DROP and ADD) 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 recorded verified for 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_at is 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 in truth.asserted_by the name it had); a SECURITY DEFINER wrapper 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 reads unproven until it is asserted again, from the moment the role is gone (nineteenth review; until then from the next verify() or its horizon). A verdict is recorded only for the author it ran as: an assert() 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 called verify() (test/sql/truth_author.sql, test/sql/truth_door.sql, test/truth_race.sh).
  • One door. verify() and verify_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 write phasevec.truth). A verifier other than the owner is granted verify() or verify_one() and nothing else. The first cut had verifiers granted SECURITY DEFINER internals, and with them a verifier forged verified and read every proof (test/sql/truth_door.sql).
  • The clock bounds the verdict. Every proof has a clock: p_timeout_ms must 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 catches query_canceled runs on but is refuted for its time. A proof cannot run another proof. A query_canceled that a proof raises itself is that proof’s error (it aborted every verify() of the watch); the caller’s statement_timeout and 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 holding UPDATE on one unrelated column, did it to every key of its own watch. recall(), disputes(), replacement() and truth_coverage() are one C door that shows a watch only to a role that may SELECT its table, and with row-level security it computes over the rows that role sees, so no field carries another (in the third cut recall()’s disputed_by named keys of hidden rows, and replacement() 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 with USAGE on the schema read every watch’s keys, verdicts, topics, reasons and support through them. brief() is the same kind of door; it is revoked from PUBLIC and 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_lists and phasevec.ivf_added is checked for NULL and 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 in pg_dump since 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 of phasevec.ivf_cents every time it is used, and ivf_absorb grows it only if it is the index it read: a row edited under the same version made ivf_absorb size 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_absorb checks 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 UPDATE it (twenty-seventh review), so a writer that only INSERTs, or holds UPDATE on some columns, does not make it: grant UPDATE on the table (row-level security still decides which rows), or let the owner or a steward make it. The check takes the row FOR KEY SHARE as 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 its supersede() 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 reads phasevec.stewards. A steward, like an author, is a role by OID and name in its cluster (or its installation, through pg_upgrade): after a restore into another cluster, name the stewards again and adopt() the claims. A claim recorded before a pg_upgrade and 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 CREATEROLE can recreate an author: run phasevec.rebind() after making a copy you do not fully trust (twenty-eighth review). A pg_basebackup copy or a standby promoted keeps the system identifier and _here(); pg_createsubscriber keeps _here(); pg_upgrade --copy or --clone leaves 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 with lo_create()) is that author, and one under a steward’s that steward, until rebind(), 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 a pg_createsubscriber subscriber 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 EXTENSION makes a _here() of its own). A publisher’s superuser can write any author into a subscriber’s phasevec.truth: subscribe phasevec.truth and phasevec.stewards only 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 DELETE and an INSERT under its key (in one transaction or two), a TRUNCATE and the rows written again, or an UPDATE that 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: UPDATE it, or INSERT ... ON CONFLICT DO UPDATE. A dump restored, pg_upgrade, VACUUM FULL and CLUSTER keep the rows (phasevec.sources comes 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 ... RENAME or OWNER TO, or a drop, rows go without their lexical reading until phasevec.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 in pg_catalog is always found.
  • Two ALTER EXTENSION pg_phasevec UPDATE at once: the second fails with PostgreSQL’s tuple concurrently updated, raised before its script runs (when it writes the extension’s row), and changes nothing. While the update waits for a lock (with lock_timeout = 0, for as long as it takes) its queued ACCESS EXCLUSIVE request 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 in SHARE ROW EXCLUSIVE mode, 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.truth or phasevec.watches reads as 0.19.0’s until migrate() (twenty-sixth review, RT26PL-1): run migrate() 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 reads unproven until the owner adopts it: a logical read replica serves no proof verdict until then (RV29-3). A subscriber made with pg_createsubscriber is a copy of the publisher’s files (above): its claims keep their authors.
  • A recipe renders a text or nothing (twenty-second review): an ERROR that 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. A RAISE of 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 catches lock_not_available and tries again in a loop holds the update to 0.20.0, which reads under a lock_timeout of 1 ms, until the update’s statement_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 the deadlock_timeout its session reads, at most 100 ms (or the caller’s lock_timeout, if shorter), for anything else, with its own deadlock check after 1 ms. A wait that lasts longer ends it with PostgreSQL’s own canceling statement due to lock timeout (or deadlock detected), nothing changed, and it is run again. Examples: a VACUUM FULL or REINDEX of 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 their deadlock_timeout is 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 the deadlock_timeout) on both sides, the update tries on until its lock_timeout: end them, or run it when they are shorter. An object made over a function the update drops between its check and its DROP (it holds the function’s lock by then, which PostgreSQL takes to record such a dependency) is not expected; if one is, the DROP fails 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 own RAISE of 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 one ALTER EXTENSION; twenty-seventh review: read from pg_locks, not from the transaction’s id), the first wait is bounded too and a deadlock in it ends the update; with lock_timeout = 0 holders of phasevec.watches that 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, six ALTER FUNCTION ... SET search_path, …), not one per function lock: a policy trigger that forbids ALTER FUNCTION still stops the update. Every event trigger fires for the script’s DDL (some 75 CREATE FUNCTION, 25 COMMENT, 18 DROP FUNCTION, 11 REVOKE): one that raises stops the update, and one that names its own table without a schema fails inside it, under the script’s search_path (RT27PU-INFO-3). (twenty-eighth review) An enabled ddl_command_start or ddl_command_end event 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 a ddl_command_start trigger makes on ALTER 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_canceled keeps 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 of max_worker_processes until it returns. Grant assert() only to roles whose code you would run as them.
  • assert() takes its author from whoever calls it. A trigger function that is not SECURITY DEFINER runs 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 reads phasevec.truth directly, compares the claim with the trigger’s cache, not with the row: after a change the trigger never saw it says verified until the next verify() of the key, or its horizon. brief() and verify() read the row. It answers NULL for a claim whose watched table the caller may not SELECT (or whose row a policy hides from it): SQL that reads phasevec.truth as such a role reads NULL where 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() and audit_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 until adopt(), 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 over INSERT ... RETURNING read 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 (an UPDATE of 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 by SECURITY DEFINER triggers, verified by pg_cron as 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: an UPDATE of 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 with pgbench -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’s max_age per 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_every and max_age scaled alike), 1000 claims with p_n 100 settle at 600 fresh and 400 unproven (each claim comes round every 100 minutes, past the default hour); with p_n 2000 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_cron running verify('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 to debug_parallel_query and parallel_leader_participation off, a session holding the 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 final verify(). 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 with SKIP 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 by SECURITY DEFINER triggers, six writers and three pg_cron writer jobs, pg_cron verifying 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 a NOLOGIN role with pg_read_all_data in 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 (usually search_path and TimeZone) 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), an UPDATE of 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 _bind in 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 a verify_one() (~16). _bind itself: 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, text or varchar (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 an assert() 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 reads refuted while its row’s text is the one refute() saw; when that text changes it reads unproven (the refutation was about the old text), and it is still retired: nothing judges it until an assert(). Its asserted_for is 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), as verify() 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_at and retired_at of a retired claim say “not before”, not when refute() 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): a DEFERRABLE key or a child committed between that check and the proof’s read is not seen by that call. The proof’s session reads with ONLY and 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 relhassubclass until VACUUM or ANALYZE clears it: until then every read of it also lists its children in pg_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 SET granted 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 claim unproven, 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 DEFINER wrapper with SET 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 (after pg_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 same SET clause, 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. A DateStyle that a SET of 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 one DateStyle in 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(), an assert()) is returned not adopted, with the reason.
  • unsupersede() gives a retired or a halted claim back its refuted; a judged claim comes back unproven and is judged again by the next verify() (its verdict before supersede() is not kept: supersede() replaces it).
  • A TRUNCATE of a watched table whose TRUNCATE trigger the update could not make yet (its table was busy; the update names it) forgets nothing, as under 0.19.0, until phasevec.watch_truncate() makes it. A TRUNCATE is no longer O(1) on a watched table (twentieth review, RT20-CRASH-4): the watch forgets its rows with a DELETE of its notes (phasevec.sources, one row per key, with their per-row trigger and foreign-key checks), under the TRUNCATE’s ACCESS EXCLUSIVE lock, so readers, writers and verify() 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 FROM the table costs more, about 8 s). A cancel rolls it back whole.
  • A TRUNCATE with 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 next verify() of each, and truth_coverage() counts them.
  • DROP TABLE of a watched table waits for the claim a verify() 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 SELECT the table.
  • verify() leaves a claim whose row another transaction holds (an assert() not committed, by any role that may assert, or another verify()), 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). An assert() 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: a verify() that is cancelled, or whose transaction aborts, keeps the verdicts it reached (p_budget ends a call cleanly). A transaction that has written may not verify: an assert() and a verify() 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_config of 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 reads current_setting('app.x', true) renders a text that depends on whether that session ever named app.x. Where that text is not the one its author vouched for, the claim reads unproven; 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 to regclass) can find one. The text noted then is not the one _render() reads, and the claim reads unproven; it is never verified that way, because verify() 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 RETURNING type of the SQL/JSON functions and the type of XMLSERIALIZE, and regclass-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 unproven of 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 of max_worker_processes free 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 reads unproven past 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 unproven past its horizon, never verified. 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 claim unproven at once, which let any session that can take a lock a proof waits for (an advisory lock needs no grant) erase a true claim’s verified. 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 of max_worker_processes, which parallel query, logical replication and other extensions share (a proof itself starts no parallel worker: thirteenth review). Leave two free for every verify() that may run at once, one for every verify_one(). Both of verify()’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 reads unproven. The keeper is a session of the extension’s owner: a role that may end the owner’s sessions (a superuser; with pg_signal_backend when the owner is not a superuser) can stop a verify() call, and nothing more – nothing is judged or recorded, the claims keep their turns, and the verdicts serve until their horizon.
  • verify() and verify_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’s pg_cron job does: SET ROLE does not make it read-only).
  • Whether an author may still read the watched table is read once per verify() call: a REVOKE during 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 it unproven.
  • A DROP ROLE of 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, not session_replication_role (a superuser’s): a superuser session set to replica keeps 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 ... SET is the database owner’s; ALTER ROLE ... SET a 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 and exit_on_error are not part of it: a worker pins them before it connects (tenth review; until then this line said a transaction_timeout set 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 still verified). What else it sets can keep a new session of the author from starting at all (a local_preload_libraries that fails to load): its claims are then tried and not judged, and read unproven past 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: choose max_age for what a stale verdict costs you, and make the rotation fit inside it.