feat(t-paliad-209): mig 098 prefix submission codes + rename code → submission_code

m's 2026-05-18 call (workstream B): the paliad.deadline_rules.code field
is a SUBMISSION identifier (the filing/event within a proceeding), not
the legal-citation rule code (which lives in rule_code / legal_source).
Two cleanups land in this migration:

1. DATA — prefix every existing submission code with its proceeding
   code so submission codes carry the full hierarchical shape:
       inf.soc       (on upc.inf.cfi)  → upc.inf.cfi.soc
       de_inf.klage  (on de.inf.lg)    → de.inf.lg.klage
       de_inf_bgh.revision (on de.inf.bgh) → de.inf.bgh.revision
   Idempotent: WHERE NOT LIKE pt.code || '.%' skips already-prefixed
   rows so re-running is a no-op.

2. SCHEMA — rename paliad.deadline_rules.code → submission_code so
   future devs don't conflate it with rule_code (legal citation) or
   proceeding_types.code. The rename is guarded by a column-existence
   check, idempotent on a second run.

Drops + recreates the deadline_search materialized view because its
SELECT bakes `dr.code AS rule_local_code` (mig 051 §4); the rebuild
sources from `dr.submission_code` and reproduces every index from mig
051 verbatim.

Backup snapshot table paliad.deadline_rules_pre_098 captures the rows
before the prefix step; serves as the audit anchor and the down's
source.

Hard assertions (§6) gate the migration on:
- every active+published row matches the 4+-segment proceeding-prefixed
  shape regex
- no NULL submission_code on active+published rows
- the column was actually renamed
This commit is contained in:
mAi
2026-05-18 15:05:46 +02:00
parent d76b8a6c64
commit bd2c7a217e
2 changed files with 430 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
-- Reverses mig 098. Restores the pre-098 submission codes on
-- paliad.deadline_rules, renames the column back to `code`, recreates
-- the deadline_search matview against the restored column, then drops
-- the snapshot table.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098 (down): revert t-paliad-209 workstream B — restore paliad.deadline_rules.code values from deadline_rules_pre_098 snapshot and rename submission_code → code; matview deadline_search rebuilt against the restored column.',
true);
-- =============================================================================
-- 1. Drop the matview so the column rename can succeed.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 2. Rename the column back. Guarded so a down run on a DB where the
-- up never ran (or where the column is already named `code`) is a
-- no-op rather than an error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN submission_code TO code;
END IF;
END $$;
-- =============================================================================
-- 3. Restore code values from the pre_098 snapshot. The snapshot was
-- captured at the first up-migration run; if the table is missing
-- (down run before up), the restore is a no-op.
-- =============================================================================
DO $$
DECLARE
v_snap_exists boolean;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules_pre_098'
) INTO v_snap_exists;
IF NOT v_snap_exists THEN
RAISE NOTICE
'mig 098 (down): snapshot table paliad.deadline_rules_pre_098 missing — nothing to restore';
RETURN;
END IF;
UPDATE paliad.deadline_rules dr
SET code = snap.code
FROM paliad.deadline_rules_pre_098 snap
WHERE dr.id = snap.id
AND dr.code <> snap.code;
END $$;
-- =============================================================================
-- 4. Recreate the deadline_search matview against the restored column.
-- Identical body to mig 051 §4, reproduced here so the down leaves
-- the schema in the same shape mig 051 created.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 5. Drop the snapshot table so a re-applied up captures a fresh
-- snapshot of the current state.
-- =============================================================================
DROP TABLE IF EXISTS paliad.deadline_rules_pre_098;

View File

@@ -0,0 +1,268 @@
-- t-paliad-209 / workstream B — submission-code prefix + rename.
--
-- m's 2026-05-18 call: the `paliad.deadline_rules.code` field is a
-- SUBMISSION identifier (the event/filing within a proceeding), not the
-- legal-citation rule code (which lives in `rule_code` / `legal_source`).
-- Two cleanups land here:
--
-- 1. DATA — prefix every existing submission code with its proceeding
-- code so submission codes carry the full hierarchical shape
-- (e.g. `inf.soc` on `upc.inf.cfi` → `upc.inf.cfi.soc`,
-- `de_inf.klage` on `de.inf.lg` → `de.inf.lg.klage`).
-- Algorithm: keep the proceeding-code prefix as-is, strip the
-- old single-segment prefix (everything before the first dot in
-- `dr.code`) and replace it with the proceeding's full `code`.
--
-- 2. SCHEMA — rename `paliad.deadline_rules.code` → `submission_code`
-- so future devs don't conflate it with `rule_code` (legal
-- citation) or `proceeding_types.code`. Explicit name encodes the
-- semantic taxonomy ratified in
-- docs/design-proceeding-code-taxonomy-2026-05-18.md §0.1.
--
-- Materialized-view dependency: `paliad.deadline_search` (mig 051) has
-- `dr.code AS rule_local_code` baked into its SELECT list. Postgres
-- rejects RENAME COLUMN when a matview's column list still resolves
-- via the old name — so the matview is dropped before the rename and
-- recreated against `submission_code` afterwards, with every index
-- reproduced. The mig 047 / 051 indexes are reproduced verbatim here.
--
-- IDs and FKs are untouched. `deadline_rules.proceeding_type_id` /
-- `parent_id` / `spawn_proceeding_type_id` reference ids; no
-- code-string FK exists on submission codes (the parent_id chain is on
-- UUID `id`, not the code string), so the data UPDATE doesn't risk
-- breaking joins.
--
-- Idempotent:
-- * The data UPDATE is gated `WHERE dr.code NOT LIKE pt.code || '.%'`
-- — rows already prefixed with their proceeding code (i.e. the
-- migration ran before) are skipped.
-- * The rename is wrapped in a DO block that checks column existence,
-- so a second run is a no-op.
-- * Snapshot table uses CREATE TABLE IF NOT EXISTS.
-- * Matview drop/recreate is DROP IF EXISTS + CREATE.
--
-- audit_reason wrapper required by the mig 079 audit trigger.
SELECT set_config(
'paliad.audit_reason',
'mig 098: t-paliad-209 workstream B — prefix every paliad.deadline_rules.code with its proceeding code, then rename code → submission_code; matview deadline_search rebuilt against the new column. See docs/design-proceeding-code-taxonomy-2026-05-18.md and the t-paliad-209 task brief.',
true);
-- =============================================================================
-- 1. Backup snapshot of paliad.deadline_rules BEFORE the prefix + rename.
-- Captures the rows as they are; serves as the source for the down
-- migration and the permanent audit anchor.
-- =============================================================================
CREATE TABLE IF NOT EXISTS paliad.deadline_rules_pre_098 AS
SELECT *, now() AS snapshotted_at
FROM paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_098 IS
'Snapshot of paliad.deadline_rules taken before mig 098 prefixed '
'every `code` with its proceeding code and renamed the column to '
'`submission_code` (t-paliad-209, 2026-05-18). Source-of-truth '
'for the down migration; persists post-rename as the permanent '
'audit record.';
-- =============================================================================
-- 2. Drop the deadline_search materialized view. It bakes `dr.code AS
-- rule_local_code` into its SELECT list (mig 051 §4), and Postgres
-- refuses to rename a column that a matview's column list still
-- resolves via the old name. The matview is recreated verbatim in §5
-- against the renamed column.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- =============================================================================
-- 3. Data UPDATE — prefix every submission code with its proceeding
-- code. Algorithm:
-- * proceeding_code = pt.code
-- * suffix = portion of dr.code after the first '.'
-- * new code = proceeding_code || '.' || suffix
--
-- regexp_replace('inf.soc', '^[^.]+\.', '') = 'soc'
-- regexp_replace('de_inf_bgh.revision', ...) = 'revision'
--
-- The WHERE clause skips rows that already start with `pt.code || '.'`
-- so re-running the migration is a no-op on already-prefixed rows.
-- Archived rows (proceeding `_archived_litigation`) get the same
-- treatment — they end up as `_archived_litigation.<suffix>`. The
-- shape regex in §6 only inspects active+published rows, so the
-- archived form sits outside the constraint by design.
-- =============================================================================
UPDATE paliad.deadline_rules dr
SET code = pt.code || '.' || regexp_replace(dr.code, '^[^.]+\.', '')
FROM paliad.proceeding_types pt
WHERE pt.id = dr.proceeding_type_id
AND dr.code IS NOT NULL
AND position('.' in dr.code) > 0
AND dr.code NOT LIKE pt.code || '.%';
-- =============================================================================
-- 4. Rename the column. Guarded in a DO block so a second run (e.g. a
-- fresh DB built up to mig 098 from an empty schema, or a manual
-- re-apply) is a no-op rather than a hard error.
-- =============================================================================
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'code'
) THEN
ALTER TABLE paliad.deadline_rules
RENAME COLUMN code TO submission_code;
END IF;
END $$;
-- =============================================================================
-- 5. Recreate the deadline_search matview against the renamed column.
-- Column list reproduced verbatim from mig 051 §4 with the single
-- edit: `dr.code AS rule_local_code` → `dr.submission_code AS
-- rule_local_code`. All indexes from mig 051 are reproduced too.
-- =============================================================================
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
-- =============================================================================
-- 6. Hard assertions. Half-applied migrations would leave the rule
-- corpus inconsistent; gate on the shape of every active+published
-- row and on column existence so this fails loudly rather than
-- leaving the schema in a half-renamed state.
-- =============================================================================
DO $$
DECLARE
v_bad_shape integer;
v_null_codes integer;
v_col_exists boolean;
BEGIN
-- 6.1 Every active+published row has the proceeding-code-prefixed
-- 4+-segment shape. Archived rows (`_archived_litigation` ones)
-- keep their shorter shape by design — they're carved out.
SELECT count(*) INTO v_bad_shape
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code !~ '^[a-z_]+\.[a-z_]+\.[a-z_]+\.[a-z_]+(\..*)?$';
IF v_bad_shape <> 0 THEN
RAISE EXCEPTION
'mig 098: expected every active+published deadline_rules row to match the 4+-segment submission_code shape, got % violators',
v_bad_shape;
END IF;
-- 6.2 No NULL submission_code on active+published rows. The column
-- is nullable for legacy reasons, but every live row should
-- carry a code after the prefix step.
SELECT count(*) INTO v_null_codes
FROM paliad.deadline_rules
WHERE is_active = true
AND lifecycle_state = 'published'
AND submission_code IS NULL;
IF v_null_codes <> 0 THEN
RAISE EXCEPTION
'mig 098: expected 0 NULL submission_code on active+published rows, got %',
v_null_codes;
END IF;
-- 6.3 Column was actually renamed. Catches the case where the DO
-- guard in §4 short-circuited because the schema hadn't yet
-- been migrated.
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'deadline_rules'
AND column_name = 'submission_code'
) INTO v_col_exists;
IF NOT v_col_exists THEN
RAISE EXCEPTION
'mig 098: column paliad.deadline_rules.submission_code missing after rename — half-applied migration';
END IF;
END $$;