feat: add safe owner-only migration reset (#1682)

* feat: add safe company migration reset

* fix: harden company reset eligibility

* fix: close company reset compliance gaps

* test: fix migration reset pg-real probes

* fix: preserve migration archive access

* docs: explain migration numbering continuity

* fix: block reset with VAT workflow state

* fix: block externally staged reset data

* fix: address migration reset review findings

* fix: clear stale migration archive estimate

* fix: retry migration archive estimates
This commit is contained in:
Mattsson
2026-08-19 12:04:24 +02:00
committed by GitHub
parent b07a4a4bca
commit 3a1b842e4a
24 changed files with 5017 additions and 57 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
-- Keep archive-and-replace resets before the first accounting record exists.
-- A replacement represents the same legal entity, so it must not start a
-- second voucher-number namespace after any draft, posting, or sequence state
-- has already been created on the retained source.
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
RENAME TO company_migration_reset_snapshot_before_20260818141018;
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_snapshot jsonb;
v_blockers jsonb;
v_journal_entries integer;
v_voucher_sequences integer;
v_authority_submissions integer;
v_direct_vat_submissions integer;
BEGIN
v_snapshot := public.company_migration_reset_snapshot_before_20260818141018(
p_company_id
);
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
RETURN v_snapshot;
END IF;
SELECT count(*) INTO v_journal_entries
FROM public.journal_entries
WHERE company_id = p_company_id;
SELECT count(*) INTO v_voucher_sequences
FROM public.voucher_sequences
WHERE company_id = p_company_id;
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
INTO v_authority_submissions
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
-- The VAT submission chain normally records a successful declaration lock
-- first. Count the direct submission endpoint as independent evidence too,
-- so a historical or interrupted audit chain still fails closed.
SELECT count(*) INTO v_direct_vat_submissions
FROM public.skatteverket_api_audit_log
WHERE company_id = p_company_id
AND outcome = 'ok'
AND endpoint = 'declaration/submit';
v_authority_submissions := v_authority_submissions + v_direct_vat_submissions;
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
INTO v_blockers
FROM jsonb_array_elements(v_snapshot -> 'blockers')
WITH ORDINALITY AS existing(blocker, position)
WHERE existing.blocker ->> 'code' NOT IN (
'non_import_committed_entries',
'authority_submission_detected'
);
IF v_journal_entries > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'journal_entries_exist',
'count', v_journal_entries
));
END IF;
IF v_voucher_sequences > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'voucher_sequence_state_exists',
'count', v_voucher_sequences
));
END IF;
IF v_authority_submissions > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'authority_submission_detected',
'count', v_authority_submissions
));
END IF;
RETURN v_snapshot || jsonb_build_object(
'eligible', jsonb_array_length(v_blockers) = 0,
'blockers', v_blockers
);
END;
$$;
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
'Internal fail-closed reset snapshot. Any journal entry or voucher sequence blocks archive-and-replace.';
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818141018(uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
FROM PUBLIC, anon, authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,240 @@
-- Close the remaining archive-and-replace boundaries found in compliance
-- review. Existing invoice records are accounting documents even before a
-- voucher is posted, and retained filing/import evidence must stay static.
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
RENAME TO company_migration_reset_snapshot_before_20260818143004;
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_snapshot jsonb;
v_blockers jsonb;
v_invoice_records integer;
BEGIN
v_snapshot := public.company_migration_reset_snapshot_before_20260818143004(
p_company_id
);
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
RETURN v_snapshot;
END IF;
v_invoice_records :=
COALESCE((v_snapshot -> 'counts' ->> 'invoices')::integer, 0)
+ COALESCE((v_snapshot -> 'counts' ->> 'supplier_invoices')::integer, 0);
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
INTO v_blockers
FROM jsonb_array_elements(v_snapshot -> 'blockers')
WITH ORDINALITY AS existing(blocker, position)
WHERE existing.blocker ->> 'code' <> 'invoice_records_exist';
IF v_invoice_records > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'invoice_records_exist',
'count', v_invoice_records
));
END IF;
RETURN v_snapshot || jsonb_build_object(
'eligible', jsonb_array_length(v_blockers) = 0,
'blockers', v_blockers
);
END;
$$;
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
'Internal fail-closed reset snapshot. Any journal, voucher sequence, or invoice record blocks archive-and-replace.';
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818143004(uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
FROM PUBLIC, anon, authenticated;
-- Check both sides of an UPDATE. The first version checked only OLD, which
-- blocked edits to an archived row but did not independently reject moving an
-- active row into an archived company through an elevated writer.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_old_company_id uuid;
v_new_company_id uuid;
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
v_old_company_id := OLD.company_id;
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
v_new_company_id := NEW.company_id;
END IF;
IF EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id IN (v_old_company_id, v_new_company_id)
) THEN
RAISE EXCEPTION 'Archived migration reset source records are immutable';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.block_migration_reset_source_mutation()
FROM PUBLIC, anon, authenticated;
-- These tables can still contain draft or disconnected evidence on an
-- otherwise eligible source. Once archived, all such rows must be as static as
-- transactions and documents. The existing trigger function checks the reset
-- audit row and does not affect active companies.
DO $$
DECLARE
v_table text;
BEGIN
FOREACH v_table IN ARRAY ARRAY[
'bank_connections',
'company_settings',
'salary_runs',
'salary_run_employees',
'salary_line_items',
'agi_declarations',
'arsredovisning_submissions',
'rot_rut_payout_requests',
'skatteverket_api_audit_log'
]
LOOP
EXECUTE format(
'CREATE TRIGGER %I BEFORE INSERT OR UPDATE OR DELETE ON public.%I '
'FOR EACH ROW EXECUTE FUNCTION public.block_migration_reset_source_mutation()',
v_table || '_block_migration_reset_source_mutation',
v_table
);
END LOOP;
END;
$$;
-- Company identity and archive metadata are part of the retained system
-- documentation. The reset transaction finishes all source-company updates
-- before inserting the audit link, so later source updates are never needed.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_company_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id = OLD.id
) THEN
RAISE EXCEPTION 'Archived migration reset source company is immutable';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER zz_companies_block_migration_reset_source_mutation
BEFORE UPDATE ON public.companies
FOR EACH ROW
EXECUTE FUNCTION public.block_migration_reset_source_company_mutation();
REVOKE ALL ON FUNCTION public.block_migration_reset_source_company_mutation()
FROM PUBLIC, anon, authenticated;
-- Journal lines are company-scoped through their parent. Check both parent
-- references on UPDATE so a line cannot be reassigned into a retained source.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_journal_line_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_old_entry_id uuid;
v_new_entry_id uuid;
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
v_old_entry_id := OLD.journal_entry_id;
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
v_new_entry_id := NEW.journal_entry_id;
END IF;
IF EXISTS (
SELECT 1
FROM public.journal_entries entry
JOIN public.company_migration_resets reset
ON reset.source_company_id = entry.company_id
WHERE entry.id IN (v_old_entry_id, v_new_entry_id)
) THEN
RAISE EXCEPTION 'Archived migration reset source records are immutable';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.block_migration_reset_source_journal_line_mutation()
FROM PUBLIC, anon, authenticated;
-- ROT/RUT request items are company-scoped through their parent and therefore
-- need the same parent lookup pattern as journal-entry lines.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_rot_rut_item_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_old_request_id uuid;
v_new_request_id uuid;
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
v_old_request_id := OLD.request_id;
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
v_new_request_id := NEW.request_id;
END IF;
IF EXISTS (
SELECT 1
FROM public.rot_rut_payout_requests request
JOIN public.company_migration_resets reset
ON reset.source_company_id = request.company_id
WHERE request.id IN (v_old_request_id, v_new_request_id)
) THEN
RAISE EXCEPTION 'Archived migration reset source records are immutable';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER rot_rut_payout_request_items_block_migration_reset_source_mutation
BEFORE INSERT OR UPDATE OR DELETE ON public.rot_rut_payout_request_items
FOR EACH ROW
EXECUTE FUNCTION public.block_migration_reset_source_rot_rut_item_mutation();
REVOKE ALL ON FUNCTION public.block_migration_reset_source_rot_rut_item_mutation()
FROM PUBLIC, anon, authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,147 @@
-- A VAT draft locked for signing can be signed outside Accounted after the
-- lock succeeds. Historical direct-lock requests persisted their state in
-- extension_data but did not always create a Skatteverket audit row. Treat any
-- persisted VAT workflow state as authority interaction evidence: a saved
-- draft must be removed through the product before self-service reset, and a
-- locked state must never be cleared merely to make the company eligible.
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
RENAME TO company_migration_reset_snapshot_before_20260818224000;
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_snapshot jsonb;
v_blockers jsonb;
v_authority_submissions integer;
v_unlogged_vat_workflows integer;
BEGIN
v_snapshot := public.company_migration_reset_snapshot_before_20260818224000(
p_company_id
);
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
RETURN v_snapshot;
END IF;
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
INTO v_authority_submissions
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
-- extension_data.value is a JSONB string for the Skatteverket extension,
-- but the key itself is sufficient evidence that a VAT draft workflow was
-- started. Avoid double-counting periods already represented by a successful
-- lock/submit audit row while still failing closed for historical direct
-- lock calls that predate complete audit coverage.
SELECT count(*) INTO v_unlogged_vat_workflows
FROM public.extension_data state
WHERE state.company_id = p_company_id
AND state.extension_id = 'skatteverket'
AND state.key LIKE 'submission\_%' ESCAPE '\'
AND NOT EXISTS (
SELECT 1
FROM public.skatteverket_api_audit_log audit
WHERE audit.company_id = p_company_id
AND audit.outcome = 'ok'
AND audit.endpoint IN ('declaration/lock', 'declaration/submit')
AND (
audit.redovisningsperiod IS NULL
OR audit.redovisningsperiod = substring(state.key FROM 12)
)
);
v_authority_submissions :=
v_authority_submissions + v_unlogged_vat_workflows;
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
INTO v_blockers
FROM jsonb_array_elements(v_snapshot -> 'blockers')
WITH ORDINALITY AS existing(blocker, position)
WHERE existing.blocker ->> 'code' <> 'authority_submission_detected';
IF v_authority_submissions > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'authority_submission_detected',
'count', v_authority_submissions
));
END IF;
RETURN v_snapshot || jsonb_build_object(
'eligible', jsonb_array_length(v_blockers) = 0,
'blockers', v_blockers
);
END;
$$;
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
'Internal fail-closed reset snapshot. VAT extension workflow state is authority interaction evidence.';
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818224000(uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
FROM PUBLIC, anon, authenticated;
-- An eligible source has no VAT submission_* rows. This narrow guard closes
-- an in-flight or elevated insert after the source is archived without making
-- unrelated extension runtime state interfere with account anonymization.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_vat_state_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_old_company_id uuid;
v_new_company_id uuid;
v_old_is_vat_state boolean := false;
v_new_is_vat_state boolean := false;
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
v_old_company_id := OLD.company_id;
v_old_is_vat_state :=
OLD.extension_id = 'skatteverket'
AND OLD.key LIKE 'submission\_%' ESCAPE '\';
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
v_new_company_id := NEW.company_id;
v_new_is_vat_state :=
NEW.extension_id = 'skatteverket'
AND NEW.key LIKE 'submission\_%' ESCAPE '\';
END IF;
IF (v_old_is_vat_state AND EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id = v_old_company_id
))
OR (v_new_is_vat_state AND EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id = v_new_company_id
)) THEN
RAISE EXCEPTION 'Archived migration reset source VAT state is immutable';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER extension_data_block_migration_reset_source_vat_state
BEFORE INSERT OR UPDATE OR DELETE ON public.extension_data
FOR EACH ROW
EXECUTE FUNCTION public.block_migration_reset_source_vat_state_mutation();
REVOKE ALL ON FUNCTION public.block_migration_reset_source_vat_state_mutation()
FROM PUBLIC, anon, authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,194 @@
-- AGI uploads and ROT/RUT payout files cross the boundary into an external
-- signing flow before Accounted can observe a completed filing. The local
-- state can therefore lag behind Skatteverket. Treat every such staging state
-- as authority interaction evidence instead of assuming that a missing
-- receipt or a locally generated status proves the filing never happened.
ALTER FUNCTION public.company_migration_reset_snapshot(uuid)
RENAME TO company_migration_reset_snapshot_before_20260818231500;
CREATE OR REPLACE FUNCTION public.company_migration_reset_snapshot(p_company_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_snapshot jsonb;
v_blockers jsonb;
v_authority_submissions integer;
v_external_staging_workflows integer;
BEGIN
v_snapshot := public.company_migration_reset_snapshot_before_20260818231500(
p_company_id
);
IF v_snapshot ->> 'code' = 'COMPANY_RESET_NOT_FOUND' THEN
RETURN v_snapshot;
END IF;
SELECT COALESCE(sum((existing.blocker ->> 'count')::integer), 0)
INTO v_authority_submissions
FROM jsonb_array_elements(v_snapshot -> 'blockers') AS existing(blocker)
WHERE existing.blocker ->> 'code' = 'authority_submission_detected';
-- An AGI underlag in Eget utrymme can be signed in Mina Sidor while the
-- declaration still says pending_signature. The extension cache is also
-- evidence because it is written before the declaration status update and
-- can survive a partial application failure. Normalize the period so the
-- declaration row and its cache count as one workflow.
--
-- A generated ROT/RUT payout file is returned for manual upload and signing.
-- Accounted cannot prove that a locally generated or cancelled request was
-- never submitted externally, so every request state is retention-protected.
SELECT count(*)
INTO v_external_staging_workflows
FROM (
SELECT
'agi:' || declaration.period_year::text
|| lpad(declaration.period_month::text, 2, '0') AS workflow
FROM public.agi_declarations declaration
WHERE declaration.company_id = p_company_id
AND declaration.status = 'pending_signature'
UNION
SELECT
'agi:' || regexp_replace(
substring(state.key FROM char_length('agi_submission_') + 1),
'[^0-9]',
'',
'g'
) AS workflow
FROM public.extension_data state
WHERE state.company_id = p_company_id
AND state.extension_id = 'skatteverket'
AND state.key LIKE 'agi\_submission\_%' ESCAPE '\'
AND NOT EXISTS (
SELECT 1
FROM public.agi_declarations declaration
WHERE declaration.company_id = p_company_id
AND declaration.period_year::text
|| lpad(declaration.period_month::text, 2, '0')
= regexp_replace(
substring(state.key FROM char_length('agi_submission_') + 1),
'[^0-9]',
'',
'g'
)
AND (
declaration.submitted_at IS NOT NULL
OR declaration.status IN ('submitted', 'accepted', 'rejected')
)
)
UNION
SELECT 'rot_rut:' || request.id::text AS workflow
FROM public.rot_rut_payout_requests request
WHERE request.company_id = p_company_id
AND NOT (
request.submitted_at IS NOT NULL
OR request.status IN ('submitted', 'paid', 'partially_paid', 'rejected')
)
) staging;
v_authority_submissions :=
v_authority_submissions + v_external_staging_workflows;
SELECT COALESCE(jsonb_agg(existing.blocker ORDER BY existing.position), '[]'::jsonb)
INTO v_blockers
FROM jsonb_array_elements(v_snapshot -> 'blockers')
WITH ORDINALITY AS existing(blocker, position)
WHERE existing.blocker ->> 'code' <> 'authority_submission_detected';
IF v_authority_submissions > 0 THEN
v_blockers := v_blockers || jsonb_build_array(jsonb_build_object(
'code', 'authority_submission_detected',
'count', v_authority_submissions
));
END IF;
RETURN v_snapshot || jsonb_build_object(
'eligible', jsonb_array_length(v_blockers) = 0,
'blockers', v_blockers
);
END;
$$;
COMMENT ON FUNCTION public.company_migration_reset_snapshot(uuid) IS
'Internal fail-closed reset snapshot. External filing staging state is authority interaction evidence.';
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot_before_20260818231500(uuid)
FROM PUBLIC, anon, authenticated;
REVOKE ALL ON FUNCTION public.company_migration_reset_snapshot(uuid)
FROM PUBLIC, anon, authenticated;
-- The retained source already rejects writes to agi_declarations and
-- rot_rut_payout_requests. Extend the narrow extension_data guard so an
-- in-flight or elevated writer cannot attach a new AGI upload state after the
-- company has been archived.
CREATE OR REPLACE FUNCTION public.block_migration_reset_source_vat_state_mutation()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_old_company_id uuid;
v_new_company_id uuid;
v_old_is_authority_state boolean := false;
v_new_is_authority_state boolean := false;
BEGIN
IF TG_OP IN ('UPDATE', 'DELETE') THEN
v_old_company_id := OLD.company_id;
v_old_is_authority_state :=
OLD.extension_id = 'skatteverket'
AND (
OLD.key LIKE 'submission\_%' ESCAPE '\'
OR OLD.key LIKE 'agi\_submission\_%' ESCAPE '\'
);
END IF;
IF TG_OP IN ('INSERT', 'UPDATE') THEN
v_new_company_id := NEW.company_id;
v_new_is_authority_state :=
NEW.extension_id = 'skatteverket'
AND (
NEW.key LIKE 'submission\_%' ESCAPE '\'
OR NEW.key LIKE 'agi\_submission\_%' ESCAPE '\'
);
END IF;
IF (v_old_is_authority_state AND EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id = v_old_company_id
))
OR (v_new_is_authority_state AND EXISTS (
SELECT 1
FROM public.company_migration_resets
WHERE source_company_id = v_new_company_id
)) THEN
RAISE EXCEPTION 'Archived migration reset source authority workflow state is immutable';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
ALTER TRIGGER extension_data_block_migration_reset_source_vat_state
ON public.extension_data
RENAME TO extension_data_block_migration_reset_source_authority_state;
COMMENT ON FUNCTION public.block_migration_reset_source_vat_state_mutation() IS
'Rejects VAT and AGI authority workflow state on a retained migration reset source.';
REVOKE ALL ON FUNCTION public.block_migration_reset_source_vat_state_mutation()
FROM PUBLIC, anon, authenticated;
NOTIFY pgrst, 'reload schema';