feat(litigationplanner): primary_party CHECK constraint + IsValidPrimaryParty helper (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 vocab (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.
Migration 135 (audit-first):
- DO block RAISEs NOTICE for every non-conforming row + RAISEs
EXCEPTION if any dirty rows exist (manual cleanup required).
Live audit (Supabase, 2026-05-26 §18.0) confirmed zero dirty rows
on the current corpus; the audit pass stays in the migration as
safety against future drift.
- ALTER TABLE … ADD CONSTRAINT deadline_rules_primary_party_chk
CHECK (primary_party IS NULL OR primary_party IN
('claimant', 'defendant', 'court', 'both'))
- Post-migration distribution NOTICE so the operator sees the
final per-value count.
- Down = DROP CONSTRAINT. No data revert needed.
Package additions (pkg/litigationplanner):
- PrimaryParty* constants (PrimaryPartyClaimant / Defendant / Court
/ Both) + PrimaryParties[] ordered list + IsValidPrimaryParty(s)
predicate. Empty string is "no value supplied" = valid (NULL maps
to empty on the wire); non-empty must match one of the four
canonical values.
- Sibling unit tests (primary_party_test.go) pin the four-value
vocab + the chip order + IsValidAppealTarget's matching shape.
Rule-editor validation hook (rule_editor_service.go):
- Create() validates input.PrimaryParty before INSERT.
- UpdateDraft() validates patch.PrimaryParty before UPDATE.
- Both surface a user-friendly 400 with the canonical vocab listed
instead of leaking the raw PG CHECK constraint-violation message.
- Uses errors.Is(err, ErrInvalidInput) so handler 400 routing
continues to work.
services/fristenrechner.go cleanup:
- The B2-inlined isValidPartyForLookup helper is replaced with the
canonical lp.IsValidPrimaryParty. No behaviour change.
No frontend changes — the rule-editor's primary_party UI already
constrains to the four values via a select; the validation hook is
defense-in-depth.
Audit:
- go build + go test (incl. new lp unit tests) all green
- Pre-migration audit confirmed: 26 claimant + 26 defendant + 38
court + 63 both + 78 NULL = 231 total, all in canonical vocab
- event_categories.party (text[] array, narrower semantic) is
NOT touched in this migration per the design doc's
"out of scope, separate follow-up" decision
This commit is contained in:
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
8
internal/db/migrations/135_primary_party_check.down.sql
Normal file
@@ -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;
|
||||
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
92
internal/db/migrations/135_primary_party_check.up.sql
Normal file
@@ -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, '<orphan>'),
|
||||
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, '<NULL>') 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 $$;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
68
pkg/litigationplanner/primary_party_test.go
Normal file
68
pkg/litigationplanner/primary_party_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user