feat(t-paliad-206): switch Go layer to lowercase dot-form proceeding codes

Sweeps internal/services + internal/handlers + internal/models to use
the new proceeding codes landed by mig 096. Stable Code* constants
live in internal/services/proceeding_mapping.go so a future rename
needs to touch one file.

Substantive changes:
- proceeding_mapping.go gains ResolveCounterclaimRouting() — the
  cascade resolver that routes upc.ccr.cfi (illustrative peer) back
  to upc.inf.cfi with with_ccr=true as default flag (design doc S1).
- deadline_search_service.go forum-bucket map updated; upc.ccr.cfi
  added to upc_cfi since it is a CFI peer.
- project_service.go CreateCounterclaim default lookup parameterised
  so the SQL string carries the constant, not a literal.
- proceeding_codes_shape_test.go: new file. Validates the shape
  regex standalone (always runs) and walks live DB rows asserting
  every active fristenrechner row matches the new shape + every
  stable Code* constant resolves to exactly one active row.

Comments and test fixtures throughout the Go tree updated to the
new shape. Tests pass under `go test ./internal/... -short`.
This commit is contained in:
mAi
2026-05-18 12:13:24 +02:00
parent cce0ada3ce
commit 216abbfc98
14 changed files with 315 additions and 107 deletions

View File

@@ -359,7 +359,7 @@ func itoa(n int) string {
// POST /api/projects/{id}/counterclaim
//
// Body: {
// "proceeding_type_id": 9, // optional, defaults to UPC_REV
// "proceeding_type_id": 9, // optional, defaults to upc.rev.cfi
// "flip_our_side": false, // optional, default-flip otherwise
// "title": "EP3456789 — Widerklage (CCR)", // optional, auto-suggested
// "case_number": "ACT_xxx_2026" // optional CCR case number

View File

@@ -174,7 +174,7 @@ type Project struct {
// InstanceLevel is the procedural instance the project sits at:
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
// proceeding code + jurisdiction by FristenrechnerService to pick
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
// the effective proceeding (de.inf.lg + appeal → de.inf.olg, etc.).
// NULL = unset / not applicable; the calculator treats NULL as
// 'first'. Backfill happens via the project-detail picker UI
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
@@ -594,7 +594,9 @@ type DeadlineRuleAudit struct {
}
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
// management) or UPC_*/DE_*/EPA_*/EP_GRANT (Fristenrechner UI).
// management) or the lowercase dot-separated fristenrechner codes
// (upc.*.*, de.*.*, epa.*.*, dpma.*.*) — see
// docs/design-proceeding-code-taxonomy-2026-05-18.md.
type ProceedingType struct {
ID int `db:"id" json:"id"`
Code string `db:"code" json:"code"`

View File

@@ -62,16 +62,16 @@ func (s *DeadlineSearchService) SetEventCategoryService(ec *EventCategoryService
//
// Empty bucket slug = no narrowing.
var ForumToProceedingCodes = map[string][]string{
"upc_cfi": {"UPC_INF", "UPC_REV", "UPC_PI", "UPC_DAMAGES", "UPC_DISCOVERY", "UPC_APP_ORDERS"},
"upc_coa": {"UPC_APP", "UPC_COST_APPEAL"},
"de_lg": {"DE_INF"},
"de_olg": {"DE_INF_OLG"},
"de_bgh": {"DE_INF_BGH", "DE_NULL_BGH", "DPMA_BGH_RB"},
"de_bpatg": {"DE_NULL", "DPMA_BPATG_BESCHWERDE"},
"epa_grant": {"EP_GRANT"},
"epa_opp": {"EPA_OPP"},
"epa_appeal": {"EPA_APP"},
"dpma": {"DPMA_OPP"},
"upc_cfi": {CodeUPCInfringement, CodeUPCRevocation, CodeUPCCounterclaim, CodeUPCPreliminary, CodeUPCDamages, CodeUPCDiscovery, CodeUPCAppealOrder},
"upc_coa": {CodeUPCAppealMerits, CodeUPCAppealCost},
"de_lg": {CodeDEInfringementLG},
"de_olg": {CodeDEInfringementOLG},
"de_bgh": {CodeDEInfringementBGH, CodeDENullityBGH, CodeDPMAAppealBGH},
"de_bpatg": {CodeDENullityBPatG, CodeDPMAAppealBPatG},
"epa_grant": {CodeEPAGrant},
"epa_opp": {CodeEPAOpposition},
"epa_appeal": {CodeEPAOppositionAppeal},
"dpma": {CodeDPMAOpposition},
}
// SearchOptions carries the optional facet filters from the URL query

View File

@@ -96,14 +96,15 @@ func TestDeadlineSearch(t *testing.T) {
}
card := findCardBySlug(t, resp, "statement-of-defence")
// Expected at minimum: UPC R.23, ZPO §276, PatG §82, EPC R.79, PatG §59.
// The actual data has 9 rule rows (UPC_INF, UPC_REV, UPC_PI,
// UPC_DAMAGES, UPC_DISCOVERY, DE_INF, DE_NULL, EPA_OPP, DPMA_OPP).
// The actual data has 9 rule rows (upc.inf.cfi, upc.rev.cfi,
// upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, de.inf.lg,
// de.null.bpatg, epa.opp.opd, dpma.opp.dpma).
mustHaveLegalSource(t, card, "UPC.RoP.23.1")
mustHaveLegalSource(t, card, "DE.ZPO.276.1")
mustHaveLegalSource(t, card, "DE.PatG.82.1")
mustHaveLegalSource(t, card, "EU.EPC-R.79.1")
mustHaveLegalSource(t, card, "DE.PatG.59.3")
mustHaveProceedingCodes(t, card, "UPC_INF", "DE_INF", "DE_NULL", "EPA_OPP", "DPMA_OPP")
mustHaveProceedingCodes(t, card, CodeUPCInfringement, CodeDEInfringementLG, CodeDENullityBPatG, CodeEPAOpposition, CodeDPMAOpposition)
})
t.Run("RoP 23 returns the UPC R.23 hit", func(t *testing.T) {
@@ -169,7 +170,7 @@ func TestDeadlineSearch(t *testing.T) {
}
// Statement-of-defence is filed by the defendant. Filtering
// party=claimant should NOT drop the concept entirely — the
// effective_party can vary per pill (e.g. EPA_OPP Erwiderung
// effective_party can vary per pill (e.g. epa.opp.opd Erwiderung
// is owed by the patentee/claimant). At least it must not
// return any card with EVERY pill on defendant side.
for _, c := range resp.Cards {
@@ -254,9 +255,9 @@ func TestDeadlineSearch(t *testing.T) {
t.Fatalf("search: %v", err)
}
// Every rule pill must be a UPC proceeding. The seed maps every
// concept under this subtree to UPC_INF or UPC_APP — no DE/EPA/
// DPMA codes should leak.
allowedRulePrefix := []string{"UPC_"}
// concept under this subtree to upc.inf.cfi or upc.apl.merits — no
// DE/EPA/DPMA codes should leak.
allowedRulePrefix := []string{"upc."}
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
@@ -289,21 +290,21 @@ func TestDeadlineSearch(t *testing.T) {
if err != nil {
t.Fatalf("search: %v", err)
}
// Junction maps three concepts × UPC_INF for this leaf:
// Junction maps three concepts × upc.inf.cfi for this leaf:
// defence-to-counterclaim-for-revocation, application-to-amend,
// reply-to-defence. Every pill must be UPC_INF.
// reply-to-defence. Every pill must be upc.inf.cfi.
for _, c := range resp.Cards {
for _, p := range c.Pills {
if p.Kind != "rule" {
continue
}
if p.Proceeding == nil || p.Proceeding.Code != "UPC_INF" {
if p.Proceeding == nil || p.Proceeding.Code != CodeUPCInfringement {
code := "(nil)"
if p.Proceeding != nil {
code = p.Proceeding.Code
}
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-UPC_INF pill on %q: proc=%s",
c.Concept.Slug, code)
t.Errorf("klageerwiderung-mit-ccr leaf leaked non-%s pill on %q: proc=%s",
CodeUPCInfringement, c.Concept.Slug, code)
}
}
}
@@ -344,8 +345,8 @@ func TestDeadlineSearch(t *testing.T) {
})
t.Run("v4 forum filter ANDs against subtree narrowing", func(t *testing.T) {
// Pick the UPC_INF subtree and add a forum chip that excludes
// UPC_INF — the result must be empty (the user contradicted
// Pick the upc.inf.cfi subtree and add a forum chip that excludes
// upc.inf.cfi — the result must be empty (the user contradicted
// themselves; empty is the correct UX).
resp, err := svc.Search(ctx, "", SearchOptions{
EventCategorySlug: "cms-eingang.gegenseite.upc-inf",

View File

@@ -238,8 +238,8 @@ func (s *EventCategoryService) ConceptIDsForSlug(ctx context.Context, slug strin
//
// Distinct from "every concept_id ever mapped" because a concept can
// appear at the root view in MULTIPLE proceeding contexts that the tree
// authors intentionally surfaced — e.g. opposition under both EPA_OPP
// and DPMA_OPP. We respect those tuples even at the root so the
// authors intentionally surfaced — e.g. opposition under both epa.opp.opd
// and dpma.opp.dpma. We respect those tuples even at the root so the
// result-card pill set matches the junction's design.
func (s *EventCategoryService) AllOutcomes(ctx context.Context) ([]ConceptOutcome, error) {
const sqlText = `

View File

@@ -98,7 +98,7 @@ var ErrUnknownProceedingType = errors.New("unknown proceeding type")
// empty/nil for the legacy behaviour.
//
// - PriorityDateStr: when non-empty (YYYY-MM-DD), rules with anchor_alt =
// 'priority_date' (e.g. EP_GRANT.ep_grant.publish per Art. 93 EPÜ) use
// 'priority_date' (e.g. epa.grant.exa.ep_grant.publish per Art. 93 EPÜ) use
// this date as their base instead of the parent's adjusted date / the
// trigger date.
// - Flags: lowercase string flags from the UI (e.g. "with_ccr",
@@ -158,13 +158,13 @@ type CalcOptions struct {
// Audit-driven extensions:
//
// - opts.Flags can flip flag-conditioned rules onto their alt_* values
// (e.g. UPC_INF inf.reply / inf.rejoin under "with_ccr"). When a
// (e.g. upc.inf.cfi inf.reply / inf.rejoin under "with_ccr"). When a
// rule's condition_flag array is non-empty, the rule renders iff
// EVERY element is in opts.Flags; rules that fail this gate are
// suppressed entirely (used by Phase B1 cross-flow rules that should
// only appear with their flag).
// - opts.PriorityDateStr overrides the anchor for rules with anchor_alt
// set (e.g. EP_GRANT publication date is 18mo from priority, not filing).
// set (e.g. epa.grant.exa publication date is 18mo from priority, not filing).
// - opts.AnchorOverrides per-rule (rule_code → YYYY-MM-DD) lets the
// caller redirect a downstream rule's parent anchor to a user-set
// date. Used for court-extended deadlines and for entering
@@ -318,7 +318,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// 3. parent set, court-determined → IsCourtSet (waypoint)
// 4. parent set, NOT court-determined → "filed-with-parent"
// semantic: rule is filed AT THE SAME TIME as its parent
// (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — R.49(2) says
// (e.g. upc.rev.cfi.rev.app_to_amend, rev.cc_inf — R.49(2) says
// Application to amend / Counterclaim for infringement are
// INCLUDED in the Defence to revocation). Use the parent's
// computed date.
@@ -432,7 +432,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for EP_GRANT publish)
// Anchor: prefer alt-anchor (e.g. priority_date for epa.grant.exa publish)
// when supplied, then parent's computed date (or user override),
// then trigger date.
baseDate := triggerDate
@@ -715,7 +715,7 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
}
// Zero-duration non-court-determined rules are "filed at the same
// time as parent" markers (UPC_REV.app_to_amend, UPC_REV.cc_inf):
// time as parent" markers (upc.rev.cfi.app_to_amend, upc.rev.cfi.cc_inf):
// effectively mean "due on the trigger date itself". The card-click
// flow doesn't need to surface those as a calc panel — but if it
// does, returning the trigger date is the right answer.

View File

@@ -46,7 +46,7 @@ func TestAllFlagsSet(t *testing.T) {
{"single flag, present → true (legacy with_ccr pattern)", []string{"with_ccr"}, mkSet("with_ccr"), true},
{"single flag, absent → false", []string{"with_ccr"}, mkSet(), false},
{"single flag, other present → false", []string{"with_ccr"}, mkSet("with_amend"), false},
{"two flags, both present → true (UPC_INF nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
{"two flags, both present → true (upc.inf.cfi nested)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
{"two flags, only one present → false", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
{"two flags, both present + extra → true (extra flags don't matter)", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend", "with_cci"), true},
}
@@ -86,10 +86,10 @@ func TestCalculateRule(t *testing.T) {
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
t.Run("plain rule calc — UPC_INF inf.sod, R.23(1), 3 months", func(t *testing.T) {
t.Run("plain rule calc — upc.inf.cfi inf.sod, R.23(1), 3 months", func(t *testing.T) {
// 2026-01-15 + 3 months = 2026-04-15. No vacation overlap.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
TriggerDate: "2026-01-15",
})
@@ -105,14 +105,14 @@ func TestCalculateRule(t *testing.T) {
if got.Rule.LegalSourceDisplay != "UPC RoP R.23(1)" {
t.Errorf("legalSourceDisplay = %q, want UPC RoP R.23(1)", got.Rule.LegalSourceDisplay)
}
if got.Proceeding.Code != "UPC_INF" {
t.Errorf("proceeding code = %q, want UPC_INF", got.Proceeding.Code)
if got.Proceeding.Code != CodeUPCInfringement {
t.Errorf("proceeding code = %q, want upc.inf.cfi", got.Proceeding.Code)
}
})
t.Run("court-determined rule → IsCourtSet=true, no dueDate", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.decision",
TriggerDate: "2026-01-15",
})
@@ -131,7 +131,7 @@ func TestCalculateRule(t *testing.T) {
// inf.def_to_ccr requires with_ccr. Without the flag, FlagsRequired
// is still surfaced so the UI can render the checkbox.
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
TriggerDate: "2026-01-15",
})
@@ -148,7 +148,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("flag-conditional rule with flag → FlagsApplied populated", func(t *testing.T) {
got, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.def_to_ccr",
TriggerDate: "2026-01-15",
Flags: []string{"with_ccr"},
@@ -163,7 +163,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("missing TriggerDate → error", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "inf.sod",
TriggerDate: "",
})
@@ -174,7 +174,7 @@ func TestCalculateRule(t *testing.T) {
t.Run("unknown rule → ErrUnknownRule", func(t *testing.T) {
_, err := svc.CalculateRule(ctx, CalcRuleParams{
ProceedingCode: "UPC_INF",
ProceedingCode: CodeUPCInfringement,
RuleLocalCode: "totally.fake",
TriggerDate: "2026-01-15",
})
@@ -417,12 +417,12 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
courts := NewCourtService(pool)
svc := NewFristenrechnerService(rules, holidays, courts)
resp, err := svc.Calculate(ctx, "UPC_INF", "2026-01-15", CalcOptions{})
resp, err := svc.Calculate(ctx, CodeUPCInfringement, "2026-01-15", CalcOptions{})
if err != nil {
t.Fatalf("Calculate UPC_INF: %v", err)
t.Fatalf("Calculate upc.inf.cfi: %v", err)
}
if len(resp.Deadlines) == 0 {
t.Fatal("Calculate UPC_INF returned no deadlines — seed-data missing?")
t.Fatal("Calculate upc.inf.cfi returned no deadlines — seed-data missing?")
}
allowed := map[string]bool{
@@ -446,6 +446,6 @@ func TestUIDeadline_WireShape_Slice8(t *testing.T) {
}
}
if !sawConditionExpr {
t.Logf("warning: no UPC_INF rule had conditionExpr populated — verify mig 084 ran")
t.Logf("warning: no upc.inf.cfi rule had conditionExpr populated — verify mig 084 ran")
}
}

View File

@@ -0,0 +1,121 @@
package services
import (
"context"
"os"
"regexp"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// shapeRegex is the lowercase dot-separated form ratified by t-paliad-204
// and enforced at the DB layer by mig 096's paliad_proceeding_code_shape
// CHECK constraint. Every active fristenrechner-category row must match.
var shapeRegex = regexp.MustCompile(`^[a-z]+\.[a-z]+\.[a-z]+$`)
// TestProceedingCodeShape walks every active fristenrechner-category row
// in paliad.proceeding_types and asserts the `code` matches the
// taxonomy regex. Catches future inserts that slip past the CHECK
// constraint (e.g. via a manual psql edit on a staging snapshot) and
// catches drift between this Go layer's stable code constants and the
// DB.
//
// Mirrors the assertions in mig 096 §8 — same regex, same shape — so a
// failure here pinpoints which row went off-shape without making a DB
// trip first.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the pattern in
// project_service_test.go.
func TestProceedingCodeShape(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
var rows []struct {
ID int `db:"id"`
Code string `db:"code"`
}
if err := pool.SelectContext(ctx, &rows,
`SELECT id, code FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND is_active = true
ORDER BY id`); err != nil {
t.Fatalf("load active fristenrechner rows: %v", err)
}
if len(rows) == 0 {
t.Fatal("no active fristenrechner rows — mig 096 likely not applied")
}
for _, r := range rows {
if !shapeRegex.MatchString(r.Code) {
t.Errorf("proceeding_types[id=%d] code=%q does not match taxonomy shape %s",
r.ID, r.Code, shapeRegex.String())
}
}
// Spot-check the stable code constants in proceeding_mapping.go all
// resolve to live rows. Catches a constant being renamed without a
// matching mig update.
stable := []string{
CodeUPCInfringement, CodeUPCRevocation, CodeUPCCounterclaim,
CodeUPCPreliminary, CodeUPCDamages, CodeUPCDiscovery,
CodeUPCAppealMerits, CodeUPCAppealOrder, CodeUPCAppealCost,
CodeDEInfringementLG, CodeDEInfringementOLG, CodeDEInfringementBGH,
CodeDENullityBPatG, CodeDENullityBGH,
CodeEPAGrant, CodeEPAOpposition, CodeEPAOppositionAppeal,
CodeDPMAOpposition, CodeDPMAAppealBPatG, CodeDPMAAppealBGH,
}
for _, c := range stable {
var hit int
if err := pool.GetContext(ctx, &hit,
`SELECT count(*) FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, c); err != nil {
t.Fatalf("count rows for %s: %v", c, err)
}
if hit != 1 {
t.Errorf("stable code constant %q matches %d active rows, want 1", c, hit)
}
}
}
// TestProceedingCodeShapeRegexStandalone exercises the regex without
// hitting the DB so the shape rule is verified on every `go test ./...`
// run (no skip when TEST_DATABASE_URL is unset).
func TestProceedingCodeShapeRegexStandalone(t *testing.T) {
good := []string{
"upc.inf.cfi", "upc.rev.cfi", "upc.ccr.cfi", "upc.apl.merits",
"upc.apl.order", "upc.apl.cost", "de.inf.lg", "de.null.bgh",
"epa.opp.opd", "epa.grant.exa", "dpma.opp.dpma",
}
for _, code := range good {
if !shapeRegex.MatchString(code) {
t.Errorf("good code %q rejected by shape regex", code)
}
}
bad := []string{
"UPC_INF", // old uppercase
"upc.inf", // missing third position
"upc.inf.cfi.extra", // four positions
"upc..cfi", // empty middle
"upc-inf-cfi", // dashes
"_archived_litigation",
}
for _, code := range bad {
if shapeRegex.MatchString(code) {
t.Errorf("bad code %q accepted by shape regex", code)
}
}
}

View File

@@ -3,19 +3,49 @@ package services
// proceeding_mapping bridges the two proceeding-type vocabularies in the
// codebase: the **litigation** conceptual category (INF / REV / APP /
// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding
// + Pipeline-A rules, and the **fristenrechner** code category (UPC_INF
// / DE_INF / EPA_OPP / …) used by the Determinator cascade + rule
// engine. Post-Phase-3-Slice-5 (t-paliad-186) projects bind to
// fristenrechner codes directly, but the litigation→fristenrechner
// + Pipeline-A rules, and the **fristenrechner** code category
// (upc.inf.cfi / de.inf.lg / epa.opp.opd / …) used by the Determinator
// cascade + rule engine. Post-Phase-3-Slice-5 (t-paliad-186) projects
// bind to fristenrechner codes directly, but the litigation→fristenrechner
// mapping is still needed for the ~40 Pipeline-A rules that remain on
// litigation proceedings and for any other surface that thinks in
// litigation terms.
//
// The mapping table here is the single source of truth — see
// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the
// design rationale + ambiguity notes. **Never silent FK promotion**:
// every ambiguous case returns ok=false so callers can degrade
// gracefully ("no narrowing") instead of guessing.
// design rationale + ambiguity notes, and
// docs/design-proceeding-code-taxonomy-2026-05-18.md for the
// lowercase dot-separated naming convention applied by mig 096
// (t-paliad-206). **Never silent FK promotion**: every ambiguous case
// returns ok=false so callers can degrade gracefully ("no narrowing")
// instead of guessing.
// Stable code constants — the strings landed by mig 096. Use these
// throughout the codebase so a future rename only needs to touch this
// file. The id-anchored FKs (deadline_rules.proceeding_type_id,
// projects.proceeding_type_id) are unaffected by the rename.
const (
CodeUPCInfringement = "upc.inf.cfi"
CodeUPCRevocation = "upc.rev.cfi"
CodeUPCCounterclaim = "upc.ccr.cfi"
CodeUPCPreliminary = "upc.pi.cfi"
CodeUPCDamages = "upc.dmgs.cfi"
CodeUPCDiscovery = "upc.disc.cfi"
CodeUPCAppealMerits = "upc.apl.merits"
CodeUPCAppealOrder = "upc.apl.order"
CodeUPCAppealCost = "upc.apl.cost"
CodeDEInfringementLG = "de.inf.lg"
CodeDEInfringementOLG = "de.inf.olg"
CodeDEInfringementBGH = "de.inf.bgh"
CodeDENullityBPatG = "de.null.bpatg"
CodeDENullityBGH = "de.null.bgh"
CodeEPAGrant = "epa.grant.exa"
CodeEPAOpposition = "epa.opp.opd"
CodeEPAOppositionAppeal = "epa.opp.boa"
CodeDPMAOpposition = "dpma.opp.dpma"
CodeDPMAAppealBPatG = "dpma.appeal.bpatg"
CodeDPMAAppealBGH = "dpma.appeal.bgh"
)
// MapLitigationToFristenrechner returns the fristenrechner code +
// condition flags implied by a (litigationCode, jurisdiction) pair.
@@ -27,61 +57,83 @@ package services
// and leave the cascade wide-open rather than auto-pick.
//
// Condition flags are returned as a slice so callers can apply them
// alongside the fristenrechner code (CCR+UPC → UPC_INF + with_ccr,
// AMD+UPC → UPC_INF + with_amend). An empty slice means no flag
// alongside the fristenrechner code (CCR+UPC → upc.inf.cfi + with_ccr,
// AMD+UPC → upc.inf.cfi + with_amend). An empty slice means no flag
// context applies.
func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) {
switch litigationCode {
case "INF":
switch jurisdiction {
case "UPC":
return "UPC_INF", nil, true
return CodeUPCInfringement, nil, true
case "DE":
return "DE_INF", nil, true
return CodeDEInfringementLG, nil, true
}
case "REV":
switch jurisdiction {
case "UPC":
return "UPC_REV", nil, true
return CodeUPCRevocation, nil, true
case "DE":
return "DE_NULL", nil, true
return CodeDENullityBPatG, nil, true
}
case "CCR":
// Counterclaim revocation — UPC fold-in is structural (the
// counterclaim lives inside an UPC_INF proceeding with the
// counterclaim lives inside an upc.inf.cfi proceeding with the
// with_ccr flag). DE Nichtigkeit is conceptually the same
// adversarial-validity test, no separate flag.
switch jurisdiction {
case "UPC":
return "UPC_INF", []string{"with_ccr"}, true
return CodeUPCInfringement, []string{"with_ccr"}, true
case "DE":
return "DE_NULL", nil, true
return CodeDENullityBPatG, nil, true
}
case "AMD":
// Amendment-application bundled into UPC_INF via with_amend.
// Amendment-application bundled into upc.inf.cfi via with_amend.
// No DE / EPA / DPMA analogue today.
if jurisdiction == "UPC" {
return "UPC_INF", []string{"with_amend"}, true
return CodeUPCInfringement, []string{"with_amend"}, true
}
case "APP":
// Appeal is ambiguous in DE (OLG vs BGH) and the project
// model doesn't carry the instance hint we'd need to
// disambiguate. UPC is unambiguous.
// disambiguate. UPC is unambiguous — upc.apl.merits covers
// the merits appeal track for inf/rev/ccr/damages.
if jurisdiction == "UPC" {
return "UPC_APP", nil, true
return CodeUPCAppealMerits, nil, true
}
case "APM":
// Preliminary injunction / urgency procedure — UPC-only
// concept in the fristenrechner taxonomy.
if jurisdiction == "UPC" {
return "UPC_PI", nil, true
return CodeUPCPreliminary, nil, true
}
case "OPP":
// Opposition — primarily EPA. DPMA has DPMA_OPP but it
// Opposition — primarily EPA. DPMA has dpma.opp.dpma but it
// doesn't surface from the litigation vocabulary today.
if jurisdiction == "EPA" {
return "EPA_OPP", nil, true
return CodeEPAOpposition, nil, true
}
}
return "", nil, false
}
// ResolveCounterclaimRouting handles the determinator's
// upc.ccr.cfi illustrative-peer route: the code exists in the dropdown
// for taxonomic completeness, but no rules are attached to it. When the
// cascade resolves to upc.ccr.cfi we route the rule lookup back to
// upc.inf.cfi with a default with_ccr=true flag — see
// docs/design-proceeding-code-taxonomy-2026-05-18.md §0.3 sub-decision S1.
//
// `code` is the proceeding code the cascade resolved to. If it's
// upc.ccr.cfi, the function returns (CodeUPCInfringement,
// []string{"with_ccr"}, true). For any other code the function returns
// (code, nil, false) and callers proceed with the code unchanged. The
// boolean signals "routing was applied"; the caller can surface the hint
// "Regeln liegen auf upc.inf.cfi (with_ccr=true); wir leiten Sie dorthin
// weiter." in the UI.
func ResolveCounterclaimRouting(code string) (effectiveCode string, defaultFlags []string, routed bool) {
if code == CodeUPCCounterclaim {
return CodeUPCInfringement, []string{"with_ccr"}, true
}
return code, nil, false
}

View File

@@ -14,20 +14,20 @@ func TestMapLitigationToFristenrechner(t *testing.T) {
}
cases := []tc{
// Unambiguous UPC fold-ins.
{"INF", "UPC", "UPC_INF", nil, true},
{"REV", "UPC", "UPC_REV", nil, true},
{"APP", "UPC", "UPC_APP", nil, true},
{"APM", "UPC", "UPC_PI", nil, true},
// CCR + UPC = UPC_INF with the with_ccr flag.
{"CCR", "UPC", "UPC_INF", []string{"with_ccr"}, true},
// AMD + UPC = UPC_INF with the with_amend flag.
{"AMD", "UPC", "UPC_INF", []string{"with_amend"}, true},
{"INF", "UPC", CodeUPCInfringement, nil, true},
{"REV", "UPC", CodeUPCRevocation, nil, true},
{"APP", "UPC", CodeUPCAppealMerits, nil, true},
{"APM", "UPC", CodeUPCPreliminary, nil, true},
// CCR + UPC = upc.inf.cfi with the with_ccr flag.
{"CCR", "UPC", CodeUPCInfringement, []string{"with_ccr"}, true},
// AMD + UPC = upc.inf.cfi with the with_amend flag.
{"AMD", "UPC", CodeUPCInfringement, []string{"with_amend"}, true},
// DE first-instance / Nichtigkeit mappings.
{"INF", "DE", "DE_INF", nil, true},
{"REV", "DE", "DE_NULL", nil, true},
{"CCR", "DE", "DE_NULL", nil, true},
{"INF", "DE", CodeDEInfringementLG, nil, true},
{"REV", "DE", CodeDENullityBPatG, nil, true},
{"CCR", "DE", CodeDENullityBPatG, nil, true},
// EPA opposition.
{"OPP", "EPA", "EPA_OPP", nil, true},
{"OPP", "EPA", CodeEPAOpposition, nil, true},
// Ambiguous: APP+DE has both OLG and BGH analogues; project
// model can't disambiguate, so degrade.
{"APP", "DE", "", nil, false},
@@ -52,3 +52,32 @@ func TestMapLitigationToFristenrechner(t *testing.T) {
}
}
}
func TestResolveCounterclaimRouting(t *testing.T) {
t.Run("upc.ccr.cfi routes to upc.inf.cfi with with_ccr", func(t *testing.T) {
gotCode, gotFlags, routed := ResolveCounterclaimRouting(CodeUPCCounterclaim)
if gotCode != CodeUPCInfringement {
t.Errorf("effective code = %q, want %q", gotCode, CodeUPCInfringement)
}
if !reflect.DeepEqual(gotFlags, []string{"with_ccr"}) {
t.Errorf("default flags = %v, want [with_ccr]", gotFlags)
}
if !routed {
t.Errorf("routed = false, want true")
}
})
t.Run("non-ccr code passes through unchanged", func(t *testing.T) {
for _, code := range []string{CodeUPCInfringement, CodeUPCRevocation, CodeDEInfringementLG, "anything-else"} {
gotCode, gotFlags, routed := ResolveCounterclaimRouting(code)
if gotCode != code {
t.Errorf("ResolveCounterclaimRouting(%q) returned %q, want pass-through", code, gotCode)
}
if gotFlags != nil {
t.Errorf("ResolveCounterclaimRouting(%q) flags = %v, want nil", code, gotFlags)
}
if routed {
t.Errorf("ResolveCounterclaimRouting(%q) routed = true, want false", code)
}
}
})
}

View File

@@ -134,8 +134,8 @@ type CreateProjectInput struct {
// 'first' (default once the picker UI lands) | 'appeal' | 'cassation'.
// NULL = unset. Phase 3 Slice 8 (t-paliad-189, design §7) — the
// SmartTimeline + calculator combine this with proceeding_code +
// jurisdiction to pick the effective rule corpus (DE_INF + appeal →
// DE_INF_OLG, etc.). Validated against the mig 080 CHECK on the
// jurisdiction to pick the effective rule corpus (de.inf.lg + appeal →
// de.inf.olg, etc.). Validated against the mig 080 CHECK on the
// column; service surfaces ErrInvalidInput on a bad value.
InstanceLevel *string `json:"instance_level,omitempty"`
@@ -1178,7 +1178,7 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
}
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
// to the design defaults: proceeding_type_id = UPC_REV, our_side = inverted
// to the design defaults: proceeding_type_id = upc.rev.cfi, our_side = inverted
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
// patent reference is resolvable, else "<parent title> — Widerklage".
//
@@ -1229,7 +1229,7 @@ func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, us
// and "both" pass through unchanged. The opts.FlipOurSide override
// supports the rare R.49.2.b CCI shape where flipping is wrong.
//
// proceeding_type_id default (§4.4): UPC_REV for the standard CCR-on-
// proceeding_type_id default (§4.4): upc.rev.cfi for the standard CCR-on-
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
// explicitly when they want it.
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
@@ -1248,7 +1248,7 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
}
// Resolve proceeding_type_id default to UPC_REV when caller didn't
// Resolve proceeding_type_id default to upc.rev.cfi when caller didn't
// override. The DB row is required because the projection layer
// dereferences it (paliad.proceeding_types.code).
procTypeID := 0
@@ -1257,9 +1257,9 @@ func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentI
} else {
err := s.db.GetContext(ctx, &procTypeID,
`SELECT id FROM paliad.proceeding_types
WHERE code = 'UPC_REV' AND is_active = true`)
WHERE code = $1 AND is_active = true`, CodeUPCRevocation)
if err != nil {
return nil, fmt.Errorf("resolve default UPC_REV proceeding type: %w", err)
return nil, fmt.Errorf("resolve default %s proceeding type: %w", CodeUPCRevocation, err)
}
}
@@ -1920,8 +1920,8 @@ func validateOurSide(s string) error {
// validateInstanceLevel checks the procedural-instance enum (Phase 3
// Slice 8, t-paliad-189, design §7). Empty string clears the column;
// the three named values map to the rule-corpus ladder DE_INF
// DE_INF_OLG → DE_INF_BGH that the SmartTimeline will surface in a
// the three named values map to the rule-corpus ladder de.inf.lg
// de.inf.olg → de.inf.bgh that the SmartTimeline will surface in a
// follow-up calculator slice. The DB-level CHECK on mig 080 enforces
// the same set; this validation gives a clearer error than letting
// the trigger fire.

View File

@@ -29,7 +29,7 @@ import (
// service layer (defence-in-depth). A non-fristenrechner-category
// id INSERT via plain SQL must raise EXCEPTION.
//
// 4. Passing a fristenrechner-category id (UPC_INF) succeeds.
// 4. Passing a fristenrechner-category id (upc.inf.cfi) succeeds.
//
// Phase 3 Slice 9 follow-up B (t-paliad-200, mig 093) retired the
// 'litigation' category from the rule corpus; the negative-case lookup
@@ -90,8 +90,9 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
var fristenrechnerID int
if err := pool.GetContext(ctx, &fristenrechnerID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND code = 'UPC_INF' AND is_active = true`); err != nil {
t.Fatalf("look up UPC_INF id: %v", err)
WHERE category = 'fristenrechner' AND code = $1 AND is_active = true`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
}
users := NewUserService(pool)

View File

@@ -46,18 +46,20 @@ func TestCreateCounterclaim_Live(t *testing.T) {
ctx := context.Background()
userID := uuid.New()
patentID := uuid.New() // sibling parent: the patent hub
caseID := uuid.New() // the parent case (UPC_INF)
caseID := uuid.New() // the parent case (upc.inf.cfi)
// Resolve UPC_INF + UPC_REV ids once. We need real ids from the
// proceeding_types seed because they're NOT NULL on the test row.
// Resolve upc.inf.cfi + upc.rev.cfi ids once. We need real ids from
// the proceeding_types seed because they're NOT NULL on the test row.
var upcInf, upcRev int
if err := pool.GetContext(ctx, &upcInf,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF'`); err != nil {
t.Fatalf("resolve UPC_INF: %v", err)
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
CodeUPCInfringement); err != nil {
t.Fatalf("resolve %s: %v", CodeUPCInfringement, err)
}
if err := pool.GetContext(ctx, &upcRev,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_REV'`); err != nil {
t.Fatalf("resolve UPC_REV: %v", err)
`SELECT id FROM paliad.proceeding_types WHERE code = $1`,
CodeUPCRevocation); err != nil {
t.Fatalf("resolve %s: %v", CodeUPCRevocation, err)
}
cleanup := func() {
@@ -102,7 +104,7 @@ func TestCreateCounterclaim_Live(t *testing.T) {
patentID, userID); err != nil {
t.Fatalf("seed patent team: %v", err)
}
// Child case (UPC_INF) under the patent.
// Child case (upc.inf.cfi) under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
@@ -151,9 +153,9 @@ func TestCreateCounterclaim_Live(t *testing.T) {
if child.OurSide == nil || *child.OurSide != "defendant" {
t.Errorf("child.OurSide = %v, want defendant", child.OurSide)
}
// 4. Default proceeding_type_id resolved to UPC_REV.
// 4. Default proceeding_type_id resolved to upc.rev.cfi.
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
t.Errorf("child.ProceedingTypeID = %v, want UPC_REV (%d)", child.ProceedingTypeID, upcRev)
t.Errorf("child.ProceedingTypeID = %v, want upc.rev.cfi (%d)", child.ProceedingTypeID, upcRev)
}
// 5. Auto-suggested title carries the patent reference + suffix.
if !strings.Contains(child.Title, "EP3456789") || !strings.Contains(child.Title, "Widerklage") {

View File

@@ -599,7 +599,7 @@ func laneLabelFor(child *models.Project, policy LevelPolicy) string {
switch policy.LaneAxis {
case "child_case":
// Append the proceeding type code when known so the lawyer can
// identify which case at a glance ("UPC-CFI München (UPC_INF)").
// identify which case at a glance ("UPC-CFI München (upc.inf.cfi)").
if child.ProceedingTypeID != nil {
return child.Title
}