fix: red-team round 3 hardening across desktop, mobile, core and server

Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -0,0 +1,307 @@
\set ON_ERROR_STOP on
-- Regression for 20260929030000_knowledge_chunks_vector_index.sql.
-- match_knowledge_chunks must return the caller's exact top-k chunks (own
-- documents + documents of teams they belong to), even when other tenants own
-- almost every row of knowledge_chunks and the planner is pushed towards
-- index scans. Before the fix an IVFFlat index trained on an empty table was
-- scanned across all tenants with probes = 1 and filtered by tenant only
-- afterwards, so the caller usually got 0-1 rows although exact matches
-- existed.
BEGIN;
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
RETURNS void
LANGUAGE plpgsql
AS $$
BEGIN
IF condition IS NOT TRUE THEN
RAISE EXCEPTION 'assertion_failed: %', message;
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION pg_temp.act_as(uid uuid)
RETURNS void
LANGUAGE sql
AS $$
SELECT set_config(
'request.jwt.claims',
json_build_object('sub', uid, 'role', 'authenticated')::text,
true
);
$$;
-- Uniform random direction in [-1, 1]^1536 (deterministic after setseed).
CREATE OR REPLACE FUNCTION pg_temp.rand_vec()
RETURNS public.vector
LANGUAGE sql
VOLATILE
AS $$
SELECT array_agg(random() * 2 - 1 ORDER BY i)::public.vector
FROM generate_series(1, 1536) AS g(i);
$$;
-- base plus a small random perturbation (still very similar to base).
CREATE OR REPLACE FUNCTION pg_temp.near_vec(base public.vector, eps double precision)
RETURNS public.vector
LANGUAGE sql
VOLATILE
AS $$
SELECT array_agg(x + eps * (random() * 2 - 1) ORDER BY i)::public.vector
FROM unnest(base::real[]) WITH ORDINALITY AS t(x, i);
$$;
-- Ids returned by match_knowledge_chunks, in result order.
CREATE OR REPLACE FUNCTION pg_temp.match_ids(q public.vector, k integer, threshold double precision)
RETURNS uuid[]
LANGUAGE sql
VOLATILE
AS $$
SELECT coalesce(array_agg(m.id ORDER BY m.ord), ARRAY[]::uuid[])
FROM public.match_knowledge_chunks(q, k, threshold)
WITH ORDINALITY AS m(id, document_id, chunk_index, content, similarity, ord);
$$;
-- ── 0) the broken IVFFlat index is gone ──────────────────────────────────
SELECT pg_temp.assert_true(
NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'knowledge_chunks'
AND indexdef ILIKE '%ivfflat%'
),
'knowledge_chunks has no IVFFlat index trained on an empty table'
);
-- ── fixtures (as postgres) ───────────────────────────────────────────────
SELECT setseed(0.4242);
INSERT INTO auth.users (
id, aud, role, email, encrypted_password, email_confirmed_at,
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
) VALUES
(
'39000000-0000-4000-8000-00000000000a', 'authenticated', 'authenticated',
'rag-searcher@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
'{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Searcher"}'::jsonb, now(), now()
),
(
'39000000-0000-4000-8000-00000000000b', 'authenticated', 'authenticated',
'rag-other-tenant@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
'{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Other"}'::jsonb, now(), now()
),
(
'39000000-0000-4000-8000-00000000000c', 'authenticated', 'authenticated',
'rag-teammate@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
'{"provider":"email","providers":["email"]}'::jsonb, '{"name":"Teammate"}'::jsonb, now(), now()
);
-- Team T: searcher + teammate. Team T2: other tenant only.
INSERT INTO public.teams (id, name, owner_id) VALUES
('39100000-0000-4000-8000-00000000000a', 'RAG Team', '39000000-0000-4000-8000-00000000000c'),
('39100000-0000-4000-8000-00000000000b', 'Other Team', '39000000-0000-4000-8000-00000000000b');
INSERT INTO public.team_members (team_id, user_id, role) VALUES
('39100000-0000-4000-8000-00000000000a', '39000000-0000-4000-8000-00000000000c', 'owner'),
('39100000-0000-4000-8000-00000000000a', '39000000-0000-4000-8000-00000000000a', 'member'),
('39100000-0000-4000-8000-00000000000b', '39000000-0000-4000-8000-00000000000b', 'owner');
INSERT INTO public.knowledge_documents (id, user_id, team_id, title) VALUES
-- visible to the searcher
('39200000-0000-4000-8000-00000000000a', '39000000-0000-4000-8000-00000000000a', NULL,
'Searcher personal doc'),
('39200000-0000-4000-8000-00000000000c', '39000000-0000-4000-8000-00000000000c',
'39100000-0000-4000-8000-00000000000a', 'Teammate shared doc'),
-- invisible to the searcher
('39200000-0000-4000-8000-00000000000b', '39000000-0000-4000-8000-00000000000b', NULL,
'Other tenant personal doc'),
('39200000-0000-4000-8000-0000000000bb', '39000000-0000-4000-8000-00000000000b',
'39100000-0000-4000-8000-00000000000b', 'Other tenant team doc');
CREATE TEMP TABLE fixture_query ON COMMIT DROP AS
SELECT pg_temp.rand_vec() AS q;
GRANT SELECT ON fixture_query TO authenticated;
-- Searcher: 60 random chunks + chunk 60 identical to the query.
INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding)
SELECT '39200000-0000-4000-8000-00000000000a', g, 'searcher chunk ' || g, pg_temp.rand_vec()
FROM generate_series(0, 59) AS g;
INSERT INTO public.knowledge_chunks (id, document_id, chunk_index, content, embedding)
SELECT '39300000-0000-4000-8000-00000000000a', '39200000-0000-4000-8000-00000000000a', 60,
'searcher exact match', q
FROM fixture_query;
-- Team doc: 4 random chunks + one close to the query.
INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding)
SELECT '39200000-0000-4000-8000-00000000000c', g, 'team chunk ' || g, pg_temp.rand_vec()
FROM generate_series(0, 3) AS g;
INSERT INTO public.knowledge_chunks (id, document_id, chunk_index, content, embedding)
SELECT '39300000-0000-4000-8000-00000000000c', '39200000-0000-4000-8000-00000000000c', 4,
'team near match', pg_temp.near_vec(q, 0.05)
FROM fixture_query;
-- Other tenant owns almost the whole table, including an exact match of its
-- own and near matches in a team the searcher is not in.
INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding)
SELECT '39200000-0000-4000-8000-00000000000b', g, 'other chunk ' || g, pg_temp.rand_vec()
FROM generate_series(0, 1999) AS g;
INSERT INTO public.knowledge_chunks (id, document_id, chunk_index, content, embedding)
SELECT '39300000-0000-4000-8000-00000000000b', '39200000-0000-4000-8000-00000000000b', 2000,
'other exact match', q
FROM fixture_query;
INSERT INTO public.knowledge_chunks (document_id, chunk_index, content, embedding)
SELECT '39200000-0000-4000-8000-0000000000bb', g, 'other team near ' || g, pg_temp.near_vec(q, 0.02)
FROM fixture_query, generate_series(0, 9) AS g;
-- A chunk without an embedding must never be returned.
INSERT INTO public.knowledge_chunks (document_id, chunk_index, content)
VALUES ('39200000-0000-4000-8000-00000000000a', 61, 'searcher unembedded');
ANALYZE public.knowledge_chunks;
ANALYZE public.knowledge_documents;
-- Exact brute-force ranking over the searcher's visible chunks.
CREATE TEMP TABLE fixture_expected ON COMMIT DROP AS
SELECT
(SELECT array_agg(r.id ORDER BY r.distance, r.id) FROM (
SELECT kc.id, kc.embedding <=> f.q AS distance
FROM public.knowledge_chunks kc, fixture_query f
WHERE kc.embedding IS NOT NULL
AND kc.document_id IN ('39200000-0000-4000-8000-00000000000a',
'39200000-0000-4000-8000-00000000000c')
ORDER BY 2, 1
LIMIT 5
) r) AS top5,
(SELECT array_agg(r.id ORDER BY r.distance, r.id) FROM (
SELECT kc.id, kc.embedding <=> f.q AS distance
FROM public.knowledge_chunks kc, fixture_query f
WHERE kc.embedding IS NOT NULL
AND kc.document_id IN ('39200000-0000-4000-8000-00000000000a',
'39200000-0000-4000-8000-00000000000c')
ORDER BY 2, 1
LIMIT 20
) r) AS top20;
GRANT SELECT ON fixture_expected TO authenticated;
SELECT pg_temp.assert_true(
(SELECT top5[1:2] FROM fixture_expected)
= ARRAY['39300000-0000-4000-8000-00000000000a',
'39300000-0000-4000-8000-00000000000c']::uuid[],
'fixture sanity: exact match then team near match rank first'
);
-- ── 1) searcher gets the exact top-k of their visible chunks ─────────────
CREATE OR REPLACE FUNCTION pg_temp.assert_searcher_results(label text)
RETURNS void
LANGUAGE plpgsql
AS $$
DECLARE
q public.vector := (SELECT f.q FROM fixture_query f);
got uuid[];
BEGIN
got := pg_temp.match_ids(q, 5, -1);
PERFORM pg_temp.assert_true(
got = (SELECT top5 FROM fixture_expected),
format('%s: top-5 equals exact ranking (got %s)', label, got)
);
got := pg_temp.match_ids(q, 20, -1);
PERFORM pg_temp.assert_true(
got = (SELECT top20 FROM fixture_expected),
format('%s: top-20 equals exact ranking (got %s rows)', label, cardinality(got))
);
-- default threshold (0.5) as used by search-knowledge: only the two
-- planted matches qualify; the other tenant's exact/near matches never leak.
got := pg_temp.match_ids(q, 20, 0.5);
PERFORM pg_temp.assert_true(
got = ARRAY['39300000-0000-4000-8000-00000000000a',
'39300000-0000-4000-8000-00000000000c']::uuid[],
format('%s: threshold keeps only the caller''s planted matches (got %s)', label, got)
);
PERFORM pg_temp.assert_true(
NOT EXISTS (
SELECT 1 FROM public.match_knowledge_chunks(q, 50, -1) m
WHERE m.document_id NOT IN ('39200000-0000-4000-8000-00000000000a',
'39200000-0000-4000-8000-00000000000c')
OR m.content = 'searcher unembedded'
),
format('%s: only visible, embedded chunks are returned', label)
);
-- match_count is clamped: 66 visible embedded chunks, cap 50.
PERFORM pg_temp.assert_true(
cardinality(pg_temp.match_ids(q, 1000, -1)) = 50,
format('%s: match_count is capped at 50', label)
);
PERFORM pg_temp.assert_true(
cardinality(pg_temp.match_ids(q, NULL, -1)) = 5,
format('%s: NULL match_count falls back to 5 instead of unlimited', label)
);
PERFORM pg_temp.assert_true(
cardinality(pg_temp.match_ids(q, 0, -1)) = 0
AND cardinality(pg_temp.match_ids(q, -3, -1)) = 0,
format('%s: non-positive match_count returns nothing', label)
);
END;
$$;
SET LOCAL ROLE authenticated;
SELECT pg_temp.act_as('39000000-0000-4000-8000-00000000000a');
-- Push the planner towards index-ordered scans, the worst case for recall:
-- with seq scans and explicit sorts penalised, an ORDER BY distance LIMIT k
-- query is planned as an ANN index scan whenever one is available (what
-- happens in production once knowledge_chunks is large).
SET LOCAL enable_seqscan = off;
SET LOCAL enable_sort = off;
SELECT pg_temp.assert_searcher_results('no ANN index');
-- ── 2) still exact when an ANN index exists and is attractive ────────────
-- A properly trained IVFFlat index scanned with probes = 1 visits ~2% of all
-- rows across tenants. The function must not let the planner use it for the
-- ranking (the pre-fix shape did).
RESET ROLE;
CREATE INDEX knowledge_chunks_embedding_probe_test
ON public.knowledge_chunks
USING ivfflat (embedding public.vector_cosine_ops)
WITH (lists = 50);
SET LOCAL ivfflat.probes = 1;
SET LOCAL ROLE authenticated;
SELECT pg_temp.act_as('39000000-0000-4000-8000-00000000000a');
SELECT pg_temp.assert_searcher_results('trained IVFFlat index present');
-- ── 3) the other tenant sees only their own chunks ───────────────────────
SELECT pg_temp.act_as('39000000-0000-4000-8000-00000000000b');
SELECT pg_temp.assert_true(
(SELECT pg_temp.match_ids(f.q, 1, 0.5) FROM fixture_query f)
= ARRAY['39300000-0000-4000-8000-00000000000b']::uuid[],
'other tenant gets their own exact match first'
);
SELECT pg_temp.assert_true(
NOT EXISTS (
SELECT 1 FROM fixture_query f, public.match_knowledge_chunks(f.q, 50, -1) m
WHERE m.document_id NOT IN ('39200000-0000-4000-8000-00000000000b',
'39200000-0000-4000-8000-0000000000bb')
),
'other tenant never sees the searcher''s or team T''s chunks'
);
-- ── 4) anonymous callers get nothing ─────────────────────────────────────
SELECT set_config('request.jwt.claims', '', true);
SELECT pg_temp.assert_true(
(SELECT cardinality(pg_temp.match_ids(f.q, 50, -1)) FROM fixture_query f) = 0,
'caller without a user id sees no chunks'
);
ROLLBACK;