feat(harness): port vectorstore dual-key + reader RPC (D11, §5.4)

Ports vectorstore/{rest_client,rest_writer,store,reader,embeddings,records},
evidence/model (leaf dep of records), and cli/_guards (require_vector_write_allowed
workstation write-guard). Renamed psdwp3->nsp, verbatim.

VectorRestClient gains an api_key property so reader/writer clients carry their
distinct keys visibly (spec D11: vector_reader / vector_writer on the same endpoint).

scripts/create_vector_reader_rpc.sql is NEW: the reader RPCs (search_similar,
list_tables) lived server-side in Supabase and were never versioned. Authored now
mirroring the writer allowlist pattern (table allowlist, security definer, revoke
from anon/authenticated, grant to vector_reader only). Writer RPC ported verbatim.

L1: test_vector_dual_key (7 tests) pins the dual-key construction + the workstation
write-guard (exit 4 without writer key).
This commit is contained in:
2026-06-26 22:55:40 +02:00
parent eb3bde90e2
commit 796a39d893
13 changed files with 994 additions and 0 deletions
@@ -0,0 +1,126 @@
-- RPC writer controllate per pgvector via Supabase/PostgREST.
-- Eseguire sul database Supabase centrale come owner dello schema `vectors`.
-- Prerequisiti:
-- - schema `vectors` con tabelle schema_records/evidence/memory
-- - colonne: record_key text unique, kind text, content_hash text, metadata jsonb,
-- embedding vector(N), indexed_at timestamptz
-- - ruolo/key PostgREST dedicato `vector_writer`
--
-- La key writer deve ricevere solo EXECUTE su queste funzioni, non privilegi raw di DELETE
-- sulle tabelle. Cleanup/rebuild restano via terminale sul server.
create or replace function public._assert_vector_write_table(table_name text, kinds text[])
returns void
language plpgsql
stable
as $$
begin
if table_name not in ('schema_records', 'evidence', 'memory') then
raise sqlstate 'PGRST' using
message = json_build_object('message', 'Unsupported vector table')::text,
detail = json_build_object('status', 400)::text;
end if;
if table_name = 'schema_records'
and exists (select 1 from unnest(kinds) k where k not in ('schema_table', 'schema_column')) then
raise sqlstate 'PGRST' using
message = json_build_object('message', 'Invalid kind for schema_records')::text,
detail = json_build_object('status', 400)::text;
elsif table_name = 'evidence'
and exists (select 1 from unnest(kinds) k where k <> 'evidence') then
raise sqlstate 'PGRST' using
message = json_build_object('message', 'Invalid kind for evidence')::text,
detail = json_build_object('status', 400)::text;
elsif table_name = 'memory'
and exists (select 1 from unnest(kinds) k where k <> 'memory') then
raise sqlstate 'PGRST' using
message = json_build_object('message', 'Invalid kind for memory')::text,
detail = json_build_object('status', 400)::text;
end if;
end;
$$;
create or replace function public.existing_vector_hashes(table_name text, kinds text[])
returns table(record_key text, content_hash text)
language plpgsql
security definer
set search_path = public, vectors, extensions
as $$
begin
perform public._assert_vector_write_table(table_name, kinds);
return query execute format(
'select record_key, content_hash from vectors.%I where kind = any ($1)',
table_name
) using kinds;
end;
$$;
create or replace function public.upsert_vector_records(table_name text, rows jsonb)
returns jsonb
language plpgsql
security definer
set search_path = public, vectors, extensions
as $$
declare
r jsonb;
affected integer := 0;
kinds text[];
begin
if jsonb_typeof(rows) <> 'array' then
raise sqlstate 'PGRST' using
message = json_build_object('message', 'rows must be a JSON array')::text,
detail = json_build_object('status', 400)::text;
end if;
select coalesce(array_agg(distinct value->>'kind'), array[]::text[])
into kinds
from jsonb_array_elements(rows) value;
perform public._assert_vector_write_table(table_name, kinds);
for r in select * from jsonb_array_elements(rows) loop
execute format(
'insert into vectors.%I (record_key, kind, content_hash, metadata, embedding)
values ($1, $2, $3, $4::jsonb, $5::vector)
on conflict (record_key) do update set
kind = excluded.kind,
content_hash = excluded.content_hash,
metadata = excluded.metadata,
embedding = excluded.embedding,
indexed_at = now()',
table_name
) using
r->>'record_key',
r->>'kind',
r->>'content_hash',
coalesce(r->'metadata', '{}'::jsonb),
('[' || (select string_agg(value::text, ',') from jsonb_array_elements(r->'embedding')) || ']');
affected := affected + 1;
end loop;
return jsonb_build_object('upserted', affected);
end;
$$;
revoke all on function public._assert_vector_write_table(text, text[]) from public;
revoke all on function public.existing_vector_hashes(text, text[]) from public;
revoke all on function public.upsert_vector_records(text, jsonb) from public;
-- Su alcuni progetti Supabase le funzioni in `public` ricevono grant automatici: revoca
-- esplicitamente dai ruoli client generici, poi abilita solo il writer dedicato.
do $$
begin
if exists (select 1 from pg_roles where rolname = 'anon') then
revoke all on function public.existing_vector_hashes(text, text[]) from anon;
revoke all on function public.upsert_vector_records(text, jsonb) from anon;
end if;
if exists (select 1 from pg_roles where rolname = 'authenticated') then
revoke all on function public.existing_vector_hashes(text, text[]) from authenticated;
revoke all on function public.upsert_vector_records(text, jsonb) from authenticated;
end if;
if exists (select 1 from pg_roles where rolname = 'vector_writer') then
grant execute on function public.existing_vector_hashes(text, text[]) to vector_writer;
grant execute on function public.upsert_vector_records(text, jsonb) to vector_writer;
end if;
end $$;
notify pgrst, 'reload schema';