-- Durable metadata-only monthly report publication for ARR2. -- PostgreSQL 15+. Migrations 008-011 remain immutable. BEGIN; DO $$ BEGIN IF current_database() <> 'booking_test' THEN RAISE EXCEPTION 'ARR monthly publication migration is allowed only in booking_test'; END IF; IF to_regclass('ingestion.artifacts') IS NULL OR to_regclass('ingestion.outbox_events') IS NULL OR to_regclass('finance.daily_versions') IS NULL THEN RAISE EXCEPTION 'ARR migrations 008 through 011 must be applied first'; END IF; IF to_regnamespace('reporting') IS NOT NULL THEN RAISE EXCEPTION 'ARR monthly publication migration is already applied'; END IF; IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conrelid = 'ingestion.artifacts'::regclass AND conname = 'artifacts_storage_provider_check' ) THEN RAISE EXCEPTION 'ingestion.artifacts storage-provider constraint is missing'; END IF; END; $$; ALTER TABLE ingestion.artifacts DROP CONSTRAINT artifacts_storage_provider_check; ALTER TABLE ingestion.artifacts ADD CONSTRAINT artifacts_storage_provider_check CHECK ( storage_provider IN ('oss', 's3', 'local_fixture', 'local') ); CREATE SCHEMA reporting; COMMENT ON SCHEMA reporting IS 'Metadata-only publication lifecycle and lineage for derived ARR reports; business rows remain in Finance facts.'; CREATE TABLE reporting.monthly_runs ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, period_start date NOT NULL, as_of_date date NOT NULL, version_no integer NOT NULL CHECK (version_no > 0), source_snapshot_sha256 character(64) NOT NULL CHECK ( source_snapshot_sha256 ~ '^[0-9a-f]{64}$' ), report_status text NOT NULL CHECK (report_status IN ( 'reserved', 'active', 'superseded', 'failed' )), processor_version text NOT NULL CHECK (btrim(processor_version) <> ''), rule_set_sha256 character(64) NOT NULL CHECK ( rule_set_sha256 ~ '^[0-9a-f]{64}$' ), result_schema_version text NOT NULL CHECK ( btrim(result_schema_version) <> '' ), row_count integer NOT NULL CHECK (row_count >= 0), channel_count integer NOT NULL CHECK (channel_count >= 5), workbook_artifact_id bigint REFERENCES ingestion.artifacts(id), result_artifact_id bigint REFERENCES ingestion.artifacts(id), semantic_sha256 character(64) CHECK ( semantic_sha256 IS NULL OR semantic_sha256 ~ '^[0-9a-f]{64}$' ), failure_code text, failure_message text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), published_at timestamptz, superseded_at timestamptz, failed_at timestamptz, CONSTRAINT monthly_runs_period_shape CHECK ( period_start = date_trunc('month', period_start)::date AND as_of_date >= period_start AND as_of_date < (period_start + interval '1 month')::date ), CONSTRAINT monthly_runs_period_version_unique UNIQUE (period_start, version_no), CONSTRAINT monthly_runs_snapshot_unique UNIQUE (period_start, source_snapshot_sha256), CONSTRAINT monthly_runs_status_shape CHECK ( ( 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 ) ) ); CREATE UNIQUE INDEX monthly_runs_one_active_period_idx ON reporting.monthly_runs (period_start) WHERE report_status = 'active'; CREATE INDEX monthly_runs_period_history_idx ON reporting.monthly_runs (period_start, version_no DESC); CREATE TABLE reporting.monthly_run_daily_versions ( report_id bigint NOT NULL REFERENCES reporting.monthly_runs(id), business_date date NOT NULL, daily_version_id bigint NOT NULL, PRIMARY KEY (report_id, business_date), CONSTRAINT monthly_run_daily_versions_version_unique UNIQUE (report_id, daily_version_id), CONSTRAINT monthly_run_daily_versions_version_fk FOREIGN KEY (daily_version_id, business_date) REFERENCES finance.daily_versions(id, business_date) ); CREATE TABLE reporting.monthly_channel_manifest ( report_id bigint NOT NULL REFERENCES reporting.monthly_runs(id), worksheet text NOT NULL CHECK ( btrim(worksheet) <> '' AND char_length(worksheet) <= 31 AND worksheet !~ '[:\\/?*\[\]]' ), worksheet_order integer NOT NULL CHECK (worksheet_order > 0), row_count integer NOT NULL CHECK (row_count >= 0), PRIMARY KEY (report_id, worksheet), CONSTRAINT monthly_channel_manifest_order_unique UNIQUE (report_id, worksheet_order) ); CREATE OR REPLACE FUNCTION reporting.validate_monthly_run_publication() RETURNS trigger LANGUAGE plpgsql AS $$ DECLARE workbook_kind text; workbook_provider text; result_kind text; result_provider text; manifest_count integer; manifest_rows bigint; manifest_min integer; manifest_max integer; lineage_count integer; BEGIN IF NEW.report_status NOT IN ('active', 'superseded') THEN RETURN NEW; END IF; SELECT artifact_kind, storage_provider INTO workbook_kind, workbook_provider FROM ingestion.artifacts WHERE id = NEW.workbook_artifact_id; SELECT artifact_kind, storage_provider INTO result_kind, result_provider FROM ingestion.artifacts WHERE id = NEW.result_artifact_id; IF workbook_kind IS DISTINCT FROM 'monthly_xlsx' OR workbook_provider IS DISTINCT FROM 'local' OR result_kind IS DISTINCT FROM 'result_json' OR result_provider IS DISTINCT FROM 'local' THEN RAISE EXCEPTION 'published monthly run must reference controlled local monthly_xlsx and result_json artifacts'; END IF; SELECT count(*), COALESCE(sum(row_count), 0), min(worksheet_order), max(worksheet_order) INTO manifest_count, manifest_rows, manifest_min, manifest_max FROM reporting.monthly_channel_manifest WHERE report_id = NEW.id; IF manifest_count <> NEW.channel_count OR manifest_rows <> NEW.row_count OR manifest_min <> 1 OR manifest_max <> manifest_count THEN RAISE EXCEPTION 'published monthly run requires a continuous reconciled channel manifest'; END IF; SELECT count(*) INTO lineage_count FROM reporting.monthly_run_daily_versions WHERE report_id = NEW.id; IF lineage_count < 1 THEN RAISE EXCEPTION 'published monthly run requires daily-version lineage'; END IF; RETURN NEW; END; $$; CREATE TRIGGER monthly_runs_publication_guard BEFORE INSERT OR UPDATE ON reporting.monthly_runs FOR EACH ROW EXECUTE FUNCTION reporting.validate_monthly_run_publication(); 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') ) THEN RAISE EXCEPTION 'published monthly report lineage and manifest are immutable'; END IF; RETURN COALESCE(NEW, OLD); END; $$; CREATE TRIGGER monthly_run_daily_versions_immutable BEFORE UPDATE OR DELETE ON reporting.monthly_run_daily_versions FOR EACH ROW EXECUTE FUNCTION reporting.protect_published_monthly_child(); CREATE TRIGGER monthly_channel_manifest_immutable BEFORE UPDATE OR DELETE ON reporting.monthly_channel_manifest FOR EACH ROW EXECUTE FUNCTION reporting.protect_published_monthly_child(); COMMENT ON TABLE reporting.monthly_runs IS 'Durable publication versions and artifact identities only; no guest-bearing monthly business rows are duplicated.'; COMMENT ON COLUMN reporting.monthly_runs.as_of_date IS 'Greatest ARRIVAL included in this publication; never derived from an upload filename or wall-clock date.'; COMMENT ON TABLE reporting.monthly_run_daily_versions IS 'Immutable current Finance daily-version pins used to build one monthly publication.'; COMMENT ON TABLE reporting.monthly_channel_manifest IS 'Privacy-minimized worksheet order and row-count manifest for one monthly publication.'; COMMIT;