diff --git a/internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql b/internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql new file mode 100644 index 0000000..cbb4275 --- /dev/null +++ b/internal/db/migrations/138_appeal_target_backfill_merits_order.down.sql @@ -0,0 +1,71 @@ +-- 138_appeal_target_backfill_merits_order DOWN — t-paliad-303, m/paliad#134 +-- +-- Removes 'schadensbemessung' from the merits-track rules and +-- 'bucheinsicht' from the order-track rules, restoring the pre-137 +-- shape (endentscheidung-only / anordnung-only / kostenentscheidung-only). + +-- --------------------------------------------------------------- +-- 0. Audit reason (required by mig 079 trigger for any UPDATE on +-- paliad.deadline_rules). +-- --------------------------------------------------------------- + +SELECT set_config( + 'paliad.audit_reason', + 'mig 138 DOWN: t-paliad-303 — strip Schadensbemessung/Bucheinsicht from applies_to_target per m/paliad#134', + true); + +-- --------------------------------------------------------------- +-- 1. Strip new targets via array_remove. +-- +-- WHERE clauses pinned to upc.apl.unified to avoid touching unrelated +-- rules that might have been added later under other proceeding types. +-- --------------------------------------------------------------- + +-- 1a. Remove schadensbemessung from merits-track rows. +UPDATE paliad.deadline_rules dr + SET applies_to_target = array_remove(dr.applies_to_target, 'schadensbemessung') + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'schadensbemessung' = ANY(dr.applies_to_target); + +-- 1b. Remove bucheinsicht from order-track rows. +UPDATE paliad.deadline_rules dr + SET applies_to_target = array_remove(dr.applies_to_target, 'bucheinsicht') + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'bucheinsicht' = ANY(dr.applies_to_target); + +-- --------------------------------------------------------------- +-- 2. Sanity check — no row may carry the new targets after the down. +-- --------------------------------------------------------------- + +DO $$ +DECLARE + schad_left int; + buch_left int; +BEGIN + SELECT COUNT(*) INTO schad_left + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'schadensbemessung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO buch_left + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'bucheinsicht' = ANY(dr.applies_to_target); + + IF schad_left > 0 THEN + RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry schadensbemessung', schad_left; + END IF; + IF buch_left > 0 THEN + RAISE EXCEPTION '[mig 138 DOWN] FAILED — % rows still carry bucheinsicht', buch_left; + END IF; + RAISE NOTICE '[mig 138 DOWN] stripped schadensbemessung + bucheinsicht from upc.apl.unified rules'; +END $$; diff --git a/internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql b/internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql new file mode 100644 index 0000000..d6cb811 --- /dev/null +++ b/internal/db/migrations/138_appeal_target_backfill_merits_order.up.sql @@ -0,0 +1,232 @@ +-- 138_appeal_target_backfill_merits_order — t-paliad-303, m/paliad#134 +-- +-- Slice B1 (mig 134) introduced the unified upc.apl.unified proceeding type +-- with 5 appeal_target enum values: endentscheidung, kostenentscheidung, +-- anordnung, schadensbemessung, bucheinsicht. The first three each carry +-- rules; schadensbemessung and bucheinsicht returned an empty timeline +-- because no rules referenced them yet. +-- +-- m's 2026-05-26 decision (#134): extend applies_to_target on the existing +-- rules — Schadensbemessung := merits track (R.224 anchored on R.118 +-- substantive decisions), Bucheinsicht := order track (R.220.2 + +-- R.224.2.b + R.235.2 + R.237 + R.238.2 etc.). Legal premise verified +-- against the 16 live rules — every endentscheidung rule is a generic +-- R.224 merits step, every anordnung rule is a generic R.220/224/235/237/ +-- 238 order step. No rule carries content specific to a particular kind +-- of underlying decision/order. Audit on the comment trail of #134. + +-- --------------------------------------------------------------- +-- 0. Audit reason (required by mig 079 trigger for any UPDATE on +-- paliad.deadline_rules — both UPDATEs below trigger it). +-- --------------------------------------------------------------- + +SELECT set_config( + 'paliad.audit_reason', + 'mig 138: t-paliad-303 — extend applies_to_target for Schadensbemessung (merits) + Bucheinsicht (order) per m/paliad#134', + true); + +-- --------------------------------------------------------------- +-- 1. Audit-first DO block. +-- +-- Resolve upc.apl.unified, count the rows we are about to touch, and +-- RAISE EXCEPTION if anything looks wrong (proceeding type missing, +-- merits/order rule counts off, or a rule already carries the new +-- target — which would mean an earlier partial run). +-- --------------------------------------------------------------- + +DO $$ +DECLARE + rec record; + upc_apl_id int; + merits_count int; + order_count int; + schad_already int; + buch_already int; +BEGIN + SELECT id INTO upc_apl_id + FROM paliad.proceeding_types + WHERE code = 'upc.apl.unified'; + IF upc_apl_id IS NULL THEN + RAISE EXCEPTION '[mig 138] upc.apl.unified proceeding_type not found — mig 134 must run first'; + END IF; + RAISE NOTICE '[mig 138] upc.apl.unified proceeding_type_id = %', upc_apl_id; + + SELECT COUNT(*) INTO merits_count + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'endentscheidung' = ANY(applies_to_target); + SELECT COUNT(*) INTO order_count + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'anordnung' = ANY(applies_to_target); + + RAISE NOTICE '[mig 138] live counts: endentscheidung=% anordnung=%', merits_count, order_count; + IF merits_count <> 7 THEN + RAISE EXCEPTION '[mig 138] expected 7 endentscheidung rules under upc.apl.unified, got %', merits_count; + END IF; + IF order_count <> 7 THEN + RAISE EXCEPTION '[mig 138] expected 7 anordnung rules under upc.apl.unified, got %', order_count; + END IF; + + SELECT COUNT(*) INTO schad_already + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'schadensbemessung' = ANY(applies_to_target); + SELECT COUNT(*) INTO buch_already + FROM paliad.deadline_rules + WHERE proceeding_type_id = upc_apl_id + AND is_active = true + AND 'bucheinsicht' = ANY(applies_to_target); + IF schad_already > 0 THEN + RAISE EXCEPTION '[mig 138] % rules already carry schadensbemessung — partial run?', schad_already; + END IF; + IF buch_already > 0 THEN + RAISE EXCEPTION '[mig 138] % rules already carry bucheinsicht — partial run?', buch_already; + END IF; + + RAISE NOTICE '[mig 138] rules to extend with schadensbemessung (merits track):'; + FOR rec IN + SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target + FROM paliad.deadline_rules dr + WHERE dr.proceeding_type_id = upc_apl_id + AND dr.is_active = true + AND 'endentscheidung' = ANY(dr.applies_to_target) + ORDER BY dr.sequence_order, dr.rule_code NULLS LAST + LOOP + RAISE NOTICE '[mig 138] merits % % % pre=% → post=%', + COALESCE(rec.rule_code, '(no-code)'), + COALESCE(rec.legal_source, '(no-source)'), + rec.name, + rec.applies_to_target, + rec.applies_to_target || 'schadensbemessung'::text; + END LOOP; + + RAISE NOTICE '[mig 138] rules to extend with bucheinsicht (order track):'; + FOR rec IN + SELECT dr.id, dr.rule_code, dr.legal_source, dr.name, dr.applies_to_target + FROM paliad.deadline_rules dr + WHERE dr.proceeding_type_id = upc_apl_id + AND dr.is_active = true + AND 'anordnung' = ANY(dr.applies_to_target) + ORDER BY dr.sequence_order, dr.rule_code NULLS LAST + LOOP + RAISE NOTICE '[mig 138] order % % % pre=% → post=%', + COALESCE(rec.rule_code, '(no-code)'), + COALESCE(rec.legal_source, '(no-source)'), + rec.name, + rec.applies_to_target, + rec.applies_to_target || 'bucheinsicht'::text; + END LOOP; +END $$; + +-- --------------------------------------------------------------- +-- 2. Extend applies_to_target. +-- +-- Narrow WHERE clauses key off upc.apl.unified + existing target + +-- absence of new target, so the UPDATEs are idempotent in spirit +-- (the audit block above already RAISE EXCEPTIONed if any row +-- already had the new value). +-- --------------------------------------------------------------- + +-- 2a. Schadensbemessung := merits track (7 rules expected). +UPDATE paliad.deadline_rules dr + SET applies_to_target = applies_to_target || 'schadensbemessung'::text + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'endentscheidung' = ANY(dr.applies_to_target) + AND NOT ('schadensbemessung' = ANY(dr.applies_to_target)); + +-- 2b. Bucheinsicht := order track (7 rules expected). +UPDATE paliad.deadline_rules dr + SET applies_to_target = applies_to_target || 'bucheinsicht'::text + FROM paliad.proceeding_types pt + WHERE pt.id = dr.proceeding_type_id + AND pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'anordnung' = ANY(dr.applies_to_target) + AND NOT ('bucheinsicht' = ANY(dr.applies_to_target)); + +-- --------------------------------------------------------------- +-- 3. Post-migration sanity check. +-- +-- Hard-fail on any divergence: the two new targets must each cover +-- 7 rules, the original three targets must be unchanged in count, +-- and no rule has lost its prior target. +-- --------------------------------------------------------------- + +DO $$ +DECLARE + schad_post int; + buch_post int; + end_post int; + anord_post int; + cost_post int; + target_distribution record; +BEGIN + SELECT COUNT(*) INTO schad_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'schadensbemessung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO buch_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'bucheinsicht' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO end_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'endentscheidung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO anord_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'anordnung' = ANY(dr.applies_to_target); + SELECT COUNT(*) INTO cost_post + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' + AND dr.is_active = true + AND 'kostenentscheidung' = ANY(dr.applies_to_target); + + RAISE NOTICE '[mig 138] post: schadensbemessung=% bucheinsicht=% endentscheidung=% anordnung=% kostenentscheidung=%', + schad_post, buch_post, end_post, anord_post, cost_post; + + IF schad_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — expected 7 schadensbemessung rules, got %', schad_post; + END IF; + IF buch_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — expected 7 bucheinsicht rules, got %', buch_post; + END IF; + IF end_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — endentscheidung count drifted: expected 7, got %', end_post; + END IF; + IF anord_post <> 7 THEN + RAISE EXCEPTION '[mig 138] FAILED — anordnung count drifted: expected 7, got %', anord_post; + END IF; + IF cost_post <> 2 THEN + RAISE EXCEPTION '[mig 138] FAILED — kostenentscheidung count drifted: expected 2, got %', cost_post; + END IF; + + FOR target_distribution IN + SELECT unnest(applies_to_target) AS target, COUNT(*) AS n + FROM paliad.deadline_rules dr + JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE pt.code = 'upc.apl.unified' AND dr.is_active = true + GROUP BY unnest(applies_to_target) + ORDER BY 1 + LOOP + RAISE NOTICE '[mig 138] post: applies_to_target=% count=%', + target_distribution.target, target_distribution.n; + END LOOP; +END $$; diff --git a/internal/services/lookup_events_test.go b/internal/services/lookup_events_test.go index 5c66aa5..a4f1dd3 100644 --- a/internal/services/lookup_events_test.go +++ b/internal/services/lookup_events_test.go @@ -144,7 +144,7 @@ func TestLookupEvents(t *testing.T) { } }) - t.Run("appeal_target=schadensbemessung returns empty (no rules seeded yet)", func(t *testing.T) { + t.Run("appeal_target=schadensbemessung returns upc.apl merits rules (mig 138 backfill)", func(t *testing.T) { matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ Jurisdiction: "UPC", AppealTarget: lp.AppealTargetSchadensbemessung, @@ -152,8 +152,68 @@ func TestLookupEvents(t *testing.T) { if err != nil { t.Fatalf("LookupEvents: %v", err) } - if len(matches) != 0 { - t.Errorf("schadensbemessung should be empty until rules seeded; got %d rows", len(matches)) + // mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track + // rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung} + // because R.224 is uniform across substantive R.118 decisions. + if len(matches) == 0 { + t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill") + } + for _, m := range matches { + if m.DepthFromAnchor != 1 { + continue + } + found := false + for _, t := range m.Rule.AppliesToTarget { + if t == lp.AppealTargetSchadensbemessung { + found = true + break + } + } + if !found { + t.Errorf("anchor row %s missing schadensbemessung target: %v", + m.Rule.Name, m.Rule.AppliesToTarget) + } + if m.ProceedingType.Code != "upc.apl.unified" { + t.Errorf("anchor row %s came from %s, want upc.apl.unified", + m.Rule.Name, m.ProceedingType.Code) + } + } + }) + + t.Run("appeal_target=bucheinsicht returns upc.apl order rules (mig 138 backfill)", func(t *testing.T) { + matches, err := catalog.LookupEvents(ctx, lp.EventLookupAxes{ + Jurisdiction: "UPC", + AppealTarget: lp.AppealTargetBucheinsicht, + }, lp.EventLookupDepthAllFollowing) + if err != nil { + t.Fatalf("LookupEvents: %v", err) + } + // mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track + // rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht} + // because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are + // uniform across the orders they appeal. + if len(matches) == 0 { + t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill") + } + for _, m := range matches { + if m.DepthFromAnchor != 1 { + continue + } + found := false + for _, t := range m.Rule.AppliesToTarget { + if t == lp.AppealTargetBucheinsicht { + found = true + break + } + } + if !found { + t.Errorf("anchor row %s missing bucheinsicht target: %v", + m.Rule.Name, m.Rule.AppliesToTarget) + } + if m.ProceedingType.Code != "upc.apl.unified" { + t.Errorf("anchor row %s came from %s, want upc.apl.unified", + m.Rule.Name, m.ProceedingType.Code) + } } }) }