-- Recoverable daily retirement and withdrawal of stale monthly publications. -- PostgreSQL 15+. Migrations 008 through 021 remain immutable. BEGIN; DO $$ BEGIN IF current_database() <> 'booking_test' THEN RAISE EXCEPTION 'ARR daily lifecycle migration is allowed only in booking_test'; END IF; IF NOT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'finance' AND table_name = 'daily_versions' AND column_name = 'excluded_pm_rows' ) OR to_regprocedure('reporting.protect_published_monthly_child()') IS NULL THEN RAISE EXCEPTION 'ARR migrations 008 through 021 must be applied before 022'; END IF; IF to_regclass('ingestion.daily_run_retirements') IS NOT NULL THEN RAISE EXCEPTION 'ARR daily lifecycle migration 022 is already applied'; END IF; END $$; CREATE TABLE ingestion.daily_run_retirements ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, processing_run_id bigint NOT NULL UNIQUE REFERENCES ingestion.processing_runs(id), business_date date NOT NULL, daily_version_id bigint REFERENCES finance.daily_versions(id), current_removed boolean NOT NULL, actor_username text NOT NULL CHECK (btrim(actor_username) <> '' AND char_length(actor_username) <= 255), retired_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT daily_run_retirements_current_shape CHECK (NOT current_removed OR daily_version_id IS NOT NULL) ); COMMENT ON TABLE ingestion.daily_run_retirements IS 'Append-only retirement receipts. Sources, processing runs, original Finance facts and prior publications are retained; no older daily version is automatically restored.'; CREATE FUNCTION ingestion.protect_daily_run_retirement() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF TG_OP <> 'INSERT' THEN RAISE EXCEPTION 'daily retirement audit is immutable'; END IF; IF NOT EXISTS ( SELECT 1 FROM ingestion.processing_runs AS run WHERE run.id = NEW.processing_run_id AND run.pipeline_type = 'opera_daily' AND run.business_date = NEW.business_date AND run.run_status IN ('accepted', 'rejected', 'failed', 'cancelled') ) OR (NEW.daily_version_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM finance.daily_versions AS version WHERE version.id = NEW.daily_version_id AND version.processing_run_id = NEW.processing_run_id AND (version.business_date = NEW.business_date OR version.version_status = 'rejected') )) THEN RAISE EXCEPTION 'daily retirement must reference its terminal run and exact daily version'; END IF; IF EXISTS ( SELECT 1 FROM finance.current_daily_versions AS current_version WHERE current_version.daily_version_id = NEW.daily_version_id ) THEN RAISE EXCEPTION 'a retired daily version cannot remain the current daily source'; END IF; RETURN NEW; END $$; CREATE TRIGGER daily_run_retirements_immutable BEFORE INSERT OR UPDATE OR DELETE ON ingestion.daily_run_retirements FOR EACH ROW EXECUTE FUNCTION ingestion.protect_daily_run_retirement(); CREATE OR REPLACE FUNCTION finance.validate_current_daily_version() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM finance.daily_versions AS version WHERE version.id = NEW.daily_version_id AND version.business_date = NEW.business_date AND version.version_status = 'active' AND NOT EXISTS ( SELECT 1 FROM ingestion.daily_run_retirements AS retired WHERE retired.processing_run_id = version.processing_run_id ) ) THEN RAISE EXCEPTION 'current business date must reference an active non-retired daily version'; END IF; RETURN NEW; END $$; ALTER TABLE reporting.monthly_runs ADD COLUMN withdrawn_at timestamptz, DROP CONSTRAINT monthly_runs_report_status_check, DROP CONSTRAINT monthly_runs_status_shape, DROP CONSTRAINT monthly_runs_snapshot_unique; ALTER TABLE reporting.monthly_runs ADD CONSTRAINT monthly_runs_report_status_check CHECK (report_status IN ( 'reserved', 'active', 'superseded', 'failed', 'withdrawn' )), ADD CONSTRAINT monthly_runs_status_shape CHECK ( (withdrawn_at IS NULL AND ( ( report_status = 'reserved' AND workbook_artifact_id IS NULL AND result_artifact_id IS NULL AND semantic_sha256 IS NULL AND failure_code IS NULL AND failure_message IS NULL AND published_at IS NULL AND superseded_at IS NULL AND failed_at IS NULL ) OR ( report_status = 'active' AND workbook_artifact_id IS NOT NULL AND result_artifact_id IS NOT NULL AND semantic_sha256 IS NOT NULL AND failure_code IS NULL AND failure_message IS NULL AND published_at IS NOT NULL AND superseded_at IS NULL AND failed_at IS NULL ) OR ( report_status = 'superseded' AND workbook_artifact_id IS NOT NULL AND result_artifact_id IS NOT NULL AND semantic_sha256 IS NOT NULL AND failure_code IS NULL AND failure_message IS NULL AND published_at IS NOT NULL AND superseded_at IS NOT NULL AND failed_at IS NULL ) OR ( report_status = 'failed' AND workbook_artifact_id IS NULL AND result_artifact_id IS NULL AND semantic_sha256 IS NULL AND failure_code IS NOT NULL AND btrim(failure_code) <> '' AND published_at IS NULL AND superseded_at IS NULL AND failed_at IS NOT NULL ) )) OR (report_status = 'withdrawn' AND withdrawn_at IS NOT NULL) ); CREATE UNIQUE INDEX monthly_runs_live_snapshot_unique ON reporting.monthly_runs (period_start, source_snapshot_sha256) WHERE report_status <> 'withdrawn'; CREATE OR REPLACE FUNCTION reporting.protect_published_monthly_child() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE parent_id bigint; BEGIN parent_id := COALESCE(OLD.report_id, NEW.report_id); IF EXISTS ( SELECT 1 FROM reporting.monthly_runs AS run WHERE run.id = parent_id AND run.report_status IN ('active', 'superseded', 'withdrawn') ) THEN RAISE EXCEPTION 'published monthly report lineage and manifest are immutable'; END IF; RETURN COALESCE(NEW, OLD); END $$; COMMENT ON COLUMN reporting.monthly_runs.withdrawn_at IS 'Publication removed from downloads because a current daily source was retired. Historical artifacts and lineage remain intact; reserved builds cannot later publish.'; -- Existing consuming application role; no source, artifact or Finance-fact -- DELETE privileges are added. Differently named roles require equivalent -- explicit grants in the release deployment, never PUBLIC or web read roles. DO $$ BEGIN IF to_regrole('arr_app') IS NOT NULL THEN EXECUTE 'GRANT SELECT, INSERT ON ingestion.daily_run_retirements TO arr_app'; EXECUTE 'GRANT USAGE, SELECT ON SEQUENCE ingestion.daily_run_retirements_id_seq TO arr_app'; EXECUTE 'GRANT DELETE ON finance.current_daily_versions TO arr_app'; END IF; END $$; COMMIT;