Contents
pg_phasevec
Readings with provenance for PostgreSQL: what a sealed reader understood of each row, and whether it still holds.
Status: 0.20.0, a 0.x preview. pg_phasevec is research software by one author, offered for evaluation by people comfortable reading PostgreSQL extensions. Its interfaces and on-disk layout can still change between 0.x releases, and it has known open issues, listed under Known limits. Do not put data you cannot rebuild behind it yet. Reports and attacks are welcome: SECURITY.md, CONTRIBUTING.md.
Vector search over a table has a quiet failure mode. The embedding was computed once, the row
changed, and the index now answers from a memory of a row that no longer exists, and nothing says
so. pg_phasevec makes that visible. It stores, next to each row of a watched table, the codes a
local embedding model (a sealed reader, identified by the sha256 of its weights) produced for
that row, together with the hash of the exact text it read. Searches (exact, IVF, hybrid with
full-text) answer only from readings that still match the row: a row edited after it was read is
reported stale, readings of different models are never mixed, and a search refuses to answer
below a declared coverage.
An optional truth layer lets roles attach SQL proofs (“claims”) to rows. A background worker
re-checks each proof as its author, and brief() serves only what still holds.
C and SQL; no other extension, no network call. The reader is yours and runs locally.
Contents
- Status: what this preview is
- The model
- Install
- A walk through it
- The surface
- Claims and proofs
- Watching its own recall
- Upgrading
- Backup, restore and copies
- Known limits
- Project
Status: what this preview is
For whom. Teams running retrieval (RAG) or agent memory inside PostgreSQL who need to know whether an answer still reflects the data, and reviewers who want to attack that claim.
What it promises, in plain words:
- A reading names the reader that took it and the exact text it was taken from. A row changed
after it was read is reported
staleand is not searched; a reading of text that is no longer there is refused. Readings from different readers are never compared. A deleted row leaves anorphanreading you can see.find()refuses to answer below the watch’smin_coverage. - A claim’s proof runs as its author, read-only, in a background worker of its own, under a cost
cap and a clock. A verdict is about the text the author read and the row the claim was made on;
when either changes, the claim reads
unprovenuntil it is judged again. - What shows claims (
brief,recall,disputes,replacement,truth_coverage) shows a watch only to a role that maySELECTits table, and under row-level security only the rows that role sees.
The threat model. The database’s superusers, the extension’s owner and the owners of watched
tables are trusted. The reader is trusted: pg_phasevec holds codes to the sha of the weights that
produced them, but it does not vouch for what those weights do. Ordinary roles are not trusted:
an author, a tenant under row-level security, or a role with USAGE on the schema should not be
able to change another role’s claims, see rows its policies hide through phasevec, or make a
verdict say what its proof does not. A copy of the cluster’s files is trusted like the cluster:
see Backup, restore and copies.
What it is not. Not a vector index for very large corpora (the coarse IVF index is simple and in memory), not a model server, not a certified or audited product. The multi-tenant rules for who may change a claim are the newest and least settled part; if your authors do not trust each other, read Known limits first.
Tested on PostgreSQL 18 and 19 (CI and the suites in test/). Newer versions are untested.
The model
- A reader is sealed.
reader_register(name, model_sha256, dims)records what it is; a trained reader carries a manifest (base, adapter and heads shas, pooling, Matryoshka dims) sealed by whoever serves it. - A watch is what must be findable: a source table, its key column, and one recipe for the
text to read (an SQL expression over the row
r).find()refuses to answer below the watch’smin_coverage, so a half-filled index does not quietly answer from the rows it happens to have. - A reading is what a reader understood of a row: the hash of the text, its codes, and when.
Each one has a state (
pending,fresh,stale,foreignfor another reader,orphanwhen the row is gone,failedwhen the reader said why, among others), andcoverage()shows the spread.
Install
make PG_CONFIG=/path/to/pg_config
make install PG_CONFIG=/path/to/pg_config
CREATE EXTENSION pg_phasevec;
- PostgreSQL 18 or 19 with its server headers, a C compiler and
make. No other extension, noshared_preload_libraries. - A UTF8 database:
CREATE EXTENSIONrefuses any other encoding. verify()andverify_one()judge each proof in a background worker: they need free slots ofmax_worker_processes(8 by default, shared with parallel query and logical replication):verify_one()one,verify()two at a time.- The build is portable: it adds
-O3to the server’s ownCFLAGSand nothing tied to the build machine’s CPU.make PHASEVEC_NATIVE=1adds-march=nativefor a binary that only has to run where it was built. reader/holds a reference reader process (C++ over llama.cpp,reader/build.sh). It is not needed to build, install or test the extension;search()andrecall()need a reader like it listening on<phasevec.socket_dir>/<watch>.sock.
Supported tables. A watch reads a plain table (not partitioned, not a partition, no
inheritance) by a key column with a unique btree index of its own, enforced at once: a
PRIMARY KEY or UNIQUE constraint on that column alone, not DEFERRABLE, with no predicate
and no expression. watch() refuses anything else and says why; DDL that later breaks it is
caught on the next read, which refuses until the table is back in shape.
A walk through it
On a throwaway database. No model is needed: a toy SQL function stands in for the reader.
CREATE EXTENSION pg_phasevec;
-- 1. A reader, sealed by the sha256 of its weights (a fake one here) and its dimensions.
SELECT phasevec.reader_register('toy', repeat('a', 64), 4);
-- 2. Watch a table: read the expression r.body of each row, keyed by id.
CREATE TABLE docs (id int PRIMARY KEY, body text);
INSERT INTO docs VALUES (1, 'apple'), (2, 'banana'), (3, 'cherry');
SELECT phasevec.watch('docs', 'docs'::regclass, 'id', 'r.body', 'toy');
-- 3. Fill it. next() hands out the rows that need reading, each with the hash of its exact
-- text; the reader turns each into codes (a 64-bit sketch and a per-dimension code);
-- record() stores them against that hash. Here the "reader" is hand-made codes.
CREATE FUNCTION toy_reader(body text, OUT code64 bigint, OUT code smallint[])
LANGUAGE sql IMMUTABLE AS $$
SELECT CASE body WHEN 'apple' THEN 1 WHEN 'banana' THEN 2 ELSE 4 END::bigint,
CASE body WHEN 'apple' THEN ARRAY[100,0,0,0]
WHEN 'banana' THEN ARRAY[0,100,0,0]
ELSE ARRAY[0,0,100,0] END::smallint[] $$;
SELECT count(phasevec.record('docs', n.key, 'toy', n.text_hash, c.code64, c.code))
FROM phasevec.next('docs') n, toy_reader(n.body) c;
SELECT state, n, share FROM phasevec.coverage('docs') WHERE n > 0; -- fresh 3
-- 4. Search with codes (a question read by the same reader).
SELECT key, round(score::numeric, 4) AS score, state
FROM phasevec.find('docs', 1, ARRAY[90,10,0,0]::smallint[], 3); -- key 1 first
-- 5. Change a row: its reading is stale at once, and the search says so.
UPDATE docs SET body = 'apricot' WHERE id = 1;
SELECT key, state FROM phasevec.reading_states ORDER BY key; -- 1 stale
SELECT key FROM phasevec.find('docs', 1, ARRAY[90,10,0,0]::smallint[], 3);
-- ERROR: phasevec: watch docs covers 66.7% of its rows and declared 95.0%: stale 33.3%
SELECT key, state
FROM phasevec.find('docs', 1, ARRAY[90,10,0,0]::smallint[], 3, p_partial => true);
With a real reader running, phasevec.search('docs', 'which fruit is red?', 10) asks in words:
the watch’s reader reads the question and phasevec answers from fresh readings only.
reader_switch() points a watch at a new reader; every old reading becomes foreign and nothing
of the old reader is mixed in.
A claim, judged as its author (continue as a superuser in the same database; verify() must run
in a transaction that has written nothing):
CREATE TABLE tasks (id int PRIMARY KEY, done boolean);
INSERT INTO tasks VALUES (5, true);
INSERT INTO docs VALUES (4, 'task 5 is done');
CREATE ROLE author LOGIN;
GRANT USAGE ON SCHEMA phasevec TO author;
GRANT SELECT, UPDATE ON docs TO author; -- the first claim on a row goes to a role that may
GRANT SELECT ON tasks TO author; -- UPDATE it (or the table's owner, or a steward)
GRANT EXECUTE ON FUNCTION phasevec.assert, phasevec.verify, phasevec.brief TO author;
SET ROLE author;
SELECT phasevec.assert('docs', '4', 'EXISTS (SELECT 1 FROM public.tasks WHERE id = 5 AND done)');
SELECT key, verdict FROM phasevec.verify('docs', p_every => interval '0'); -- 4 | verified
SELECT key, verdict, line FROM phasevec.brief('docs') ORDER BY key;
RESET ROLE;
UPDATE tasks SET done = false WHERE id = 5;
SET ROLE author;
SELECT key, verdict FROM phasevec.verify('docs', p_every => interval '0'); -- 4 | refuted
SELECT key FROM phasevec.brief('docs') ORDER BY key; -- no 4
RESET ROLE;
The surface
| Readers | reader_register, reader_seal, reader_compute, reader_switch |
| Fill | watch, next, record, record_audit, record_failure, watch_truncate |
| Search | search (by words), find / find_ivf (by codes), find_hybrid (vector + lexical), alike (rows like a key) |
| Index | ivf_build, ivf_maintain, ivf_maintain_all, ivf_absorb |
| Lexical | lexical, lexical_maintain, lexical_refresh |
| Audit | coverage, coverage_check, truncations, dupes, audit_sample, counts_compact |
| Recall | recall_approve, recall_check |
| Truth | assert, verify, verify_one, refute, supersede, unsupersede, disputes, recall, truth_coverage, replacement |
| Authorship | steward, adopt, rebind, migrate |
| Brief | brief (what a watch still holds as true, best first, inside a character budget) |
Full signatures: \df phasevec.*. Privileges: the maintenance and truth functions are revoked
from PUBLIC and granted by the extension’s owner. search, find, find_ivf and find_npu
are SECURITY DEFINER and open to any role with USAGE on the schema: they give keys, scores
and reading states of every watch (not the rows' text, not verdicts). Revoke them from PUBLIC
where keys are themselves sensitive.
Claims and proofs
assert(watch, key, proof) records a claim about a row: an SQL boolean expression, run later as
the asserting role. verify() runs due proofs in background workers, each in a read-only,
security-restricted session of the author with the settings the author’s session had, under a
cost cap and a clock (p_timeout_ms). A verdict serves while it is fresh (the watch’s max_age)
and while the row’s text is the one the author read; otherwise the claim reads unproven.
refute() retires a claim, supersede(old, new) replaces one, brief() serves what still holds.
Who may change a claim. A key has one claim, about one row.
| The claim is… | Who may change it |
|---|---|
| absent, or dead (its row is gone and another is under the key) | the table’s owner; a steward of the watch; a role that may UPDATE that row (row-level security included). Reading the row is never enough, and the row must exist. |
| alive, its author still that role | the author (same OID and name, in this cluster or installation, not cut by rebind()); the table’s owner; a steward |
alive, its author gone (dropped, renamed, from another cluster, cut by rebind(), or asserted before 0.20.0) |
the table’s owner or a steward, who settles it or gives it to an author with adopt() |
| made by a role only as a steward | that role only while it is still a steward |
A steward is a role the table’s owner names with phasevec.steward('watch', 'role') and takes
back with phasevec.steward('watch', 'role', false). It counts while that owner owns the table,
and reaches only the rows row-level security lets it see. A SECURITY DEFINER function owned by
the table’s owner acts as the owner for whoever calls it.
Watching its own recall
A search does not fail when it degrades: find_ivf and find keep returning k plausible rows.
recall_approve() records what a watch is served now as a baseline; recall_check() compares
what it is served later.
SELECT phasevec.recall_approve('docs'); -- find_ivf, nprobe 16, m 200
SELECT phasevec.recall_approve('notes', p_m => 1000, p_via => 'find');
SELECT watch, verdict, baseline, current, drift FROM phasevec.recall_check();
- Each sampled row asks with its own reading, which must carry a 256-bit code (
record(..., p_code256)); a watch filled with per-dimension codes only cannot be approved. The truth isfindover every live reading; recall@k is the share of the true top-k the served top-k holds. - Verdicts:
okwithin 0.02 of the baseline,degradedwithin 0.10,criticalbelow; a watch that cannot be measured is a rowCOULD NOT MEASURE: <why>, never an exception. - The settings are the stored ones; lowering
nprobeormin a session changes nothing. - All three functions are
SECURITY DEFINERand revoked fromPUBLIC:GRANT EXECUTE ON FUNCTION phasevec.recall_check(text) TO monitor;gives a monitoring role the check and nothing else. - The check costs about
sample x rowscosine scores: sizep_sampleto it, and keep the tables analyzed.
Upgrading
ALTER EXTENSION pg_phasevec UPDATE after installing the new library and scripts. Upgrades to
0.20.0 from 0.17.0 (PGXN), 0.18.0 and 0.19.0 have been run and checked.
- Reconnect after the update (or restart): a session that loaded the old library keeps it. Its writes to a watched table are refused with a message that says so.
- Run the update alone, in a transaction of its own, at
READ COMMITTED, with alock_timeoutthat says how long it may wait (it holds nothing of phasevec’s while it waits for others, and stops with nothing changed, naming the sessions in its way, when the time is up), not together with DDL on a watched table. It refuses a database that is not UTF8, a watch outside the supported tables, and objects outside the extension that depend on functions it drops, naming each. - Every claim waits for its table’s owner. Proofs asserted before 0.20.0 have no author and
do not run;
verifiedverdicts go back tounproven. The table’s owner (or a steward) runsSELECT * FROM phasevec.adopt('watch', 'role')to assert them again in the name ofrole(adopt as the least-privileged role that can read what the proofs read). - For each watch whose table was being written during the update, a
WARNINGgives the statement to run afterwards:SELECT phasevec.watch_truncate('<watch>');. - Every row is rendered again with pinned settings; a recipe whose text depended on the session
(a date, a float, a money value) reads
staleonce.
The details, and why each step is there, are in doc/review-history.md.
Backup, restore and copies
- pg_dump / pg_restore carry phasevec’s data (since 0.19.0): readers, watches, readings,
claims, the coarse index. Plain, custom and directory formats,
-1, and parallel-j Nrestores bring back the same rows, verdicts and counts (test/review30.sh). After a restore every key is marked for the coarse index’s nextivf_maintain. - A dump of 0.19.0 restored where 0.20.0 is installed: run
SELECT * FROM phasevec.migrate();as the extension’s owner before the database takes writes. Until then the doors refuse. - Into another cluster, authors and stewards are roles of the old cluster: every claim is an orphan until the table’s owner adopts it, and stewards must be named again.
- A copy of the cluster’s files (
pg_basebackup, a promoted standby,pg_createsubscriber,pg_upgrade --copyor--clone) carries the cluster’s identity and its OID counter, so a role made there under an author’s OID and name is that author. RunSELECT * FROM phasevec.rebind();in every database with pg_phasevec after making a copy you do not fully trust: every claim whose author is not the table’s owner becomes an orphan, and the stewards are removed.rebind()is revoked fromPUBLIC: the extension’s owner runs it or grants it to a table’s owner.
Known limits
Open findings of the last red-team pass (round 30) against this code, not fixed in 0.20.0:
- A writer’s right can be checked before a wait and used after it. A role that may
UPDATEa row may claim it when its claim is dead; that right is computed before the claim’s lock is taken and reused after it, so with a concurrent delete and re-insert of the row by another tenant, a claim can be taken over on that tenant’s new row (RV30-1). rebind()leaves refutations and replacements in force. A claim retired byrefute()or replaced bysupersede()by an author thatrebind()cuts keeps its verdict, andadopt()does not list it; the table’s owner must find and settle such claims by hand (RT29PA-REBIND-RETIRED).rebind()does not hold on a livepg_createsubscribersubscription: the publisher’s nextverify()writes the binding back. Drop the subscription beforerebind(), or rebind after every replicated change (RT29PL-1).rebind()works per database: run it in each one. It cuts authorship, not table privileges: audit the copy’s roles and grants too.- A lexical configuration is checked by its owner’s name, not OID: a
CREATEROLErole that re-creates a departed owner’s name, where it may create the configuration again, has its configuration used; renaming the owner role stops lexical reading silently (RT29PL-3). - The update with
lock_timeout = 0and any enabledddl_command_*event trigger retries untilstatement_timeout; give it alock_timeout(RT29PU-2). supersede()of another role’s live claim by the table’s owner keeps that claim’s author, who may undo it withunsupersede().- A key has one claim; a role that only
INSERTs does not get the first claim on its row. An application that rewrites a row byDELETEandINSERTmakes a new row and loses its claims:UPDATEit, orINSERT ... ON CONFLICT DO UPDATE. - A recipe renders a text or nothing: an error from the server or another session while it renders (a lock timeout, a cancel) ends the write that rendered it, even if the recipe catches it.
verify()andverify_one()refuse a read-only transaction, session or standby.- On PostgreSQL 18 there is no coarse-index copy shared across backends (
GetNamedDSAis new in 19): each connection keeps its own, with the same answers. - The integrity of phasevec’s own tables among themselves is kept by its functions and triggers, not by foreign keys (a parallel restore loads them in any order). Write them only through phasevec’s functions.
The full list of limits as each review left them, including the ones of the update procedure, is in doc/review-history.md.
Project
- CHANGELOG.md: what changed in each release.
- doc/review-history.md: what each review found, and which test plays it.
- SECURITY.md: how to report a vulnerability (privately).
- CONTRIBUTING.md: reports and attack cases welcome; code contributions not accepted for now.
- Tests:
test/installcheck.sh(the regression suite, against a throwaway cluster) and the multi-sessiontest/*.shsuites.
License
Apache License 2.0; see LICENSE and NOTICE. Copyright 2026 Manuel Reyes Bravo. “pg_phasevec” is the project’s name; see TRADEMARK.md.