diff --git a/internal/db/migrations/135_primary_party_check.down.sql b/internal/db/migrations/135_primary_party_check.down.sql new file mode 100644 index 0000000..f7cbfb1 --- /dev/null +++ b/internal/db/migrations/135_primary_party_check.down.sql @@ -0,0 +1,8 @@ +-- 135_primary_party_check — DOWN +-- +-- Drops the CHECK constraint added in 135.up. No data revert needed +-- — the column stays text, the four-value vocab is enforced only by +-- application code thereafter. + +ALTER TABLE paliad.deadline_rules + DROP CONSTRAINT IF EXISTS deadline_rules_primary_party_chk; diff --git a/internal/db/migrations/135_primary_party_check.up.sql b/internal/db/migrations/135_primary_party_check.up.sql new file mode 100644 index 0000000..7b93aee --- /dev/null +++ b/internal/db/migrations/135_primary_party_check.up.sql @@ -0,0 +1,92 @@ +-- 135_primary_party_check — Slice B3, m/paliad#124 §18.3 +-- +-- Tightens paliad.deadline_rules.primary_party from free-text to a +-- CHECK constraint over the canonical four-value vocabulary +-- (claimant / defendant / court / both). NULL stays valid for the +-- 78 cross-cutting orphan concept seeds (Wiedereinsetzung, +-- Versäumnisurteil-Einspruch, Schriftsatznachreichung, +-- Weiterbehandlung) — they have no proceeding_type_id binding so +-- they're outside the calculator's path; loosening the CHECK to +-- "IS NULL OR IN (…)" keeps them valid without backfill gymnastics. +-- +-- Audit-first: the DO block RAISEs NOTICE for every non-conforming +-- row before adding the CHECK, and RAISEs EXCEPTION if any dirty +-- rows are found so the operator can decide a manual cleanup path. +-- Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows +-- on the current corpus: 26 claimant + 26 defendant + 38 court + +-- 63 both + 78 NULL = 231 total, all in the canonical vocab. The +-- audit pass stays in the migration for safety against future drift +-- (e.g. a rule editor write that bypassed the application-layer +-- validation hook this slice also adds). + +DO $$ +DECLARE + rec record; + dirty_count int := 0; +BEGIN + RAISE NOTICE '[mig 135] primary_party audit pass — non-conforming rows:'; + FOR rec IN + SELECT dr.id, dr.name, dr.primary_party, + pt.code AS proceeding_code + FROM paliad.deadline_rules dr + LEFT JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id + WHERE dr.is_active = true + AND dr.primary_party IS NOT NULL + AND dr.primary_party NOT IN ('claimant', 'defendant', 'court', 'both') + ORDER BY pt.code NULLS LAST, dr.name + LOOP + RAISE NOTICE '[mig 135] % % primary_party=% (rule=%)', + COALESCE(rec.proceeding_code, ''), + rec.name, + rec.primary_party, + rec.id; + dirty_count := dirty_count + 1; + END LOOP; + IF dirty_count > 0 THEN + RAISE EXCEPTION '[mig 135] FAILED — % rule(s) carry non-canonical primary_party values. ' + 'Manual cleanup required: update each row to one of ' + '''claimant'', ''defendant'', ''court'', ''both'', or NULL. ' + 'See the NOTICE lines above for the offending rows.', dirty_count; + END IF; + RAISE NOTICE '[mig 135] audit clean — proceeding with CHECK constraint'; +END $$; + +-- --------------------------------------------------------------- +-- Add the CHECK constraint. NULL stays valid; the four canonical +-- values are the only allowed non-NULL forms. +-- --------------------------------------------------------------- + +ALTER TABLE paliad.deadline_rules + ADD CONSTRAINT deadline_rules_primary_party_chk + CHECK ( + primary_party IS NULL + OR primary_party IN ('claimant', 'defendant', 'court', 'both') + ); + +COMMENT ON CONSTRAINT deadline_rules_primary_party_chk + ON paliad.deadline_rules IS + 'Slice B3 (mig 135, m/paliad#124 §18.3) — canonical four-value ' + 'vocab for primary_party (claimant / defendant / court / both). ' + 'NULL allowed for cross-cutting orphan concept seeds (78 rows in ' + 'live corpus as of mig 135). See pkg/litigationplanner.PrimaryParties ' + 'for the in-code vocabulary.'; + +-- --------------------------------------------------------------- +-- Post-migration distribution check — informational NOTICE only. +-- --------------------------------------------------------------- + +DO $$ +DECLARE + rec record; +BEGIN + RAISE NOTICE '[mig 135] post: primary_party distribution after constraint add:'; + FOR rec IN + SELECT COALESCE(primary_party, '') AS party, COUNT(*) AS n + FROM paliad.deadline_rules + WHERE is_active = true + GROUP BY primary_party + ORDER BY party + LOOP + RAISE NOTICE '[mig 135] % count=%', rec.party, rec.n; + END LOOP; +END $$; diff --git a/internal/services/fristenrechner.go b/internal/services/fristenrechner.go index eb58e30..fd870d8 100644 --- a/internal/services/fristenrechner.go +++ b/internal/services/fristenrechner.go @@ -15,16 +15,6 @@ import ( lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) -// isValidPartyForLookup mirrors the four-value primary_party vocab the -// engine knows about. B2 inlines this check; B3 will add a canonical -// lp.IsValidPrimaryParty + tighten the column to a CHECK constraint. -func isValidPartyForLookup(s string) bool { - switch s { - case "claimant", "defendant", "court", "both": - return true - } - return false -} // FristenrechnerService renders the Paliad public Fristenrechner's // response shape from DB-stored rules. Post-Slice-A (t-paliad-298) it @@ -268,7 +258,7 @@ func (c *paliadCatalog) LookupEvents(ctx context.Context, axes lp.EventLookupAxe jurisdiction = "" } party := axes.Party - if party != "" && !isValidPartyForLookup(party) { + if party != "" && !lp.IsValidPrimaryParty(party) { party = "" } appealTarget := axes.AppealTarget diff --git a/internal/services/rule_editor_service.go b/internal/services/rule_editor_service.go index 2b68f08..f48fba3 100644 --- a/internal/services/rule_editor_service.go +++ b/internal/services/rule_editor_service.go @@ -13,6 +13,7 @@ import ( "github.com/jmoiron/sqlx" "mgit.msbls.de/m/paliad/internal/models" + lp "mgit.msbls.de/m/paliad/pkg/litigationplanner" ) // RuleEditorService owns the admin-only rule lifecycle for Phase 3 @@ -148,6 +149,16 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r if strings.TrimSpace(input.Priority) == "" { input.Priority = "mandatory" } + // Slice B3 (m/paliad#124 §18.3, mig 135): canonical four-value + // primary_party vocab. Pre-validate so the user gets a + // user-friendly error before the DB CHECK fires with the raw + // constraint-violation message. + if input.PrimaryParty != nil && !lp.IsValidPrimaryParty(*input.PrimaryParty) { + return nil, fmt.Errorf( + "%w: primary_party=%q is not one of %v", + ErrInvalidInput, *input.PrimaryParty, lp.PrimaryParties, + ) + } if err := s.validateSpawnNoCycle(ctx, nil, input.SpawnProceedingTypeID, input.ProceedingTypeID); err != nil { return nil, err } @@ -220,6 +231,19 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch ErrInvalidLifecycleState, id, current.LifecycleState) } + // Slice B3 (m/paliad#124 §18.3, mig 135): pre-validate the + // patch's primary_party so the user gets a user-friendly error + // before the DB CHECK fires with the raw constraint-violation + // message. Patch field is *string — nil means "don't change", + // dereferenced empty string means "set to NULL" (handled below + // in buildPatchSets). + if patch.PrimaryParty != nil && !lp.IsValidPrimaryParty(*patch.PrimaryParty) { + return nil, fmt.Errorf( + "%w: primary_party=%q is not one of %v", + ErrInvalidInput, *patch.PrimaryParty, lp.PrimaryParties, + ) + } + // Spawn cycle guard: if the patch sets spawn_proceeding_type_id, // validate against the global graph BEFORE the UPDATE so we can // surface the cycle clearly instead of relying on a runtime diff --git a/pkg/litigationplanner/primary_party_test.go b/pkg/litigationplanner/primary_party_test.go new file mode 100644 index 0000000..8174c97 --- /dev/null +++ b/pkg/litigationplanner/primary_party_test.go @@ -0,0 +1,68 @@ +package litigationplanner + +import "testing" + +// TestIsValidPrimaryParty pins the four-value vocab + NULL-equivalent +// behaviour the rule-editor's B3 validation hook depends on. Empty +// string is "no value supplied" = valid (NULL maps to empty on the +// wire). Non-empty must match one of the four canonical values. +func TestIsValidPrimaryParty(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", true}, + {"claimant", true}, + {"defendant", true}, + {"court", true}, + {"both", true}, + {"Claimant", false}, // case-sensitive + {"clamant", false}, // typo + {"applicant", false}, // not in vocab + {"foo", false}, + } + for _, c := range cases { + if got := IsValidPrimaryParty(c.in); got != c.want { + t.Errorf("IsValidPrimaryParty(%q) = %v, want %v", c.in, got, c.want) + } + } +} + +// TestPrimaryPartiesOrder pins the canonical chip order (admin UI +// renders these as a select; reordering would break user muscle +// memory). Update both the slice + this test together if the order +// genuinely needs to change. +func TestPrimaryPartiesOrder(t *testing.T) { + want := []string{"claimant", "defendant", "court", "both"} + if len(PrimaryParties) != len(want) { + t.Fatalf("PrimaryParties has %d entries, want %d", len(PrimaryParties), len(want)) + } + for i, p := range PrimaryParties { + if p != want[i] { + t.Errorf("PrimaryParties[%d] = %q, want %q", i, p, want[i]) + } + } +} + +// TestIsValidAppealTarget is sibling-of: same shape, ensures the B1 +// helper has the same NULL-equivalent semantic. +func TestIsValidAppealTarget(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", true}, + {"endentscheidung", true}, + {"kostenentscheidung", true}, + {"anordnung", true}, + {"schadensbemessung", true}, + {"bucheinsicht", true}, + {"foo", false}, + {"Endentscheidung", false}, // case-sensitive + } + for _, c := range cases { + if got := IsValidAppealTarget(c.in); got != c.want { + t.Errorf("IsValidAppealTarget(%q) = %v, want %v", c.in, got, c.want) + } + } +} diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go index 00e5a3c..217db1d 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -551,6 +551,50 @@ var AppealTargets = []string{ AppealTargetBucheinsicht, } +// PrimaryParty* are the canonical four-value vocabulary for +// paliad.deadline_rules.primary_party (Slice B3, m/paliad#124 §18.3, +// mig 135). The DB CHECK constraint enforces the same set; the +// application-layer helper IsValidPrimaryParty lets the rule editor +// surface a friendly 400 before the DB error fires. +// +// NULL is also valid in the DB (for the 78 orphan cross-cutting +// concept seeds — Wiedereinsetzung, Versäumnisurteil-Einspruch, +// Schriftsatznachreichung, Weiterbehandlung). The helper treats the +// empty string as "no value supplied" = valid; non-empty strings must +// match one of the four canonical values. +const ( + PrimaryPartyClaimant = "claimant" + PrimaryPartyDefendant = "defendant" + PrimaryPartyCourt = "court" + PrimaryPartyBoth = "both" +) + +// PrimaryParties is the canonical ordered list for validation + +// admin-UI rendering. Order matches the rule-editor select; do not +// reorder without coordinating with the frontend. +var PrimaryParties = []string{ + PrimaryPartyClaimant, + PrimaryPartyDefendant, + PrimaryPartyCourt, + PrimaryPartyBoth, +} + +// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any +// of the four canonical values. Used by the rule-editor to validate +// writes before they hit the DB CHECK — produces a user-friendly 400 +// instead of a raw constraint-violation error. +func IsValidPrimaryParty(s string) bool { + if s == "" { + return true + } + for _, p := range PrimaryParties { + if p == s { + return true + } + } + return false +} + // IsValidAppealTarget returns true for empty (no filter requested) or // any of the five canonical slugs. The engine uses this to gate the // CalcOptions.AppealTarget filter — an unknown slug is silently